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:
parent
7eba7dfe22
commit
ada4bfee5e
3 changed files with 91 additions and 19 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue