Bridge auf direkte Anthropic API umgebaut — ~2s statt 5-10min
- @anthropic-ai/sdk statt Claude CLI — kein Hook/MCP/Plugin-Overhead - OAuth-Token automatisch aus ~/.claude/.credentials.json geladen - Streaming via messages.stream() — Text erscheint sofort - Conversation-History für Multi-Turn (clear-history Befehl) - AbortController für sofortigen STOPP - Kosten-Berechnung pro Request - API-Key auch per set-api-key Befehl oder ANTHROPIC_API_KEY Env-Var Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
11ecbd83f3
commit
c78e7f3dcc
2 changed files with 191 additions and 226 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
rl.on('line', (line) => {
|
||||
if (!line.trim()) return;
|
||||
// Nachricht zur History hinzufügen
|
||||
conversationHistory.push({ role: 'user', content: message });
|
||||
|
||||
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 });
|
||||
}
|
||||
const startTime = Date.now();
|
||||
|
||||
// Streaming Response
|
||||
const stream = client.messages.stream({
|
||||
model: MODEL,
|
||||
max_tokens: 8192,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: conversationHistory,
|
||||
}, {
|
||||
signal: activeAbortController.signal,
|
||||
});
|
||||
|
||||
// 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`);
|
||||
}
|
||||
let fullResponse = '';
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
// Text-Chunks streamen
|
||||
stream.on('text', (text) => {
|
||||
fullResponse += text;
|
||||
sendEvent('text', { text });
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
activeProcesses.delete(agentId);
|
||||
// Warten auf vollständige Antwort
|
||||
const finalMessage = await stream.finalMessage();
|
||||
|
||||
sendEvent('agent-stopped', {
|
||||
id: agentId,
|
||||
code,
|
||||
});
|
||||
inputTokens = finalMessage.usage?.input_tokens || 0;
|
||||
outputTokens = finalMessage.usage?.output_tokens || 0;
|
||||
|
||||
if (activeProcesses.size === 0) {
|
||||
sendEvent('all-stopped');
|
||||
}
|
||||
});
|
||||
// Kosten berechnen (Claude Sonnet 4 Preise)
|
||||
const cost = (inputTokens * 3 / 1_000_000) + (outputTokens * 15 / 1_000_000);
|
||||
|
||||
proc.on('error', (err) => {
|
||||
sendError(requestId, `Claude konnte nicht gestartet werden: ${err.message}`);
|
||||
activeProcesses.delete(agentId);
|
||||
});
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
return agentId;
|
||||
}
|
||||
// Antwort zur History hinzufügen
|
||||
conversationHistory.push({ role: 'assistant', content: fullResponse });
|
||||
|
||||
// ============ 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,
|
||||
text: fullResponse,
|
||||
cost,
|
||||
tokens: { input: inputTokens, output: outputTokens },
|
||||
duration_ms: durationMs,
|
||||
model: MODEL,
|
||||
});
|
||||
|
||||
// Session-ID für Fortsetzung merken
|
||||
if (event.session_id) {
|
||||
currentSessionId = event.session_id;
|
||||
}
|
||||
break;
|
||||
sendEvent('agent-stopped', { id: agentId, code: 0 });
|
||||
|
||||
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,
|
||||
});
|
||||
} 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 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 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();
|
||||
} finally {
|
||||
activeAbortController = null;
|
||||
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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue