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:
parent
79d1d47823
commit
5655fbc976
3 changed files with 357 additions and 1 deletions
121
src/components/SasquatchActionCard.tsx
Normal file
121
src/components/SasquatchActionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ import { useCVMClient } from '@/hooks/useCVMClient';
|
|||
import { usePersonalContext } from '@/hooks/usePersonalContext';
|
||||
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
|
||||
import { SasquatchProDialog } from '@/components/SasquatchProDialog';
|
||||
import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchActions';
|
||||
import { SasquatchActionCard } from '@/components/SasquatchActionCard';
|
||||
import { callSasquatchAI } from '@/lib/sasquatchBrain';
|
||||
import { answerFromGlossary } from '@/lib/glossary';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -92,6 +94,12 @@ interface ChatMessage {
|
|||
timestamp: number;
|
||||
isLive?: boolean; // came from CVM live data
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
|
@ -201,7 +209,22 @@ Example: {"tool":"get_my_listings","args":{}}
|
|||
Skate spots the user created/added. Args: none.
|
||||
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 ─────────────────────────────────
|
||||
|
||||
|
|
@ -340,6 +363,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
const cvm = useCVMClient();
|
||||
const personal = usePersonalContext();
|
||||
const { isPro } = useSasquatchPro();
|
||||
const { publishDraft } = useSasquatchActions();
|
||||
const [proDialogOpen, setProDialogOpen] = useState(false);
|
||||
|
||||
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
|
||||
|
|
@ -451,6 +475,36 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
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 ───────────────────────────────────────────
|
||||
|
||||
const handleSendMessage = useCallback(async (questionOverride?: string) => {
|
||||
|
|
@ -534,6 +588,60 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
} 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) {
|
||||
const { tool: toolName, args: toolArgs } = toolCall;
|
||||
setStreamingText(`🔍 Querying CVM for ${toolName.replace(/_/g, ' ')}...`);
|
||||
|
|
@ -926,6 +1034,17 @@ Instructions:
|
|||
)}
|
||||
</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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
116
src/hooks/useSasquatchActions.ts
Normal file
116
src/hooks/useSasquatchActions.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue