- 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:
geoffsee
2025-07-17 16:12:27 -04:00
parent bb5afa099a
commit 5b896d9d07
14 changed files with 517 additions and 39 deletions

View File

@@ -24,10 +24,68 @@ export class ProviderRepository {
}; };
static async getModelFamily(model: any, env: GenericEnv) { static async getModelFamily(model: any, env: GenericEnv) {
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Looking up model "${model}"`);
const allModels = await env.KV_STORAGE.get('supportedModels'); const allModels = await env.KV_STORAGE.get('supportedModels');
const models = JSON.parse(allModels); const models = JSON.parse(allModels);
const modelData = models.filter((m: ModelMeta) => m.id === model);
return modelData[0].provider; // eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Found ${models.length} total models in KV storage`);
// eslint-disable-next-line prettier/prettier
console.log('[DEBUG_LOG] ProviderRepository.getModelFamily: Available model IDs:', models.map((m: ModelMeta) => m.id));
// First try exact match
let modelData = models.filter((m: ModelMeta) => m.id === model);
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Exact match attempt for "${model}" found ${modelData.length} results`);
// If no exact match, try to find by partial match (handle provider prefixes)
if (modelData.length === 0) {
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Trying partial match for "${model}"`);
modelData = models.filter((m: ModelMeta) => {
// Check if the model ID ends with the requested model name
// This handles cases like "accounts/fireworks/models/mixtral-8x22b-instruct" matching "mixtral-8x22b-instruct"
const endsWithMatch = m.id.endsWith(model);
const modelEndsWithStoredBase = model.endsWith(m.id.split('/').pop() || '');
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Checking "${m.id}" - endsWith: ${endsWithMatch}, modelEndsWithBase: ${modelEndsWithStoredBase}`);
return endsWithMatch || modelEndsWithStoredBase;
});
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Partial match found ${modelData.length} results`);
}
// If still no match, try to find by the base model name (last part after /)
if (modelData.length === 0) {
const baseModelName = model.split('/').pop();
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Trying base name match for "${baseModelName}"`);
modelData = models.filter((m: ModelMeta) => {
const baseStoredName = m.id.split('/').pop();
const matches = baseStoredName === baseModelName;
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Comparing base names "${baseStoredName}" === "${baseModelName}": ${matches}`);
return matches;
});
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Base name match found ${modelData.length} results`);
}
const selectedProvider = modelData[0]?.provider;
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: Final result for "${model}" -> provider: "${selectedProvider}"`);
if (modelData.length > 0) {
// eslint-disable-next-line prettier/prettier
console.log('[DEBUG_LOG] ProviderRepository.getModelFamily: Selected model data:', modelData[0]);
} else {
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.getModelFamily: No matching model found for "${model}"`);
}
return selectedProvider;
} }
static async getModelMeta(meta: any, env: GenericEnv) { static async getModelMeta(meta: any, env: GenericEnv) {
@@ -41,12 +99,19 @@ export class ProviderRepository {
} }
setProviders(env: GenericEnv) { setProviders(env: GenericEnv) {
// eslint-disable-next-line prettier/prettier
console.log('[DEBUG_LOG] ProviderRepository.setProviders: Starting provider detection');
const indicies = { const indicies = {
providerName: 0, providerName: 0,
providerValue: 1, providerValue: 1,
}; };
const valueDelimiter = '_'; const valueDelimiter = '_';
const envKeys = Object.keys(env); const envKeys = Object.keys(env);
// eslint-disable-next-line prettier/prettier
console.log('[DEBUG_LOG] ProviderRepository.setProviders: Environment keys ending with KEY:', envKeys.filter(key => key.endsWith('KEY')));
for (let i = 0; i < envKeys.length; i++) { for (let i = 0; i < envKeys.length; i++) {
if (envKeys.at(i)?.endsWith('KEY')) { if (envKeys.at(i)?.endsWith('KEY')) {
const detectedProvider = envKeys const detectedProvider = envKeys
@@ -55,9 +120,15 @@ export class ProviderRepository {
.at(indicies.providerName) .at(indicies.providerName)
?.toLowerCase(); ?.toLowerCase();
const detectedProviderValue = env[envKeys.at(i) as string]; const detectedProviderValue = env[envKeys.at(i) as string];
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.setProviders: Processing ${envKeys[i]} -> detected provider: "${detectedProvider}", has value: ${!!detectedProviderValue}`);
if (detectedProviderValue) { if (detectedProviderValue) {
switch (detectedProvider) { switch (detectedProvider) {
case 'anthropic': case 'anthropic':
// eslint-disable-next-line prettier/prettier
console.log('[DEBUG_LOG] ProviderRepository.setProviders: Adding Claude provider (anthropic)');
this.#providers.push({ this.#providers.push({
name: 'claude', name: 'claude',
key: env.ANTHROPIC_API_KEY, key: env.ANTHROPIC_API_KEY,
@@ -65,6 +136,8 @@ export class ProviderRepository {
}); });
break; break;
case 'gemini': case 'gemini':
// eslint-disable-next-line prettier/prettier
console.log('[DEBUG_LOG] ProviderRepository.setProviders: Adding Google provider (gemini)');
this.#providers.push({ this.#providers.push({
name: 'google', name: 'google',
key: env.GEMINI_API_KEY, key: env.GEMINI_API_KEY,
@@ -72,6 +145,8 @@ export class ProviderRepository {
}); });
break; break;
case 'cloudflare': case 'cloudflare':
// eslint-disable-next-line prettier/prettier
console.log('[DEBUG_LOG] ProviderRepository.setProviders: Adding Cloudflare provider');
this.#providers.push({ this.#providers.push({
name: 'cloudflare', name: 'cloudflare',
key: env.CLOUDFLARE_API_KEY, key: env.CLOUDFLARE_API_KEY,
@@ -82,6 +157,8 @@ export class ProviderRepository {
}); });
break; break;
default: default:
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.setProviders: Adding default provider "${detectedProvider}"`);
this.#providers.push({ this.#providers.push({
name: detectedProvider as SupportedProvider, name: detectedProvider as SupportedProvider,
key: env[envKeys[i] as string], key: env[envKeys[i] as string],
@@ -89,8 +166,14 @@ export class ProviderRepository {
ProviderRepository.OPENAI_COMPAT_ENDPOINTS[detectedProvider as SupportedProvider], ProviderRepository.OPENAI_COMPAT_ENDPOINTS[detectedProvider as SupportedProvider],
}); });
} }
} else {
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.setProviders: Skipping ${envKeys[i]} - no value provided`);
} }
} }
} }
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ProviderRepository.setProviders: Final configured providers (${this.#providers.length}):`, this.#providers.map(p => ({ name: p.name, endpoint: p.endpoint, hasKey: !!p.key })));
} }
} }

View File

@@ -236,7 +236,7 @@ export abstract class BaseChatProvider implements ChatStreamProvider {
// Process chunk normally for non-tool-call responses // Process chunk normally for non-tool-call responses
if (!chunk.choices[0]?.delta?.tool_calls) { if (!chunk.choices[0]?.delta?.tool_calls) {
console.log('after-tool-call-chunk', chunk); // console.log('after-tool-call-chunk', chunk);
const shouldBreak = await this.processChunk(chunk, dataCallback); const shouldBreak = await this.processChunk(chunk, dataCallback);
if (shouldBreak) { if (shouldBreak) {
conversationComplete = true; conversationComplete = true;

View File

@@ -18,7 +18,7 @@ export class FireworksAiChatProvider extends BaseChatProvider {
} }
return { return {
model: `${modelPrefix}${param.model}`, model: `${param.model}`,
messages: safeMessages, messages: safeMessages,
stream: true, stream: true,
}; };

View File

@@ -9,14 +9,14 @@ export interface MapsControlResult {
*/ */
export const MapsTools = { export const MapsTools = {
type: 'function', type: 'function',
description:
'Interface for controlling a web-rendered map to explore publicly available geospatial data',
/** /**
* Mock implementation of a maps control command. * Mock implementation of a maps control command.
*/ */
function: { function: {
name: 'maps_control', name: 'maps_control',
description:
'Interface for controlling a web-rendered map to explore publicly available geospatial data',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {

View File

@@ -8,6 +8,7 @@ import {
MenuButton, MenuButton,
MenuItem, MenuItem,
MenuList, MenuList,
Spinner,
Text, Text,
useDisclosure, useDisclosure,
useOutsideClick, useOutsideClick,
@@ -41,19 +42,38 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled })
const [controlledOpen, setControlledOpen] = useState<boolean>(false); const [controlledOpen, setControlledOpen] = useState<boolean>(false);
const [supportedModels, setSupportedModels] = useState<any[]>([]); const [supportedModels, setSupportedModels] = useState<any[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState<boolean>(true);
useEffect(() => { useEffect(() => {
setControlledOpen(isOpen); setControlledOpen(isOpen);
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {
setIsLoadingModels(true);
fetch('/api/models') fetch('/api/models')
.then(response => response.json()) .then(response => response.json())
.then(models => { .then(models => {
setSupportedModels(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 => { .catch(err => {
console.error('Could not fetch models: ', err); console.error('Could not fetch models: ', err);
setIsLoadingModels(false);
}); });
}, []); }, []);
@@ -108,8 +128,8 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled })
<MenuButton <MenuButton
as={IconButton} as={IconButton}
bg="text.accent" bg="text.accent"
icon={<Settings size={20} />} icon={isLoadingModels ? <Spinner size="sm" /> : <Settings size={20} />}
isDisabled={isDisabled} isDisabled={isDisabled || isLoadingModels}
aria-label="Settings" aria-label="Settings"
_hover={{ bg: 'rgba(255, 255, 255, 0.2)' }} _hover={{ bg: 'rgba(255, 255, 255, 0.2)' }}
_focus={{ boxShadow: 'none' }} _focus={{ boxShadow: 'none' }}
@@ -118,8 +138,8 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled })
) : ( ) : (
<MenuButton <MenuButton
as={Button} as={Button}
rightIcon={<ChevronDown size={16} />} rightIcon={isLoadingModels ? <Spinner size="sm" /> : <ChevronDown size={16} />}
isDisabled={isDisabled} isDisabled={isDisabled || isLoadingModels}
variant="ghost" variant="ghost"
display="flex" display="flex"
justifyContent="space-between" justifyContent="space-between"
@@ -128,7 +148,7 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled })
{...MsM_commonButtonStyles} {...MsM_commonButtonStyles}
> >
<Text noOfLines={1} maxW="100px" fontSize="sm"> <Text noOfLines={1} maxW="100px" fontSize="sm">
{clientChatStore.model} {isLoadingModels ? 'Loading...' : clientChatStore.model}
</Text> </Text>
</MenuButton> </MenuButton>
)} )}

View File

@@ -1,5 +1,5 @@
import { Box } from '@chakra-ui/react'; 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'; import { useComponent } from '../contexts/ComponentContext.tsx';
@@ -11,6 +11,23 @@ export const LandingComponent: React.FC = () => {
const [mapActive, setMapActive] = useState(true); const [mapActive, setMapActive] = useState(true);
const [aiActive, setAiActive] = useState(false); 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 component = useComponent();
const { setEnabledComponent } = component; const { setEnabledComponent } = component;
@@ -21,12 +38,14 @@ export const LandingComponent: React.FC = () => {
if (aiActive) { if (aiActive) {
setEnabledComponent('ai'); setEnabledComponent('ai');
} }
}, []); }, [mapActive, aiActive, setEnabledComponent]);
return ( return (
<Box as="section" bg="background.primary" overflow="hidden"> <Box as="section" bg="background.primary" overflow="hidden">
<Box position="fixed" right={0} maxWidth="300px" minWidth="200px" zIndex={1000}> <Box position="fixed" right={0} maxWidth="300px" minWidth="200px" zIndex={1000}>
<Tweakbox <Tweakbox
id="app-tweaker"
persist={true}
sliders={{ sliders={{
intensity: { intensity: {
value: intensity, value: intensity,
@@ -68,7 +87,6 @@ export const LandingComponent: React.FC = () => {
}} }}
/> />
</Box> </Box>
{/*<BevyScene speed={speed} intensity={intensity} glow={glow} visible={bevyScene} />*/}
</Box> </Box>
); );
}; };

View File

@@ -37,10 +37,10 @@ const key =
function Map(props: { visible: boolean }) { function Map(props: { visible: boolean }) {
return ( return (
/* Full-screen wrapper — fills the viewport and becomes the positioning context */ /* 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 */} {/* Button bar — absolutely positioned inside the wrapper */}
<MapNext mapboxPublicKey={atob(key)} /> <MapNext mapboxPublicKey={atob(key)} visible={props.visible} />
{/*<Map*/} {/*<Map*/}
{/* mapboxAccessToken={atob(key)}*/} {/* mapboxAccessToken={atob(key)}*/}
{/* initialViewState={mapView}*/} {/* initialViewState={mapView}*/}

View File

@@ -1,5 +1,6 @@
import { Box } from '@chakra-ui/react'; 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, { import Map, {
FullscreenControl, FullscreenControl,
GeolocateControl, GeolocateControl,
@@ -9,25 +10,36 @@ import Map, {
ScaleControl, ScaleControl,
} from 'react-map-gl/mapbox'; } from 'react-map-gl/mapbox';
import clientChatStore from '../../stores/ClientChatStore';
import PORTS from './nautical-base-data.json'; import PORTS from './nautical-base-data.json';
import Pin from './pin'; 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 [popupInfo, setPopupInfo] = useState(null);
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isTokenLoading, setIsTokenLoading] = useState(false); const [isTokenLoading, setIsTokenLoading] = useState(false);
const [authenticated, setAuthenticated] = useState(false); const [authenticated, setAuthenticated] = useState(false);
const mapRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
setAuthenticated(true); setAuthenticated(true);
setIsTokenLoading(false); setIsTokenLoading(false);
}, []); }, []);
const [mapView, setMapView] = useState({ // Handle map resize when component becomes visible
longitude: -122.4, useEffect(() => {
latitude: 37.8, if (props.visible && mapRef.current) {
zoom: 14, // 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 () => { const handleNavigationClick = useCallback(async () => {
console.log('handling navigation in map'); 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 handleMapViewChange = useCallback(async (evt: any) => {
const { longitude, latitude, zoom } = evt.viewState; 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( const pins = useMemo(
@@ -98,13 +113,15 @@ Type '{ city: string; population: string; image: string; state: string; latitude
{/* </Button>*/} {/* </Button>*/}
{/*</HStack>*/} {/*</HStack>*/}
<Map <Map
ref={mapRef}
initialViewState={{ initialViewState={{
latitude: 40, latitude: clientChatStore.mapState.latitude,
longitude: -100, longitude: clientChatStore.mapState.longitude,
zoom: 3.5, zoom: clientChatStore.mapState.zoom,
bearing: 0, bearing: clientChatStore.mapState.bearing,
pitch: 0, pitch: clientChatStore.mapState.pitch,
}} }}
onMove={handleMapViewChange}
mapStyle="mapbox://styles/geoffsee/cmd1qz39x01ga01qv5acea02y" mapStyle="mapbox://styles/geoffsee/cmd1qz39x01ga01qv5acea02y"
attributionControl={false} attributionControl={false}
mapboxAccessToken={props.mapboxPublicKey} mapboxAccessToken={props.mapboxPublicKey}
@@ -170,3 +187,6 @@ Type '{ city: string; population: string; image: string; state: string; latitude
</Box> </Box>
); );
} }
const MapNext = observer(MapNextComponent);
export default MapNext;

View File

@@ -14,7 +14,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
interface SliderControl { interface SliderControl {
value: number; value: number;
@@ -34,6 +34,8 @@ interface SwitchControl {
} }
interface TweakboxProps { interface TweakboxProps {
id: string;
persist: boolean;
sliders: { sliders: {
speed: SliderControl; speed: SliderControl;
intensity: SliderControl; intensity: SliderControl;
@@ -44,7 +46,7 @@ interface TweakboxProps {
} & Record<string, SwitchControl>; } & Record<string, SwitchControl>;
} }
const Tweakbox = observer(({ sliders, switches }: TweakboxProps) => { const Tweakbox = observer(({ id, persist, sliders, switches }: TweakboxProps) => {
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
return ( return (

View File

@@ -1,4 +1,4 @@
import { Box } from '@chakra-ui/react'; import { Box, useMediaQuery } from '@chakra-ui/react';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import Chat from '../../components/chat/Chat.tsx'; import Chat from '../../components/chat/Chat.tsx';
@@ -21,10 +21,11 @@ export default function IndexPage() {
const component = useComponent(); const component = useComponent();
const mediaQuery = useMediaQuery();
return ( return (
<Box height="100%" width="100%"> <Box height="100%" width="100%">
<LandingComponent /> <LandingComponent />
<Box <Box
display={component.enabledComponent === 'ai' ? undefined : 'none'} display={component.enabledComponent === 'ai' ? undefined : 'none'}
width="100%" width="100%"
@@ -36,8 +37,8 @@ export default function IndexPage() {
</Box> </Box>
<Box <Box
display={component.enabledComponent === 'gpsmap' ? undefined : 'none'} display={component.enabledComponent === 'gpsmap' ? undefined : 'none'}
width="100%" width={{ base: '100%', md: '100%' }}
height="100%" height={{ base: '100%', md: '100%' }}
padding={'unset'} padding={'unset'}
> >
<ReactMap visible={component.enabledComponent === 'gpsmap'} /> <ReactMap visible={component.enabledComponent === 'gpsmap'} />

View File

@@ -3,13 +3,14 @@
// --------------------------- // ---------------------------
import { types, type Instance } from 'mobx-state-tree'; import { types, type Instance } from 'mobx-state-tree';
import { MapStore } from './MapStore';
import { MessagesStore } from './MessagesStore'; import { MessagesStore } from './MessagesStore';
import { ModelStore } from './ModelStore'; import { ModelStore } from './ModelStore';
import { StreamStore } from './StreamStore'; import { StreamStore } from './StreamStore';
import { UIStore } from './UIStore'; import { UIStore } from './UIStore';
export const ClientChatStore = types export const ClientChatStore = types
.compose(MessagesStore, UIStore, ModelStore, StreamStore) .compose(MessagesStore, UIStore, ModelStore, StreamStore, MapStore)
.named('ClientChatStore'); .named('ClientChatStore');
const clientChatStore = ClientChatStore.create(); const clientChatStore = ClientChatStore.create();

View 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>;

View File

@@ -2,6 +2,8 @@ import { flow, getParent, type Instance, types } from 'mobx-state-tree';
import Message, { batchContentUpdate } from '../models/Message'; import Message, { batchContentUpdate } from '../models/Message';
import clientChatStore from './ClientChatStore.ts';
import type { MapControlCommand } from './MapStore';
import type { RootDeps } from './RootDeps.ts'; import type { RootDeps } from './RootDeps.ts';
import UserOptionsStore from './UserOptionsStore'; import UserOptionsStore from './UserOptionsStore';
@@ -92,6 +94,30 @@ export const StreamStore = types
return; 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 // Get the last message
const lastMessage = root.items[root.items.length - 1]; const lastMessage = root.items[root.items.length - 1];
@@ -152,4 +178,4 @@ export const StreamStore = types
return { sendMessage, stopIncomingMessage, cleanup, setEventSource, setStreamId }; return { sendMessage, stopIncomingMessage, cleanup, setEventSource, setStreamId };
}); });
export interface IStreamStore extends Instance<typeof StreamStore> {} export type IStreamStore = Instance<typeof StreamStore>;

View File

@@ -183,7 +183,7 @@ const ChatService = types
modelMeta.set(mdl.id, { ...mdl, ...meta }); modelMeta.set(mdl.id, { ...mdl, ...meta });
} catch (err) { } catch (err) {
// logger.error(`Metadata fetch failed for ${mdl.id}`, err); // logger.error(`Metadata fetch failed for ${mdl.id}`, err);
modelMeta.set(mdl.id, { provider: provider.name, mdl }); modelMeta.set(mdl.id, { provider: provider.name, ...mdl });
} }
} }
} catch (err) { } catch (err) {
@@ -277,8 +277,23 @@ const ChatService = types
}) { }) {
const { streamConfig, streamParams, controller, encoder, streamId } = params; const { streamConfig, streamParams, controller, encoder, streamId } = params;
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Processing model "${streamConfig.model}" for stream ${streamId}`,
);
const modelFamily = await ProviderRepository.getModelFamily(streamConfig.model, self.env); const modelFamily = await ProviderRepository.getModelFamily(streamConfig.model, self.env);
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Detected model family "${modelFamily}" for model "${streamConfig.model}"`,
);
// eslint-disable-next-line prettier/prettier
console.log(
'[DEBUG_LOG] ChatService.runModelHandler: Available model handlers:',
Object.keys(modelHandlers),
);
const useModelHandler = () => { const useModelHandler = () => {
// @ts-expect-error - language server does not have enough information to validate modelFamily as an indexer for modelHandlers // @ts-expect-error - language server does not have enough information to validate modelFamily as an indexer for modelHandlers
return modelHandlers[modelFamily]; return modelHandlers[modelFamily];
@@ -287,9 +302,28 @@ const ChatService = types
const handler = useModelHandler(); const handler = useModelHandler();
if (handler) { if (handler) {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Found handler for model family "${modelFamily}"`,
);
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Calling handler for model "${streamConfig.model}" with maxTokens: ${streamParams.maxTokens}`,
);
try { try {
await handler(streamParams, Common.Utils.handleStreamData(controller, encoder)); await handler(streamParams, Common.Utils.handleStreamData(controller, encoder));
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Successfully completed handler for model "${streamConfig.model}"`,
);
} catch (error: any) { } catch (error: any) {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Handler error for model "${streamConfig.model}":`,
error.message,
);
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
if ( if (
@@ -318,10 +352,80 @@ const ChatService = types
); );
} }
if (message.includes('404')) { if (message.includes('404')) {
throw new ClientError(`Something went wrong, try again.`, 413, {}); // Try to find a fallback model from the same provider
try {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Model "${streamConfig.model}" not found, attempting fallback`,
);
const allModels = await self.env.KV_STORAGE.get('supportedModels');
const models = JSON.parse(allModels);
// Find all models from the same provider
const sameProviderModels = models.filter(
(m: any) => m.provider === modelFamily && m.id !== streamConfig.model,
);
if (sameProviderModels.length > 0) {
// Try the first available model from the same provider
const fallbackModel = sameProviderModels[0];
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Trying fallback model "${fallbackModel.id}" from provider "${modelFamily}"`,
);
// Update streamParams with the fallback model
const fallbackStreamParams = { ...streamParams, model: fallbackModel.id };
// Try the fallback model
await handler(
fallbackStreamParams,
Common.Utils.handleStreamData(controller, encoder),
);
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Successfully completed handler with fallback model "${fallbackModel.id}"`,
);
return; // Success with fallback
} else {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: No fallback models available for provider "${modelFamily}"`,
);
}
} catch (fallbackError: any) {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: Fallback attempt failed:`,
fallbackError.message,
);
}
throw new ClientError(
`Model not found or unavailable. Please try a different model.`,
404,
{
model: streamConfig.model,
},
);
} }
throw error; throw error;
} }
} else {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.runModelHandler: No handler found for model family "${modelFamily}" (model: "${streamConfig.model}")`,
);
throw new ClientError(
`No handler available for model family "${modelFamily}". Model: "${streamConfig.model}"`,
500,
{
model: streamConfig.model,
modelFamily: modelFamily,
availableHandlers: Object.keys(modelHandlers),
},
);
} }
}, },
@@ -333,11 +437,27 @@ const ChatService = types
}) { }) {
const { streamId, streamConfig, savedStreamConfig, durableObject } = params; const { streamId, streamConfig, savedStreamConfig, durableObject } = params;
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.createSseReadableStream: Creating stream ${streamId} for model "${streamConfig.model}"`,
);
// eslint-disable-next-line prettier/prettier
console.log(`[DEBUG_LOG] ChatService.createSseReadableStream: Stream config:`, {
model: streamConfig.model,
systemPrompt: streamConfig.systemPrompt?.substring(0, 100) + '...',
messageCount: streamConfig.messages?.length,
});
return new ReadableStream({ return new ReadableStream({
async start(controller) { async start(controller) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
try { try {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.createSseReadableStream: Starting stream processing for ${streamId}`,
);
const dynamicContext = Schema.Message.create(streamConfig.preprocessedContext); const dynamicContext = Schema.Message.create(streamConfig.preprocessedContext);
// Process the stream data using the appropriate handler // Process the stream data using the appropriate handler
@@ -347,6 +467,16 @@ const ChatService = types
durableObject, durableObject,
); );
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.createSseReadableStream: Created stream params for ${streamId}:`,
{
model: streamParams.model,
maxTokens: streamParams.maxTokens,
messageCount: streamParams.messages?.length,
},
);
await self.runModelHandler({ await self.runModelHandler({
streamConfig, streamConfig,
streamParams, streamParams,
@@ -355,6 +485,11 @@ const ChatService = types
streamId, streamId,
}); });
} catch (error) { } catch (error) {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.createSseReadableStream: Error in stream ${streamId}:`,
error,
);
console.error(`chatService::handleSseStream::${streamId}::Error`, error); console.error(`chatService::handleSseStream::${streamId}::Error`, error);
if (error instanceof ClientError) { if (error instanceof ClientError) {
@@ -376,6 +511,10 @@ const ChatService = types
controller.close(); controller.close();
} finally { } finally {
try { try {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.createSseReadableStream: Closing stream ${streamId}`,
);
controller.close(); controller.close();
} catch (_) { } catch (_) {
// Ignore errors when closing the controller, as it might already be closed // Ignore errors when closing the controller, as it might already be closed
@@ -388,21 +527,53 @@ const ChatService = types
handleSseStream: flow(function* ( handleSseStream: flow(function* (
streamId: string, streamId: string,
): Generator<Promise<string>, Response, unknown> { ): Generator<Promise<string>, Response, unknown> {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: Handling SSE stream request for ${streamId}`,
);
// Check if a stream is already active for this ID // Check if a stream is already active for this ID
if (self.activeStreams.has(streamId)) { if (self.activeStreams.has(streamId)) {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: Stream ${streamId} already active, returning 409`,
);
return new Response('Stream already active', { status: 409 }); return new Response('Stream already active', { status: 409 });
} }
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: Retrieving stream configuration for ${streamId}`,
);
// Retrieve the stream configuration from the durable object // Retrieve the stream configuration from the durable object
const objectId = self.env.SERVER_COORDINATOR.idFromName('stream-index'); const objectId = self.env.SERVER_COORDINATOR.idFromName('stream-index');
const durableObject = self.env.SERVER_COORDINATOR.get(objectId); const durableObject = self.env.SERVER_COORDINATOR.get(objectId);
const savedStreamConfig: any = yield durableObject.getStreamData(streamId); const savedStreamConfig: any = yield durableObject.getStreamData(streamId);
if (!savedStreamConfig) { if (!savedStreamConfig) {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: No stream configuration found for ${streamId}, returning 404`,
);
return new Response('Stream not found', { status: 404 }); return new Response('Stream not found', { status: 404 });
} }
const streamConfig = JSON.parse(savedStreamConfig); const streamConfig = JSON.parse(savedStreamConfig);
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: Retrieved stream config for ${streamId}:`,
{
model: streamConfig.model,
messageCount: streamConfig.messages?.length,
systemPrompt: streamConfig.systemPrompt?.substring(0, 100) + '...',
},
);
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: Creating SSE readable stream for ${streamId}`,
);
const stream = self.createSseReadableStream({ const stream = self.createSseReadableStream({
streamId, streamId,
@@ -414,18 +585,37 @@ const ChatService = types
// Use `tee()` to create two streams: one for processing and one for the response // Use `tee()` to create two streams: one for processing and one for the response
const [processingStream, responseStream] = stream.tee(); const [processingStream, responseStream] = stream.tee();
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: Setting active stream for ${streamId}`,
);
self.setActiveStream(streamId, { self.setActiveStream(streamId, {
...streamConfig, ...streamConfig,
}); });
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: Setting up processing stream pipeline for ${streamId}`,
);
processingStream.pipeTo( processingStream.pipeTo(
new WritableStream({ new WritableStream({
close() { close() {
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: Processing stream closed for ${streamId}, removing active stream`,
);
self.removeActiveStream(streamId); self.removeActiveStream(streamId);
}, },
}), }),
); );
// eslint-disable-next-line prettier/prettier
console.log(
`[DEBUG_LOG] ChatService.handleSseStream: Returning response stream for ${streamId}`,
);
// Return the second stream as the response // Return the second stream as the response
return new Response(responseStream, { return new Response(responseStream, {
headers: { headers: {