Sasquatch Pro Phase 2: action drafting (draft → you confirm)

Sasquatch can now DRAFT actions from natural language and publish them
only after the user reviews & confirms — it never publishes on its own.

- useSasquatchActions: publishes confirmed drafts via the app's normal
  publish path. Supports post_status (NIP-38 kind 30315, auto-expiring)
  and list_gear (NIP-99 kind 30402, skateboarding-tagged), reusing the
  existing event schemas.
- SasquatchActionCard: inline "review & confirm" card with editable key
  fields (status text / listing title, price, condition), Publish +
  Cancel, and done/cancelled states. Nothing is signed until Publish.
- SasquatchGuide: new draft_action tool (Pro-gated). The AI emits a
  draft; a card renders in-chat; confirmDraft signs & publishes. Non-Pro
  users get an upgrade nudge. Draft lifecycle tracked per message.
- Prompt teaches the model to use draft_action for "post/sell/list/set
  my status" requests and to fill gear fields from the stash first.

Examples now working (Pro): "post that I'm skating Southbank for 3 hours"
· "list my Spitfire wheels for 8000 sats".

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-29 23:41:11 +07:00
parent 79d1d47823
commit 5655fbc976
3 changed files with 357 additions and 1 deletions

View file

@ -0,0 +1,121 @@
/**
* SasquatchActionCard — the "review & confirm" card for a drafted action.
*
* Sasquatch proposes a draft; the user can tweak the key field(s), then Publish
* or Cancel. Nothing is signed or published until Publish is tapped.
*/
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Loader2, Check, X, Wand2 } from 'lucide-react';
import {
summarizeDraft,
type SasquatchDraft,
} from '@/hooks/useSasquatchActions';
interface Props {
draft: SasquatchDraft;
onPublish: (draft: SasquatchDraft) => Promise<void>;
onCancel: () => void;
/** Set once resolved so the card locks into a done/cancelled state. */
status: 'pending' | 'publishing' | 'done' | 'cancelled';
resultLabel?: string;
}
export function SasquatchActionCard({ draft, onPublish, onCancel, status, resultLabel }: Props) {
const [edited, setEdited] = useState<SasquatchDraft>(draft);
const summary = summarizeDraft(edited);
if (status === 'done') {
return (
<div className="rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-3 text-xs text-[#ccff00] flex items-center gap-2">
<Check className="h-4 w-4 shrink-0" /> {resultLabel ?? 'Done!'}
</div>
);
}
if (status === 'cancelled') {
return (
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-500 flex items-center gap-2">
<X className="h-4 w-4 shrink-0" /> Cancelled — nothing was published.
</div>
);
}
const busy = status === 'publishing';
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/[0.06] p-3 space-y-2.5">
<div className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider text-red-400">
<Wand2 className="h-3 w-3" /> Draft — review & confirm
</div>
<p className="text-sm font-bold text-white">{summary.title}</p>
{/* Editable key field(s) */}
{edited.type === 'post_status' ? (
<Input
value={edited.text}
onChange={(e) => setEdited({ ...edited, text: e.target.value })}
disabled={busy}
className="h-9 bg-black/40 border-white/10 text-white text-sm"
placeholder="Status text"
/>
) : (
<div className="grid grid-cols-2 gap-2">
<Input
value={edited.title}
onChange={(e) => setEdited({ ...edited, title: e.target.value })}
disabled={busy}
className="col-span-2 h-9 bg-black/40 border-white/10 text-white text-sm"
placeholder="Title"
/>
<Input
value={edited.price ?? ''}
onChange={(e) => setEdited({ ...edited, price: e.target.value ? Number(e.target.value) : undefined })}
disabled={busy}
type="number"
inputMode="numeric"
className="h-9 bg-black/40 border-white/10 text-white text-sm"
placeholder="Price (sats)"
/>
<Input
value={edited.condition ?? ''}
onChange={(e) => setEdited({ ...edited, condition: e.target.value })}
disabled={busy}
className="h-9 bg-black/40 border-white/10 text-white text-sm"
placeholder="Condition"
/>
</div>
)}
{/* Read-only summary lines */}
<ul className="text-[11px] text-gray-400 space-y-0.5">
{summary.lines.map((l, i) => (
<li key={i} className="truncate">• {l}</li>
))}
</ul>
<div className="flex gap-2 pt-0.5">
<Button
variant="outline"
size="sm"
className="flex-1 gap-1.5 border-white/15 text-gray-300 h-8"
onClick={onCancel}
disabled={busy}
>
<X className="h-3.5 w-3.5" /> Cancel
</Button>
<Button
size="sm"
className="flex-1 gap-1.5 bg-red-500 hover:bg-red-600 font-bold h-8"
onClick={() => onPublish(edited)}
disabled={busy}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
{summary.cta}
</Button>
</div>
</div>
);
}

View file

@ -18,6 +18,8 @@ import { useCVMClient } from '@/hooks/useCVMClient';
import { usePersonalContext } from '@/hooks/usePersonalContext'; import { usePersonalContext } from '@/hooks/usePersonalContext';
import { useSasquatchPro } from '@/hooks/useSasquatchPro'; import { useSasquatchPro } from '@/hooks/useSasquatchPro';
import { SasquatchProDialog } from '@/components/SasquatchProDialog'; import { SasquatchProDialog } from '@/components/SasquatchProDialog';
import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchActions';
import { SasquatchActionCard } from '@/components/SasquatchActionCard';
import { callSasquatchAI } from '@/lib/sasquatchBrain'; import { callSasquatchAI } from '@/lib/sasquatchBrain';
import { answerFromGlossary } from '@/lib/glossary'; import { answerFromGlossary } from '@/lib/glossary';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -92,6 +94,12 @@ interface ChatMessage {
timestamp: number; timestamp: number;
isLive?: boolean; // came from CVM live data isLive?: boolean; // came from CVM live data
isStreaming?: boolean; isStreaming?: boolean;
/** A drafted action (Sasquatch Pro) awaiting user confirmation. */
draft?: SasquatchDraft;
/** Lifecycle of the drafted action's confirmation card. */
draftStatus?: 'pending' | 'publishing' | 'done' | 'cancelled';
/** Success label after publishing the draft. */
draftResult?: string;
} }
// ─── System prompt for the AI ───────────────────────────────────────────────── // ─── System prompt for the AI ─────────────────────────────────────────────────
@ -201,7 +209,22 @@ Example: {"tool":"get_my_listings","args":{}}
Skate spots the user created/added. Args: none. Skate spots the user created/added. Args: none.
Example: {"tool":"get_my_spots","args":{}} Example: {"tool":"get_my_spots","args":{}}
Rule: for "my/mine" questions ALWAYS use a personal tool — never guess about the user's data.`; ## Action drafts (Pro — you DRAFT, the user confirms before anything is published)
Never publish directly. To help the user DO something, emit a draft_action tool
call. The app shows a confirmation card; the user reviews and taps Publish.
### draft_action → post_status
Draft a skating status (NIP-38). Args: action:"post_status", text(string, include an emoji), expiresInMinutes?(number, default 120).
Use for "post that I'm skating X", "set my status", "let people know I'm at the park".
Example: {"tool":"draft_action","args":{"action":"post_status","text":"🛹 Skating Southbank — come thru","expiresInMinutes":180}}
### draft_action → list_gear
Draft a gear-for-sale listing (NIP-99). Args: action:"list_gear", title(string), description?(string), price?(number, sats), category?("deck"|"trucks"|"wheels"|"bearings"|"shoes"|"clothing"|"other"), condition?(string), size?(string).
Use for "list my X for sale", "sell my Y". If selling an item from their stash, fill fields from get_my_stash first.
Example: {"tool":"draft_action","args":{"action":"list_gear","title":"Spitfire F4 99a 53mm","price":8000,"category":"wheels","condition":"like new"}}
Rule: for "my/mine" questions ALWAYS use a personal tool — never guess about the user's data.
Rule: for "post/sell/list/set my status" requests use draft_action — never claim you posted something.`;
// ─── Detect if a question needs live CVM data ───────────────────────────────── // ─── Detect if a question needs live CVM data ─────────────────────────────────
@ -340,6 +363,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const cvm = useCVMClient(); const cvm = useCVMClient();
const personal = usePersonalContext(); const personal = usePersonalContext();
const { isPro } = useSasquatchPro(); const { isPro } = useSasquatchPro();
const { publishDraft } = useSasquatchActions();
const [proDialogOpen, setProDialogOpen] = useState(false); const [proDialogOpen, setProDialogOpen] = useState(false);
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false); const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
@ -451,6 +475,36 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
setChatMessages((prev) => [...prev, msg]); setChatMessages((prev) => [...prev, msg]);
}, []); }, []);
// Update the draft lifecycle on a specific message (identified by timestamp).
const setDraftStatus = useCallback(
(ts: number, draftStatus: ChatMessage['draftStatus'], draftResult?: string) => {
setChatMessages((prev) =>
prev.map((m) => (m.timestamp === ts ? { ...m, draftStatus, draftResult } : m)),
);
},
[],
);
// Publish a confirmed draft (Pro action). Signs + publishes via the app's
// normal publish path; the human already reviewed it in the card.
const confirmDraft = useCallback(
async (ts: number, draft: SasquatchDraft) => {
setDraftStatus(ts, 'publishing');
try {
const { label } = await publishDraft(draft);
setDraftStatus(ts, 'done', label);
} catch (err) {
appendMessage({
role: 'sasquatch',
text: `Couldn't publish that: ${err instanceof Error ? err.message : 'unknown error'}. Try again in a sec.`,
timestamp: Date.now(),
});
setDraftStatus(ts, 'pending');
}
},
[publishDraft, setDraftStatus, appendMessage],
);
// ── Core: handle a user question ─────────────────────────────────────────── // ── Core: handle a user question ───────────────────────────────────────────
const handleSendMessage = useCallback(async (questionOverride?: string) => { const handleSendMessage = useCallback(async (questionOverride?: string) => {
@ -534,6 +588,60 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
} catch { /* not valid JSON, treat as plain answer */ } } catch { /* not valid JSON, treat as plain answer */ }
} }
// ── draft_action (Pro) — build a draft card the user must confirm ──
if (toolCall && toolCall.tool === 'draft_action') {
setStreamingText('');
if (!isPro) {
appendMessage({
role: 'sasquatch',
text: "Drafting actions (posting a status, listing gear) is a **Sasquatch Pro** perk 🦧⚡ — tap **⚡ Go Pro** up top to unlock it.",
timestamp: Date.now(),
});
aiSucceeded = true;
} else {
const a = toolCall.args as Record<string, unknown>;
const action = String(a.action ?? '');
let draft: SasquatchDraft | null = null;
if (action === 'post_status' && typeof a.text === 'string') {
draft = {
type: 'post_status',
text: a.text,
expiresInMinutes: typeof a.expiresInMinutes === 'number' ? a.expiresInMinutes : undefined,
};
} else if (action === 'list_gear' && typeof a.title === 'string') {
draft = {
type: 'list_gear',
title: a.title,
description: typeof a.description === 'string' ? a.description : undefined,
price: typeof a.price === 'number' ? a.price : undefined,
currency: typeof a.currency === 'string' ? a.currency : undefined,
category: typeof a.category === 'string' ? a.category : undefined,
condition: typeof a.condition === 'string' ? a.condition : undefined,
size: typeof a.size === 'string' ? a.size : undefined,
};
}
if (draft) {
appendMessage({
role: 'sasquatch',
text: "Here's a draft — review it and tap to publish. Nothing goes live until you confirm. 🛹",
timestamp: Date.now(),
draft,
draftStatus: 'pending',
});
} else {
appendMessage({
role: 'sasquatch',
text: "I couldn't quite build that draft — try telling me the details again (e.g. \"list my Spitfire wheels for 8000 sats\").",
timestamp: Date.now(),
});
}
aiSucceeded = true;
}
setStreamingText('');
setIsThinking(false);
return;
}
if (toolCall) { if (toolCall) {
const { tool: toolName, args: toolArgs } = toolCall; const { tool: toolName, args: toolArgs } = toolCall;
setStreamingText(`🔍 Querying CVM for ${toolName.replace(/_/g, ' ')}...`); setStreamingText(`🔍 Querying CVM for ${toolName.replace(/_/g, ' ')}...`);
@ -926,6 +1034,17 @@ Instructions:
)} )}
</div> </div>
<div className="whitespace-pre-wrap"><ChatText text={msg.text} /></div> <div className="whitespace-pre-wrap"><ChatText text={msg.text} /></div>
{msg.draft && (
<div className="mt-2">
<SasquatchActionCard
draft={msg.draft}
status={msg.draftStatus ?? 'pending'}
resultLabel={msg.draftResult}
onPublish={(d) => confirmDraft(msg.timestamp, d)}
onCancel={() => setDraftStatus(msg.timestamp, 'cancelled')}
/>
</div>
)}
</div> </div>
))} ))}

View file

@ -0,0 +1,116 @@
/**
* useSasquatchActions — publish the actions Sasquatch drafts (Pro).
* ─────────────────────────────────────────────────────────────────────────────
* Sasquatch never publishes on its own. The AI proposes a structured DRAFT; the
* user reviews it in a confirmation card; only on confirm do we sign & publish
* here. All actions reuse the app's existing event schemas.
*
* Supported drafts:
* • post_status — NIP-38 skating status (kind 30315), optional expiry
* • list_gear — NIP-99 marketplace listing (kind 30402), skateboarding-tagged
*/
import { useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
import { USER_STATUS_KIND } from '@/hooks/useUserStatus';
// ─── Draft shapes ─────────────────────────────────────────────────────────────
export interface PostStatusDraft {
type: 'post_status';
/** Freeform status text, e.g. "🛹 Skating Southbank". */
text: string;
/** Minutes until it auto-expires (default 120). */
expiresInMinutes?: number;
}
export interface ListGearDraft {
type: 'list_gear';
title: string;
description?: string;
/** Price in the given currency (default sats). Omit for "make offer". */
price?: number;
currency?: string;
/** deck | trucks | wheels | bearings | shoes | clothing | other */
category?: string;
condition?: string;
size?: string;
}
export type SasquatchDraft = PostStatusDraft | ListGearDraft;
// ─── Human-readable summary for the confirmation card ────────────────────────
export function summarizeDraft(d: SasquatchDraft): { title: string; lines: string[]; cta: string } {
if (d.type === 'post_status') {
const mins = d.expiresInMinutes ?? 120;
return {
title: 'Post a skating status',
lines: [`"${d.text}"`, `Auto-expires in ${mins} min`],
cta: 'Post status',
};
}
const lines: string[] = [];
if (d.category) lines.push(`Category: ${d.category}`);
if (d.condition) lines.push(`Condition: ${d.condition}`);
if (d.size) lines.push(`Size: ${d.size}`);
lines.push(d.price ? `Price: ${d.price.toLocaleString()} ${d.currency ?? 'sats'}` : 'Price: make offer');
if (d.description) lines.push(d.description);
return { title: `List "${d.title}" for sale`, lines, cta: 'Publish listing' };
}
// ─── The hook ─────────────────────────────────────────────────────────────────
export function useSasquatchActions() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const publishDraft = useCallback(
async (draft: SasquatchDraft): Promise<{ ok: true; label: string }> => {
if (!user) throw new Error('You must be logged in.');
if (draft.type === 'post_status') {
const tags: string[][] = [['d', 'general']];
const mins = draft.expiresInMinutes ?? 120;
tags.push(['expiration', String(Math.floor(Date.now() / 1000) + mins * 60)]);
await publishEvent({ kind: USER_STATUS_KIND, content: draft.text.slice(0, 140), tags });
return { ok: true, label: 'Status posted 🛹' };
}
// list_gear
const now = Math.floor(Date.now() / 1000);
const identifier = `gear-${now}-${Math.random().toString(36).slice(2, 8)}`;
const currency = draft.currency ?? 'sats';
const tags: string[][] = [
['d', identifier],
['title', draft.title.slice(0, 120)],
['t', 'skateboarding'],
['listing_type', 'sell'],
['status', 'active'],
['published_at', String(now)],
['alt', `Gear listing: ${draft.title}`],
];
if (draft.category) tags.push(['t', draft.category]);
if (typeof draft.price === 'number' && draft.price > 0) {
tags.push(['price_type', 'fixed']);
tags.push(['price', String(draft.price), currency]);
} else {
tags.push(['price_type', 'offer']);
}
if (draft.condition) tags.push(['condition', draft.condition]);
if (draft.size) tags.push(['size', draft.size]);
await publishEvent({
kind: MARKETPLACE_ITEM_KIND,
content: (draft.description ?? '').slice(0, 2000),
tags,
});
return { ok: true, label: 'Listing published 💸' };
},
[user, publishEvent],
);
return { publishDraft };
}