diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 01c9806..c5fc95b 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -432,6 +432,47 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string { return `${emoji} Found **${items.length}** ${label}:\n${lines}`; } +/** + * Map an AI tool call to a LocalIntent so we can format its result locally and + * skip the second "prettify" AI round-trip. Returns null for tools that genuinely + * need AI synthesis (e.g. the personal Pro tools), which fall back to the AI pass. + * + * This is the "results-first" win: for a list of spots/events/etc. the user wants + * the data, not extra prose — so we render it instantly instead of waiting on a + * second model call. + */ +function toolCallToIntent( + toolName: string, + toolArgs: Record, +): LocalIntent | null { + const loc = String( + (toolArgs.city ?? toolArgs.location ?? toolArgs.country ?? '') as string, + ).trim(); + switch (toolName) { + case 'find_spots': + return { tool: 'find_spots', location: loc }; + case 'find_events': + return { tool: 'find_events', location: loc }; + case 'find_coaches': + return { tool: 'find_coaches', location: loc }; + case 'find_crews': + return { tool: 'find_crews', location: loc }; + case 'find_shops': + return { tool: 'find_shops', location: loc }; + case 'find_tours': + return { tool: 'find_tours', location: loc }; + case 'find_gear': + return { tool: 'find_gear', search: String((toolArgs.search ?? '') as string) }; + case 'get_community_stats': + return { tool: 'get_community_stats' }; + case 'get_weather': + return { tool: 'get_weather', city: String((toolArgs.city ?? '') as string) }; + default: + // Personal/Pro tools (get_my_*) and anything unmapped → let the AI present it. + return null; + } +} + // ─── Static FAQ fallback (for protocol/app questions without AI) ────────────── const STATIC_FAQ: Record = { @@ -1015,12 +1056,21 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { usedCVM = true; setStreamingText(''); - // Second pass — give AI the structured CVM result to present naturally - const toolResultStr = JSON.stringify(toolResult, null, 2); - accumulated = await callAI([ - { - role: 'user', - content: `You are The Sasquatch, a chill skater AI guide. Present the following live data to the user naturally and concisely. + // Results-first: for the finder tools (spots/events/coaches/gear/ + // crews/shops/tours/stats/weather) the user wants the data, not more + // prose — so format it locally and SKIP the second AI round-trip. + // This shaves a full model call (~2–4s) off the most common queries. + const localIntent = toolCallToIntent(toolName, toolArgs); + if (localIntent) { + accumulated = formatLocalResults(localIntent, toolResult); + } else { + // Personal/Pro tools still need AI synthesis (e.g. summarising a + // Stash, or explaining a pro_required error) — keep the second pass. + const toolResultStr = JSON.stringify(toolResult, null, 2); + accumulated = await callAI([ + { + role: 'user', + content: `You are The Sasquatch, a chill skater AI guide. Present the following live data to the user naturally and concisely. IMPORTANT: Do NOT output any JSON tool calls. Just answer in plain conversational text with bullet points. @@ -1034,8 +1084,9 @@ Instructions: - If count > 0: list results as bullet points with name, brief description, and url link - Keep it short and skater-friendly - Never make up data not in the results above`, - }, - ]); + }, + ]); + } } catch (toolErr) { setCvmConnected(false); console.warn('[Sasquatch] CVM call failed:', toolErr); diff --git a/src/hooks/useCVMClient.ts b/src/hooks/useCVMClient.ts index 94e67ad..98d6e42 100644 --- a/src/hooks/useCVMClient.ts +++ b/src/hooks/useCVMClient.ts @@ -18,6 +18,15 @@ const DATA_RELAYS = [ 'wss://relay.damus.io', ]; +/** + * How long to wait for relays before giving up. Kept intentionally short: the + * useful relays almost always answer within ~2s, so a longer wait just makes a + * single slow/dead relay hold the whole response hostage. 4.5s is a good + * balance between "catch a slightly slow relay" and "don't make the user wait + * on a relay that's never going to answer". + */ +const CVM_QUERY_TIMEOUT = 4500; + // Tool result types export interface SpotResult { @@ -191,7 +200,7 @@ export function useCVMClient() { search?: string; limit?: number; }): Promise<{ count: number; spots: SpotResult[] }> => { - const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = await relayGroup.query( @@ -297,7 +306,7 @@ export function useCVMClient() { limit?: number; }): Promise<{ count: number; events: EventResult[] }> => { const now = Math.floor(Date.now() / 1000); - const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = await relayGroup.query( @@ -387,7 +396,7 @@ export function useCVMClient() { const kinds = params.role && params.role !== 'any' ? [ROLE_KIND[params.role]] : ALL_LAB_KINDS; - const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = await relayGroup.query([{ kinds, limit: 500 }], { signal }); @@ -454,7 +463,7 @@ export function useCVMClient() { search?: string; limit?: number; }): Promise<{ count: number; listings: GearResult[] }> => { - const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = await relayGroup.query( @@ -524,7 +533,7 @@ export function useCVMClient() { search?: string; limit?: number; }): Promise<{ count: number; crews: CrewResult[] }> => { - const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); const raw = await relayGroup.query( @@ -599,7 +608,7 @@ export function useCVMClient() { search?: string; limit?: number; }): Promise<{ count: number; shops: ShopResult[] }> => { - const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); // Kind 30017 = NIP-15 stall; skate shops mark themselves with t=skateshop. @@ -653,7 +662,7 @@ export function useCVMClient() { search?: string; limit?: number; }): Promise<{ count: number; tours: TourResult[] }> => { - const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const signal = AbortSignal.any([AbortSignal.timeout(CVM_QUERY_TIMEOUT)]); const relayGroup = nostr.group(DATA_RELAYS); // Kind 30823 = local skate tours (guides showing hidden spots). @@ -698,7 +707,10 @@ export function useCVMClient() { ); const getCommunityStats = useCallback(async (): Promise => { - const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + // Stats fan out to ~18 parallel queries and aggregate counts, so give them + // a slightly longer window than a single-list fetch — but still far short + // of the old 10s so a laggy relay can't stall the whole answer. + const signal = AbortSignal.any([AbortSignal.timeout(6000)]); const relayGroup = nostr.group(DATA_RELAYS); const now = Math.floor(Date.now() / 1000); diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts index 3ce45f0..eb5a026 100644 --- a/src/lib/sasquatchBrain.ts +++ b/src/lib/sasquatchBrain.ts @@ -38,11 +38,20 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const POLLINATIONS_URL = 'https://text.pollinations.ai/openai'; const POLLINATIONS_MODELS = ['openai', 'mistral']; +// Only try the top few OpenRouter models before failing over to the keyless +// backup. Grinding through all 8 on a busy free tier is the #1 cause of a +// 15–20s "why is this so slow" wait; the first couple of models answer the vast +// majority of the time, and Pollinations catches the rest fast. +const MAX_OPENROUTER_MODELS = 3; + /** Try OpenRouter's free models, respecting 429 backoff. Returns text or ''. */ async function tryOpenRouter(messages: ChatTurn[], maxTokens: number): Promise { // No key configured → skip OpenRouter entirely (keyless backup handles it). if (!OPENROUTER_API_KEY) return ''; - for (const model of FREE_MODELS) { + const models = FREE_MODELS.slice(0, MAX_OPENROUTER_MODELS); + for (const model of models) { + // Single 429 retry per model with a short, capped backoff — a rate-limited + // model rarely frees up quickly, so we'd rather move on than wait it out. for (let attempt = 0; attempt < 2; attempt++) { try { const response = await fetch(OPENROUTER_URL, { @@ -59,8 +68,8 @@ async function tryOpenRouter(messages: ChatTurn[], maxTokens: number): Promise 0 - ? Math.min(retryAfter * 1000, 2500) - : 600; + ? Math.min(retryAfter * 1000, 800) + : 400; console.warn(`[Sasquatch] ${model} rate-limited, retrying in ${waitMs}ms`); await sleep(waitMs); continue;