Bridge komplett auf Claude Agent SDK umgebaut — Opus 4.6 funktioniert

- @anthropic-ai/claude-agent-sdk statt raw API oder CLI-Spawn
- query() Funktion mit async generator für Streaming
- OAuth-Auth funktioniert automatisch (Claude Max Abo)
- Opus 4.6 als Default, kein Rate-Limit, ~5s Antwort
- AbortController für STOPP-Button
- Kein CLI-Overhead, keine Hooks, kein MCP-Init

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-13 21:33:01 +02:00
parent 03f84ceb56
commit 583fc2cb82
2 changed files with 89 additions and 196 deletions

View file

@ -22,6 +22,7 @@
"vite": "^5.0.0" "vite": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.104",
"@anthropic-ai/claude-code": "^0.2.0", "@anthropic-ai/claude-code": "^0.2.0",
"@anthropic-ai/sdk": "^0.88.0", "@anthropic-ai/sdk": "^0.88.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",

View file

@ -1,105 +1,23 @@
#!/usr/bin/env node #!/usr/bin/env node
// Claude Desktop — Bridge zwischen Tauri-Backend und Anthropic API // Claude Desktop — Bridge via Claude Agent SDK
// //
// Direkte API-Anbindung statt Claude CLI — kein Overhead, ~2s Antwort // Nutzt @anthropic-ai/claude-agent-sdk (query-Funktion)
// // OAuth-Auth funktioniert automatisch (Claude Max Abo)
// Kommunikation: stdin/stdout als NDJSON // Kein CLI-Spawn, kein Overhead — direkte SDK-Aufrufe
//
// 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": {...} }
// { "type": "event", "event": "text", "payload": { "text": "..." } }
// { "type": "event", "event": "result", "payload": { "cost": 0.01, ... } }
// { "type": "event", "event": "agent-stopped", "payload": {...} }
import Anthropic from '@anthropic-ai/sdk'; import { query } from '@anthropic-ai/claude-agent-sdk';
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
// Prozess am Leben halten — MUSS vor allem anderen stehen // Prozess am Leben halten
const keepAlive = setInterval(() => {}, 60000); const keepAlive = setInterval(() => {}, 60000);
process.stdin.resume(); // stdin offen halten auch ohne readline process.stdin.resume();
// ============ State ============ // ============ State ============
let client = null; let activeAbort = null;
let apiKey = null; let currentAgentId = null;
let conversationHistory = []; // Messages für Multi-Turn const MODEL = process.env.CLAUDE_MODEL || 'opus';
let activeAbortController = null;
const MODEL = process.env.CLAUDE_MODEL || 'claude-opus-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 ============ // ============ Kommunikation mit Tauri ============
@ -119,93 +37,101 @@ function sendError(id, error) {
sendToTauri({ type: 'response', id, error }); sendToTauri({ type: 'response', id, error });
} }
// ============ Claude API aufrufen ============ // ============ Claude Agent SDK ============
async function sendToAnthropic(message, requestId) { async function sendMessage(message, requestId) {
if (!client) { currentAgentId = randomUUID();
sendError(requestId, 'Kein API-Key gesetzt. Bitte zuerst API-Key konfigurieren.'); activeAbort = new AbortController();
sendEvent('agent-stopped', { id: 'none', code: 1 });
return;
}
const agentId = randomUUID();
// AbortController für STOPP
activeAbortController = new AbortController();
sendEvent('agent-started', { sendEvent('agent-started', {
id: agentId, id: currentAgentId,
type: 'Main', type: 'Main',
task: message.substring(0, 100), task: message.substring(0, 100),
}); });
sendResponse(requestId, { agentId, status: 'gestartet' }); sendResponse(requestId, { agentId: currentAgentId, status: 'gestartet' });
// Nachricht zur History hinzufügen const startTime = Date.now();
conversationHistory.push({ role: 'user', content: message }); let fullText = '';
let usedModel = MODEL;
try { try {
const startTime = Date.now(); const conversation = query({
prompt: message,
// Streaming Response options: {
const stream = client.messages.stream({
model: MODEL, model: MODEL,
max_tokens: 8192, maxTurns: 25,
system: SYSTEM_PROMPT, abortController: activeAbort,
messages: conversationHistory, },
}, {
signal: activeAbortController.signal,
}); });
let fullResponse = ''; for await (const event of conversation) {
let inputTokens = 0; switch (event.type) {
let outputTokens = 0; case 'assistant':
// Text aus der Nachricht extrahieren
if (event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'text' && block.text) {
fullText += block.text;
sendEvent('text', { text: block.text });
}
}
}
if (event.message?.model) {
usedModel = event.message.model;
}
break;
// Text-Chunks streamen case 'tool_use':
stream.on('text', (text) => { sendEvent('tool-start', {
fullResponse += text; id: event.tool_use_id || randomUUID(),
sendEvent('text', { text }); tool: event.name || 'unknown',
input: event.input || {},
}); });
break;
// Warten auf vollständige Antwort case 'tool_result':
const finalMessage = await stream.finalMessage(); sendEvent('tool-end', {
id: event.tool_use_id || '',
inputTokens = finalMessage.usage?.input_tokens || 0; success: !event.is_error,
outputTokens = finalMessage.usage?.output_tokens || 0; });
break;
// 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 });
case 'result':
// Endergebnis
sendEvent('result', { sendEvent('result', {
text: fullResponse, text: fullText,
cost, cost: event.total_cost_usd || 0,
tokens: { input: inputTokens, output: outputTokens }, tokens: {
duration_ms: durationMs, input: event.usage?.input_tokens || 0,
model: MODEL, output: event.usage?.output_tokens || 0,
},
session_id: event.session_id || '',
duration_ms: Date.now() - startTime,
model: usedModel,
}); });
break;
sendEvent('agent-stopped', { id: agentId, code: 0 }); default:
// Andere Events still ignorieren
break;
}
}
} catch (err) { } catch (err) {
if (err.name === 'AbortError') { if (err.name === 'AbortError') {
sendEvent('agent-stopped', { id: agentId, code: -1 }); // Abgebrochen — kein Fehler
} else { } else {
const errorMsg = err.message || String(err); sendEvent('text', { text: `\n\n**Fehler:** ${err.message || err}` });
sendEvent('text', { text: `\n\n**Fehler:** ${errorMsg}` });
sendEvent('agent-stopped', { id: agentId, code: 1 });
} }
} finally { } finally {
activeAbortController = null; sendEvent('agent-stopped', { id: currentAgentId, code: 0 });
sendEvent('all-stopped'); sendEvent('all-stopped');
currentAgentId = null;
activeAbort = null;
} }
} }
// ============ Befehle verarbeiten ============ // ============ Befehle von Tauri ============
function handleCommand(msg) { function handleCommand(msg) {
switch (msg.command) { switch (msg.command) {
@ -214,38 +140,20 @@ function handleCommand(msg) {
sendError(msg.id, 'Keine Nachricht angegeben'); sendError(msg.id, 'Keine Nachricht angegeben');
return; return;
} }
sendToAnthropic(msg.message, msg.id); sendMessage(msg.message, msg.id);
break; break;
case 'stop': case 'stop':
if (activeAbortController) { if (activeAbort) {
activeAbortController.abort(); activeAbort.abort();
} }
sendResponse(msg.id, { status: 'gestoppt' }); sendResponse(msg.id, { status: 'gestoppt' });
break; break;
case 'set-api-key':
if (!msg.apiKey) {
sendError(msg.id, 'Kein API-Key angegeben');
return;
}
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': case 'status':
sendResponse(msg.id, { sendResponse(msg.id, {
hasApiKey: !!apiKey,
model: MODEL, model: MODEL,
historyLength: conversationHistory.length, isProcessing: !!currentAgentId,
isProcessing: !!activeAbortController,
}); });
break; break;
@ -260,38 +168,22 @@ function handleCommand(msg) {
// ============ Main ============ // ============ Main ============
// API-Key laden
const savedKey = loadApiKey();
if (savedKey) {
initClient(savedKey);
}
// stdin zeilenweise lesen
const rl = createInterface({ input: process.stdin }); const rl = createInterface({ input: process.stdin });
rl.on('line', (line) => { rl.on('line', (line) => {
if (!line.trim()) return; if (!line.trim()) return;
try { try {
const msg = JSON.parse(line); handleCommand(JSON.parse(line));
handleCommand(msg);
} catch (err) { } catch (err) {
process.stderr.write(`Ungültige Eingabe: ${err.message}\n`); process.stderr.write(`Ungültige Eingabe: ${err.message}\n`);
} }
}); });
// NICHT beenden wenn stdin schließt — wir warten auf weitere Befehle per stdin
rl.on('close', () => { rl.on('close', () => {
process.stderr.write('⚠️ stdin geschlossen — Bridge bleibt trotzdem aktiv\n'); process.stderr.write('stdin geschlossen\n');
}); });
// Sauber beenden
process.on('SIGTERM', () => { clearInterval(keepAlive); process.exit(0); }); process.on('SIGTERM', () => { clearInterval(keepAlive); process.exit(0); });
process.on('SIGINT', () => { clearInterval(keepAlive); process.exit(0); }); process.on('SIGINT', () => { clearInterval(keepAlive); process.exit(0); });
// Bereit-Signal // Bereit
sendEvent('ready', { sendEvent('ready', { version: '1.0.0', pid: process.pid, model: MODEL });
version: '0.2.0',
pid: process.pid,
hasApiKey: !!apiKey,
model: MODEL,
});