From 2a55d1b3df5da9f21f355382f278aa30076abc12 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 19:26:19 +0700 Subject: [PATCH] =?UTF-8?q?Crew=20Agent=20Phases=200=E2=80=932:=20opt-in?= =?UTF-8?q?=20AI=20guide=20(=F0=9F=90=9D=20Sasquatch)=20per=20crew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 — identity (lib/crewAgent.ts): - Deterministic per-crew agent keypair derived from the crew coordinate (sha256 of a public salt + coord). Documented security tradeoff: the key is reproducible by design and holds no funds — used only for the agent to speak for itself. Includes kind-0 profile builder (bot:true, NIP-24) and a signAsCrewAgent helper for Phase 3. Phase 1 — opt-in toggle: - SkateCrew.agentEnabled parsed from a queryable ["t","crew-agent"] marker. - "Crew Agent" toggle (Beta) in CrewForm; publishes the marker when on. - NIP.md documents the crew-agent tag and the agent identity derivation. Phase 2 — crew-scoped Sasquatch with receipts: - lib/sasquatchBrain.ts: shared AI caller (model fallback) + tool-call parser. - useCrewAgentChat: reuses the CVM data tools, seeded with crew context (name/location) so "near us" resolves to the crew's turf. Ephemeral/local, no keys, no publishing yet. - CrewAgentPanel: chat dialog rendering agent answers PLUS structured receipt cards (spots / events / Lab pros / gear / crews) linking into the app. - CrewDetailPage: "Ask Sasquatch" card + panel, shown only when the crew enabled the agent. Also: Lab category tabs → fixed 5-across grid (no horizontal scroll). Bump 1.0.68. Co-authored-by: shakespeare.diy --- NIP.md | 26 +++ package.json | 2 +- src/components/CrewAgentPanel.tsx | 256 ++++++++++++++++++++++++++++++ src/components/CrewForm.tsx | 38 +++++ src/hooks/useCrewAgentChat.ts | 154 ++++++++++++++++++ src/lib/crewAgent.ts | 103 ++++++++++++ src/lib/sasquatchBrain.ts | 61 +++++++ src/lib/types.ts | 11 +- src/pages/CrewDetailPage.tsx | 37 +++++ 9 files changed, 685 insertions(+), 3 deletions(-) create mode 100644 src/components/CrewAgentPanel.tsx create mode 100644 src/hooks/useCrewAgentChat.ts create mode 100644 src/lib/crewAgent.ts create mode 100644 src/lib/sasquatchBrain.ts diff --git a/NIP.md b/NIP.md index bd13257..89f943f 100644 --- a/NIP.md +++ b/NIP.md @@ -1746,6 +1746,7 @@ bare `p` tag on the crew is only a claim until the member self-labels. | `founded` | Year founded (cosmetic) | | `t: ` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. | | `t: crew-focus-` | Optional **Lab scene** for the crew, surfacing it inside The Lab's matching category. One of `crew-focus-builder` (DIY & Builders), `crew-focus-photo` (Photo & Film — covers filming, photography & editing), or `crew-focus-coaching`. Absent = a "Mixed / General" crew that only appears on the Crews page. Queryable at the relay level via `#t`. | +| `t: crew-agent` | Optional marker that the crew has enabled its **AI Crew Agent** ("🐝 Sasquatch"). When present, clients may derive the crew's agent identity (see below) and expose an "Ask Sasquatch" experience for members. Absent = no agent. | | `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). | | `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. | | `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. | @@ -1859,6 +1860,31 @@ spots on the map and cross-link back to the crew. } ``` +### Crew Agent identity (🐝 Sasquatch) + +When a crew carries the `["t","crew-agent"]` marker, it opts into an **AI Crew +Agent** — a Nostr identity that belongs to the crew and can answer members' +questions with live SkateSpots data (spots, events, Lab pros, gear, weather). + +The agent's keypair is **deterministically derived** from the crew coordinate: + +``` +secret = sha256("skatespots:crew-agent:v1" | "35006::") +pubkey = secp256k1(secret) +``` + +Any client can reproduce the same agent identity for a given crew with no stored +state. The agent publishes a kind-0 profile with `bot: true` (NIP-24) named +`🐝 Sasquatch · `. + +> **Security note:** because the salt is public and the seed is public crew data, +> this key is *reproducible by anyone* and is **not a secure identity**. It is used +> only for the agent to speak for itself (its profile, and — in a later phase — +> helper replies in its own crew chat). It holds no funds and must never be trusted +> for sensitive signing. A future revision may replace this with a founder-controlled +> stored key without changing the querying model (the `crew-agent` marker stays the +> same). + ## CLINK Payments (`noffer` tag) SkateSpots supports an **optional, opt-in** self-custodial payment code on Lab diff --git a/package.json b/package.json index b7a1c12..dbda488 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.0.67", + "version": "1.0.68", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/components/CrewAgentPanel.tsx b/src/components/CrewAgentPanel.tsx new file mode 100644 index 0000000..9be0dac --- /dev/null +++ b/src/components/CrewAgentPanel.tsx @@ -0,0 +1,256 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Send, Loader2, MapPin, ChevronRight, Sparkles } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { CREW_AGENT_AVATAR } from '@/lib/crewAgent'; +import { useCrewAgentChat, type CrewContext, type CrewAgentMessage } from '@/hooks/useCrewAgentChat'; + +interface CrewAgentPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + ctx: CrewContext; +} + +/** Turn a skate-spots.org URL into an in-app path so links stay inside the app. */ +function toAppPath(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} + +interface ReceiptItem { + key: string; + title: string; + subtitle?: string; + image?: string; + to: string; +} + +/** Normalise a CVM tool result into flat receipt cards. */ +function toReceiptItems(tool: string, data: unknown): ReceiptItem[] { + const d = data as Record; + const take = (arr: unknown): T[] => (Array.isArray(arr) ? (arr as T[]).slice(0, 6) : []); + + if (tool === 'find_spots') { + return take>(d.spots).map((s, i) => ({ + key: `${s.url}-${i}`, + title: s.name || 'Spot', + subtitle: [s.difficulty, s.location].filter(Boolean).join(' · ') || undefined, + image: s.image, + to: toAppPath(s.url), + })); + } + if (tool === 'find_events') { + return take>(d.events).map((e, i) => ({ + key: `${e.url}-${i}`, + title: e.title || 'Event', + subtitle: e.location, + image: e.image, + to: toAppPath(e.url), + })); + } + if (tool === 'find_coaches') { + return take>(d.profiles).map((p, i) => ({ + key: `${p.url}-${i}`, + title: p.title || p.role || 'Lab pro', + subtitle: [p.role, p.location].filter(Boolean).join(' · ') || undefined, + image: p.image, + to: toAppPath(p.url), + })); + } + if (tool === 'find_gear') { + return take>(d.listings).map((g, i) => ({ + key: `${g.url}-${i}`, + title: g.title || 'Gear', + subtitle: g.condition, + image: g.image, + to: toAppPath(g.url), + })); + } + if (tool === 'find_crews') { + return take>(d.crews).map((c, i) => ({ + key: `${c.url}-${i}`, + title: c.name || 'Crew', + subtitle: c.location, + image: c.image, + to: toAppPath(c.url), + })); + } + return []; +} + +function ReceiptCards({ tool, data, onNavigate }: { tool: string; data: unknown; onNavigate: () => void }) { + const items = toReceiptItems(tool, data); + if (items.length === 0) return null; + return ( +
+ {items.map((item) => ( + +
+ {item.image ? ( + + ) : ( + + )} +
+
+

{item.title}

+ {item.subtitle &&

{item.subtitle}

} +
+ + + ))} +
+ ); +} + +function MessageRow({ m, onNavigate }: { m: CrewAgentMessage; onNavigate: () => void }) { + const isAgent = m.role === 'agent'; + return ( +
+ {isAgent && ( + + + 🐝 + + )} +
+
+ {m.text} +
+ {isAgent && m.receipts && ( + + )} +
+
+ ); +} + +const SUGGESTIONS = [ + 'Best spots near us?', + 'Any events this month?', + 'Find a filmer nearby', + "What's the weather for skating?", +]; + +export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps) { + const { messages, isThinking, send } = useCrewAgentChat(ctx); + const [draft, setDraft] = useState(''); + const scrollRef = useRef(null); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + }, [messages, isThinking]); + + const submit = (text: string) => { + const t = text.trim(); + if (!t || isThinking) return; + setDraft(''); + send(t); + }; + + return ( + + + + + 🐝 Ask Sasquatch + + Crew Agent + + + + Your crew's AI guide — spots, events, coaches & gear, with live data. + + + +
+ {messages.length === 0 && !isThinking && ( +
+ + + 🐝 + +

+ Yo! I'm Sasquatch, {ctx.crewName}'s guide. Ask me anything about your local scene. +

+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+
+ )} + + {messages.map((m) => ( + onOpenChange(false)} /> + ))} + + {isThinking && ( +
+ + Sasquatch is thinking… +
+ )} +
+ +
+
{ + e.preventDefault(); + submit(draft); + }} + className="flex items-center gap-2" + > + setDraft(e.target.value)} + placeholder="Ask about your local scene…" + maxLength={300} + className="flex-1" + /> + +
+

+ AI can be wrong — links go to real listings. +

+
+
+
+ ); +} diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 1bee7d3..f62f907 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -123,6 +123,7 @@ export function CrewForm({ existing }: CrewFormProps) { ); const [customVibeInput, setCustomVibeInput] = useState(''); const [focus, setFocus] = useState(existing?.focus ?? 'mixed'); + const [agentEnabled, setAgentEnabled] = useState(existing?.agentEnabled ?? false); const [founded, setFounded] = useState(existing?.founded ?? ''); const [image, setImage] = useState(existing?.image ?? null); const [website, setWebsite] = useState(existing?.website ?? ''); @@ -281,6 +282,8 @@ export function CrewForm({ existing }: CrewFormProps) { for (const v of customVibes) tags.push(['t', v]); // Lab scene focus — only tagged when not the default "mixed" crew. if (focus !== 'mixed') tags.push(['t', CREW_FOCUS_TAG[focus]]); + // Crew AI agent — queryable marker tag when enabled. + if (agentEnabled) tags.push(['t', 'crew-agent']); if (founded.trim()) tags.push(['founded', founded.trim()]); if (image) tags.push(['image', image]); if (website.trim()) tags.push(['website', website.trim()]); @@ -447,6 +450,41 @@ export function CrewForm({ existing }: CrewFormProps) { + {/* Crew AI agent (Sasquatch) */} +
+
+
+ +

+ Add Sasquatch, an AI guide for your crew. Members can ask it + about nearby spots, events, coaches and gear — it answers with live data and real links. Runs on your + own device; no extra setup. +

+
+ +
+
+ {/* Members */}
diff --git a/src/hooks/useCrewAgentChat.ts b/src/hooks/useCrewAgentChat.ts new file mode 100644 index 0000000..8ca8749 --- /dev/null +++ b/src/hooks/useCrewAgentChat.ts @@ -0,0 +1,154 @@ +import { useState, useCallback } from 'react'; +import { useCVMClient } from '@/hooks/useCVMClient'; +import { callSasquatchAI, parseToolCall, type ChatTurn } from '@/lib/sasquatchBrain'; + +export interface CrewAgentMessage { + id: string; + role: 'user' | 'agent'; + text: string; + timestamp: number; + /** Raw structured tool result, so the UI can render receipt cards. */ + receipts?: { tool: string; data: unknown }; +} + +export interface CrewContext { + crewName: string; + location?: string; + /** City derived from location (best-effort) — helps seed CVM queries. */ + city?: string; +} + +function buildCrewSystemPrompt(ctx: CrewContext): string { + const where = ctx.city || ctx.location || 'their local area'; + return `You are 🐝 Sasquatch, the AI guide for the "${ctx.crewName}" skate crew on SkateSpots (a decentralized skateboarding platform on Nostr). You're chatting inside the crew's page. Chill, skater-friendly, concise. + +The crew's home turf is: ${ctx.location || 'unknown'}. When a member asks about "near us", "here", "local", or doesn't give a city, assume ${where}. + +## Tools — query live data. Respond with ONLY a JSON tool call when you need data, nothing else on that line: +{"tool":"","args":{}} + +- find_spots — args: city?, country?, difficulty?, features?(string[]), search?, limit? +- find_events — args: city?, country?, event_type?, from_date?, to_date?, limit? +- find_coaches — args: role?("coach"|"filmer"|"editor"|"any"), skills?(string[]), location?, limit? +- find_gear — args: category?, condition?, max_price_sats?, search?, limit? +- find_crews — args: city?, country?, vibe?, search?, limit? +- get_community_stats — args: none +- get_weather — args: city (required) + +Rules: +1. Data questions (spots/events/coaches/gear/stats/weather) → ALWAYS use a tool, never guess. +2. Tool call = ONE JSON object on its own line. For local queries seed the args with the crew's city (${where}) and limit:20. +3. Be concise and skater-friendly. Never invent spot names, dates, or user data. +4. 0 results = honest "none listed yet" + encourage adding content at skate-spots.org.`; +} + +/** + * Crew-scoped Sasquatch chat (Phase 2). Reuses the CVM data tools and the + * shared AI brain, seeded with the crew's context. Ephemeral/local — no keys, + * no publishing (that's Phase 3). Answers can carry structured `receipts` so + * the UI renders spot / Lab / event cards. + */ +export function useCrewAgentChat(ctx: CrewContext) { + const cvm = useCVMClient(); + const [messages, setMessages] = useState([]); + const [isThinking, setIsThinking] = useState(false); + + const append = useCallback((m: Omit) => { + setMessages((prev) => [ + ...prev, + { ...m, id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, timestamp: Date.now() }, + ]); + }, []); + + const runTool = useCallback( + async (tool: string, args: Record): Promise => { + switch (tool) { + case 'find_spots': + return cvm.findSpots(args as Parameters[0]); + case 'find_events': + return cvm.findEvents(args as Parameters[0]); + case 'find_coaches': + return cvm.findCoaches(args as Parameters[0]); + case 'find_gear': + return cvm.findGear(args as Parameters[0]); + case 'find_crews': + return cvm.findCrews(args as Parameters[0]); + case 'get_community_stats': + return cvm.getCommunityStats(); + default: + return { error: `Unknown tool: ${tool}` }; + } + }, + [cvm], + ); + + const send = useCallback( + async (question: string) => { + const q = question.trim(); + if (!q || isThinking) return; + setIsThinking(true); + append({ role: 'user', text: q }); + + try { + const system = buildCrewSystemPrompt(ctx); + // First pass — AI answers directly or emits a tool call. + const firstPass: ChatTurn[] = [ + { role: 'user', content: `${system}\n\n---\n\nMember: ${q}` }, + ]; + const aiText = await callSasquatchAI(firstPass); + const toolCall = parseToolCall(aiText); + + if (!toolCall) { + append({ role: 'agent', text: aiText || "Hmm, couldn't process that. Try rephrasing!" }); + return; + } + + // Run the tool, then a second pass to present the data naturally. + let toolResult: unknown; + try { + toolResult = await runTool(toolCall.tool, toolCall.args); + } catch (toolErr) { + console.warn('[CrewAgent] tool failed:', toolErr); + append({ + role: 'agent', + text: "Couldn't reach the live data right now — try again in a moment. 🛹", + }); + return; + } + + const presented = await callSasquatchAI([ + { + role: 'user', + content: `You are 🐝 Sasquatch. Present this live data to the crew member naturally and concisely. Do NOT output any JSON. Plain conversational text with short bullet points. + +Member asked: "${q}" + +Live data from ${toolCall.tool}: +${JSON.stringify(toolResult, null, 2)} + +- If count is 0: say nothing found yet, encourage adding at skate-spots.org +- If count > 0: a short intro line — the app shows the results as cards below your message +- Keep it short and skater-friendly. Never invent data.`, + }, + ]); + + append({ + role: 'agent', + text: presented || 'Here you go 👇', + receipts: { tool: toolCall.tool, data: toolResult }, + }); + } catch (err) { + console.warn('[CrewAgent] AI unavailable:', err); + append({ + role: 'agent', + text: "The AI's a bit slow right now (free tier limits). Try again in a moment, or check the 🗺️ Map. 🛹", + }); + } finally { + setIsThinking(false); + } + }, + [ctx, isThinking, append, runTool], + ); + + return { messages, isThinking, send }; +} diff --git a/src/lib/crewAgent.ts b/src/lib/crewAgent.ts new file mode 100644 index 0000000..80b9fbd --- /dev/null +++ b/src/lib/crewAgent.ts @@ -0,0 +1,103 @@ +/** + * Crew Agent identity (Phase 0) + * ───────────────────────────── + * The Crew Agent ("🐝 Sasquatch") is a Nostr identity that belongs to a crew. + * Its key is DETERMINISTICALLY DERIVED from the crew's addressable coordinate, + * so any client can reproduce the same agent identity for a given crew without + * storing a key anywhere. + * + * ⚠️ SECURITY TRADEOFF (documented, deliberate for v1): + * Because the key is derived from public data (the crew coordinate) plus a + * fixed, public salt in this source, ANYONE who reads this code can derive the + * same private key. This is acceptable for v1 because the agent only ever: + * - publishes its own kind-0 profile, and + * - (later, Phase 3) posts helper replies into its own crew's chat. + * It holds no funds and speaks only for itself. It is NOT a secure identity and + * must never be trusted for signing anything sensitive. A future version can + * swap this for a founder-controlled stored key without changing call sites. + */ + +import { getPublicKey, finalizeEvent, nip19, type EventTemplate } from 'nostr-tools'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { SKATE_CREW_KIND } from '@/lib/types'; + +/** Public, fixed salt — versioned so we can rotate the scheme later if needed. */ +const AGENT_KEY_SALT = 'skatespots:crew-agent:v1'; + +export interface CrewAgentIdentity { + /** 32-byte private key (hex). */ + secretKeyHex: string; + /** 32-byte private key (bytes) — for signing. */ + secretKey: Uint8Array; + /** Public key (hex). */ + pubkey: string; + /** npub encoding of the public key. */ + npub: string; +} + +/** + * Build the crew's addressable coordinate `kind:pubkey:identifier`. + * This is the stable, public seed for the agent identity. + */ +export function crewCoord(crewPubkey: string, identifier: string): string { + return `${SKATE_CREW_KIND}:${crewPubkey}:${identifier}`; +} + +/** + * Deterministically derive the crew agent's keypair from the crew coordinate. + * Same crew → same agent identity, everywhere, with no stored state. + */ +export function deriveCrewAgentIdentity(crewPubkey: string, identifier: string): CrewAgentIdentity { + const coord = crewCoord(crewPubkey, identifier); + // 32-byte secret = sha256(salt | coord). secp256k1 accepts any 32-byte scalar + // in range; sha256 output is overwhelmingly within range, and nostr-tools / + // noble will reject the astronomically unlikely invalid case at sign time. + const secretKey = sha256(new TextEncoder().encode(`${AGENT_KEY_SALT}|${coord}`)); + const secretKeyHex = bytesToHex(secretKey); + const pubkey = getPublicKey(secretKey); + const npub = nip19.npubEncode(pubkey); + return { secretKeyHex, secretKey, pubkey, npub }; +} + +/** Just the agent pubkey — cheap when you don't need to sign. */ +export function crewAgentPubkey(crewPubkey: string, identifier: string): string { + return deriveCrewAgentIdentity(crewPubkey, identifier).pubkey; +} + +/** Sasquatch mascot avatar used for the agent's profile. */ +export const CREW_AGENT_AVATAR = + 'https://npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx.blossom.band/2ff0cb5bd8622fed54a80784f5cc9d1f569714e9a16de65857a727e63404f5c2.png'; + +/** + * Build (unsigned) the agent's kind-0 profile metadata for a crew. + * `bot: true` follows NIP-24 so clients can honestly label it as automated. + */ +export function buildCrewAgentProfile(crewName: string): EventTemplate { + const metadata = { + name: `🐝 Sasquatch · ${crewName}`, + display_name: `Sasquatch · ${crewName}`, + about: `The AI crew guide for ${crewName} on SkateSpots. Ask me about spots, events, coaches, gear and more — I answer with live data. 🛹`, + picture: CREW_AGENT_AVATAR, + bot: true, + }; + return { + kind: 0, + content: JSON.stringify(metadata), + tags: [], + created_at: Math.floor(Date.now() / 1000), + }; +} + +/** + * Sign an event template AS the crew agent (using its derived key). + * Used to publish the agent's profile now, and its chat replies in Phase 3. + */ +export function signAsCrewAgent( + crewPubkey: string, + identifier: string, + template: EventTemplate, +) { + const { secretKey } = deriveCrewAgentIdentity(crewPubkey, identifier); + return finalizeEvent(template, secretKey); +} diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts new file mode 100644 index 0000000..ba69540 --- /dev/null +++ b/src/lib/sasquatchBrain.ts @@ -0,0 +1,61 @@ +/** + * Sasquatch brain — the shared AI caller used by both the global Sasquatch + * guide and the per-crew Crew Agent. Kept framework-agnostic (no React) so any + * surface can reuse the same model list + fallback behaviour. + */ + +// ─── OpenRouter free models — 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'; + +// Try multiple free models in order — if one is rate-limited, fall back. +export const FREE_MODELS = [ + 'openai/gpt-oss-20b:free', + 'google/gemma-4-31b-it:free', + 'meta-llama/llama-3.3-70b-instruct:free', +]; + +export type ChatTurn = { role: 'user' | 'assistant'; content: string }; + +/** Call the AI with model fallback. Throws only if every model fails. */ +export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise { + let lastError = ''; + for (const model of FREE_MODELS) { + try { + 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, messages, max_tokens: maxTokens }), + }); + if (!response.ok) { + const err = await response.text(); + console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err); + lastError = `${response.status}: ${err}`; + continue; + } + const data = await response.json(); + const content = data.choices?.[0]?.message?.content ?? ''; + if (content) return content; + } catch (err) { + console.warn(`[Sasquatch] model ${model} threw:`, err); + lastError = String(err); + } + } + throw new Error(`All AI models failed. Last error: ${lastError}`); +} + +/** Parse a `{"tool":"name","args":{…}}` tool call out of an AI response, if any. */ +export function parseToolCall(aiText: string): { tool: string; args: Record } | null { + const jsonMatch = aiText.match(/\{[\s\S]*"tool"\s*:\s*"(\w+)"[\s\S]*\}/); + if (!jsonMatch) return null; + try { + return JSON.parse(jsonMatch[0]); + } catch { + return null; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index b7781d6..e89fa7c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -945,6 +945,7 @@ export interface SkateCrew { image?: string; // Crew logo / photo vibes: string[]; // Style/focus tags focus: CrewFocus; // Optional Lab scene (builder/photo/coaching) or 'mixed' + agentEnabled: boolean; // Whether the crew's AI agent ("🐝 Sasquatch") is on founded?: string; // Year founded (cosmetic) website?: string; instagram?: string; @@ -1468,13 +1469,18 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null { const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN); const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const image = event.tags.find(([name]) => name === 'image')?.[1]; - // Vibes are `t` tags that aren't the crew marker or a crew-focus scene tag. + // Vibes are `t` tags that aren't the crew marker, a crew-focus scene tag, or + // the crew-agent marker. const vibes = event.tags - .filter(([name, value]) => name === 't' && value !== 'skatecrew' && !CREW_FOCUS_FROM_TAG[value]) + .filter(([name, value]) => + name === 't' && value !== 'skatecrew' && value !== 'crew-agent' && !CREW_FOCUS_FROM_TAG[value] + ) .map(([, value]) => value); // Lab scene focus — first matching crew-focus `t` tag wins, else 'mixed'. const focusTag = event.tags.find(([name, value]) => name === 't' && !!CREW_FOCUS_FROM_TAG[value])?.[1]; const focus: CrewFocus = focusTag ? CREW_FOCUS_FROM_TAG[focusTag] : 'mixed'; + // Crew AI agent enabled when the `crew-agent` marker tag is present. + const agentEnabled = event.tags.some(([name, value]) => name === 't' && value === 'crew-agent'); const founded = event.tags.find(([name]) => name === 'founded')?.[1]; const website = event.tags.find(([name]) => name === 'website')?.[1]; const instagram = event.tags.find(([name]) => name === 'instagram')?.[1]; @@ -1504,6 +1510,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null { image, vibes, focus, + agentEnabled, founded, website, instagram, diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 48d0842..a86bf41 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useParams, Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; @@ -13,6 +14,7 @@ import { ClinkPayButton } from '@/components/ClinkPayButton'; import { CollectiveMembers } from '@/components/CollectiveMembers'; import { CrewNip05Badge } from '@/components/CrewNip05Badge'; import { CrewChat } from '@/components/CrewChat'; +import { CrewAgentPanel } from '@/components/CrewAgentPanel'; import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard'; import { LinkedLabCard } from '@/components/CrossLinkCards'; import { @@ -123,6 +125,7 @@ export default function CrewDetailPage() { const author = useAuthor(crew?.pubkey); const { isFounder, isAdmin, role, canManage } = useCrewRole(crew); const { toast } = useToast(); + const [agentOpen, setAgentOpen] = useState(false); useSeoMeta({ title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots', @@ -345,6 +348,31 @@ export default function CrewDetailPage() { + {/* Crew Agent — AI guide, shown when the crew enabled it */} + {crew.agentEnabled && ( + + )} + {/* Crew Chat — private E2E-encrypted group chat for members */}
+ + {/* Crew Agent chat panel */} + {crew.agentEnabled && ( + + )} ); }