mirror of
https://github.com/geoffsee/open-gsio.git
synced 2025-09-08 22:56:46 +00:00
- Introduce MapStore
to manage map state and controls using MobX-State-Tree.
- Integrate `MapStore` into `ClientChatStore`. - Add support for handling map control tool responses in `StreamStore`. - Update `InputMenu` with loading state while fetching models and UI improvements. - Include `useLayoutEffect` in `LandingComponent` for persistent state management. - Enhance `ChatService` with debug logs, model fallback handling, and better error reporting.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Spinner,
|
||||
Text,
|
||||
useDisclosure,
|
||||
useOutsideClick,
|
||||
@@ -41,19 +42,38 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled })
|
||||
|
||||
const [controlledOpen, setControlledOpen] = useState<boolean>(false);
|
||||
const [supportedModels, setSupportedModels] = useState<any[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setControlledOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingModels(true);
|
||||
fetch('/api/models')
|
||||
.then(response => response.json())
|
||||
.then(models => {
|
||||
setSupportedModels(models);
|
||||
|
||||
// Update the ModelStore with supported models
|
||||
const modelIds = models.map((model: any) => model.id);
|
||||
clientChatStore.setSupportedModels(modelIds);
|
||||
|
||||
// If no model is currently selected or the current model is not in the list,
|
||||
// select a random model from the available ones
|
||||
if (!clientChatStore.model || !modelIds.includes(clientChatStore.model)) {
|
||||
if (models.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * models.length);
|
||||
const randomModel = models[randomIndex];
|
||||
clientChatStore.setModel(randomModel.id);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoadingModels(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Could not fetch models: ', err);
|
||||
setIsLoadingModels(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -108,8 +128,8 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled })
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
bg="text.accent"
|
||||
icon={<Settings size={20} />}
|
||||
isDisabled={isDisabled}
|
||||
icon={isLoadingModels ? <Spinner size="sm" /> : <Settings size={20} />}
|
||||
isDisabled={isDisabled || isLoadingModels}
|
||||
aria-label="Settings"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.2)' }}
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
@@ -118,8 +138,8 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled })
|
||||
) : (
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronDown size={16} />}
|
||||
isDisabled={isDisabled}
|
||||
rightIcon={isLoadingModels ? <Spinner size="sm" /> : <ChevronDown size={16} />}
|
||||
isDisabled={isDisabled || isLoadingModels}
|
||||
variant="ghost"
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
@@ -128,7 +148,7 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled })
|
||||
{...MsM_commonButtonStyles}
|
||||
>
|
||||
<Text noOfLines={1} maxW="100px" fontSize="sm">
|
||||
{clientChatStore.model}
|
||||
{isLoadingModels ? 'Loading...' : clientChatStore.model}
|
||||
</Text>
|
||||
</MenuButton>
|
||||
)}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useComponent } from '../contexts/ComponentContext.tsx';
|
||||
|
||||
@@ -11,6 +11,23 @@ export const LandingComponent: React.FC = () => {
|
||||
const [mapActive, setMapActive] = useState(true);
|
||||
const [aiActive, setAiActive] = useState(false);
|
||||
|
||||
const appCtlState = `app-ctl-state`;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const value = localStorage.getItem(appCtlState);
|
||||
if (value) {
|
||||
const parsed = JSON.parse(value);
|
||||
setIntensity(parsed.intensity);
|
||||
setMapActive(parsed.mapActive);
|
||||
setAiActive(parsed.aiActive);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// create a hook for saving the state as a json object when it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem(appCtlState, JSON.stringify({ intensity, mapActive, aiActive }));
|
||||
});
|
||||
|
||||
const component = useComponent();
|
||||
const { setEnabledComponent } = component;
|
||||
|
||||
@@ -21,12 +38,14 @@ export const LandingComponent: React.FC = () => {
|
||||
if (aiActive) {
|
||||
setEnabledComponent('ai');
|
||||
}
|
||||
}, []);
|
||||
}, [mapActive, aiActive, setEnabledComponent]);
|
||||
|
||||
return (
|
||||
<Box as="section" bg="background.primary" overflow="hidden">
|
||||
<Box position="fixed" right={0} maxWidth="300px" minWidth="200px" zIndex={1000}>
|
||||
<Tweakbox
|
||||
id="app-tweaker"
|
||||
persist={true}
|
||||
sliders={{
|
||||
intensity: {
|
||||
value: intensity,
|
||||
@@ -68,7 +87,6 @@ export const LandingComponent: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/*<BevyScene speed={speed} intensity={intensity} glow={glow} visible={bevyScene} />*/}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@@ -37,10 +37,10 @@ const key =
|
||||
function Map(props: { visible: boolean }) {
|
||||
return (
|
||||
/* Full-screen wrapper — fills the viewport and becomes the positioning context */
|
||||
<Box position={'absolute'} top={0} w="100vw" h={'100vh'} overflow="hidden">
|
||||
<Box position={'absolute'} top={0} w="100%" h={'100vh'} overflow="hidden">
|
||||
{/* Button bar — absolutely positioned inside the wrapper */}
|
||||
|
||||
<MapNext mapboxPublicKey={atob(key)} />
|
||||
<MapNext mapboxPublicKey={atob(key)} visible={props.visible} />
|
||||
{/*<Map*/}
|
||||
{/* mapboxAccessToken={atob(key)}*/}
|
||||
{/* initialViewState={mapView}*/}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Map, {
|
||||
FullscreenControl,
|
||||
GeolocateControl,
|
||||
@@ -9,25 +10,36 @@ import Map, {
|
||||
ScaleControl,
|
||||
} from 'react-map-gl/mapbox';
|
||||
|
||||
import clientChatStore from '../../stores/ClientChatStore';
|
||||
|
||||
import PORTS from './nautical-base-data.json';
|
||||
import Pin from './pin';
|
||||
|
||||
export default function MapNext(props: any = { mapboxPublicKey: '' } as any) {
|
||||
function MapNextComponent(props: any = { mapboxPublicKey: '', visible: true } as any) {
|
||||
const [popupInfo, setPopupInfo] = useState(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [isTokenLoading, setIsTokenLoading] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const mapRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAuthenticated(true);
|
||||
setIsTokenLoading(false);
|
||||
}, []);
|
||||
|
||||
const [mapView, setMapView] = useState({
|
||||
longitude: -122.4,
|
||||
latitude: 37.8,
|
||||
zoom: 14,
|
||||
});
|
||||
// Handle map resize when component becomes visible
|
||||
useEffect(() => {
|
||||
if (props.visible && mapRef.current) {
|
||||
// Small delay to ensure the container is fully visible
|
||||
const timer = setTimeout(() => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.resize();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [props.visible]);
|
||||
|
||||
const handleNavigationClick = useCallback(async () => {
|
||||
console.log('handling navigation in map');
|
||||
@@ -39,7 +51,10 @@ export default function MapNext(props: any = { mapboxPublicKey: '' } as any) {
|
||||
|
||||
const handleMapViewChange = useCallback(async (evt: any) => {
|
||||
const { longitude, latitude, zoom } = evt.viewState;
|
||||
setMapView({ longitude, latitude, zoom });
|
||||
clientChatStore.setMapView(longitude, latitude, zoom);
|
||||
// setMapView({ longitude, latitude, zoom });
|
||||
|
||||
// Update the store with the new view state
|
||||
}, []);
|
||||
|
||||
const pins = useMemo(
|
||||
@@ -98,13 +113,15 @@ Type '{ city: string; population: string; image: string; state: string; latitude
|
||||
{/* </Button>*/}
|
||||
{/*</HStack>*/}
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{
|
||||
latitude: 40,
|
||||
longitude: -100,
|
||||
zoom: 3.5,
|
||||
bearing: 0,
|
||||
pitch: 0,
|
||||
latitude: clientChatStore.mapState.latitude,
|
||||
longitude: clientChatStore.mapState.longitude,
|
||||
zoom: clientChatStore.mapState.zoom,
|
||||
bearing: clientChatStore.mapState.bearing,
|
||||
pitch: clientChatStore.mapState.pitch,
|
||||
}}
|
||||
onMove={handleMapViewChange}
|
||||
mapStyle="mapbox://styles/geoffsee/cmd1qz39x01ga01qv5acea02y"
|
||||
attributionControl={false}
|
||||
mapboxAccessToken={props.mapboxPublicKey}
|
||||
@@ -170,3 +187,6 @@ Type '{ city: string; population: string; image: string; state: string; latitude
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const MapNext = observer(MapNextComponent);
|
||||
export default MapNext;
|
||||
|
@@ -14,7 +14,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface SliderControl {
|
||||
value: number;
|
||||
@@ -34,6 +34,8 @@ interface SwitchControl {
|
||||
}
|
||||
|
||||
interface TweakboxProps {
|
||||
id: string;
|
||||
persist: boolean;
|
||||
sliders: {
|
||||
speed: SliderControl;
|
||||
intensity: SliderControl;
|
||||
@@ -44,7 +46,7 @@ interface TweakboxProps {
|
||||
} & Record<string, SwitchControl>;
|
||||
}
|
||||
|
||||
const Tweakbox = observer(({ sliders, switches }: TweakboxProps) => {
|
||||
const Tweakbox = observer(({ id, persist, sliders, switches }: TweakboxProps) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { Box, useMediaQuery } from '@chakra-ui/react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import Chat from '../../components/chat/Chat.tsx';
|
||||
@@ -21,10 +21,11 @@ export default function IndexPage() {
|
||||
|
||||
const component = useComponent();
|
||||
|
||||
const mediaQuery = useMediaQuery();
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%">
|
||||
<LandingComponent />
|
||||
|
||||
<Box
|
||||
display={component.enabledComponent === 'ai' ? undefined : 'none'}
|
||||
width="100%"
|
||||
@@ -36,8 +37,8 @@ export default function IndexPage() {
|
||||
</Box>
|
||||
<Box
|
||||
display={component.enabledComponent === 'gpsmap' ? undefined : 'none'}
|
||||
width="100%"
|
||||
height="100%"
|
||||
width={{ base: '100%', md: '100%' }}
|
||||
height={{ base: '100%', md: '100%' }}
|
||||
padding={'unset'}
|
||||
>
|
||||
<ReactMap visible={component.enabledComponent === 'gpsmap'} />
|
||||
|
@@ -3,13 +3,14 @@
|
||||
// ---------------------------
|
||||
import { types, type Instance } from 'mobx-state-tree';
|
||||
|
||||
import { MapStore } from './MapStore';
|
||||
import { MessagesStore } from './MessagesStore';
|
||||
import { ModelStore } from './ModelStore';
|
||||
import { StreamStore } from './StreamStore';
|
||||
import { UIStore } from './UIStore';
|
||||
|
||||
export const ClientChatStore = types
|
||||
.compose(MessagesStore, UIStore, ModelStore, StreamStore)
|
||||
.compose(MessagesStore, UIStore, ModelStore, StreamStore, MapStore)
|
||||
.named('ClientChatStore');
|
||||
|
||||
const clientChatStore = ClientChatStore.create();
|
||||
|
117
packages/client/src/stores/MapStore.ts
Normal file
117
packages/client/src/stores/MapStore.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { types, type Instance } from 'mobx-state-tree';
|
||||
|
||||
export interface MapControlCommand {
|
||||
action: string;
|
||||
value?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export const MapStore = types
|
||||
.model('MapStore', {
|
||||
// Current map view state
|
||||
longitude: types.optional(types.number, -122.4),
|
||||
latitude: types.optional(types.number, 37.8),
|
||||
zoom: types.optional(types.number, 14),
|
||||
// Map control state
|
||||
isControlActive: types.optional(types.boolean, false),
|
||||
})
|
||||
.volatile(self => ({
|
||||
// Store pending map commands from AI
|
||||
pendingCommands: [] as MapControlCommand[],
|
||||
mapState: {
|
||||
latitude: self.latitude,
|
||||
longitude: self.longitude,
|
||||
zoom: self.zoom,
|
||||
bearing: 0,
|
||||
pitch: 0,
|
||||
} as any,
|
||||
}))
|
||||
.actions(self => ({
|
||||
// Update map view state
|
||||
setMapView(longitude: number, latitude: number, zoom: number) {
|
||||
self.longitude = longitude;
|
||||
self.latitude = latitude;
|
||||
self.zoom = zoom;
|
||||
|
||||
// Also update the mapState object to keep it in sync
|
||||
self.mapState = {
|
||||
...self.mapState,
|
||||
longitude,
|
||||
latitude,
|
||||
zoom,
|
||||
};
|
||||
},
|
||||
|
||||
// Handle map control commands from AI
|
||||
executeMapCommand(command: MapControlCommand) {
|
||||
console.log('[DEBUG_LOG] Executing map command:', command);
|
||||
|
||||
switch (command.action) {
|
||||
case 'zoom_to': {
|
||||
if (command.data?.target) {
|
||||
// For now, we'll implement a simple zoom behavior
|
||||
// In a real implementation, this could parse coordinates or location names
|
||||
const zoomLevel = 10; // Default zoom level for zoom_to commands
|
||||
self.zoom = zoomLevel;
|
||||
console.log('[DEBUG_LOG] Zoomed to level:', zoomLevel);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'add_point': {
|
||||
if (command.data?.pointId) {
|
||||
console.log('[DEBUG_LOG] Adding point:', command.data.pointId);
|
||||
// Point addition logic would go here
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'add_dataset':
|
||||
case 'remove_dataset': {
|
||||
if (command.data?.datasetId) {
|
||||
console.log('[DEBUG_LOG] Dataset operation:', command.action, command.data.datasetId);
|
||||
// Dataset management logic would go here
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'search_datasets': {
|
||||
console.log('[DEBUG_LOG] Searching datasets:', command.data?.searchTerm);
|
||||
// Dataset search logic would go here
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn('[DEBUG_LOG] Unknown map command:', command.action);
|
||||
}
|
||||
|
||||
self.isControlActive = true;
|
||||
|
||||
// Clear the command after a short delay
|
||||
setTimeout(() => {
|
||||
self.isControlActive = false;
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
// Add a command to the pending queue
|
||||
addPendingCommand(command: MapControlCommand) {
|
||||
self.pendingCommands.push(command);
|
||||
},
|
||||
|
||||
// Process all pending commands
|
||||
processPendingCommands() {
|
||||
while (self.pendingCommands.length > 0) {
|
||||
const command = self.pendingCommands.shift();
|
||||
if (command) {
|
||||
this.executeMapCommand(command);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Clear all pending commands
|
||||
clearPendingCommands() {
|
||||
self.pendingCommands.splice(0);
|
||||
},
|
||||
}));
|
||||
|
||||
export type IMapStore = Instance<typeof MapStore>;
|
@@ -2,6 +2,8 @@ import { flow, getParent, type Instance, types } from 'mobx-state-tree';
|
||||
|
||||
import Message, { batchContentUpdate } from '../models/Message';
|
||||
|
||||
import clientChatStore from './ClientChatStore.ts';
|
||||
import type { MapControlCommand } from './MapStore';
|
||||
import type { RootDeps } from './RootDeps.ts';
|
||||
import UserOptionsStore from './UserOptionsStore';
|
||||
|
||||
@@ -92,6 +94,30 @@ export const StreamStore = types
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tool call responses
|
||||
if (parsed.type === 'tool_result') {
|
||||
console.log('[DEBUG_LOG] Received tool result:', parsed);
|
||||
|
||||
// Check if this is a map control tool call
|
||||
if (parsed.tool_name === 'maps_control' && parsed.result?.data) {
|
||||
const mapCommand: MapControlCommand = {
|
||||
action: parsed.result.data.action,
|
||||
value: parsed.args?.value,
|
||||
data: parsed.result.data,
|
||||
};
|
||||
|
||||
console.log('[DEBUG_LOG] Processing map command:', mapCommand);
|
||||
|
||||
// Execute the map command through the store
|
||||
if ('executeMapCommand' in root) {
|
||||
(root as any).executeMapCommand(mapCommand);
|
||||
} else {
|
||||
console.warn('[DEBUG_LOG] MapStore not available in root');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the last message
|
||||
const lastMessage = root.items[root.items.length - 1];
|
||||
|
||||
@@ -152,4 +178,4 @@ export const StreamStore = types
|
||||
return { sendMessage, stopIncomingMessage, cleanup, setEventSource, setStreamId };
|
||||
});
|
||||
|
||||
export interface IStreamStore extends Instance<typeof StreamStore> {}
|
||||
export type IStreamStore = Instance<typeof StreamStore>;
|
||||
|
Reference in New Issue
Block a user