feat: switch Sasquatch to free OpenRouter model - works for everyone

Replaced Shakespeare AI (paid, NIP-98) with OpenRouter free model
(meta-llama/llama-3.1-8b-instruct:free). No login required, no credits
needed - anyone can use Sasquatch AI for free.

- Removed useShakespeare hook dependency from SasquatchGuide
- Added direct callAI() function using OpenRouter API
- AI + CVM live data still works the same way
- Removed login nudge, updated placeholder and AI badge

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-16 22:05:27 +07:00
parent d18b62431e
commit 9be89bf287

View file

@ -1,12 +1,10 @@
/**
* SasquatchGuide — AI-powered skate assistant
* ─────────────────────────────────────────────────────────────────────────────
* Upgraded from static FAQ to a live AI agent that:
* 1. Uses Shakespeare AI (LLM) for natural language understanding
* 2. Calls the SkateSpots CVM server on Nostr for live data
* (spots, events, coaches, gear) when the question needs real data
* 3. Falls back to static FAQ answers for protocol/app questions
* (What is Nostr? What are zaps? etc.)
* Uses OpenRouter free model (no credits needed, works for any user) for
* natural language understanding, then calls the SkateSpots CVM server on
* Nostr for live data (spots, events, coaches, gear).
* Falls back to static FAQ if AI is unavailable.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
@ -16,10 +14,37 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useShakespeare } from '@/hooks/useShakespeare';
import { useCVMClient, CVM_SERVER_PUBKEY } from '@/hooks/useCVMClient';
import { useCVMClient } from '@/hooks/useCVMClient';
import { cn } from '@/lib/utils';
// ─── OpenRouter free model — no credits, no NIP-98, works for everyone ────────
const OPENROUTER_API_KEY = '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';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
const FREE_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
async function callAI(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<string> {
const response = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://skate-spots.org',
'X-Title': 'SkateSpots',
},
body: JSON.stringify({
model: FREE_MODEL,
messages,
max_tokens: 400,
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`AI error ${response.status}: ${err}`);
}
const data = await response.json();
return data.choices[0]?.message?.content ?? '';
}
export type SasquatchContext = 'home' | 'onboarding' | 'map' | 'lab';
export interface GuideMessage {
@ -120,7 +145,6 @@ function CVMStatus({ connected }: { connected: boolean | null }) {
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const { user } = useCurrentUser();
const shakespeare = useShakespeare();
const cvm = useCVMClient();
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
@ -177,49 +201,33 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
return;
}
// 2. Build conversation history for the AI
// 2. Build conversation history for AI
const history = chatMessages.map((m) => ({
role: m.role === 'user' ? ('user' as const) : ('assistant' as const),
content: m.text,
}));
history.push({ role: 'user', content: question });
// 3. If user is logged in, try Shakespeare AI (with graceful fallback)
if (user && shakespeare.isAuthenticated) {
let accumulated = '';
let aiSucceeded = false;
try {
// 3. Try AI — free model via OpenRouter, works for everyone no credits needed
let accumulated = '';
let aiSucceeded = false;
try {
// Embed system prompt into first user message (no system role needed)
const historyWithContext: Array<{ role: 'user' | 'assistant'; content: string }> = [
{ role: 'user', content: `${SYSTEM_PROMPT}\n\n---\n\nUser question: ${question}` },
...history.slice(0, -1).map((m) => ({ role: m.role, content: m.content })),
];
// First pass: get AI response which may include a TOOL: call
// Prefix the system prompt into the first user message for compatibility
const historyWithContext = history.length > 0
? [
{
role: 'user' as const,
content: `${SYSTEM_PROMPT}\n\n---\n\nUser question: ${history[0].content}`,
},
...history.slice(1),
]
: [{ role: 'user' as const, content: SYSTEM_PROMPT }];
const firstResponse = await shakespeare.sendChatMessage(
historyWithContext,
'shakespeare/claude-sonnet-4.6'
);
const aiText = firstResponse.choices[0]?.message?.content as string ?? '';
const aiText = await callAI(historyWithContext);
// 4. Check if AI wants to call a CVM tool
const toolMatch = aiText.match(/TOOL:(\w+)\s+(\{.*?\})/s);
if (toolMatch) {
const toolName = toolMatch[1];
let toolArgs: Record<string, unknown> = {};
try {
toolArgs = JSON.parse(toolMatch[2]);
} catch { /* keep empty */ }
try { toolArgs = JSON.parse(toolMatch[2]); } catch { /* keep empty */ }
// Show "searching..." indicator
setStreamingText(`🔍 Searching Nostr for ${toolName.replace('_', ' ')}...`);
setStreamingText(`🔍 Searching Nostr for ${toolName.replace(/_/g, ' ')}...`);
try {
// Call the live CVM server
@ -245,59 +253,39 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
}
setCvmConnected(true);
setStreamingText('');
// 5. Send tool result back to AI for a natural language response
setStreamingText('');
const toolResultStr = JSON.stringify(toolResult, null, 2);
const secondResponse = await shakespeare.sendChatMessage([
{
role: 'user' as const,
content: `${SYSTEM_PROMPT}\n\n---\n\nUser asked: ${question}\n\nTool result from ${toolName}:\n${toolResultStr}\n\nNow answer the user's question naturally based on this live data. Be concise and skater-friendly.`,
},
], 'shakespeare/claude-sonnet-4.6');
accumulated = secondResponse.choices[0]?.message?.content as string ?? '';
accumulated = await callAI([
{ role: 'user', content: `${SYSTEM_PROMPT}\n\n---\n\nUser asked: ${question}\n\nLive data from ${toolName}:\n${toolResultStr}\n\nAnswer naturally and concisely based on this data.` },
]);
} catch (toolErr) {
// CVM server unreachable — answer from AI knowledge alone
setCvmConnected(false);
console.warn('[Sasquatch] CVM tool call failed:', toolErr);
accumulated = aiText.replace(/TOOL:\w+\s+\{.*?\}/s, '').trim() +
"\n\n*(Couldn't reach live data right now — try again in a moment!)*";
}
} else {
// No tool call needed — AI answered from knowledge
accumulated = aiText;
}
setStreamingText('');
appendMessage({
role: 'sasquatch',
text: accumulated || "Hmm, couldn't process that. Try rephrasing!",
timestamp: Date.now(),
isLive: !!toolMatch && cvmConnected === true,
});
aiSucceeded = true;
} catch (aiErr) {
// AI failed — fall back to static FAQ silently
console.warn('[Sasquatch] AI unavailable, using static FAQ:', aiErr);
}
if (!aiSucceeded) {
const fallback = staticAnswer ??
"Couldn't reach the AI right now. Check the map for spots, The Lab for coaches, or ask again in a moment! 🛹";
appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() });
}
} else {
// 6. Not logged in — use static FAQ or generic fallback
const fallback =
staticAnswer ??
"I can answer much more with a Nostr login! For now: browse the map to discover spots, check The Lab for coaches, and hit Settings to connect your Lightning wallet. 🛹";
setStreamingText('');
appendMessage({
role: 'sasquatch',
text: fallback,
text: accumulated || "Hmm, couldn't process that. Try rephrasing!",
timestamp: Date.now(),
isLive: !!toolMatch && cvmConnected === true,
});
aiSucceeded = true;
} catch (aiErr) {
console.warn('[Sasquatch] AI unavailable, using static FAQ:', aiErr);
}
if (!aiSucceeded) {
const fallback = staticAnswer ??
"Couldn't reach the AI right now. Check the map for spots, The Lab for coaches, or ask again in a moment! 🛹";
appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() });
}
} catch (err) {
console.error('[Sasquatch] Error:', err);
@ -365,9 +353,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
</div>
<div className="text-xs text-amber-700 flex items-center gap-1">
The Sasquatch
{user && (
<span className="text-[9px] bg-green-100 text-green-700 px-1 rounded">AI</span>
)}
<span className="text-[9px] bg-green-100 text-green-700 px-1 rounded">AI</span>
</div>
</div>
<div className="flex gap-1">
@ -495,11 +481,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !inputDisabled && handleSendMessage()}
placeholder={
!user
? 'Login for AI answers...'
: 'Ask anything about skating...'
}
placeholder="Ask anything about skating..."
disabled={inputDisabled}
className="text-sm border-amber-300 focus-visible:ring-amber-400"
/>
@ -517,12 +499,10 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
</Button>
</div>
{/* Not logged in nudge */}
{!user && (
<p className="text-[10px] text-amber-600 text-center">
Login with Nostr for AI-powered answers + live data 🛹
</p>
)}
{/* AI badge */}
<p className="text-[10px] text-amber-600 text-center">
Powered by AI — free for everyone 🛹
</p>
{/* Reset */}
{chatMessages.length > 0 && (