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 <noreply@anthropic.com>
This commit is contained in:
1
design/ai-agent-wiring.pen
Normal file
1
design/ai-agent-wiring.pen
Normal file
File diff suppressed because one or more lines are too long
@@ -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(<AiPanel onClose={vi.fn()} />)
|
||||
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(<AiPanel onClose={onClose} />)
|
||||
// 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(<AiPanel onClose={vi.fn()} />)
|
||||
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(<AiPanel onClose={vi.fn()} />)
|
||||
expect(screen.getByText('Crea una nota evento per la riunione con Marco domani')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders response text from mock data', () => {
|
||||
render(<AiPanel onClose={vi.fn()} />)
|
||||
expect(screen.getByText(/Ho creato la nota evento/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders disabled input bar', () => {
|
||||
render(<AiPanel onClose={vi.fn()} />)
|
||||
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(<AiPanel onClose={vi.fn()} onOpenNote={onOpenNote} />)
|
||||
// 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(<AiPanel onClose={vi.fn()} />)
|
||||
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(<AiPanel onClose={vi.fn()} />)
|
||||
const sendBtn = screen.getByTestId('agent-send')
|
||||
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border"
|
||||
@@ -45,12 +20,19 @@ function PanelHeader({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
AI
|
||||
AI Agent
|
||||
</span>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClear}
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClose}
|
||||
title="Close AI panel (\u2318I)"
|
||||
title="Close AI panel (⌘I)"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
||||
style={{ paddingTop: 40 }}
|
||||
>
|
||||
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
||||
Ask the AI agent to work with your vault
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
{hasKey
|
||||
? 'Creates notes, searches, edits frontmatter, and more'
|
||||
: 'Set your Anthropic API key in Settings (⌘,)'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageHistory({ messages, isActive, onOpenNote }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages.length])
|
||||
}, [messages, isActive])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isActive && <EmptyState />}
|
||||
{messages.map((msg, i) => (
|
||||
<AiMessage key={i} {...msg} onOpenNote={onOpenNote} />
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 border-t border-border"
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<input
|
||||
className="flex-1 border border-border bg-transparent text-muted-foreground"
|
||||
style={{ fontSize: 13, borderRadius: 8, padding: '8px 10px', outline: 'none', fontFamily: 'inherit' }}
|
||||
placeholder="Ask the AI agent..."
|
||||
disabled
|
||||
/>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none"
|
||||
style={{
|
||||
background: 'var(--muted)',
|
||||
color: 'var(--muted-foreground)',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: 'not-allowed',
|
||||
}}
|
||||
disabled
|
||||
title="Coming in Task 3"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<select
|
||||
value={model}
|
||||
onChange={e => onModelChange(e.target.value)}
|
||||
className="border border-border bg-transparent text-muted-foreground"
|
||||
style={{ fontSize: 11, borderRadius: 4, padding: '2px 6px', outline: 'none' }}
|
||||
data-testid="agent-model-select"
|
||||
>
|
||||
{AGENT_MODEL_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: sendDisabled ? 'var(--muted)' : 'var(--primary)',
|
||||
color: sendDisabled ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: sendDisabled ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={onSend}
|
||||
disabled={sendDisabled}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<aside
|
||||
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
|
||||
data-testid="ai-panel"
|
||||
>
|
||||
<PanelHeader onClose={onClose} />
|
||||
<MessageHistory messages={MOCK_MESSAGES} onOpenNote={onOpenNote} />
|
||||
<InputBar />
|
||||
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
|
||||
<MessageHistory
|
||||
messages={agent.messages}
|
||||
isActive={isActive}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
<InputBar
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSend={handleSend}
|
||||
onKeyDown={handleKeyDown}
|
||||
isActive={isActive}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
196
src/hooks/useAiAgent.ts
Normal file
196
src/hooks/useAiAgent.ts
Normal file
@@ -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<string, string>
|
||||
}
|
||||
|
||||
export function useAiAgent() {
|
||||
const [messages, setMessages] = useState<AiAgentMessage[]>([])
|
||||
const [status, setStatus] = useState<AgentStatus>('idle')
|
||||
const abortRef = useRef({ aborted: false })
|
||||
const undoRef = useRef<UndoSnapshot | null>(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<string>()
|
||||
|
||||
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<string, string> = {
|
||||
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<string, unknown>
|
||||
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<string, string> = {
|
||||
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
|
||||
}
|
||||
385
src/utils/ai-agent.ts
Normal file
385
src/utils/ai-agent.ts
Normal file
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
): 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<AnthropicMessage> {
|
||||
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<AnthropicMessage>
|
||||
}
|
||||
|
||||
export async function runAgentLoop(
|
||||
userMessage: string,
|
||||
model: string,
|
||||
systemPrompt: string,
|
||||
callbacks: AgentStepCallback,
|
||||
abortSignal?: { aborted: boolean },
|
||||
): Promise<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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<Uint8Array>, res: import('http').ServerResponse): Promise<void> {
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user