feat: replace Anthropic API with Claude CLI for AI chat and agent panels (#159)
* feat: add claude CLI subprocess backend for AI panels Add claude_cli.rs module that spawns the local `claude` CLI as a subprocess instead of calling the Anthropic API directly. This removes the need for users to configure an API key — the CLI uses the user's existing Claude authentication. - find_claude_binary(): discovers claude in PATH or common locations - check_cli(): returns install/version status for the frontend - run_chat_stream(): spawns claude -p with stream-json for chat panel - run_agent_stream(): spawns claude with MCP vault tools for agent panel - Parses stream-json NDJSON events and emits typed Tauri events - Remove http:default capability (no longer calling APIs from frontend) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: replace Anthropic API with Claude CLI for AI chat and agent panels Remove direct Anthropic API calls and the entire tool-use loop from the frontend. Both AI Chat and AI Agent panels now delegate to the `claude` CLI subprocess via Tauri commands, streaming NDJSON events back to the UI. Key changes: - ai-chat.ts / ai-agent.ts: replace SSE/API streaming with Tauri invoke + listen - useAIChat / useAiAgent hooks: simplified to use CLI streaming callbacks - AIChatPanel / AiPanel: remove API key dialogs, model selectors, undo support - SettingsPanel: remove Anthropic key field (no longer needed) - claude_cli.rs: add comprehensive tests (37 total, 90% coverage) - mock-handlers: replace ai_chat with check_claude_cli / stream_claude_* stubs - Net -432 lines across 17 files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add --verbose flag to claude CLI subprocess args * chore: exclude search.rs and lib.rs from coverage (qmd integration + Tauri setup) --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// localStorage mock (jsdom doesn't provide a full implementation)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
// Mock the mock-tauri module before importing ai-agent
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
}))
|
||||
|
||||
import { buildAgentSystemPrompt, executeToolViaWs, runAgentLoop, type AgentStepCallback } from './ai-agent'
|
||||
import { buildAgentSystemPrompt, streamClaudeAgent } from './ai-agent'
|
||||
|
||||
// --- buildAgentSystemPrompt ---
|
||||
|
||||
@@ -31,151 +24,28 @@ describe('buildAgentSystemPrompt', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- runAgentLoop calls Anthropic API directly ---
|
||||
// --- streamClaudeAgent ---
|
||||
|
||||
describe('runAgentLoop', () => {
|
||||
const mockCallbacks: AgentStepCallback = {
|
||||
onThinking: vi.fn(),
|
||||
onToolStart: vi.fn(),
|
||||
onToolDone: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
}
|
||||
describe('streamClaudeAgent', () => {
|
||||
it('calls onText and onDone in non-Tauri environment', async () => {
|
||||
const onText = vi.fn()
|
||||
const onToolStart = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorageMock.clear()
|
||||
localStorageMock.setItem('laputa:anthropic-api-key', 'test-key-123')
|
||||
})
|
||||
|
||||
it('calls https://api.anthropic.com/v1/messages directly', async () => {
|
||||
const mockResponse = {
|
||||
id: 'msg_123',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hello!' }],
|
||||
stop_reason: 'end_turn',
|
||||
}
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
await runAgentLoop('test message', 'claude-3-5-haiku-20241022', 'system prompt', mockCallbacks)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce()
|
||||
const [url, options] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('https://api.anthropic.com/v1/messages')
|
||||
expect(options?.method).toBe('POST')
|
||||
|
||||
const headers = options?.headers as Record<string, string>
|
||||
expect(headers['x-api-key']).toBe('test-key-123')
|
||||
expect(headers['anthropic-version']).toBe('2023-06-01')
|
||||
expect(headers['Content-Type']).toBe('application/json')
|
||||
|
||||
const body = JSON.parse(options?.body as string)
|
||||
expect(body.model).toBe('claude-3-5-haiku-20241022')
|
||||
expect(body.max_tokens).toBe(4096)
|
||||
expect(body.system).toBe('system prompt')
|
||||
expect(body.messages).toEqual([{ role: 'user', content: 'test message' }])
|
||||
expect(body.tools).toBeDefined()
|
||||
// API key must NOT be in the body
|
||||
expect(body.apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls onText and onDone for a text-only response', async () => {
|
||||
const mockResponse = {
|
||||
id: 'msg_123',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Here is your answer.' }],
|
||||
stop_reason: 'end_turn',
|
||||
}
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 }),
|
||||
)
|
||||
|
||||
await runAgentLoop('question', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks)
|
||||
|
||||
expect(mockCallbacks.onThinking).toHaveBeenCalled()
|
||||
expect(mockCallbacks.onText).toHaveBeenCalledWith('Here is your answer.')
|
||||
expect(mockCallbacks.onDone).toHaveBeenCalled()
|
||||
expect(mockCallbacks.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onError when API key is missing', async () => {
|
||||
localStorageMock.clear()
|
||||
|
||||
await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks)
|
||||
|
||||
expect(mockCallbacks.onError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No API key configured'),
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onError with API error message on non-ok response', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { message: 'Invalid API key' } }), { status: 401 }),
|
||||
)
|
||||
|
||||
await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks)
|
||||
|
||||
expect(mockCallbacks.onError).toHaveBeenCalledWith('Invalid API key')
|
||||
})
|
||||
|
||||
it('does not send apiKey in the request body', async () => {
|
||||
const mockResponse = {
|
||||
id: 'msg_123',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
stop_reason: 'end_turn',
|
||||
}
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 }),
|
||||
)
|
||||
|
||||
await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks)
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)
|
||||
expect(body).not.toHaveProperty('apiKey')
|
||||
expect(body).not.toHaveProperty('maxTokens')
|
||||
})
|
||||
})
|
||||
|
||||
// --- executeToolViaWs ---
|
||||
|
||||
describe('executeToolViaWs', () => {
|
||||
it('resolves with error when WebSocket is unavailable', async () => {
|
||||
// Mock WebSocket to simulate a connection failure without triggering
|
||||
// jsdom/undici unhandled rejections (jsdom's WebSocket internally throws
|
||||
// InvalidArgumentError: invalid onError method on failed connections).
|
||||
const OriginalWebSocket = globalThis.WebSocket
|
||||
const mockWs = {
|
||||
readyState: WebSocket.CONNECTING,
|
||||
close: vi.fn(),
|
||||
send: vi.fn(),
|
||||
onopen: null as ((ev: Event) => void) | null,
|
||||
onerror: null as ((ev: Event) => void) | null,
|
||||
onmessage: null as ((ev: MessageEvent) => void) | null,
|
||||
}
|
||||
// @ts-expect-error - partial mock for test
|
||||
globalThis.WebSocket = vi.fn(() => {
|
||||
// Fire onerror asynchronously to simulate failed connection
|
||||
setTimeout(() => {
|
||||
if (mockWs.onerror) mockWs.onerror(new Event('error'))
|
||||
}, 0)
|
||||
return mockWs
|
||||
await streamClaudeAgent('test message', 'system prompt', '/tmp/vault', {
|
||||
onText,
|
||||
onToolStart,
|
||||
onError,
|
||||
onDone,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await executeToolViaWs('read_note', { path: 'test.md' })
|
||||
expect(result.isError).toBe(true)
|
||||
} finally {
|
||||
globalThis.WebSocket = OriginalWebSocket
|
||||
}
|
||||
// Wait for the setTimeout mock response
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(onToolStart).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,261 +1,14 @@
|
||||
/**
|
||||
* AI Agent utilities — Anthropic tool-use loop, tool definitions, WS bridge execution.
|
||||
* AI Agent utilities — Claude CLI agent mode with MCP vault tools.
|
||||
*
|
||||
* The agent loop: call Claude with tools → if tool_use, execute via WS → feed result → repeat.
|
||||
* The Claude CLI handles the tool-use loop internally via MCP.
|
||||
* The frontend receives streaming events for text, tool calls, and completion.
|
||||
*/
|
||||
|
||||
import { getApiKey } from './ai-chat'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
// --- Tool definitions (mirrors mcp-server/index.js TOOLS) ---
|
||||
// --- Agent system prompt ---
|
||||
|
||||
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, args: Record<string, unknown>) => 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.
|
||||
@@ -266,130 +19,75 @@ export function buildAgentSystemPrompt(vaultContext?: string): string {
|
||||
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.')
|
||||
// --- Claude CLI agent streaming ---
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: system || undefined,
|
||||
messages,
|
||||
tools,
|
||||
}),
|
||||
type ClaudeAgentStreamEvent =
|
||||
| { kind: 'Init'; session_id: string }
|
||||
| { kind: 'TextDelta'; text: string }
|
||||
| { kind: 'ToolStart'; tool_name: string; tool_id: string }
|
||||
| { kind: 'ToolDone'; tool_id: string }
|
||||
| { kind: 'Result'; text: string; session_id: string }
|
||||
| { kind: 'Error'; message: string }
|
||||
| { kind: 'Done' }
|
||||
|
||||
export interface AgentStreamCallbacks {
|
||||
onText: (text: string) => void
|
||||
onToolStart: (toolName: string, toolId: string) => void
|
||||
onError: (message: string) => void
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream an agent task through the Claude CLI subprocess with MCP tools.
|
||||
* The CLI handles the tool-use loop; we receive events for UI updates.
|
||||
*/
|
||||
export async function streamClaudeAgent(
|
||||
message: string,
|
||||
systemPrompt: string | undefined,
|
||||
vaultPath: string,
|
||||
callbacks: AgentStreamCallbacks,
|
||||
): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText('AI Agent requires the Claude CLI. Install it and run the native app.')
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return
|
||||
}
|
||||
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
|
||||
const unlisten = await listen<ClaudeAgentStreamEvent>('claude-agent-stream', (event) => {
|
||||
const data = event.payload
|
||||
switch (data.kind) {
|
||||
case 'TextDelta':
|
||||
callbacks.onText(data.text)
|
||||
break
|
||||
case 'ToolStart':
|
||||
callbacks.onToolStart(data.tool_name, data.tool_id)
|
||||
break
|
||||
case 'Error':
|
||||
callbacks.onError(data.message)
|
||||
break
|
||||
case 'Done':
|
||||
callbacks.onDone()
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
try {
|
||||
await invoke<string>('stream_claude_agent', {
|
||||
request: {
|
||||
message,
|
||||
system_prompt: systemPrompt || null,
|
||||
vault_path: vaultPath,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
callbacks.onError(err instanceof Error ? err.message : String(err))
|
||||
callbacks.onDone()
|
||||
} finally {
|
||||
unlisten()
|
||||
}
|
||||
|
||||
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 as unknown as unknown[])
|
||||
} 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, toolBlock.input)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// localStorage mock (jsdom doesn't provide a full implementation)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
// Mock the mock-tauri module before importing ai-chat
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
}))
|
||||
|
||||
import {
|
||||
getApiKey, setApiKey, estimateTokens, buildSystemPrompt,
|
||||
nextMessageId, streamChat,
|
||||
estimateTokens, buildSystemPrompt,
|
||||
nextMessageId, checkClaudeCli, streamClaudeChat,
|
||||
} from './ai-chat'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// --- getApiKey / setApiKey ---
|
||||
|
||||
describe('getApiKey / setApiKey', () => {
|
||||
beforeEach(() => localStorageMock.clear())
|
||||
|
||||
it('returns empty string when no key set', () => {
|
||||
expect(getApiKey()).toBe('')
|
||||
})
|
||||
|
||||
it('round-trips an API key', () => {
|
||||
setApiKey('sk-test-abc')
|
||||
expect(getApiKey()).toBe('sk-test-abc')
|
||||
})
|
||||
})
|
||||
|
||||
// --- estimateTokens ---
|
||||
|
||||
describe('estimateTokens', () => {
|
||||
@@ -85,94 +63,36 @@ describe('nextMessageId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamChat calls Anthropic API directly ---
|
||||
// --- checkClaudeCli ---
|
||||
|
||||
describe('streamChat', () => {
|
||||
const onChunk = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
onChunk.mockClear()
|
||||
onDone.mockClear()
|
||||
onError.mockClear()
|
||||
localStorageMock.clear()
|
||||
localStorageMock.setItem('laputa:anthropic-api-key', 'test-key-456')
|
||||
})
|
||||
|
||||
it('calls https://api.anthropic.com/v1/messages with correct headers', async () => {
|
||||
const sseData = [
|
||||
'data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n',
|
||||
'data: {"type":"message_stop"}\n\n',
|
||||
].join('')
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(sseData, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
}),
|
||||
)
|
||||
|
||||
await streamChat(
|
||||
[{ role: 'user', content: 'hello' }], 'system', 'claude-3-5-haiku-20241022',
|
||||
onChunk, onDone, onError,
|
||||
)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce()
|
||||
const [url, options] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('https://api.anthropic.com/v1/messages')
|
||||
|
||||
const headers = options?.headers as Record<string, string>
|
||||
expect(headers['x-api-key']).toBe('test-key-456')
|
||||
expect(headers['anthropic-version']).toBe('2023-06-01')
|
||||
|
||||
const body = JSON.parse(options?.body as string)
|
||||
expect(body.stream).toBe(true)
|
||||
expect(body.model).toBe('claude-3-5-haiku-20241022')
|
||||
expect(body.messages).toEqual([{ role: 'user', content: 'hello' }])
|
||||
// API key must NOT be in the body
|
||||
expect(body.apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls onError when API key is missing', async () => {
|
||||
localStorageMock.clear()
|
||||
|
||||
await streamChat([], '', 'model', onChunk, onDone, onError)
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No API key configured'),
|
||||
)
|
||||
expect(onChunk).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onError with parsed error on non-ok response', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { message: 'Invalid x-api-key' } }), { status: 401 }),
|
||||
)
|
||||
|
||||
await streamChat(
|
||||
[{ role: 'user', content: 'hi' }], '', 'model',
|
||||
onChunk, onDone, onError,
|
||||
)
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Invalid x-api-key')
|
||||
})
|
||||
|
||||
it('does not send apiKey in the request body', async () => {
|
||||
const sseData = 'data: {"type":"message_stop"}\n\n'
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(sseData, { status: 200 }),
|
||||
)
|
||||
|
||||
await streamChat(
|
||||
[{ role: 'user', content: 'hi' }], '', 'model',
|
||||
onChunk, onDone, onError,
|
||||
)
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)
|
||||
expect(body).not.toHaveProperty('apiKey')
|
||||
expect(body).not.toHaveProperty('maxTokens')
|
||||
expect(body.max_tokens).toBe(4096)
|
||||
describe('checkClaudeCli', () => {
|
||||
it('returns not installed in non-Tauri environment', async () => {
|
||||
const status = await checkClaudeCli()
|
||||
expect(status.installed).toBe(false)
|
||||
expect(status.version).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamClaudeChat ---
|
||||
|
||||
describe('streamClaudeChat', () => {
|
||||
it('returns mock session in non-Tauri environment', async () => {
|
||||
const onText = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
const sessionId = await streamClaudeChat('hello', undefined, undefined, {
|
||||
onText,
|
||||
onError,
|
||||
onDone,
|
||||
})
|
||||
|
||||
// Wait for the setTimeout mock response
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
expect(sessionId).toBe('mock-session')
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
/**
|
||||
* AI Chat utilities — Anthropic API client, token estimation, context building.
|
||||
* AI Chat utilities — Claude CLI integration, token estimation, context building.
|
||||
*/
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// --- localStorage key for API key ---
|
||||
const API_KEY_STORAGE_KEY = 'laputa:anthropic-api-key'
|
||||
|
||||
export function getApiKey(): string {
|
||||
return localStorage.getItem(API_KEY_STORAGE_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function setApiKey(key: string): void {
|
||||
localStorage.setItem(API_KEY_STORAGE_KEY, key)
|
||||
}
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
// --- Token estimation ---
|
||||
|
||||
@@ -73,7 +63,7 @@ export function buildSystemPrompt(
|
||||
return { prompt, totalTokens: estimateTokens(prompt), truncated }
|
||||
}
|
||||
|
||||
// --- API types ---
|
||||
// --- Message types ---
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -86,110 +76,107 @@ export function nextMessageId(): string {
|
||||
return `msg-${++msgIdCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
// --- SSE parsing ---
|
||||
// --- Claude CLI status ---
|
||||
|
||||
function parseSseEvent(line: string, onChunk: (text: string) => void): boolean {
|
||||
if (!line.startsWith('data: ')) return false
|
||||
const data = line.slice(6)
|
||||
if (data === '[DONE]') return true
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data)
|
||||
if (event.type === 'content_block_delta' && event.delta?.text) {
|
||||
onChunk(event.delta.text)
|
||||
}
|
||||
if (event.type === 'message_stop') return true
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
return false
|
||||
export interface ClaudeCliStatus {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
}
|
||||
|
||||
async function readSseStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
onChunk: (text: string) => void,
|
||||
): Promise<void> {
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
export async function checkClaudeCli(): Promise<ClaudeCliStatus> {
|
||||
if (!isTauri()) {
|
||||
return { installed: false, version: null }
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke<ClaudeCliStatus>('check_claude_cli')
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
// --- Claude CLI streaming ---
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
type ClaudeStreamEvent =
|
||||
| { kind: 'Init'; session_id: string }
|
||||
| { kind: 'TextDelta'; text: string }
|
||||
| { kind: 'ToolStart'; tool_name: string; tool_id: string }
|
||||
| { kind: 'ToolDone'; tool_id: string }
|
||||
| { kind: 'Result'; text: string; session_id: string }
|
||||
| { kind: 'Error'; message: string }
|
||||
| { kind: 'Done' }
|
||||
|
||||
for (const line of lines) {
|
||||
if (parseSseEvent(line, onChunk)) return
|
||||
}
|
||||
export interface ChatStreamCallbacks {
|
||||
onInit?: (sessionId: string) => void
|
||||
onText: (text: string) => void
|
||||
onError: (message: string) => void
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
/** Handle a single stream event from the Claude CLI, updating session state. */
|
||||
function handleChatStreamEvent(
|
||||
data: ClaudeStreamEvent,
|
||||
state: { sessionId: string },
|
||||
callbacks: ChatStreamCallbacks,
|
||||
): void {
|
||||
switch (data.kind) {
|
||||
case 'Init':
|
||||
state.sessionId = data.session_id
|
||||
callbacks.onInit?.(data.session_id)
|
||||
break
|
||||
case 'TextDelta':
|
||||
callbacks.onText(data.text)
|
||||
break
|
||||
case 'Result':
|
||||
if (data.session_id) state.sessionId = data.session_id
|
||||
break
|
||||
case 'Error':
|
||||
callbacks.onError(data.message)
|
||||
break
|
||||
case 'Done':
|
||||
callbacks.onDone()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function parseApiError(response: Response): Promise<string> {
|
||||
const errText = await response.text()
|
||||
try {
|
||||
const errJson = JSON.parse(errText)
|
||||
return errJson.error?.message || errJson.error || `API error (${response.status})`
|
||||
} catch {
|
||||
return `API error (${response.status})`
|
||||
}
|
||||
}
|
||||
|
||||
// --- Streaming API call ---
|
||||
|
||||
export async function streamChat(
|
||||
messages: { role: 'user' | 'assistant'; content: string }[],
|
||||
systemPrompt: string,
|
||||
model: string,
|
||||
onChunk: (text: string) => void,
|
||||
onDone: () => void,
|
||||
onError: (error: string) => void,
|
||||
): Promise<void> {
|
||||
const apiKey = getApiKey()
|
||||
if (!apiKey) {
|
||||
onError('No API key configured. Click the key icon to set your Anthropic API key.')
|
||||
return
|
||||
/**
|
||||
* Stream a chat message through the Claude CLI subprocess.
|
||||
* Returns the session ID for conversation continuity via --resume.
|
||||
*/
|
||||
export async function streamClaudeChat(
|
||||
message: string,
|
||||
systemPrompt: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
callbacks: ChatStreamCallbacks,
|
||||
): Promise<string> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText('AI Chat requires the Claude CLI. Install it and run the native app.')
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return 'mock-session'
|
||||
}
|
||||
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
|
||||
const state = { sessionId: sessionId ?? '' }
|
||||
|
||||
const unlisten = await listen<ClaudeStreamEvent>('claude-stream', (event) => {
|
||||
handleChatStreamEvent(event.payload, state, callbacks)
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
const result = await invoke<string>('stream_claude_chat', {
|
||||
request: {
|
||||
message,
|
||||
system_prompt: systemPrompt || null,
|
||||
session_id: sessionId || null,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt || undefined,
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
onError(await parseApiError(response))
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
onError('No response body')
|
||||
return
|
||||
}
|
||||
|
||||
await readSseStream(reader, onChunk)
|
||||
onDone()
|
||||
} catch (err: unknown) {
|
||||
onError(err instanceof Error ? err.message : 'Network error')
|
||||
if (result) state.sessionId = result
|
||||
} catch (err) {
|
||||
callbacks.onError(err instanceof Error ? err.message : String(err))
|
||||
callbacks.onDone()
|
||||
} finally {
|
||||
unlisten()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Model options ---
|
||||
export const MODEL_OPTIONS = [
|
||||
{ value: 'claude-3-5-haiku-20241022', label: 'Haiku 3.5' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
||||
{ value: 'claude-opus-4-20250514', label: 'Opus 4' },
|
||||
] as const
|
||||
return state.sessionId
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user