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:
parent
d18b62431e
commit
9be89bf287
1 changed files with 68 additions and 88 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue