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:
parent
03f84ceb56
commit
583fc2cb82
2 changed files with 89 additions and 196 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
maxTurns: 25,
|
||||||
max_tokens: 8192,
|
abortController: activeAbort,
|
||||||
system: SYSTEM_PROMPT,
|
},
|
||||||
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 || '',
|
||||||
|
success: !event.is_error,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
inputTokens = finalMessage.usage?.input_tokens || 0;
|
case 'result':
|
||||||
outputTokens = finalMessage.usage?.output_tokens || 0;
|
// Endergebnis
|
||||||
|
sendEvent('result', {
|
||||||
// Kosten berechnen (Claude Sonnet 4 Preise)
|
text: fullText,
|
||||||
const cost = (inputTokens * 3 / 1_000_000) + (outputTokens * 15 / 1_000_000);
|
cost: event.total_cost_usd || 0,
|
||||||
|
tokens: {
|
||||||
const durationMs = Date.now() - startTime;
|
input: event.usage?.input_tokens || 0,
|
||||||
|
output: event.usage?.output_tokens || 0,
|
||||||
// Antwort zur History hinzufügen
|
},
|
||||||
conversationHistory.push({ role: 'assistant', content: fullResponse });
|
session_id: event.session_id || '',
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
sendEvent('result', {
|
model: usedModel,
|
||||||
text: fullResponse,
|
});
|
||||||
cost,
|
break;
|
||||||
tokens: { input: inputTokens, output: outputTokens },
|
|
||||||
duration_ms: durationMs,
|
|
||||||
model: MODEL,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue