Accelerate Sasquatch custom questions (live-data queries)

Three low-risk speedups focused on the slowest path — questions that hit live
relay data:

1. Relay timeout 10s → 4.5s (6s for the fan-out community stats). The useful
   relays answer in ~2s; the old 10s just let one slow/dead relay stall the
   whole reply.

2. Results-first: for finder tools (spots/events/coaches/gear/crews/shops/
   tours/stats/weather) format the CVM result locally and SKIP the second
   "prettify" AI call. Removes a full model round-trip (~2–4s) — users want the
   list, not extra prose. New toolCallToIntent() maps a tool call to the existing
   formatLocalResults() path. Personal/Pro tools (get_my_*) still use AI synthesis.

3. Faster AI fallback: cap OpenRouter attempts to the top 3 free models before
   the keyless backup, and shorten 429 backoff (≤800ms vs ≤2500ms). Avoids the
   worst-case 15–20s grind through 8 models on a busy free tier.

Conversational/knowledge answers (single AI call) keep Sasquatch's personality
and are untouched.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-09 23:11:14 +07:00
parent 7eba7dfe22
commit ada4bfee5e
3 changed files with 91 additions and 19 deletions

View file

@ -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<string, unknown>,
): 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<string, string> = {
@ -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);

View file

@ -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<CommunityStats> => {
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);

View file

@ -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<string> {
// 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<s
if (response.status === 429 && attempt === 0) {
const retryAfter = Number(response.headers.get('retry-after'));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 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;