Make Crew Agent genuinely crew-aware; swap bee emoji for Sasquatch avatar
Sasquatch now knows the crew it lives on. The agent is seeded with a rich
crew dossier — its own upcoming events, recent feed digest, roster size,
founder, open call, vibe, band, home spots, website/IG — and the system
prompt instructs it to answer crew-specific questions ("what's our next
jam?", "who runs this?", "what are we looking for?") directly from memory,
while still using CVM tools for wider-network discovery.
- useCrewAgentChat: expanded CrewContext + crew dossier + two-mode prompt
- CrewDetailPage: wires real crew events + feed digest into the agent ctx
- CrewAgentPanel: crew-specific suggestions, crew-aware copy
- Replaced 🐝 bee emoji with the Sasquatch mascot avatar everywhere
(panel header/rows/empty state, crew page button, CrewForm toggle)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
0b786e546f
commit
878da94b7b
6 changed files with 192 additions and 32 deletions
|
|
@ -128,7 +128,7 @@ function MessageRow({ m, onNavigate }: { m: CrewAgentMessage; onNavigate: () =>
|
|||
{isAgent && (
|
||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
|
||||
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-xs">🐝</AvatarFallback>
|
||||
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-[10px] font-black">SQ</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div className={cn('flex max-w-[82%] flex-col', !isAgent && 'items-end')}>
|
||||
|
|
@ -149,9 +149,10 @@ function MessageRow({ m, onNavigate }: { m: CrewAgentMessage; onNavigate: () =>
|
|||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"What's our next jam?",
|
||||
'Who runs this crew?',
|
||||
'What are we looking for?',
|
||||
'Best spots near us?',
|
||||
'Any events this month?',
|
||||
'Find a filmer nearby',
|
||||
"What's the weather for skating?",
|
||||
];
|
||||
|
||||
|
|
@ -176,13 +177,17 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
|
|||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-0 overflow-hidden bg-black border-teal-500/30 flex flex-col max-h-[85vh]">
|
||||
<DialogHeader className="p-4 pb-3 border-b border-white/5">
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<span className="text-lg leading-none">🐝</span> Ask Sasquatch
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
|
||||
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-[9px] font-black">SQ</AvatarFallback>
|
||||
</Avatar>
|
||||
Ask Sasquatch
|
||||
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
|
||||
Crew Agent
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Your crew's AI guide — spots, events, coaches & gear, with live data.
|
||||
Knows this crew — its jams, roster & open call — plus the wider scene with live data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -191,10 +196,11 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
|
|||
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
|
||||
<AvatarFallback className="bg-teal-900/40 text-teal-300">🐝</AvatarFallback>
|
||||
<AvatarFallback className="bg-teal-900/40 text-teal-300 font-black">SQ</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
||||
Yo! I'm Sasquatch, {ctx.crewName}'s guide. Ask me anything about your local scene.
|
||||
Yo! I'm Sasquatch, {ctx.crewName}'s guide. Ask me about the crew — our next jam,
|
||||
who runs it, what we're after — or the wider scene.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useUploadFile } from '@/hooks/useUploadFile';
|
|||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
|
||||
import { SpotPicker } from '@/components/SpotPicker';
|
||||
import { LabLinkField } from '@/components/LabLinkField';
|
||||
import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
||||
|
|
@ -779,13 +780,20 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="flex items-center gap-2 text-teal-400">
|
||||
<span className="text-base leading-none">🐝</span> Crew Agent
|
||||
<img
|
||||
src={CREW_AGENT_AVATAR}
|
||||
alt="Sasquatch"
|
||||
className="h-5 w-5 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
Crew Agent
|
||||
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">Beta</span>
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Add <strong className="text-gray-300">Sasquatch</strong>, an AI guide for your crew. Members can ask it
|
||||
about nearby spots, events, coaches and gear — it answers with live SkateSpots data and real links.
|
||||
Runs right in the browser; no keys or extra setup. It's early (free-tier AI, so occasionally slow).
|
||||
Add <strong className="text-gray-300">Sasquatch</strong>, an AI guide who actually knows your crew.
|
||||
Members can ask about <strong className="text-gray-300">your next jam, who runs the crew, what you're
|
||||
looking for and your vibe</strong> — plus nearby spots, events, coaches and gear from live SkateSpots
|
||||
data. Runs right in the browser; no keys or setup. It's early (free-tier AI, so occasionally slow).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -11,20 +11,133 @@ export interface CrewAgentMessage {
|
|||
receipts?: { tool: string; data: unknown };
|
||||
}
|
||||
|
||||
/** A crew-hosted event, flattened for the agent's context. */
|
||||
export interface CrewAgentEvent {
|
||||
title: string;
|
||||
/** Unix seconds. */
|
||||
start: number;
|
||||
location?: string;
|
||||
/** In-app path to the event, e.g. /event/<pubkey>/<d>. */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/** A recent crew-feed post, flattened for the agent's context. */
|
||||
export interface CrewAgentPost {
|
||||
author: string;
|
||||
/** Short excerpt of the note. */
|
||||
text: string;
|
||||
/** Unix seconds. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface CrewContext {
|
||||
crewName: string;
|
||||
location?: string;
|
||||
/** City derived from location (best-effort) — helps seed CVM queries. */
|
||||
city?: string;
|
||||
/** Freeform crew manifesto / about. */
|
||||
bio?: string;
|
||||
/** Style / focus tags. */
|
||||
vibes?: string[];
|
||||
/** Founder display name. */
|
||||
founderName?: string;
|
||||
/** Total member count (founder + listed members). */
|
||||
memberCount?: number;
|
||||
/** Human-readable open-call labels, e.g. ["Filmer", "New Members"]. */
|
||||
lookingFor?: string[];
|
||||
/** Crew band name, if the crew doubles as a music project. */
|
||||
bandName?: string;
|
||||
/** Number of home spots linked to the crew. */
|
||||
homeSpotCount?: number;
|
||||
/** Crew website. */
|
||||
website?: string;
|
||||
/** Crew Instagram handle. */
|
||||
instagram?: string;
|
||||
/** The crew's OWN upcoming/recent events — soonest first. */
|
||||
events?: CrewAgentEvent[];
|
||||
/** A short digest of the crew's recent feed activity. */
|
||||
recentPosts?: CrewAgentPost[];
|
||||
}
|
||||
|
||||
function fmtDate(unix: number): string {
|
||||
try {
|
||||
return new Date(unix * 1000).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return 'TBD';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact, human-readable digest of everything Sasquatch knows about
|
||||
* THIS crew. This is the crew's "memory" — the stuff no global bot could know —
|
||||
* and is injected into every turn so the agent answers crew questions directly.
|
||||
*/
|
||||
function buildCrewDossier(ctx: CrewContext): string {
|
||||
const lines: string[] = [];
|
||||
const where = ctx.location || 'unknown';
|
||||
|
||||
lines.push(`Crew name: ${ctx.crewName}`);
|
||||
lines.push(`Home turf: ${where}`);
|
||||
if (ctx.founderName) lines.push(`Founded/run by: ${ctx.founderName}`);
|
||||
if (typeof ctx.memberCount === 'number') {
|
||||
lines.push(`Roster size: ${ctx.memberCount} ${ctx.memberCount === 1 ? 'member' : 'members'}`);
|
||||
}
|
||||
if (ctx.vibes?.length) lines.push(`Vibe / style: ${ctx.vibes.join(', ')}`);
|
||||
if (ctx.bio) lines.push(`About: ${ctx.bio.slice(0, 400)}`);
|
||||
if (ctx.bandName) lines.push(`Crew band: ${ctx.bandName}`);
|
||||
if (typeof ctx.homeSpotCount === 'number' && ctx.homeSpotCount > 0) {
|
||||
lines.push(`Home spots on file: ${ctx.homeSpotCount}`);
|
||||
}
|
||||
if (ctx.lookingFor?.length) {
|
||||
lines.push(`Open call — the crew is looking for: ${ctx.lookingFor.join(', ')}`);
|
||||
}
|
||||
if (ctx.website) lines.push(`Website: ${ctx.website}`);
|
||||
if (ctx.instagram) lines.push(`Instagram: @${ctx.instagram.replace('@', '')}`);
|
||||
|
||||
// The crew's OWN events — the highest-value crew-specific data.
|
||||
if (ctx.events?.length) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const upcoming = ctx.events.filter((e) => e.start >= now);
|
||||
const list = (upcoming.length ? upcoming : ctx.events).slice(0, 6);
|
||||
lines.push(
|
||||
`\nCrew's own events (${upcoming.length ? 'upcoming' : 'most recent'}):`,
|
||||
);
|
||||
for (const e of list) {
|
||||
const loc = e.location ? ` @ ${e.location}` : '';
|
||||
lines.push(`- "${e.title}" — ${fmtDate(e.start)}${loc}`);
|
||||
}
|
||||
} else {
|
||||
lines.push('\nCrew events: none hosted yet.');
|
||||
}
|
||||
|
||||
// Recent feed digest — gives the agent a sense of what the crew is up to.
|
||||
if (ctx.recentPosts?.length) {
|
||||
lines.push('\nRecent crew feed activity:');
|
||||
for (const p of ctx.recentPosts.slice(0, 5)) {
|
||||
lines.push(`- ${p.author}: "${p.text.slice(0, 120).replace(/\s+/g, ' ')}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
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.
|
||||
const dossier = buildCrewDossier(ctx);
|
||||
|
||||
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}.
|
||||
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 own page with its members. Chill, skater-friendly, concise, and you know THIS crew personally.
|
||||
|
||||
## Tools — query live data. Respond with ONLY a JSON tool call when you need data, nothing else on that line:
|
||||
## What you know about this crew (your memory)
|
||||
${dossier}
|
||||
|
||||
When a member asks about "us", "our crew", "here", "our next jam", "who runs this", "what are we looking for", "our vibe", etc. — answer DIRECTLY from the crew memory above. Do NOT call a tool for questions about the crew itself. If the memory doesn't cover it, say so honestly.
|
||||
|
||||
## Tools — for discovering things OUTSIDE the crew (the wider SkateSpots network).
|
||||
Respond with ONLY a JSON tool call when you need external data, nothing else on that line:
|
||||
{"tool":"<name>","args":{}}
|
||||
|
||||
- find_spots — args: city?, country?, difficulty?, features?(string[]), search?, limit?
|
||||
|
|
@ -36,17 +149,20 @@ The crew's home turf is: ${ctx.location || 'unknown'}. When a member asks about
|
|||
- 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.`;
|
||||
1. Questions about THIS crew (its events, roster, vibe, open call, founder, band, home spots, website) → answer from memory, NO tool.
|
||||
2. Questions about the wider scene (spots/events/coaches/gear/crews near us, stats, weather) → ALWAYS use a tool, never guess. Seed local queries with the crew's city (${where}) and limit:20.
|
||||
3. A tool call is ONE JSON object on its own line — nothing else.
|
||||
4. Be concise and skater-friendly. Never invent spot names, dates, member names, or user data.
|
||||
5. 0 results (or empty crew data) = 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.
|
||||
* Crew-scoped Sasquatch chat. Reuses the CVM data tools and the shared AI
|
||||
* brain, but is seeded with a rich, crew-specific dossier (its own events,
|
||||
* feed digest, roster, open call, vibe, band, home spots) so it can answer
|
||||
* questions about the crew itself directly — not just re-skin the global bot.
|
||||
* Ephemeral/local — no keys, no publishing. Answers can carry structured
|
||||
* `receipts` so the UI renders spot / Lab / event cards.
|
||||
*/
|
||||
export function useCrewAgentChat(ctx: CrewContext) {
|
||||
const cvm = useCVMClient();
|
||||
|
|
@ -91,7 +207,7 @@ export function useCrewAgentChat(ctx: CrewContext) {
|
|||
|
||||
try {
|
||||
const system = buildCrewSystemPrompt(ctx);
|
||||
// First pass — AI answers directly or emits a tool call.
|
||||
// First pass — AI answers directly from crew memory or emits a tool call.
|
||||
const firstPass: ChatTurn[] = [
|
||||
{ role: 'user', content: `${system}\n\n---\n\nMember: ${q}` },
|
||||
];
|
||||
|
|
@ -119,7 +235,7 @@ export function useCrewAgentChat(ctx: CrewContext) {
|
|||
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.
|
||||
content: `You are Sasquatch, guide for the "${ctx.crewName}" crew. 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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Crew Agent identity (Phase 0)
|
||||
* ─────────────────────────────
|
||||
* The Crew Agent ("🐝 Sasquatch") is a Nostr identity that belongs to a crew.
|
||||
* 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.
|
||||
|
|
@ -75,7 +75,7 @@ export const CREW_AGENT_AVATAR =
|
|||
*/
|
||||
export function buildCrewAgentProfile(crewName: string): EventTemplate {
|
||||
const metadata = {
|
||||
name: `🐝 Sasquatch · ${crewName}`,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1923,7 +1923,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
|
||||
agentEnabled: boolean; // Whether the crew's AI agent ("Sasquatch") is on
|
||||
founded?: string; // Year founded (cosmetic)
|
||||
website?: string;
|
||||
instagram?: string;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
|||
import { useCrewChatUnread } from '@/hooks/useCrewChatUnread';
|
||||
import { CrewChat } from '@/components/CrewChat';
|
||||
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
|
||||
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
|
||||
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
|
||||
import { CrewLookingForCard } from '@/components/CrewLookingFor';
|
||||
import { CrewRequestToJoin } from '@/components/CrewRequestToJoin';
|
||||
|
|
@ -54,7 +55,7 @@ import { useCrewFeed } from '@/hooks/useCrewFeed';
|
|||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SHOW_CREW_TREASURY, type CrewRole } from '@/lib/types';
|
||||
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SHOW_CREW_TREASURY, CREW_LOOKING_FOR_LABELS, type CrewRole } from '@/lib/types';
|
||||
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import NotFound from './NotFound';
|
||||
|
|
@ -336,8 +337,11 @@ export default function CrewDetailPage() {
|
|||
onClick={() => setAgentOpen(true)}
|
||||
className="group flex w-full items-center gap-3 rounded-2xl border-2 border-teal-500/30 bg-gradient-to-br from-teal-500/10 to-transparent p-4 text-left transition-all hover:border-teal-500/60 hover:from-teal-500/15 active:scale-[0.99]"
|
||||
>
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-teal-500/15 text-2xl">
|
||||
🐝
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-teal-500/15">
|
||||
<Avatar className="h-12 w-12 rounded-2xl">
|
||||
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" className="object-cover" />
|
||||
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black">SQ</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-2 font-black text-white">
|
||||
|
|
@ -619,12 +623,38 @@ export default function CrewDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crew Agent chat panel */}
|
||||
{/* Crew Agent chat panel — seeded with the crew's OWN data so Sasquatch
|
||||
can answer crew-specific questions (its events, roster, open call,
|
||||
vibe, band, home spots) directly, not just re-skin the global bot. */}
|
||||
{crew.agentEnabled && (
|
||||
<CrewAgentPanel
|
||||
open={agentOpen}
|
||||
onOpenChange={setAgentOpen}
|
||||
ctx={{ crewName: crew.name, location: crew.location, city: crew.location }}
|
||||
ctx={{
|
||||
crewName: crew.name,
|
||||
location: crew.location,
|
||||
city: crew.location,
|
||||
bio: crew.bio,
|
||||
vibes: crew.vibes,
|
||||
founderName,
|
||||
memberCount: 1 + crew.members.length,
|
||||
lookingFor: crew.lookingFor.map((s) => CREW_LOOKING_FOR_LABELS[s]?.label ?? s),
|
||||
bandName: crew.bandName,
|
||||
homeSpotCount: crew.spotRefs.length,
|
||||
website: crew.website,
|
||||
instagram: crew.instagram,
|
||||
events: (crewEvents ?? []).map((e) => ({
|
||||
title: e.title,
|
||||
start: e.start,
|
||||
location: e.location,
|
||||
path: `/event/${e.pubkey}/${e.identifier}`,
|
||||
})),
|
||||
recentPosts: (feed ?? []).slice(0, 5).map((ev) => ({
|
||||
author: genUserName(ev.pubkey),
|
||||
text: ev.content ?? '',
|
||||
createdAt: ev.created_at,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
|
|
|
|||
Loading…
Reference in a new issue