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:
lucaronin
2026-02-28 22:16:06 +01:00
parent b610f1c673
commit e405ea74d9
6 changed files with 814 additions and 100 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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)
})
})

View File

@@ -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
View 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
View 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)
}

View File

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