From 7a43e418e2cc4dcda85d476afd4f3ab54eb8f43f Mon Sep 17 00:00:00 2001 From: Lemon Date: Sun, 29 Mar 2026 23:34:42 -0700 Subject: [PATCH] Add create_spell tool executor with ephemeral key publishing Build the tool execution handler for create_spell: converts structured parameters into kind:777 tags, generates an ephemeral keypair (like Shakespeare's NostrPublishEventsTool), signs and publishes the spell event plus a minimal kind:0 profile to relays. The executor returns the published NostrEvent on the DisplayMessage for inline rendering. Also makes executeToolCall async and updates the tool call loop to await each execution sequentially. --- src/pages/AIChatPage.tsx | 147 ++++++++++++++++++++++++++++++++++----- 1 file changed, 130 insertions(+), 17 deletions(-) diff --git a/src/pages/AIChatPage.tsx b/src/pages/AIChatPage.tsx index c8764460..e268da85 100644 --- a/src/pages/AIChatPage.tsx +++ b/src/pages/AIChatPage.tsx @@ -3,6 +3,8 @@ import { useSeoMeta } from '@unhead/react'; import Markdown from 'react-markdown'; import rehypeSanitize from 'rehype-sanitize'; import { Bot, Send, Trash2, Palette, Type } from 'lucide-react'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'; +import { useNostr } from '@nostrify/react'; import { PageHeader } from '@/components/PageHeader'; import { useShakespeare, type ChatMessage, type Model, type ChatCompletionTool } from '@/hooks/useShakespeare'; @@ -22,6 +24,7 @@ import { DorkThinking } from '@/components/DorkThinking'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import type { NostrEvent } from '@nostrify/nostrify'; import type { ThemeConfig } from '@/themes'; // ─── Tool Definitions ─── @@ -166,6 +169,8 @@ interface DisplayMessage { content: string; timestamp: Date; toolCalls?: ToolCall[]; + /** A spell event published by the create_spell tool, to be rendered inline. */ + spellEvent?: NostrEvent; } interface ToolCall { @@ -183,20 +188,69 @@ function isValidHsl(value: unknown): value is string { return /^\d{1,3}\s+\d{1,3}%\s+\d{1,3}%$/.test(value.trim()); } +/** Build the kind:777 tags array from create_spell tool arguments. */ +function buildSpellTags(args: Record): string[][] { + const tags: string[][] = []; + + if (typeof args.name === 'string') tags.push(['name', args.name]); + + const cmd = typeof args.cmd === 'string' ? args.cmd : 'REQ'; + tags.push(['cmd', cmd]); + + if (Array.isArray(args.kinds)) { + for (const k of args.kinds) { + if (typeof k === 'number') tags.push(['k', String(k)]); + } + } + + if (Array.isArray(args.authors)) { + tags.push(['authors', ...(args.authors as string[])]); + } + + if (Array.isArray(args.tag_filters)) { + for (const tf of args.tag_filters as Array<{ letter: string; values: string[] }>) { + if (tf.letter && Array.isArray(tf.values)) { + tags.push(['tag', tf.letter, ...tf.values]); + } + } + } + + if (typeof args.since === 'string') tags.push(['since', args.since]); + if (typeof args.until === 'string') tags.push(['until', args.until]); + if (typeof args.limit === 'number') tags.push(['limit', String(args.limit)]); + if (typeof args.search === 'string') tags.push(['search', args.search]); + + if (Array.isArray(args.relays) && args.relays.length > 0) { + tags.push(['relays', ...(args.relays as string[])]); + } + + tags.push(['alt', `Spell: ${args.name ?? 'unnamed'}`]); + + return tags; +} + +interface ToolExecutorResult { + /** JSON string returned to the AI as the tool result. */ + result: string; + /** The published spell event, if this was a create_spell call. */ + spellEvent?: NostrEvent; +} + function useToolExecutor() { const { applyCustomTheme } = useTheme(); + const { nostr } = useNostr(); - const executeToolCall = useCallback((name: string, args: Record): string => { + const executeToolCall = useCallback(async (name: string, args: Record): Promise => { switch (name) { case 'set_theme': { const { background, text, primary, font, background_url, background_mode } = args; // Validate required color values if (!isValidHsl(background) || !isValidHsl(text) || !isValidHsl(primary)) { - return JSON.stringify({ + return { result: JSON.stringify({ error: 'Invalid HSL color values. Each must be a string like "228 20% 10%".', received: { background, text, primary }, - }); + }) }; } // Build theme config @@ -214,9 +268,9 @@ function useToolExecutor() { if (bundled) { themeConfig.font = { family: bundled.family }; } else { - return JSON.stringify({ + return { result: JSON.stringify({ error: `Unknown font "${font}". Available fonts: ${AVAILABLE_FONTS}`, - }); + }) }; } } @@ -234,19 +288,70 @@ function useToolExecutor() { applyCustomTheme(themeConfig); // Build result summary - const result: Record = { + const resultData: Record = { success: true, colors: { background, text, primary }, }; - if (themeConfig.font) result.font = themeConfig.font.family; - if (themeConfig.background) result.background = { url: themeConfig.background.url, mode: themeConfig.background.mode }; + if (themeConfig.font) resultData.font = themeConfig.font.family; + if (themeConfig.background) resultData.background = { url: themeConfig.background.url, mode: themeConfig.background.mode }; - return JSON.stringify(result); + return { result: JSON.stringify(resultData) }; } + + case 'create_spell': { + try { + if (typeof args.name !== 'string' || !args.name.trim()) { + return { result: JSON.stringify({ error: 'A spell name is required.' }) }; + } + + const tags = buildSpellTags(args); + const content = typeof args.description === 'string' ? args.description : ''; + + // Generate ephemeral keypair + const sk = generateSecretKey(); + const pubkey = getPublicKey(sk); + + // Finalize and sign with ephemeral key + const spellEvent = finalizeEvent({ + kind: 777, + content, + tags, + created_at: Math.floor(Date.now() / 1000), + }, sk) as NostrEvent; + + // Publish a minimal kind:0 profile so the ephemeral key has an identity + const profileEvent = finalizeEvent({ + kind: 0, + content: JSON.stringify({ name: 'Dork Spellcaster', about: 'Spells created by Dork AI' }), + tags: [], + created_at: Math.floor(Date.now() / 1000), + }, sk) as NostrEvent; + + await Promise.all([ + nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) }), + nostr.event(spellEvent, { signal: AbortSignal.timeout(5000) }), + ]); + + return { + result: JSON.stringify({ + success: true, + event_id: spellEvent.id, + pubkey, + name: args.name, + }), + spellEvent, + }; + } catch (err) { + return { result: JSON.stringify({ + error: `Failed to publish spell: ${err instanceof Error ? err.message : 'Unknown error'}`, + }) }; + } + } + default: - return JSON.stringify({ error: `Unknown tool: ${name}` }); + return { result: JSON.stringify({ error: `Unknown tool: ${name}` }) }; } - }, [applyCustomTheme]); + }, [applyCustomTheme, nostr]); return { executeToolCall }; } @@ -383,7 +488,10 @@ export function AIChatPage() { if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) { // Execute tool calls - const toolCalls: ToolCall[] = assistantMsg.tool_calls.map((tc) => { + let spellEvent: NostrEvent | undefined; + const toolCalls: ToolCall[] = []; + + for (const tc of assistantMsg.tool_calls) { let args: Record = {}; try { args = JSON.parse(tc.function.arguments); @@ -391,15 +499,19 @@ export function AIChatPage() { // If parsing fails, pass empty args } - const result = executeToolCall(tc.function.name, args); + const execResult = await executeToolCall(tc.function.name, args); - return { + if (execResult.spellEvent) { + spellEvent = execResult.spellEvent; + } + + toolCalls.push({ id: tc.id, name: tc.function.name, arguments: args, - result, - }; - }); + result: execResult.result, + }); + } // Add assistant message with tool calls noted const toolMsg: DisplayMessage = { @@ -408,6 +520,7 @@ export function AIChatPage() { content: assistantMsg.content || '', timestamp: new Date(), toolCalls, + spellEvent, }; const messagesWithTool = [...newMessages, toolMsg]; setMessages(messagesWithTool);