diff --git a/package.json b/package.json index 1c6a420..3b6eaa6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@anthropic-ai/claude-code": "^0.2.0", + "@anthropic-ai/sdk": "^0.88.0", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0", "marked": "^18.0.0", diff --git a/scripts/claude-bridge.js b/scripts/claude-bridge.js index bb0d052..8897241 100644 --- a/scripts/claude-bridge.js +++ b/scripts/claude-bridge.js @@ -1,30 +1,101 @@ #!/usr/bin/env node -// Claude Desktop — Bridge zwischen Tauri-Backend und Claude CLI +// Claude Desktop — Bridge zwischen Tauri-Backend und Anthropic API // -// Kommunikation: stdin/stdout als NDJSON (eine JSON-Zeile pro Nachricht) +// Direkte API-Anbindung statt Claude CLI — kein Overhead, ~2s Antwort +// +// Kommunikation: stdin/stdout als NDJSON // // Eingehend (von Tauri): // { "command": "message", "id": "req-1", "message": "Fixe den Bug..." } // { "command": "stop", "id": "req-2" } +// { "command": "set-api-key", "id": "req-3", "apiKey": "sk-ant-..." } // // Ausgehend (an Tauri): // { "type": "event", "event": "ready" } -// { "type": "event", "event": "agent-started", "payload": { "id": "...", "type": "Main" } } +// { "type": "event", "event": "agent-started", "payload": {...} } // { "type": "event", "event": "text", "payload": { "text": "..." } } -// { "type": "event", "event": "tool-start", "payload": { "id": "...", "tool": "Read", "input": {...} } } -// { "type": "event", "event": "tool-end", "payload": { "id": "...", "success": true } } -// { "type": "event", "event": "result", "payload": { "cost": 0.01, "tokens": {...} } } -// { "type": "event", "event": "agent-stopped", "payload": { "id": "..." } } +// { "type": "event", "event": "result", "payload": { "cost": 0.01, ... } } +// { "type": "event", "event": "agent-stopped", "payload": {...} } -import { spawn } from 'node:child_process'; +import Anthropic from '@anthropic-ai/sdk'; import { createInterface } from 'node:readline'; import { randomUUID } from 'node:crypto'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; -// Aktive Claude-Prozesse -const activeProcesses = new Map(); +// ============ State ============ -// Session-ID für --resume -let currentSessionId = null; +let client = null; +let apiKey = null; +let conversationHistory = []; // Messages für Multi-Turn +let activeAbortController = null; +const MODEL = 'claude-sonnet-4-20250514'; + +const SYSTEM_PROMPT = `Du bist Claude, ein hilfreicher KI-Assistent von Anthropic. +Du antwortest auf Deutsch. Du bist direkt, präzise und hilfreich. +Wenn du Code schreibst, nutze Kommentare auf Deutsch.`; + +// ============ API-Key Management ============ + +function getConfigPath() { + return join(homedir(), '.claude', 'claude-desktop-config.json'); +} + +function loadApiKey() { + // 1. Env-Var + if (process.env.ANTHROPIC_API_KEY) { + return process.env.ANTHROPIC_API_KEY; + } + + // 2. Gespeicherte Config (eigener Key) + const configPath = getConfigPath(); + if (existsSync(configPath)) { + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + if (config.apiKey) return config.apiKey; + } catch {} + } + + // 3. Claude Code OAuth-Token (aus Claude Max Abo) + const credentialsPath = join(homedir(), '.claude', '.credentials.json'); + if (existsSync(credentialsPath)) { + try { + const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8')); + const oauth = creds.claudeAiOauth; + if (oauth?.accessToken) { + // Prüfen ob Token noch gültig + if (oauth.expiresAt && oauth.expiresAt > Date.now()) { + process.stderr.write(`🔑 OAuth-Token aus Claude Code geladen (gültig bis ${new Date(oauth.expiresAt).toLocaleString('de-DE')})\n`); + return oauth.accessToken; + } else { + process.stderr.write(`⚠️ OAuth-Token abgelaufen\n`); + } + } + } catch {} + } + + return null; +} + +function saveApiKey(key) { + const configPath = getConfigPath(); + try { + const config = existsSync(configPath) + ? JSON.parse(readFileSync(configPath, 'utf-8')) + : {}; + config.apiKey = key; + writeFileSync(configPath, JSON.stringify(config, null, 2)); + } catch (err) { + process.stderr.write(`Config speichern fehlgeschlagen: ${err.message}\n`); + } +} + +function initClient(key) { + apiKey = key; + client = new Anthropic({ apiKey: key }); + return true; +} // ============ Kommunikation mit Tauri ============ @@ -44,37 +115,19 @@ function sendError(id, error) { sendToTauri({ type: 'response', id, error }); } -// ============ Claude CLI aufrufen ============ +// ============ Claude API aufrufen ============ -function spawnClaude(message, requestId) { - const agentId = randomUUID(); - const args = [ - '-p', // Print-Modus (nicht interaktiv) - '--output-format', 'stream-json', // Streaming JSON Events - '--verbose', // Pflicht für stream-json - ]; - - // Bei Fortsetzung letzte Session verwenden - if (currentSessionId) { - args.push('--resume', currentSessionId); +async function sendToAnthropic(message, requestId) { + if (!client) { + sendError(requestId, 'Kein API-Key gesetzt. Bitte zuerst API-Key konfigurieren.'); + sendEvent('agent-stopped', { id: 'none', code: 1 }); + return; } - // Nachricht als Argument - args.push(message); + const agentId = randomUUID(); - // Claude CLI Pfad — npx oder global installiert - const claudePath = process.env.CLAUDE_PATH || 'claude'; - - const proc = spawn(claudePath, args, { - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - // Keine interaktive UI - CLAUDE_CODE_HEADLESS: '1', - }, - }); - - activeProcesses.set(agentId, { proc, requestId }); + // AbortController für STOPP + activeAbortController = new AbortController(); sendEvent('agent-started', { id: agentId, @@ -82,180 +135,73 @@ function spawnClaude(message, requestId) { task: message.substring(0, 100), }); - // NDJSON-Zeilen von stdout parsen - let fullText = ''; - let toolCounter = 0; + sendResponse(requestId, { agentId, status: 'gestartet' }); - const rl = createInterface({ input: proc.stdout }); + // Nachricht zur History hinzufügen + conversationHistory.push({ role: 'user', content: message }); - rl.on('line', (line) => { - if (!line.trim()) return; + try { + const startTime = Date.now(); - try { - const event = JSON.parse(line); - handleClaudeEvent(event, agentId, requestId, { fullText: () => fullText, addText: (t) => { fullText += t; } }); - } catch { - // Nicht-JSON Zeile — als Text weiterleiten - fullText += line; - sendEvent('text', { text: line }); - } - }); - - // stderr für Debug-Infos - const stderrRl = createInterface({ input: proc.stderr }); - stderrRl.on('line', (line) => { - if (process.env.CLAUDE_DEBUG) { - process.stderr.write(`[claude-stderr] ${line}\n`); - } - }); - - proc.on('close', (code) => { - activeProcesses.delete(agentId); - - sendEvent('agent-stopped', { - id: agentId, - code, + // Streaming Response + const stream = client.messages.stream({ + model: MODEL, + max_tokens: 8192, + system: SYSTEM_PROMPT, + messages: conversationHistory, + }, { + signal: activeAbortController.signal, }); - if (activeProcesses.size === 0) { - sendEvent('all-stopped'); + let fullResponse = ''; + let inputTokens = 0; + let outputTokens = 0; + + // Text-Chunks streamen + stream.on('text', (text) => { + fullResponse += text; + sendEvent('text', { text }); + }); + + // Warten auf vollständige Antwort + const finalMessage = await stream.finalMessage(); + + inputTokens = finalMessage.usage?.input_tokens || 0; + outputTokens = finalMessage.usage?.output_tokens || 0; + + // Kosten berechnen (Claude Sonnet 4 Preise) + const cost = (inputTokens * 3 / 1_000_000) + (outputTokens * 15 / 1_000_000); + + const durationMs = Date.now() - startTime; + + // Antwort zur History hinzufügen + conversationHistory.push({ role: 'assistant', content: fullResponse }); + + sendEvent('result', { + text: fullResponse, + cost, + tokens: { input: inputTokens, output: outputTokens }, + duration_ms: durationMs, + model: MODEL, + }); + + sendEvent('agent-stopped', { id: agentId, code: 0 }); + + } catch (err) { + if (err.name === 'AbortError') { + sendEvent('agent-stopped', { id: agentId, code: -1 }); + } else { + const errorMsg = err.message || String(err); + sendEvent('text', { text: `\n\n**Fehler:** ${errorMsg}` }); + sendEvent('agent-stopped', { id: agentId, code: 1 }); } - }); - - proc.on('error', (err) => { - sendError(requestId, `Claude konnte nicht gestartet werden: ${err.message}`); - activeProcesses.delete(agentId); - }); - - return agentId; -} - -// ============ Claude Stream-JSON Events verarbeiten ============ - -function handleClaudeEvent(event, agentId, requestId, textState) { - // Das stream-json Format hat verschiedene Event-Typen: - // { "type": "assistant", "message": { "content": [...], ... } } - // { "type": "content_block_start", "content_block": { "type": "text", "text": "..." } } - // { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "..." } } - // { "type": "content_block_start", "content_block": { "type": "tool_use", "name": "Read", ... } } - // { "type": "content_block_delta", "delta": { "type": "input_json_delta", ... } } - // { "type": "result", "result": "...", "cost_usd": 0.01, ... } - // { "type": "system", "subtype": "session_id", "session_id": "..." } - - const type = event.type; - - switch (type) { - case 'system': - if (event.session_id) { - currentSessionId = event.session_id; - } - break; - - case 'rate_limit_event': - // Ignorieren - break; - - case 'assistant': - // Vollständige Nachricht — Inhalt extrahieren - if (event.message?.content) { - for (const block of event.message.content) { - if (block.type === 'text') { - sendEvent('text', { text: block.text }); - } - } - } - break; - - case 'content_block_start': - if (event.content_block?.type === 'text') { - // Text-Block startet - if (event.content_block.text) { - sendEvent('text', { text: event.content_block.text }); - } - } else if (event.content_block?.type === 'tool_use') { - // Tool-Aufruf startet - sendEvent('tool-start', { - id: event.content_block.id || randomUUID(), - tool: event.content_block.name, - input: event.content_block.input || {}, - }); - } - break; - - case 'content_block_delta': - if (event.delta?.type === 'text_delta' && event.delta.text) { - sendEvent('text', { text: event.delta.text }); - } - break; - - case 'content_block_stop': - // Block fertig — wenn es ein Tool war, Tool-Ende senden - break; - - case 'tool_result': - case 'tool_use_result': - sendEvent('tool-end', { - id: event.tool_use_id || event.id || '', - success: !event.is_error, - output: typeof event.content === 'string' - ? event.content - : JSON.stringify(event.content)?.substring(0, 500), - }); - break; - - case 'result': - // Endergebnis mit Kosten - sendEvent('result', { - text: event.result || '', - cost: event.total_cost_usd || event.cost_usd || 0, - tokens: { - input: event.usage?.input_tokens || 0, - output: event.usage?.output_tokens || 0, - cache_read: event.usage?.cache_read_input_tokens || 0, - cache_create: event.usage?.cache_creation_input_tokens || 0, - }, - session_id: event.session_id || currentSessionId, - duration_ms: event.duration_ms || 0, - num_turns: event.num_turns || 0, - }); - - // Session-ID für Fortsetzung merken - if (event.session_id) { - currentSessionId = event.session_id; - } - break; - - default: - // Unbekannte Events durchreichen - if (event.subagent_id || event.agent_id) { - // Sub-Agent Events - sendEvent('subagent-event', { - agentId: event.subagent_id || event.agent_id, - type, - data: event, - }); - } - break; + } finally { + activeAbortController = null; + sendEvent('all-stopped'); } } -// ============ Alle Prozesse stoppen ============ - -function stopAll() { - for (const [agentId, { proc }] of activeProcesses) { - try { - proc.kill('SIGTERM'); - // Nach 2 Sekunden hart killen - setTimeout(() => { - try { proc.kill('SIGKILL'); } catch {} - }, 2000); - } catch {} - } - activeProcesses.clear(); - sendEvent('all-stopped'); -} - -// ============ Eingehende Befehle verarbeiten ============ +// ============ Befehle verarbeiten ============ function handleCommand(msg) { switch (msg.command) { @@ -264,25 +210,39 @@ function handleCommand(msg) { sendError(msg.id, 'Keine Nachricht angegeben'); return; } - const agentId = spawnClaude(msg.message, msg.id); - sendResponse(msg.id, { agentId, status: 'gestartet' }); + sendToAnthropic(msg.message, msg.id); break; case 'stop': - stopAll(); + if (activeAbortController) { + activeAbortController.abort(); + } sendResponse(msg.id, { status: 'gestoppt' }); break; - case 'status': - const agents = []; - for (const [id, { proc }] of activeProcesses) { - agents.push({ - id, - running: !proc.killed, - pid: proc.pid, - }); + case 'set-api-key': + if (!msg.apiKey) { + sendError(msg.id, 'Kein API-Key angegeben'); + return; } - sendResponse(msg.id, { agents, sessionId: currentSessionId }); + initClient(msg.apiKey); + saveApiKey(msg.apiKey); + sendResponse(msg.id, { status: 'ok' }); + sendEvent('api-key-set'); + break; + + case 'clear-history': + conversationHistory = []; + sendResponse(msg.id, { status: 'ok', cleared: true }); + break; + + case 'status': + sendResponse(msg.id, { + hasApiKey: !!apiKey, + model: MODEL, + historyLength: conversationHistory.length, + isProcessing: !!activeAbortController, + }); break; case 'ping': @@ -296,6 +256,12 @@ function handleCommand(msg) { // ============ Main ============ +// API-Key laden +const savedKey = loadApiKey(); +if (savedKey) { + initClient(savedKey); +} + // stdin zeilenweise lesen const rl = createInterface({ input: process.stdin }); @@ -310,15 +276,13 @@ rl.on('line', (line) => { }); // Sauber beenden -process.on('SIGTERM', () => { - stopAll(); - process.exit(0); -}); +process.on('SIGTERM', () => { process.exit(0); }); +process.on('SIGINT', () => { process.exit(0); }); -process.on('SIGINT', () => { - stopAll(); - process.exit(0); +// Bereit-Signal +sendEvent('ready', { + version: '0.2.0', + pid: process.pid, + hasApiKey: !!apiKey, + model: MODEL, }); - -// Bereit-Signal senden -sendEvent('ready', { version: '0.1.0', pid: process.pid });