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:
Luca Rossi
2026-03-01 19:49:58 +01:00
committed by GitHub
parent 2a1b17f8c6
commit 894cb45779
18 changed files with 1221 additions and 1079 deletions

View File

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

View File

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

View File

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

View File

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