25 Commits

Author SHA1 Message Date
geoffsee
1451491ec5 add search and layer control 2025-07-18 13:07:13 -04:00
geoffsee
da32a5d780 Update map view defaults, add terrain support, and configure pitch settings 2025-07-17 18:30:18 -04:00
geoffsee
5b896d9d07 - 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.
2025-07-17 16:12:27 -04:00
geoffsee
bb5afa099a Replace yacht control tooling with map control functionality, updating relevant references and logic. 2025-07-17 14:44:04 -04:00
geoffsee
ce9bc4db07 "Swap default states for mapActive and aiActive in LandingComponent" 2025-07-17 14:11:15 -04:00
geoffsee
bd71bfcad3 - Remove unused BevyScene and related dependencies.
- Refactor `InstallButton` and relocate it to `install/`.
- Update `Toolbar` imports to reflect the new `InstallButton` structure.
- Introduce `handleInstall` functionality for PWA installation prompt handling.
2025-07-17 14:04:47 -04:00
Geoff Seemueller
4edee1e191 Potential fix for code scanning alert no. 5: Shell command built from environment values
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Signed-off-by: Geoff Seemueller <28698553+geoffsee@users.noreply.github.com>
2025-07-17 13:47:50 -04:00
geoffsee
734f48d4a7 remove webhost in assistant prompt 2025-07-17 13:47:50 -04:00
geoffsee
66363cdf39 set ai as the default landing 2025-07-17 13:47:50 -04:00
geoffsee
36f8fcee87 Integrate PWA service worker registration using virtual:pwa-register. 2025-07-17 13:47:50 -04:00
geoffsee
f055cd39fe Update InputMenu to use clientChatStore.reset() instead of setActiveConversation when closing. 2025-07-17 13:47:50 -04:00
geoffsee
0183503425 Refactored layout components and styling: removed unused imports, adjusted positioning and padding for consistency. 2025-07-17 13:47:50 -04:00
geoffsee
a7ad06093a simplify landing page for my peace 2025-07-17 13:47:50 -04:00
geoffsee
c26d2467f4 sweet lander 2025-07-17 13:47:50 -04:00
geoffsee
818e0e672a chat + maps + ai + tools 2025-07-17 13:47:50 -04:00
geoffsee
48655474e3 mirror error handling behavior in cloudflare worker 2025-07-17 13:47:50 -04:00
geoffsee
ffabfd4ce5 add top level error handler to the router 2025-07-17 13:47:50 -04:00
geoffsee
fa5b7466bc Optimize WASM handling and integrate service worker caching.
Removed unused pointer events in BevyScene, updated Vite config with Workbox for service worker caching, and adjusted file paths in generate-bevy-bundle.js. Added WASM size optimization to ensure smaller and efficient builds, skipping optimization for files below 30MB.
2025-07-17 13:47:50 -04:00
geoffsee
6cc5e038a7 Add visible prop to toggle components and simplify conditional rendering 2025-07-17 13:47:50 -04:00
geoffsee
e72198628c Add "Install App" button to the toolbar using react-use-pwa-install library 2025-07-17 13:47:50 -04:00
geoffsee
c0428094c8 **Integrate PWA asset generator and update favicon and manifest configuration** 2025-07-17 13:47:50 -04:00
geoffsee
3901337163 - Refactor BevyScene to replace script injection with dynamic import.
- Update `NavItem` to provide fallback route for invalid `path`.
- Temporarily stub metric API endpoints with placeholders.
2025-07-17 13:47:50 -04:00
geoffsee
0ff8b5c03e * Introduced BevyScene React component in landing-component for rendering a 3D cockpit visualization.
* Included WebAssembly asset `yachtpit.js` for cockpit functionality.
* Added Bevy MIT license file.
* Implemented a service worker to cache assets locally instead of fetching them remotely.
* Added collapsible functionality to **Tweakbox** and included the `@chakra-ui/icons` dependency.
* Applied the `hidden` prop to the Tweakbox Heading for better accessibility.
* Refactored **Particles** component for improved performance, clarity, and maintainability.

  * Introduced helper functions for particle creation and count management.
  * Added responsive resizing with particle repositioning.
  * Optimized animation updates, including velocity adjustments for speed changes.
  * Ensured canvas size and particle state are cleanly managed on component unmount.
2025-07-17 13:47:50 -04:00
geoffsee
858282929c Refactor chat-stream-provider to simplify tool structure. Optimize WeatherTool implementation with enriched function schema. 2025-07-17 13:47:50 -04:00
geoffsee
06b6a68b9b Enable tool-based message generation in chat-stream-provider and add BasicValueTool and WeatherTool.
Updated dependencies to latest versions in `bun.lock`. Modified development script in `package.json` to include watch mode.
2025-07-17 13:47:50 -04:00
31 changed files with 922 additions and 273 deletions

View File

@@ -46,7 +46,6 @@ describe('AssistantSdk', () => {
expect(prompt).toContain('# Assistant Knowledge'); expect(prompt).toContain('# Assistant Knowledge');
expect(prompt).toContain('### Date: '); expect(prompt).toContain('### Date: ');
expect(prompt).toContain('### Web Host: ');
expect(prompt).toContain('### User Location: '); expect(prompt).toContain('### User Location: ');
expect(prompt).toContain('### Timezone: '); expect(prompt).toContain('### Timezone: ');
}); });

View File

@@ -23,7 +23,7 @@ export class AssistantSdk {
return `# Assistant Knowledge return `# Assistant Knowledge
## Assistant Name ## Assistant Name
### yachtpit-ai ### open-gsio
## Current Context ## Current Context
### Date: ${currentDate} ${currentTime} ### Date: ${currentDate} ${currentTime}
${maxTokens ? `### Max Response Length: ${maxTokens} tokens (maximum)` : ''} ${maxTokens ? `### Max Response Length: ${maxTokens} tokens (maximum)` : ''}

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

@@ -1,8 +1,8 @@
import { OpenAI } from 'openai'; import { OpenAI } from 'openai';
import ChatSdk from '../chat-sdk/chat-sdk.ts'; import ChatSdk from '../chat-sdk/chat-sdk.ts';
import { mapControlAi, MapsTools } from '../tools/maps.ts';
import { getWeather, WeatherTool } from '../tools/weather.ts'; import { getWeather, WeatherTool } from '../tools/weather.ts';
import { yachtpitAi, YachtpitTools } from '../tools/yachtpit.ts';
import type { GenericEnv } from '../types'; import type { GenericEnv } from '../types';
export interface CommonProviderParams { export interface CommonProviderParams {
@@ -38,14 +38,14 @@ export abstract class BaseChatProvider implements ChatStreamProvider {
const client = this.getOpenAIClient(param); const client = this.getOpenAIClient(param);
const tools = [WeatherTool, YachtpitTools]; const tools = [WeatherTool, MapsTools];
const callFunction = async (name, args) => { const callFunction = async (name, args) => {
if (name === 'get_weather') { if (name === 'get_weather') {
return getWeather(args.latitude, args.longitude); return getWeather(args.latitude, args.longitude);
} }
if (name === 'ship_control') { if (name === 'maps_control') {
return yachtpitAi({ action: args.action, value: args.value }); return mapControlAi({ action: args.action, value: args.value });
} }
}; };
@@ -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

@@ -0,0 +1,111 @@
export interface MapsControlResult {
message: string;
status: 'success' | 'error';
data?: any;
}
/**
* A mock interface for controlling a map.
*/
export const MapsTools = {
type: 'function',
/**
* Mock implementation of a maps control command.
*/
function: {
name: 'maps_control',
description:
'Interface for controlling a web-rendered map to explore publicly available geospatial data',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['add_point', 'zoom_to', 'search_datasets', 'add_dataset', 'remove_dataset'],
description: 'Action to perform on the geospatial map.',
},
value: {
type: 'string',
description: 'Numeric value for the action, indicating a code for reference',
},
},
required: ['action'],
additionalProperties: false,
},
},
};
export function mapControlAi(args: { action: string; value?: string }): Promise<MapsControlResult> {
switch (args.action) {
case 'add_point': {
if (!args.value) {
return Promise.resolve({
status: 'error',
message: 'Missing point coordinates or reference code.',
});
}
return Promise.resolve({
status: 'success',
message: `Point added to map with reference: ${args.value}`,
data: { pointId: args.value, action: 'add_point' },
});
}
case 'zoom_to': {
if (!args.value) {
return Promise.resolve({ status: 'error', message: 'Missing zoom target reference.' });
}
return Promise.resolve({
status: 'success',
message: `Map zoomed to: ${args.value}`,
data: { target: args.value, action: 'zoom_to' },
});
}
case 'search_datasets': {
const searchTerm = args.value || 'all';
return Promise.resolve({
status: 'success',
message: `Searching datasets for: ${searchTerm}`,
data: {
searchTerm,
action: 'search_datasets',
results: [
{ id: 'osm', name: 'OpenStreetMap', type: 'base_layer' },
{ id: 'satellite', name: 'Satellite Imagery', type: 'base_layer' },
{ id: 'maritime', name: 'Maritime Data', type: 'overlay' },
],
},
});
}
case 'add_dataset': {
if (!args.value) {
return Promise.resolve({ status: 'error', message: 'Missing dataset reference.' });
}
return Promise.resolve({
status: 'success',
message: `Dataset added to map: ${args.value}`,
data: { datasetId: args.value, action: 'add_dataset' },
});
}
case 'remove_dataset': {
if (!args.value) {
return Promise.resolve({ status: 'error', message: 'Missing dataset reference.' });
}
return Promise.resolve({
status: 'success',
message: `Dataset removed from map: ${args.value}`,
data: { datasetId: args.value, action: 'remove_dataset' },
});
}
default:
return Promise.resolve({
status: 'error',
message: `Invalid action: ${args.action}. Valid actions are: add_point, zoom_to, search_datasets, add_dataset, remove_dataset`,
});
}
}

View File

@@ -1,68 +0,0 @@
export interface ShipControlResult {
message: string;
status: 'success' | 'error';
data?: any;
}
/**
* A mock interface for controlling a ship.
*/
export const YachtpitTools = {
type: 'function',
description: 'Interface for controlling a ship: set speed, change heading, report status, etc.',
/**
* Mock implementation of a ship control command.
*/
function: {
name: 'ship_control',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['set_speed', 'change_heading', 'report_status', 'stop'],
description: 'Action to perform on the ship.',
},
value: {
type: 'number',
description:
'Numeric value for the action, such as speed (knots) or heading (degrees). Only required for set_speed and change_heading.',
},
},
required: ['action'],
additionalProperties: false,
},
},
};
export function yachtpitAi(args: { action: string; value?: number }): Promise<ShipControlResult> {
switch (args.action) {
case 'set_speed':
if (typeof args.value !== 'number') {
return { status: 'error', message: 'Missing speed value.' };
}
return { status: 'success', message: `Speed set to ${args.value} knots.` };
case 'change_heading':
if (typeof args.value !== 'number') {
return { status: 'error', message: 'Missing heading value.' };
}
return { status: 'success', message: `Heading changed to ${args.value} degrees.` };
case 'report_status':
// Return a simulated ship status
return {
status: 'success',
message: 'Ship status reported.',
data: {
speed: 12,
heading: 87,
engine: 'nominal',
position: { lat: 42.35, lon: -70.88 },
},
};
case 'stop':
return { status: 'success', message: 'Ship stopped.' };
default:
return { status: 'error', message: 'Invalid action.' };
}
}

View File

@@ -1,4 +1,4 @@
import { execSync } from 'node:child_process'; import { execSync, execFileSync } from 'node:child_process';
import { import {
existsSync, existsSync,
readdirSync, readdirSync,
@@ -175,7 +175,7 @@ function optimizeWasmSize() {
if (sizeInMb > 30) { if (sizeInMb > 30) {
logger.info(`WASM size is ${sizeInMb.toFixed(2)}MB, optimizing...`); logger.info(`WASM size is ${sizeInMb.toFixed(2)}MB, optimizing...`);
execSync(`wasm-opt -Oz -o ${wasmPath} ${wasmPath}`, { execFileSync('wasm-opt', ['-Oz', '-o', wasmPath, wasmPath], {
encoding: 'utf-8', encoding: 'utf-8',
}); });
logger.info(`✅ WASM size optimized`); logger.info(`✅ WASM size optimized`);

View File

@@ -1,35 +0,0 @@
import { IconButton } from '@chakra-ui/react';
import { HardDriveDownload } from 'lucide-react';
import React from 'react';
import { toolbarButtonZIndex } from './toolbar/Toolbar.tsx';
function InstallButton() {
// const install = usePWAInstall();
const install = () => {
console.warn('this does not work in all browsers');
};
return (
<IconButton
aria-label="Install App"
title="Install App"
icon={<HardDriveDownload />}
size="md"
bg="transparent"
stroke="text.accent"
color="text.accent"
onClick={() => install}
_hover={{
bg: 'transparent',
svg: {
stroke: 'accent.secondary',
transition: 'stroke 0.3s ease-in-out',
},
}}
zIndex={toolbarButtonZIndex}
/>
);
}
export default InstallButton;

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>
)} )}
@@ -171,7 +191,7 @@ const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled })
bg="background.tertiary" bg="background.tertiary"
color="text.primary" color="text.primary"
onClick={() => { onClick={() => {
clientChatStore.setActiveConversation('conversation:new'); clientChatStore.reset();
onClose(); onClose();
}} }}
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }} _hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}

View File

@@ -49,7 +49,7 @@ const InputTextArea: React.FC<InputTextAreaProps> = observer(
color="text.primary" color="text.primary"
borderRadius="20px" borderRadius="20px"
border="none" border="none"
placeholder="To Gilligan's island!" placeholder="Free my mind..."
_placeholder={{ _placeholder={{
color: 'gray.400', color: 'gray.400',
textWrap: 'nowrap', textWrap: 'nowrap',

View File

@@ -9,7 +9,7 @@ export function formatConversationMarkdown(messages: Instance<typeof IMessage>[]
if (message.role === 'user') { if (message.role === 'user') {
return `**You**: ${message.content}`; return `**You**: ${message.content}`;
} else if (message.role === 'assistant') { } else if (message.role === 'assistant') {
return `**yachtpit-ai**: ${message.content}`; return `**open-gsio**: ${message.content}`;
} }
return ''; return '';
}) })

View File

@@ -51,7 +51,7 @@ const MessageBubble = observer(({ msg, scrollRef }) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isUser = msg.role === 'user'; const isUser = msg.role === 'user';
const senderName = isUser ? 'You' : 'yachtpit-ai'; const senderName = isUser ? 'You' : 'open-gsio';
const isLoading = !msg.content || !(msg.content.trim().length > 0); const isLoading = !msg.content || !(msg.content.trim().length > 0);
const messageRef = useRef(); const messageRef = useRef();

View File

@@ -104,7 +104,7 @@ describe('MessageBubble', () => {
it('should render assistant message correctly', () => { it('should render assistant message correctly', () => {
render(<MessageBubble msg={mockAssistantMessage} scrollRef={mockScrollRef} />); render(<MessageBubble msg={mockAssistantMessage} scrollRef={mockScrollRef} />);
expect(screen.getByText('yachtpit-ai')).toBeInTheDocument(); expect(screen.getByText('open-gsio')).toBeInTheDocument();
expect(screen.getByTestId('message-content')).toHaveTextContent('Assistant response'); expect(screen.getByTestId('message-content')).toHaveTextContent('Assistant response');
}); });

View File

@@ -0,0 +1,7 @@
import React, { useEffect, useState } from 'react';
function InstallButton() {
return <button onClick={handleInstall}>Install App</button>;
}
export default InstallButton;

View File

@@ -0,0 +1,61 @@
import { IconButton } from '@chakra-ui/react';
import { HardDriveDownload } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { toolbarButtonZIndex } from '../toolbar/Toolbar.tsx';
function InstallButton() {
const [deferredPrompt, setDeferredPrompt] = useState(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
const handleBeforeInstallPrompt = e => {
// Prevent the default prompt
e.preventDefault();
setDeferredPrompt(e);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
};
}, []);
const handleInstall = () => {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(choiceResult => {
if (choiceResult.outcome === 'accepted') {
console.log('User accepted the installation prompt');
} else {
console.log('User dismissed the installation prompt');
}
});
setDeferredPrompt(null);
}
};
return (
<IconButton
aria-label="Install App"
title="Install App"
icon={<HardDriveDownload />}
size="md"
bg="transparent"
stroke="text.accent"
color="text.accent"
onClick={handleInstall}
_hover={{
bg: 'transparent',
svg: {
stroke: 'accent.secondary',
transition: 'stroke 0.3s ease-in-out',
},
}}
zIndex={toolbarButtonZIndex}
/>
);
}
export default InstallButton;

View File

@@ -1,58 +0,0 @@
import { Box, useBreakpointValue } from '@chakra-ui/react';
import React, { memo, useEffect, useMemo } from 'react';
export interface BevySceneProps {
speed?: number;
intensity?: number; // 0-1 when visible
glow?: boolean;
visible?: boolean; // NEW — defaults to true
}
const BevySceneInner: React.FC<BevySceneProps> = ({
speed = 1,
intensity = 1,
glow = false,
visible,
}) => {
const maxWidth = useBreakpointValue({ base: 640, md: 720 }, { ssr: true });
/* initialise once */
useEffect(() => {
let dispose: (() => void) | void;
(async () => {
const { default: init } = await import(/* webpackIgnore: true */ '/public/yachtpit.js');
dispose = await init(); // zero-arg, uses #yachtpit-canvas
})();
return () => {
if (typeof dispose === 'function') dispose();
};
}, []);
/* memoised styles */
const wrapperStyles = useMemo(
() => ({
position: 'absolute' as const,
inset: 0,
zIndex: 1,
maxWidth: maxWidth,
opacity: visible ? Math.min(Math.max(intensity, 0), 1) : 0,
filter: glow ? 'blur(1px)' : 'none',
transition: `opacity ${speed}s ease-in-out`,
display: visible ? 'block' : 'none', // optional: reclaim hit-testing entirely
}),
[visible, intensity, glow, speed],
);
return (
<Box as="div" sx={wrapperStyles}>
<canvas
id="yachtpit-canvas"
width={useBreakpointValue({ base: 640, md: 1280 }, { ssr: true })}
height={useBreakpointValue({ base: 360, md: 720 }, { ssr: true })}
aria-hidden
/>
</Box>
);
};
export const BevyScene = memo(BevySceneInner);

View File

@@ -1,19 +1,33 @@
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';
import { BevyScene } from './BevyScene.tsx'; // import { BevyScene } from './BevyScene.tsx';
import Tweakbox from './Tweakbox.tsx'; import Tweakbox from './Tweakbox.tsx';
export const LandingComponent: React.FC = () => { export const LandingComponent: React.FC = () => {
const [speed, setSpeed] = useState(0.2);
const [intensity, setIntensity] = useState(0.99); const [intensity, setIntensity] = useState(0.99);
const [glow, setGlow] = useState(false);
const [bevyScene, setBevyScene] = useState(true);
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;
@@ -24,26 +38,14 @@ export const LandingComponent: React.FC = () => {
if (aiActive) { if (aiActive) {
setEnabledComponent('ai'); setEnabledComponent('ai');
} }
}, []); }, [mapActive, aiActive, setEnabledComponent]);
return ( return (
<Box <Box as="section" bg="background.primary" overflow="hidden">
as="section" <Box position="fixed" right={0} maxWidth="300px" minWidth="200px" zIndex={1000}>
bg="background.primary"
w="100%"
h="100vh"
overflow="hidden"
position="relative"
>
<Box
position="fixed"
bottom="100x"
right="12px"
maxWidth="300px"
minWidth="200px"
zIndex={1000}
>
<Tweakbox <Tweakbox
id="app-tweaker"
persist={true}
sliders={{ sliders={{
intensity: { intensity: {
value: intensity, value: intensity,
@@ -56,13 +58,6 @@ export const LandingComponent: React.FC = () => {
}, },
}} }}
switches={{ switches={{
bevyScene: {
value: bevyScene,
onChange(enabled) {
setBevyScene(enabled);
},
label: 'Instruments',
},
GpsMap: { GpsMap: {
value: mapActive, value: mapActive,
onChange(enabled) { onChange(enabled) {
@@ -74,7 +69,7 @@ export const LandingComponent: React.FC = () => {
} }
setMapActive(enabled); setMapActive(enabled);
}, },
label: 'Map', label: 'GPS',
}, },
AI: { AI: {
value: aiActive, value: aiActive,
@@ -92,7 +87,6 @@ export const LandingComponent: React.FC = () => {
}} }}
/> />
</Box> </Box>
<BevyScene speed={speed} intensity={intensity} glow={glow} visible={bevyScene} />
</Box> </Box>
); );
}; };

View File

@@ -1,7 +1,8 @@
import ReactMap from 'react-map-gl/mapbox'; // ↔ v5+ uses this import path
import 'mapbox-gl/dist/mapbox-gl.css'; import 'mapbox-gl/dist/mapbox-gl.css';
import { Box, HStack, Button, Input, Center } from '@chakra-ui/react'; import { Box, Button, HStack, Input } from '@chakra-ui/react';
import { useState, useEffect, useCallback } from 'react'; import { useCallback, useEffect, useState } from 'react';
import clientChatStore from '../../stores/ClientChatStore.ts';
import MapNext from './MapNext.tsx'; import MapNext from './MapNext.tsx';
@@ -30,17 +31,175 @@ interface AuthParams {
token: string | null; token: string | null;
} }
export type Layer = { name: string; value: string };
export type Layers = Layer[];
// public key // public key
const key = const key =
'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn'; 'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn';
const layers = [
{ name: 'Bathymetry', value: 'mapbox://styles/geoffsee/cmd1qz39x01ga01qv5acea02y' },
{ name: 'Satellite', value: 'mapbox://styles/mapbox/satellite-v9' },
];
function LayerSelector(props: { onClick: (e) => Promise<void> }) {
const [isOpen, setIsOpen] = useState(false);
return (
<Box position="relative">
<Button colorScheme="blue" size="sm" variant="solid" onClick={() => setIsOpen(!isOpen)}>
Layer
</Button>
{isOpen && (
<Box
position="absolute"
top="100%"
left={0}
w="200px"
bg="background.secondary"
boxShadow="md"
zIndex={2}
>
{layers.map(layer => (
<Box
id={layer.value}
p={2}
cursor="pointer"
_hover={{ bg: 'whiteAlpha.200' }}
onClick={async e => {
setIsOpen(false);
await props.onClick(e);
}}
>
{layer.name}
</Box>
))}
</Box>
)}
</Box>
);
}
function Map(props: { visible: boolean }) { function Map(props: { visible: boolean }) {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedLayer, setSelectedLayer] = useState(layers[0]);
const [searchInput, setSearchInput] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
// const handleSearchClick = useCallback(async () => {
// console
// }, []);
//
async function selectSearchResult({ lat, lon }) {
// clientChatStore.mapState.latitude = searchResult.lat;
// clientChatStore.mapState.longitude = searchResult.lon;
await clientChatStore.setMapView(lon, lat, 15);
}
async function handleSc(e) {
if (isSearchOpen && searchInput.length > 1) {
try {
console.log(`trying to geocode ${searchInput}`);
const geocode = await fetch('https://geocode.geoffsee.com', {
method: 'POST',
mode: 'cors',
body: JSON.stringify({
location: searchInput,
}),
});
const coordinates = await geocode.json();
const { lat, lon } = coordinates;
console.log(`got geocode coordinates: ${coordinates}`);
setSearchResults([{ lat, lon }]);
} catch (e) {
// continue without
}
} else {
setIsSearchOpen(!isSearchOpen);
}
}
useEffect(() => {
console.log(selectedLayer);
}, [selectedLayer]);
function handleLayerChange(e) {
setSelectedLayer(layers.find(layer => layer.value === e.target.id));
}
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 w="100%" h="100vh" position="relative" 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)} /> <HStack position="relative" zIndex={1}>
<Box display="flex" alignItems="center">
<Button size="sm" variant="solid" onClick={handleSc} mr={2}>
Search
</Button>
{isSearchOpen && (
<Box
w="200px"
transition="all 0.3s"
transform={`translateX(${isSearchOpen ? '0' : '100%'})`}
background="background.secondary"
opacity={isSearchOpen ? 1 : 0}
color="white"
>
<Input
placeholder="Search..."
size="sm"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
color="white"
bg="background.secondary"
border="none"
borderRadius="0"
_focus={{
outline: 'none',
}}
_placeholder={{
color: '#d1cfcf',
}}
/>
{searchResults.length > 0 && (
<Box
position="absolute"
top="100%"
left={0}
w="200px"
bg="background.secondary"
boxShadow="md"
zIndex={2}
>
{searchResults.map((result, index) => (
<Box
key={index}
p={2}
cursor="pointer"
_hover={{ bg: 'whiteAlpha.200' }}
onClick={async () => {
// setSearchInput(result);
console.log(`selecting result ${result.lat}, ${result.lon}`);
await selectSearchResult(result);
setSearchResults([]);
setIsSearchOpen(false);
}}
>
{`${result.lat}, ${result.lon}`}
</Box>
))}
</Box>
)}
</Box>
)}
</Box>
<LayerSelector onClick={handleLayerChange} />
</HStack>
<MapNext mapboxPublicKey={atob(key)} visible={props.visible} layer={selectedLayer} />
{/*<Map*/} {/*<Map*/}
{/* mapboxAccessToken={atob(key)}*/} {/* mapboxAccessToken={atob(key)}*/}
{/* initialViewState={mapView}*/} {/* initialViewState={mapView}*/}

View File

@@ -1,5 +1,6 @@
import { Box, Button, HStack, Input } 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,
@@ -7,27 +8,42 @@ import Map, {
NavigationControl, NavigationControl,
Popup, Popup,
ScaleControl, ScaleControl,
Source,
} from 'react-map-gl/mapbox'; } from 'react-map-gl/mapbox';
import clientChatStore from '../../stores/ClientChatStore';
import type { Layer } from './Map.tsx';
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, layer: {} as Layer } 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 +55,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,14 +117,19 @@ 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,
}} }}
mapStyle="mapbox://styles/geoffsee/cmd1qz39x01ga01qv5acea02y" viewState={clientChatStore.mapState}
onMove={handleMapViewChange}
terrain={{ source: 'mapbox-dem', exaggeration: 1.5 }}
maxPitch={85}
mapStyle={props.layer.value}
attributionControl={false} attributionControl={false}
mapboxAccessToken={props.mapboxPublicKey} mapboxAccessToken={props.mapboxPublicKey}
style={{ style={{
@@ -118,11 +142,17 @@ Type '{ city: string; population: string; image: string; state: string; latitude
right: 0, right: 0,
}} }}
> >
<GeolocateControl position="top-left" /> <Source
id="mapbox-dem"
type="raster-dem"
url="mapbox://mapbox.mapbox-terrain-dem-v1"
tileSize={512}
maxzoom={14}
/>
<GeolocateControl position="top-left" style={{ marginTop: '6rem' }} />
<FullscreenControl position="top-left" /> <FullscreenControl position="top-left" />
<NavigationControl position="top-left" /> <NavigationControl position="top-left" />
<ScaleControl position="top-left" /> <ScaleControl position="top-left" />
{pins} {pins}
{popupInfo && ( {popupInfo && (
@@ -171,3 +201,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

@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import BuiltWithButton from '../BuiltWithButton'; import BuiltWithButton from '../BuiltWithButton';
import InstallButton from '../InstallButton.tsx'; import InstallButton from '../install/InstallButton.tsx';
import GithubButton from './GithubButton'; import GithubButton from './GithubButton';
import SupportThisSiteButton from './SupportThisSiteButton'; import SupportThisSiteButton from './SupportThisSiteButton';

View File

@@ -6,7 +6,7 @@ import { useIsMobile } from '../components/contexts/MobileContext';
function Content({ children }) { function Content({ children }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
return ( return (
<Flex flexDirection="column" w="100%" h="100vh" p={!isMobile ? 4 : 1}> <Flex flexDirection="column" w="100%" h="100vh">
{children} {children}
</Flex> </Flex>
); );

View File

@@ -1,4 +1,6 @@
// runs before anything else // runs before anything else
import { registerSW } from 'virtual:pwa-register';
import UserOptionsStore from '../stores/UserOptionsStore'; import UserOptionsStore from '../stores/UserOptionsStore';
UserOptionsStore.initialize(); UserOptionsStore.initialize();
@@ -6,7 +8,11 @@ UserOptionsStore.initialize();
try { try {
const isLocal = window.location.hostname.includes('localhost'); const isLocal = window.location.hostname.includes('localhost');
if (!isLocal) { if (!isLocal) {
navigator.serviceWorker.register('/service-worker.js'); if ('serviceWorker' in navigator) {
// && !/localhost/.test(window.location)) {
registerSW();
}
// navigator.serviceWorker.register('/service-worker.js');
} else { } else {
(async () => { (async () => {
await navigator.serviceWorker.getRegistrations().then(registrations => { await navigator.serviceWorker.getRegistrations().then(registrations => {

View File

@@ -1,4 +1,4 @@
import { Box, Grid, GridItem, Stack } 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,28 +21,28 @@ export default function IndexPage() {
const component = useComponent(); const component = useComponent();
const mediaQuery = useMediaQuery();
return ( return (
<Grid templateColumns="repeat(2, 1fr)" height="100%" width="100%" gap={0}> <Box height="100%" width="100%">
<GridItem> <LandingComponent />
<LandingComponent /> <Box
</GridItem> display={component.enabledComponent === 'ai' ? undefined : 'none'}
<GridItem p={2}> width="100%"
<Box height="100%"
display={component.enabledComponent === 'ai' ? undefined : 'none'} overflowY="scroll"
width="100%" padding={'unset'}
height="100%" >
overflowY="scroll" <Chat />
> </Box>
<Chat /> <Box
</Box> display={component.enabledComponent === 'gpsmap' ? undefined : 'none'}
<Box width={{ base: '100%', md: '100%' }}
display={component.enabledComponent === 'gpsmap' ? undefined : 'none'} height={{ base: '100%', md: '100%' }}
width="100%" padding={'unset'}
height="100%" >
> <ReactMap visible={component.enabledComponent === 'gpsmap'} />
<ReactMap visible={component.enabledComponent === 'gpsmap'} /> </Box>
</Box> </Box>
</GridItem>
</Grid>
); );
} }

View File

@@ -1,5 +1,5 @@
export const welcome_home_text = ` export const welcome_home_text = `
# yachtpit-ai # open-gsio
--- ---
<br/> <br/>

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,122 @@
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
// 37°47'21"N 122°23'52"W
longitude: types.optional(types.number, -87.6319),
latitude: types.optional(types.number, 41.883415),
zoom: types.optional(types.number, 14.5),
bearing: types.optional(types.number, 15.165878375019094),
pitch: types.optional(types.number, 45),
// Map control state
isControlActive: types.optional(types.boolean, false),
})
.volatile(self => ({
// Store pending map commands from AI
pendingCommands: [] as MapControlCommand[],
// 41.88341413374059-87.630091075785714.57273962016686450
mapState: {
latitude: self.latitude,
longitude: self.longitude,
zoom: self.zoom,
bearing: self.bearing,
pitch: self.pitch,
} as any,
}))
.actions(self => ({
// Update map view state
setMapView(longitude: number, latitude: number, zoom: number) {
console.log(latitude, longitude, zoom, self.mapState.pitch, self.mapState.bearing);
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

@@ -22,10 +22,6 @@ const prebuildPlugin = () => ({
console.log('Generated robots.txt -> public/robots.txt'); console.log('Generated robots.txt -> public/robots.txt');
child_process.execSync('bun run generate:fonts'); child_process.execSync('bun run generate:fonts');
console.log('Copied fonts -> public/static/fonts'); console.log('Copied fonts -> public/static/fonts');
child_process.execSync('bun run generate:bevy:bundle', {
stdio: 'inherit',
});
console.log('Bundled bevy app -> public/yachtpit.html');
} }
}, },
}); });

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: {