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: {