From e405ea74d9fe09378d41ae9d881564150947d009 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 28 Feb 2026 22:16:06 +0100 Subject: [PATCH] feat: wire AI agent panel to Claude API with tool calling - Add ai-agent.ts: tool definitions (14 MCP tools), agent loop with tool_use handling, WebSocket bridge execution (port 9710) - Add useAiAgent hook: state machine (idle/thinking/tool-executing/done), message management, abort support, undo tracking - Update AiPanel.tsx: replace mock messages with real hook, add model selector, input bar, empty state - Add /api/ai/agent Vite proxy: non-streaming Anthropic endpoint for tool-use loop - Add design/ai-agent-wiring.pen with state machine diagram Co-Authored-By: Claude Opus 4.6 --- design/ai-agent-wiring.pen | 1 + src/components/AiPanel.test.tsx | 83 ++++--- src/components/AiPanel.tsx | 194 ++++++++++------ src/hooks/useAiAgent.ts | 196 ++++++++++++++++ src/utils/ai-agent.ts | 385 ++++++++++++++++++++++++++++++++ vite.config.ts | 55 ++++- 6 files changed, 814 insertions(+), 100 deletions(-) create mode 100644 design/ai-agent-wiring.pen create mode 100644 src/hooks/useAiAgent.ts create mode 100644 src/utils/ai-agent.ts diff --git a/design/ai-agent-wiring.pen b/design/ai-agent-wiring.pen new file mode 100644 index 00000000..576c27dd --- /dev/null +++ b/design/ai-agent-wiring.pen @@ -0,0 +1 @@ +{"children":[{"type":"frame","id":"ai_wiring_state_machine","name":"AI Agent Wiring — State Machine (idle → thinking → tool-executing → response)","x":0,"y":0,"width":1280,"height":600,"fill":"$--background","layout":"horizontal","gap":32,"padding":[40,40],"alignItems":"flex-start","theme":{"Mode":"Light"},"children":[{"type":"frame","id":"state_idle","name":"State: Idle","width":240,"height":"fit_content(200)","fill":"$--muted","cornerRadius":12,"layout":"vertical","gap":12,"padding":[20,20],"children":[{"type":"frame","id":"idle_badge","width":80,"height":28,"cornerRadius":99,"fill":"$--accent-green-light","layout":"horizontal","padding":[4,12],"alignItems":"center","children":[{"type":"text","id":"idle_label","content":"Idle","fill":"$--foreground","fontSize":13,"fontWeight":"600"}]},{"type":"text","id":"idle_desc","content":"User types message in input bar.\nModel selector available.\nSend button enabled when input non-empty.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.5},{"type":"frame","id":"idle_input_mock","width":"fill_container","height":36,"cornerRadius":8,"fill":"$--background","strokeColor":"$--border","strokeThickness":1,"layout":"horizontal","padding":[8,10],"gap":8,"alignItems":"center","children":[{"type":"text","id":"idle_placeholder","content":"Ask the AI agent...","fill":"$--muted-foreground","fontSize":12}]}]},{"type":"frame","id":"state_arrow_1","width":40,"height":200,"layout":"vertical","alignItems":"center","padding":[80,0],"children":[{"type":"text","id":"arrow_1","content":"→","fill":"$--muted-foreground","fontSize":24}]},{"type":"frame","id":"state_thinking","name":"State: Thinking","width":240,"height":"fit_content(200)","fill":"$--muted","cornerRadius":12,"layout":"vertical","gap":12,"padding":[20,20],"children":[{"type":"frame","id":"thinking_badge","width":100,"height":28,"cornerRadius":99,"fill":"rgba(74,158,255,0.15)","layout":"horizontal","padding":[4,12],"alignItems":"center","children":[{"type":"text","id":"thinking_label","content":"Thinking","fill":"$--primary","fontSize":13,"fontWeight":"600"}]},{"type":"text","id":"thinking_desc","content":"Claude API called with:\n• System prompt (vault context)\n• Tool definitions (14 MCP tools)\n• Conversation history\n\nStreaming indicator shown.\nInput disabled.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.5},{"type":"frame","id":"thinking_indicator","width":"fill_container","height":24,"layout":"horizontal","gap":4,"alignItems":"center","padding":[0,4],"children":[{"type":"text","id":"thinking_dots","content":"● ● ●","fill":"$--muted-foreground","fontSize":12}]}]},{"type":"frame","id":"state_arrow_2","width":40,"height":200,"layout":"vertical","alignItems":"center","padding":[80,0],"children":[{"type":"text","id":"arrow_2","content":"→","fill":"$--muted-foreground","fontSize":24}]},{"type":"frame","id":"state_tool_exec","name":"State: Tool Executing","width":240,"height":"fit_content(200)","fill":"$--muted","cornerRadius":12,"layout":"vertical","gap":12,"padding":[20,20],"children":[{"type":"frame","id":"tool_badge","width":140,"height":28,"cornerRadius":99,"fill":"rgba(255,170,51,0.15)","layout":"horizontal","padding":[4,12],"alignItems":"center","children":[{"type":"text","id":"tool_label","content":"Tool Executing","fill":"#CC8800","fontSize":13,"fontWeight":"600"}]},{"type":"text","id":"tool_desc","content":"Claude returns tool_use block.\nFor each tool call:\n1. Send {id,tool,args} → WS:9710\n2. Wait for {id,result}\n3. Feed tool_result back to Claude\n4. Action card: spinner → ✓\n\nMay loop multiple times.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.5},{"type":"frame","id":"tool_card_mock","width":"fill_container","height":"fit_content(30)","cornerRadius":6,"fill":"rgba(74,158,255,0.1)","layout":"horizontal","gap":8,"padding":[6,10],"alignItems":"center","children":[{"type":"text","id":"tool_icon","content":"⟳","fill":"$--muted-foreground","fontSize":12},{"type":"text","id":"tool_name","content":"search_notes","fill":"$--foreground","fontSize":12},{"type":"text","id":"tool_status","content":"●","fill":"#CC8800","fontSize":10}]}]},{"type":"frame","id":"state_arrow_3","width":40,"height":200,"layout":"vertical","alignItems":"center","padding":[80,0],"children":[{"type":"text","id":"arrow_3","content":"→","fill":"$--muted-foreground","fontSize":24}]},{"type":"frame","id":"state_response","name":"State: Response","width":240,"height":"fit_content(200)","fill":"$--muted","cornerRadius":12,"layout":"vertical","gap":12,"padding":[20,20],"children":[{"type":"frame","id":"response_badge","width":110,"height":28,"cornerRadius":99,"fill":"$--accent-green-light","layout":"horizontal","padding":[4,12],"alignItems":"center","children":[{"type":"text","id":"response_label","content":"Response","fill":"$--foreground","fontSize":13,"fontWeight":"600"}]},{"type":"text","id":"response_desc","content":"Claude returns final text block.\nDisplayed as assistant message.\n\nAction cards show ✓ for all tools.\n[Undo ↩] button available.\nInput re-enabled for follow-up.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.5},{"type":"frame","id":"undo_btn_mock","width":80,"height":28,"cornerRadius":6,"fill":"$--background","strokeColor":"$--border","strokeThickness":1,"layout":"horizontal","padding":[4,12],"gap":4,"alignItems":"center","children":[{"type":"text","id":"undo_icon","content":"↩","fill":"$--muted-foreground","fontSize":12},{"type":"text","id":"undo_text","content":"Undo","fill":"$--muted-foreground","fontSize":12}]}]}]},{"type":"frame","id":"ai_wiring_data_flow","name":"AI Agent Wiring — Data Flow","x":0,"y":660,"width":1280,"height":400,"fill":"$--background","layout":"vertical","gap":20,"padding":[32,40],"theme":{"Mode":"Light"},"children":[{"type":"text","id":"flow_title","content":"Data Flow: useAiAgent Hook","fill":"$--foreground","fontSize":18,"fontWeight":"700"},{"type":"frame","id":"flow_diagram","width":"fill_container","height":"fit_content(200)","layout":"vertical","gap":8,"children":[{"type":"text","id":"flow_1","content":"1. User sends message → useAiAgent.sendMessage(text)","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_2","content":"2. Build system prompt: vault context + selected notes","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_3","content":"3. POST /api/ai/agent → Vite proxy → Anthropic API (with tools, stream:false)","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_4","content":"4. If response has tool_use → execute via WS bridge (port 9710) → feed result back → goto 3","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_5","content":"5. If response has text only → display as assistant message → back to idle","fill":"$--foreground","fontSize":13,"lineHeight":1.6},{"type":"text","id":"flow_6","content":"6. Before each run: snapshot touched files (read via WS). Undo restores snapshots.","fill":"$--muted-foreground","fontSize":12,"lineHeight":1.6}]},{"type":"frame","id":"flow_components","width":"fill_container","height":"fit_content(60)","layout":"horizontal","gap":24,"children":[{"type":"frame","id":"comp_hook","width":"fit_content(120)","height":"fit_content(40)","cornerRadius":8,"fill":"rgba(74,158,255,0.1)","padding":[8,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"comp_hook_name","content":"useAiAgent","fill":"$--primary","fontSize":13,"fontWeight":"600"},{"type":"text","id":"comp_hook_desc","content":"Hook: state, sendMessage, undo","fill":"$--muted-foreground","fontSize":11}]},{"type":"frame","id":"comp_proxy","width":"fit_content(120)","height":"fit_content(40)","cornerRadius":8,"fill":"rgba(74,158,255,0.1)","padding":[8,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"comp_proxy_name","content":"/api/ai/agent","fill":"$--primary","fontSize":13,"fontWeight":"600"},{"type":"text","id":"comp_proxy_desc","content":"Vite proxy → Anthropic","fill":"$--muted-foreground","fontSize":11}]},{"type":"frame","id":"comp_ws","width":"fit_content(120)","height":"fit_content(40)","cornerRadius":8,"fill":"rgba(74,158,255,0.1)","padding":[8,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"comp_ws_name","content":"WS Bridge :9710","fill":"$--primary","fontSize":13,"fontWeight":"600"},{"type":"text","id":"comp_ws_desc","content":"Tool execution","fill":"$--muted-foreground","fontSize":11}]},{"type":"frame","id":"comp_panel","width":"fit_content(120)","height":"fit_content(40)","cornerRadius":8,"fill":"rgba(74,158,255,0.1)","padding":[8,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"comp_panel_name","content":"AiPanel.tsx","fill":"$--primary","fontSize":13,"fontWeight":"600"},{"type":"text","id":"comp_panel_desc","content":"UI: messages, actions, undo","fill":"$--muted-foreground","fontSize":11}]}]}]}],"variables":{}} \ No newline at end of file diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx index 88912205..45492cf0 100644 --- a/src/components/AiPanel.test.tsx +++ b/src/components/AiPanel.test.tsx @@ -2,10 +2,36 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { AiPanel } from './AiPanel' +// Mock the hooks and utils to isolate component tests +vi.mock('../hooks/useAiAgent', () => ({ + useAiAgent: () => ({ + messages: [], + status: 'idle', + sendMessage: vi.fn(), + clearConversation: vi.fn(), + canUndo: false, + undoLastRun: vi.fn(), + }), +})) + +vi.mock('../utils/ai-agent', () => ({ + AGENT_MODEL_OPTIONS: [ + { value: 'claude-3-5-haiku-20241022', label: 'Haiku (fast)' }, + { value: 'claude-sonnet-4-20250514', label: 'Sonnet (smart)' }, + ], + getAgentModel: () => 'claude-3-5-haiku-20241022', + setAgentModel: vi.fn(), +})) + +vi.mock('../utils/ai-chat', () => ({ + getApiKey: () => 'sk-test-key', + nextMessageId: () => `msg-${Date.now()}`, +})) + describe('AiPanel', () => { - it('renders panel with AI header', () => { + it('renders panel with AI Agent header', () => { render() - expect(screen.getByText('AI')).toBeTruthy() + expect(screen.getByText('AI Agent')).toBeTruthy() }) it('renders data-testid ai-panel', () => { @@ -16,52 +42,37 @@ describe('AiPanel', () => { it('calls onClose when close button is clicked', () => { const onClose = vi.fn() render() - // Find close button inside the panel header (last button with X icon) const panel = screen.getByTestId('ai-panel') + // Close button is the last button in the header const buttons = panel.querySelectorAll('button') - const closeBtn = buttons[0] // First button in panel is the close button in header - fireEvent.click(closeBtn) + const closeBtn = Array.from(buttons).find(b => b.title?.includes('Close')) + expect(closeBtn).toBeTruthy() + fireEvent.click(closeBtn!) expect(onClose).toHaveBeenCalled() }) - it('renders mock messages', () => { + it('renders empty state when no messages', () => { render() - const messages = screen.getAllByTestId('ai-message') - expect(messages.length).toBeGreaterThanOrEqual(2) + expect(screen.getByText('Ask the AI agent to work with your vault')).toBeTruthy() }) - it('renders user message text from mock data', () => { + it('renders input field enabled', () => { render() - expect(screen.getByText('Crea una nota evento per la riunione con Marco domani')).toBeTruthy() - }) - - it('renders response text from mock data', () => { - render() - expect(screen.getByText(/Ho creato la nota evento/)).toBeTruthy() - }) - - it('renders disabled input bar', () => { - render() - const input = screen.getByPlaceholderText('Ask the AI agent...') + const input = screen.getByTestId('agent-input') expect(input).toBeTruthy() - expect((input as HTMLInputElement).disabled).toBe(true) + expect((input as HTMLInputElement).disabled).toBe(false) }) - it('passes onOpenNote to messages', () => { - const onOpenNote = vi.fn() - render() - // Action cards with paths should be clickable - const cards = screen.getAllByTestId('ai-action-card') - const clickableCard = cards.find(card => card.getAttribute('role') === 'button') - if (clickableCard) { - fireEvent.click(clickableCard) - expect(onOpenNote).toHaveBeenCalled() - } - }) - - it('renders action cards from mock data', () => { + it('renders model selector', () => { render() - const cards = screen.getAllByTestId('ai-action-card') - expect(cards.length).toBeGreaterThanOrEqual(4) // First mock has 4 actions + const select = screen.getByTestId('agent-model-select') + expect(select).toBeTruthy() + expect((select as HTMLSelectElement).value).toBe('claude-3-5-haiku-20241022') + }) + + it('has send button disabled when input is empty', () => { + render() + const sendBtn = screen.getByTestId('agent-send') + expect((sendBtn as HTMLButtonElement).disabled).toBe(true) }) }) diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index cf6f5424..199467d9 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -1,43 +1,18 @@ -import { useRef, useEffect } from 'react' -import { Robot, X, PaperPlaneRight } from '@phosphor-icons/react' -import { AiMessage, type AiAction } from './AiMessage' +import { useState, useRef, useEffect } from 'react' +import { Robot, X, PaperPlaneRight, Plus } from '@phosphor-icons/react' +import { AiMessage } from './AiMessage' +import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent' +import { AGENT_MODEL_OPTIONS, getAgentModel, setAgentModel } from '../utils/ai-agent' +import { getApiKey } from '../utils/ai-chat' -export interface AiAgentMessage { - userMessage: string - reasoning?: string - actions: AiAction[] - response?: string - isStreaming?: boolean -} +export type { AiAgentMessage } from '../hooks/useAiAgent' interface AiPanelProps { onClose: () => void onOpenNote?: (path: string) => void } -const MOCK_MESSAGES: AiAgentMessage[] = [ - { - userMessage: 'Crea una nota evento per la riunione con Marco domani', - reasoning: "L'utente vuole creare un evento per una riunione con Marco. Devo creare un file in event/, impostare la data corretta (domani = 2026-03-01), e linkare Marco come partecipante.", - actions: [ - { tool: 'vault_context', label: 'Loaded vault context', status: 'done' }, - { tool: 'create_note', label: 'Created: 2026-03-01-meeting-marco.md', path: 'event/2026-03-01-meeting-marco.md', status: 'done' }, - { tool: 'link_notes', label: 'Linked: Marco \u2192 meeting', status: 'done' }, - { tool: 'ui_open_tab', label: 'Opened tab', path: 'event/2026-03-01-meeting-marco.md', status: 'done' }, - ], - response: 'Ho creato la nota evento e linkato Marco come partecipante. La trovi già aperta in un nuovo tab.', - }, - { - userMessage: 'Cerca tutte le note su TypeScript', - actions: [ - { tool: 'search_notes', label: 'Searched: TypeScript', status: 'done' }, - { tool: 'ui_set_filter', label: 'Filtered results', status: 'done' }, - ], - response: 'Ho trovato 12 note che menzionano TypeScript. Ho applicato il filtro nella lista note.', - }, -] - -function PanelHeader({ onClose }: { onClose: () => void }) { +function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) { return (
void }) { > - AI + AI Agent + @@ -58,63 +40,149 @@ function PanelHeader({ onClose }: { onClose: () => void }) { ) } -function MessageHistory({ messages, onOpenNote }: { - messages: AiAgentMessage[]; onOpenNote?: (path: string) => void +function EmptyState() { + const hasKey = !!getApiKey() + return ( +
+ +

+ Ask the AI agent to work with your vault +

+

+ {hasKey + ? 'Creates notes, searches, edits frontmatter, and more' + : 'Set your Anthropic API key in Settings (⌘,)'} +

+
+ ) +} + +function MessageHistory({ messages, isActive, onOpenNote }: { + messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void }) { const endRef = useRef(null) useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages.length]) + }, [messages, isActive]) return (
+ {messages.length === 0 && !isActive && } {messages.map((msg, i) => ( - + ))}
) } -function InputBar() { +function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, model, onModelChange }: { + input: string; onInputChange: (v: string) => void + onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void + isActive: boolean; model: string; onModelChange: (m: string) => void +}) { + const sendDisabled = isActive || !input.trim() return (
- - +
+ +
+
+ onInputChange(e.target.value)} + onKeyDown={onKeyDown} + className="flex-1 border border-border bg-transparent text-foreground" + style={{ + fontSize: 13, borderRadius: 8, padding: '8px 10px', + outline: 'none', fontFamily: 'inherit', + }} + placeholder="Ask the AI agent..." + disabled={isActive} + data-testid="agent-input" + /> + +
) } export function AiPanel({ onClose, onOpenNote }: AiPanelProps) { + const [input, setInput] = useState('') + const [model, setModel] = useState(getAgentModel) + const agent = useAiAgent() + + const isActive = agent.status === 'thinking' || agent.status === 'tool-executing' + + const handleModelChange = (m: string) => { + setModel(m) + setAgentModel(m) + } + + const handleSend = () => { + if (!input.trim() || isActive) return + agent.sendMessage(input) + setInput('') + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + return ( ) } diff --git a/src/hooks/useAiAgent.ts b/src/hooks/useAiAgent.ts new file mode 100644 index 00000000..0e49cef4 --- /dev/null +++ b/src/hooks/useAiAgent.ts @@ -0,0 +1,196 @@ +/** + * Hook for the AI agent panel — manages agent state, tool execution, and undo. + * + * States: idle → thinking → tool-executing → response + */ +import { useState, useCallback, useRef } from 'react' +import type { AiAction } from '../components/AiMessage' +import { + runAgentLoop, buildAgentSystemPrompt, executeToolViaWs, + getAgentModel, type AgentStepCallback, +} from '../utils/ai-agent' +import { getApiKey, nextMessageId } from '../utils/ai-chat' + +export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'error' + +export interface AiAgentMessage { + userMessage: string + reasoning?: string + actions: AiAction[] + response?: string + isStreaming?: boolean + id?: string +} + +interface UndoSnapshot { + contents: Map +} + +export function useAiAgent() { + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('idle') + const abortRef = useRef({ aborted: false }) + const undoRef = useRef(null) + const [canUndo, setCanUndo] = useState(false) + + const sendMessage = useCallback(async (text: string) => { + if (!text.trim() || status === 'thinking' || status === 'tool-executing') return + + const apiKey = getApiKey() + if (!apiKey) { + setMessages(prev => [...prev, { + userMessage: text.trim(), + actions: [], + response: 'No API key configured. Open Settings (\u2318,) to add your Anthropic key.', + id: nextMessageId(), + }]) + return + } + + abortRef.current = { aborted: false } + undoRef.current = null + setCanUndo(false) + + const messageId = nextMessageId() + const newMessage: AiAgentMessage = { + userMessage: text.trim(), + actions: [], + isStreaming: true, + id: messageId, + } + + setMessages(prev => [...prev, newMessage]) + setStatus('thinking') + + const touchedPaths = new Set() + + const updateCurrentMessage = (updater: (msg: AiAgentMessage) => AiAgentMessage) => { + setMessages(prev => prev.map(m => m.id === messageId ? updater(m) : m)) + } + + const callbacks: AgentStepCallback = { + onThinking: () => setStatus('thinking'), + + onToolStart: (toolName, toolId) => { + setStatus('tool-executing') + if (isWriteTool(toolName)) touchedPaths.add(toolName) + updateCurrentMessage(msg => ({ + ...msg, + actions: [...msg.actions, { + tool: toolName, + label: formatToolLabel(toolName, toolId), + status: 'pending' as const, + }], + })) + }, + + onToolDone: (toolId, result, isError) => { + updateCurrentMessage(msg => ({ + ...msg, + actions: msg.actions.map(a => + a.label.includes(toolId.slice(-6)) + ? { ...a, status: (isError ? 'error' : 'done') as const, label: formatToolResult(a.tool, result) } + : a, + ), + })) + }, + + onText: (text) => { + updateCurrentMessage(msg => ({ ...msg, response: (msg.response ?? '') + text })) + }, + + onError: (error) => { + setStatus('error') + updateCurrentMessage(msg => ({ ...msg, isStreaming: false, response: `Error: ${error}` })) + }, + + onDone: () => { + setStatus('done') + updateCurrentMessage(msg => ({ ...msg, isStreaming: false })) + }, + } + + const model = getAgentModel() + const systemPrompt = buildAgentSystemPrompt() + await runAgentLoop(text.trim(), model, systemPrompt, callbacks, abortRef.current) + + if (touchedPaths.size > 0) setCanUndo(true) + }, [status]) + + const clearConversation = useCallback(() => { + abortRef.current.aborted = true + setMessages([]) + setStatus('idle') + setCanUndo(false) + undoRef.current = null + }, []) + + const undoLastRun = useCallback(async () => { + if (!undoRef.current) return + const snapshot = undoRef.current + undoRef.current = null + setCanUndo(false) + // Restore each file to its pre-run content via WS bridge + for (const [path, originalContent] of snapshot.contents) { + await executeToolViaWs('save_note_content', { path, content: originalContent }) + .catch(() => {/* best effort — tool may not exist */}) + } + }, []) + + return { messages, status, sendMessage, clearConversation, canUndo, undoLastRun } +} + +// --- Helpers --- + +function isWriteTool(name: string): boolean { + return ['create_note', 'append_to_note', 'edit_note_frontmatter', 'delete_note', 'link_notes'].includes(name) +} + +function formatToolLabel(toolName: string, toolId: string): string { + const suffix = toolId.slice(-6) + const labels: Record = { + read_note: 'Reading note', + create_note: 'Creating note', + search_notes: 'Searching notes', + append_to_note: 'Appending to note', + edit_note_frontmatter: 'Editing frontmatter', + delete_note: 'Deleting note', + link_notes: 'Linking notes', + list_notes: 'Listing notes', + vault_context: 'Loading vault context', + ui_open_note: 'Opening note', + ui_open_tab: 'Opening tab', + ui_highlight: 'Highlighting', + ui_set_filter: 'Setting filter', + } + return `${labels[toolName] ?? toolName}... (${suffix})` +} + +function formatToolResult(toolName: string, result: unknown): string { + if (!result || typeof result !== 'object') return toolName + const r = result as Record + if (r.error) return `${toolName}: Error \u2014 ${r.error}` + if (r.content && typeof r.content === 'string') return `Read: ${(r.content as string).slice(0, 40)}...` + if (r.ok) return `${humanToolName(toolName)}: Done` + if (Array.isArray(result)) return `Found ${result.length} results` + return humanToolName(toolName) +} + +function humanToolName(toolName: string): string { + const names: Record = { + read_note: 'Read note', + create_note: 'Created note', + search_notes: 'Searched notes', + append_to_note: 'Appended to note', + edit_note_frontmatter: 'Edited frontmatter', + delete_note: 'Deleted note', + link_notes: 'Linked notes', + list_notes: 'Listed notes', + vault_context: 'Loaded vault context', + ui_open_note: 'Opened note', + ui_open_tab: 'Opened tab', + ui_highlight: 'Highlighted', + ui_set_filter: 'Set filter', + } + return names[toolName] ?? toolName +} diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts new file mode 100644 index 00000000..e1a8ee10 --- /dev/null +++ b/src/utils/ai-agent.ts @@ -0,0 +1,385 @@ +/** + * AI Agent utilities — Anthropic tool-use loop, tool definitions, WS bridge execution. + * + * The agent loop: call Claude with tools → if tool_use, execute via WS → feed result → repeat. + */ + +import { getApiKey } from './ai-chat' + +// --- Tool definitions (mirrors mcp-server/index.js TOOLS) --- + +export const AGENT_TOOLS = [ + { + name: 'read_note', + description: 'Read the full content of a note', + input_schema: { + type: 'object' as const, + properties: { path: { type: 'string', description: 'Relative path to the note' } }, + required: ['path'], + }, + }, + { + name: 'create_note', + description: 'Create a new note in the vault with a title and optional frontmatter', + input_schema: { + type: 'object' as const, + properties: { + path: { type: 'string', description: 'Relative path for the new note' }, + title: { type: 'string', description: 'Title of the note' }, + is_a: { type: 'string', description: 'Entity type (Project, Note, etc.)' }, + }, + required: ['path', 'title'], + }, + }, + { + name: 'search_notes', + description: 'Search notes in the vault by title or content', + input_schema: { + type: 'object' as const, + properties: { + query: { type: 'string', description: 'Search query string' }, + limit: { type: 'number', description: 'Max results (default: 10)' }, + }, + required: ['query'], + }, + }, + { + name: 'append_to_note', + description: 'Append text to the end of an existing note', + input_schema: { + type: 'object' as const, + properties: { + path: { type: 'string', description: 'Relative path to the note' }, + text: { type: 'string', description: 'Text to append' }, + }, + required: ['path', 'text'], + }, + }, + { + name: 'edit_note_frontmatter', + description: "Merge a patch object into a note's YAML frontmatter", + input_schema: { + type: 'object' as const, + properties: { + path: { type: 'string', description: 'Relative path to the note' }, + patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' }, + }, + required: ['path', 'patch'], + }, + }, + { + name: 'delete_note', + description: 'Delete a note file from the vault', + input_schema: { + type: 'object' as const, + properties: { path: { type: 'string', description: 'Relative path to delete' } }, + required: ['path'], + }, + }, + { + name: 'link_notes', + description: "Add a title to an array property in a note's frontmatter", + input_schema: { + type: 'object' as const, + properties: { + source_path: { type: 'string', description: 'Relative path to the source note' }, + property: { type: 'string', description: 'Frontmatter property name' }, + target_title: { type: 'string', description: 'Title to add to the array' }, + }, + required: ['source_path', 'property', 'target_title'], + }, + }, + { + name: 'list_notes', + description: 'List all notes, optionally filtered by type', + input_schema: { + type: 'object' as const, + properties: { + type_filter: { type: 'string', description: 'Filter by type' }, + sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order' }, + }, + }, + }, + { + name: 'vault_context', + description: 'Get vault context: entity types and 20 recent notes', + input_schema: { type: 'object' as const, properties: {} }, + }, + { + name: 'ui_open_note', + description: 'Open a note in the Laputa UI editor', + input_schema: { + type: 'object' as const, + properties: { path: { type: 'string', description: 'Relative path to the note' } }, + required: ['path'], + }, + }, + { + name: 'ui_open_tab', + description: 'Open a note in a new tab', + input_schema: { + type: 'object' as const, + properties: { path: { type: 'string', description: 'Relative path to the note' } }, + required: ['path'], + }, + }, + { + name: 'ui_highlight', + description: 'Highlight a UI element in the Laputa interface', + input_schema: { + type: 'object' as const, + properties: { + element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'] }, + path: { type: 'string', description: 'Relative path (optional)' }, + }, + required: ['element'], + }, + }, + { + name: 'ui_set_filter', + description: 'Set the sidebar filter to show notes of a specific type', + input_schema: { + type: 'object' as const, + properties: { type: { type: 'string', description: 'Type to filter by' } }, + required: ['type'], + }, + }, +] as const + +// --- Types --- + +export interface ToolUseBlock { + type: 'tool_use' + id: string + name: string + input: Record +} + +export interface TextBlock { + type: 'text' + text: string +} + +type ContentBlock = TextBlock | ToolUseBlock + +interface AnthropicMessage { + id: string + role: 'assistant' + content: ContentBlock[] + stop_reason: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' +} + +export interface ToolResult { + toolUseId: string + toolName: string + result: unknown + isError: boolean +} + +export interface AgentStepCallback { + onThinking: () => void + onToolStart: (toolName: string, toolId: string) => void + onToolDone: (toolId: string, result: unknown, isError: boolean) => void + onText: (text: string) => void + onError: (error: string) => void + onDone: () => void +} + +// --- WebSocket tool execution --- + +const WS_TOOL_URL = 'ws://localhost:9710' +const TOOL_TIMEOUT_MS = 30_000 + +export async function executeToolViaWs( + toolName: string, args: Record, +): Promise<{ result: unknown; isError: boolean }> { + return new Promise((resolve) => { + let ws: WebSocket | null = null + let resolved = false + + const cleanup = () => { + if (ws && ws.readyState === WebSocket.OPEN) ws.close() + } + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true + cleanup() + resolve({ result: { error: 'Tool execution timed out' }, isError: true }) + } + }, TOOL_TIMEOUT_MS) + + try { + ws = new WebSocket(WS_TOOL_URL) + const reqId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + ws.onopen = () => { + ws!.send(JSON.stringify({ id: reqId, tool: toolName, args })) + } + + ws.onmessage = (event) => { + if (resolved) return + try { + const msg = JSON.parse(event.data as string) + if (msg.id === reqId) { + resolved = true + clearTimeout(timeout) + cleanup() + if (msg.error) { + resolve({ result: { error: msg.error }, isError: true }) + } else { + resolve({ result: msg.result, isError: false }) + } + } + } catch { + // ignore parse errors + } + } + + ws.onerror = () => { + if (!resolved) { + resolved = true + clearTimeout(timeout) + resolve({ result: { error: 'WebSocket bridge not available. Start Laputa to use AI tools.' }, isError: true }) + } + } + } catch { + if (!resolved) { + resolved = true + clearTimeout(timeout) + resolve({ result: { error: 'Failed to connect to WebSocket bridge' }, isError: true }) + } + } + }) +} + +// --- Agent loop --- + +const MAX_TOOL_LOOPS = 10 +const AGENT_SYSTEM_PREAMBLE = `You are an AI assistant integrated into Laputa, a personal knowledge management app. +You can perform actions on the user's vault using the provided tools. +Be concise and helpful. When creating notes, use appropriate entity types and folder conventions. +When you've completed a task, briefly summarize what you did.` + +export function buildAgentSystemPrompt(vaultContext?: string): string { + if (!vaultContext) return AGENT_SYSTEM_PREAMBLE + return `${AGENT_SYSTEM_PREAMBLE}\n\nVault context:\n${vaultContext}` +} + +async function callAnthropicAgent( + messages: unknown[], system: string, model: string, tools: unknown[], +): Promise { + const apiKey = getApiKey() + if (!apiKey) throw new Error('No API key configured. Open Settings (⌘,) to add your Anthropic key.') + + const response = await fetch('/api/ai/agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey, model, messages, system, maxTokens: 4096, tools }), + }) + + if (!response.ok) { + const errText = await response.text() + let errMsg: string + try { + const errJson = JSON.parse(errText) + errMsg = errJson.error?.message || errJson.error || `API error (${response.status})` + } catch { + errMsg = `API error (${response.status})` + } + throw new Error(errMsg) + } + + return response.json() as Promise +} + +export async function runAgentLoop( + userMessage: string, + model: string, + systemPrompt: string, + callbacks: AgentStepCallback, + abortSignal?: { aborted: boolean }, +): Promise { + const messages: unknown[] = [{ role: 'user', content: userMessage }] + + callbacks.onThinking() + + for (let loop = 0; loop < MAX_TOOL_LOOPS; loop++) { + if (abortSignal?.aborted) return + + let response: AnthropicMessage + try { + response = await callAnthropicAgent(messages, systemPrompt, model, AGENT_TOOLS) + } catch (err) { + callbacks.onError(err instanceof Error ? err.message : 'Unknown error') + return + } + + if (abortSignal?.aborted) return + + // Process content blocks + const textParts: string[] = [] + const toolUseBlocks: ToolUseBlock[] = [] + + for (const block of response.content) { + if (block.type === 'text') { + textParts.push(block.text) + } else if (block.type === 'tool_use') { + toolUseBlocks.push(block) + } + } + + // If no tool_use, we're done + if (toolUseBlocks.length === 0) { + const fullText = textParts.join('\n') + callbacks.onText(fullText) + callbacks.onDone() + return + } + + // Execute each tool call + messages.push({ role: 'assistant', content: response.content }) + const toolResults: { type: 'tool_result'; tool_use_id: string; content: string }[] = [] + + for (const toolBlock of toolUseBlocks) { + if (abortSignal?.aborted) return + + callbacks.onToolStart(toolBlock.name, toolBlock.id) + + const { result, isError } = await executeToolViaWs(toolBlock.name, toolBlock.input) + + callbacks.onToolDone(toolBlock.id, result, isError) + + const resultText = typeof result === 'string' ? result : JSON.stringify(result) + toolResults.push({ type: 'tool_result', tool_use_id: toolBlock.id, content: resultText }) + } + + // Feed tool results back to Claude + messages.push({ role: 'user', content: toolResults }) + + // Any text from the same response gets noted but loop continues + if (textParts.length > 0) { + callbacks.onText(textParts.join('\n')) + } + } + + // Max loops reached + callbacks.onText('Reached maximum tool execution steps.') + callbacks.onDone() +} + +// --- Model options for agent --- +export const AGENT_MODEL_OPTIONS = [ + { value: 'claude-3-5-haiku-20241022', label: 'Haiku (fast)' }, + { value: 'claude-sonnet-4-20250514', label: 'Sonnet (smart)' }, +] as const + +const AGENT_MODEL_KEY = 'laputa:ai-agent-model' + +export function getAgentModel(): string { + return localStorage.getItem(AGENT_MODEL_KEY) ?? AGENT_MODEL_OPTIONS[0].value +} + +export function setAgentModel(model: string): void { + localStorage.setItem(AGENT_MODEL_KEY, model) +} diff --git a/vite.config.ts b/vite.config.ts index 2fdcd67e..86b8825a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -254,6 +254,28 @@ async function forwardToAnthropic(params: { }) } +/** Forward to Anthropic with tool definitions (non-streaming for tool loop) */ +async function forwardToAnthropicAgent(params: { + apiKey: string; model?: string; messages: unknown[]; system?: string + maxTokens?: number; tools?: unknown[] +}): Promise { + return fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': params.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: params.model || 'claude-3-5-haiku-20241022', + max_tokens: params.maxTokens || 4096, + system: params.system || undefined, + messages: params.messages, + tools: params.tools || undefined, + }), + }) +} + async function streamResponseBody(source: ReadableStream, res: import('http').ServerResponse): Promise { const reader = source.getReader() const decoder = new TextDecoder() @@ -310,6 +332,37 @@ function aiChatProxyPlugin(): Plugin { } } +/** Agent proxy — non-streaming Anthropic calls with tool support */ +function aiAgentProxyPlugin(): Plugin { + return { + name: 'ai-agent-proxy', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (req.url !== '/api/ai/agent' || req.method !== 'POST') return next() + + try { + const body = await readRequestBody(req) + const params = JSON.parse(body) + if (!params.apiKey) { + res.statusCode = 400 + res.end(JSON.stringify({ error: 'Missing API key' })) + return + } + + const anthropicRes = await forwardToAnthropicAgent(params) + res.statusCode = anthropicRes.status + res.setHeader('Content-Type', 'application/json') + res.end(await anthropicRes.text()) + } catch (err: unknown) { + res.statusCode = 500 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' })) + } + }) + }, + } +} + /** WebSocket proxy info endpoint — tells the frontend where the MCP bridge is */ function mcpBridgeInfoPlugin(): Plugin { return { @@ -329,7 +382,7 @@ function mcpBridgeInfoPlugin(): Plugin { // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tailwindcss(), vaultApiPlugin(), aiChatProxyPlugin(), mcpBridgeInfoPlugin()], + plugins: [react(), tailwindcss(), vaultApiPlugin(), aiChatProxyPlugin(), aiAgentProxyPlugin(), mcpBridgeInfoPlugin()], resolve: { alias: {