claude-desktop/pwa/server/bridge.js
Eddy 4e36b04cc9
All checks were successful
Build AppImage / build (push) Has been skipped
PWA Mobile-App: API-Server + SvelteKit-Frontend (Phase 1+2)
Backend (pwa/server/):
- Express + WebSocket API-Server auf Port 3100
- Claude Agent SDK Bridge mit Streaming
- Bearer-Token Authentifizierung
- REST: /api/status, /api/models, /api/sessions, /api/stop
- WebSocket: /ws mit Live-Text-Streaming
- Dockerfile für Container-Deployment

Frontend (pwa/client/):
- SvelteKit 5 PWA mit Dark Theme
- Mobil-optimierter Chat (WhatsApp/Telegram-Feeling)
- Message-Bubbles mit Markdown + Live-Streaming
- Session-Drawer (Swipe von links)
- Settings-Modal (Server/Token/Modell)
- Service Worker für Auto-Updates
- PWA-Manifest für "Add to Homescreen"
- Safe-Area-Insets für Notch-Handys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 06:38:12 +02:00

502 lines
15 KiB
JavaScript

// Claude Chat API — Bridge zum Claude Agent SDK
//
// Adaptiert die Logik aus scripts/claude-bridge.js fuer HTTP/WebSocket-Nutzung.
// Nutzt @anthropic-ai/claude-agent-sdk (query-Funktion) mit OAuth-Auth (Claude Max Abo).
import { query } from '@anthropic-ai/claude-agent-sdk';
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
// ============ State ============
let activeAbort = null;
let currentAgentId = null;
let currentModel = process.env.CLAUDE_MODEL || 'sonnet';
let agentMode = 'solo'; // solo | handlanger | experten | auto
// In-Memory Sessions
// Map: sessionId → { id, title, createdAt, messages: [], claudeSessionId }
const sessions = new Map();
// ============ Orchestrator Prompts ============
const ORCHESTRATOR_PROMPTS = {
handlanger: `
Du bist der HAUPT-AGENT im HANDLANGER-MODUS.
KRITISCH: Dir stehen NUR Task + TodoWrite zur Verfuegung.
Du kannst NICHT direkt lesen, suchen oder ausfuehren — du MUSST delegieren!
Task-Tool mit den RICHTIGEN Sub-Agent-Typen:
- "general-purpose" — Standard-Agent mit VOLLEM Tool-Zugriff (Bash, Read, Write, Grep, Glob).
- "Explore" — read-only Agent. NUR fuer reine Code-/Dateisuche.
Arbeitsweise:
1. Waehle den RICHTIGEN subagent_type basierend auf der Aufgabe.
2. Rufe das Task-Tool auf mit EXAKTER Anweisung.
3. Pruefe im Ergebnis das "tool_uses"-Feld. Wenn tool_uses:0 → Sub-Agent hat halluziniert!
4. Verarbeite das Ergebnis und gib dem User die Zusammenfassung.
`,
experten: `
Du bist der HAUPT-AGENT und arbeitest im EXPERTEN-MODUS.
Task-Tool Sub-Agent-Typen (autonome Experten):
- **research**: Durchsucht Code/Docs, findet Infos.
- **implement**: Schreibt Code nach Best-Practices.
- **test**: Schreibt und fuehrt Tests.
- **review**: Prueft Code auf Qualitaet/Sicherheit.
Arbeitsweise:
1. ZERLEGE die Aufgabe in Experten-Bereiche
2. DELEGIERE via Task(subagent_type: "research"|"implement"|"test"|"review", prompt: "...")
3. Formuliere das WAS, nicht das WIE
4. Integriere die Zusammenfassungen
`,
auto: `
Du analysierst Aufgaben und waehlst den optimalen Arbeitsmodus.
Entscheide basierend auf:
- SOLO: Einfache, schnelle Aufgaben
- HANDLANGER: Koordinations-intensive Aufgaben
- EXPERTEN: Komplexe Features
`,
};
// ============ Verfuegbare Modelle ============
const AVAILABLE_MODELS = [
{ id: 'haiku', name: 'Claude Haiku', description: 'Schnell & guenstig' },
{ id: 'sonnet', name: 'Claude Sonnet', description: 'Ausgewogen' },
{ id: 'opus', name: 'Claude Opus', description: 'Leistungsstark' },
];
// Tools die Subagents spawnen
const SUBAGENT_TOOLS = ['Task', 'Agent', 'spawn_agent', 'launch_agent'];
// ============ Hilfsfunktionen ============
// AUTO-Modus: Heuristik waehlt passenden Modus
function chooseAutoMode(message) {
const text = (message || '').toLowerCase();
const charCount = text.length;
const expertKeywords = [
'implementiere', 'implementier ', 'refactor', 'architektur', 'entwickle',
'erstelle feature', 'feature ', 'design', 'baue ', 'optimiere',
'migration', 'umbau', 'umstruktur',
];
const handlangerKeywords = [
'lies ', 'suche ', 'finde ', 'zeig mir ', 'untersuche',
'analysiere', 'durchsuche', 'alle dateien', 'sammle',
'liste alle', 'vergleiche',
];
if (charCount < 80) return 'solo';
if (expertKeywords.some(kw => text.includes(kw))) return 'experten';
if (handlangerKeywords.some(kw => text.includes(kw)) && charCount > 120) return 'handlanger';
if (charCount > 300) return 'handlanger';
return 'solo';
}
// Subagent-Typ aus Tool-Input ermitteln
function getSubagentType(toolName, input) {
if (input?.subagent_type) return input.subagent_type.toLowerCase();
if (input?.agent_type) return input.agent_type.toLowerCase();
const desc = (input?.description || input?.prompt || '').toLowerCase();
if (desc.includes('explore') || desc.includes('search') || desc.includes('find')) return 'explore';
if (desc.includes('implement') || desc.includes('write') || desc.includes('code')) return 'code';
if (desc.includes('test') || desc.includes('verify')) return 'test';
if (desc.includes('review') || desc.includes('check')) return 'review';
return 'explore';
}
// Tool-Input fuer Logging kuerzen
function summarizeToolInput(tool, input) {
if (!input) return '';
if (tool === 'Read') return input.file_path || '';
if (tool === 'Edit' || tool === 'Write') {
const path = input.file_path || '';
const size = input.content ? `(${input.content.length} chars)` : '';
return `${path} ${size}`;
}
if (tool === 'Grep') return `"${input.pattern}" in ${input.path || '.'}`;
if (tool === 'Bash') {
const cmd = input.command || '';
return cmd.length > 50 ? cmd.substring(0, 50) + '...' : cmd;
}
if (tool === 'Task') return input.description || input.prompt || '';
const firstString = Object.values(input).find(v => typeof v === 'string');
if (firstString) return firstString.length > 50 ? firstString.substring(0, 50) + '...' : firstString;
return '';
}
// ============ Haupt-Funktion: Nachricht senden ============
/**
* Sendet eine Nachricht an Claude und streamt Events zurueck.
* Gibt einen EventEmitter zurueck der folgende Events feuert:
* 'text' → { content: "..." }
* 'tool_call' → { name: "...", args: {...}, id: "..." }
* 'tool_result' → { id: "...", success: boolean }
* 'agent_started' → { id: "...", name: "...", type: "..." }
* 'agent_stopped' → { id: "...", success: boolean }
* 'result' → { content: "...", cost, tokens, session_id, duration_ms, model }
* 'error' → { message: "..." }
*
* @param {string} text - Nachricht des Users
* @param {string} [model] - Modell-Override (sonst currentModel)
* @param {string} [mode] - Modus-Override (sonst agentMode)
* @param {string} [sessionId] - Session-ID fuer Kontext
* @returns {EventEmitter}
*/
export function sendMessage(text, model, mode, sessionId) {
const emitter = new EventEmitter();
// Asynchron starten, damit der Caller den Emitter sofort bekommt
setImmediate(() => _processMessage(emitter, text, model, mode, sessionId));
return emitter;
}
async function _processMessage(emitter, text, model, mode, sessionId) {
const useModel = model || currentModel;
const useMode = mode || agentMode;
currentAgentId = randomUUID();
activeAbort = new AbortController();
// Session finden oder erstellen
let session = null;
if (sessionId && sessions.has(sessionId)) {
session = sessions.get(sessionId);
}
// User-Nachricht in Session speichern
if (session) {
session.messages.push({
role: 'user',
content: text,
timestamp: new Date().toISOString(),
});
}
// Subagent-Tracking fuer diesen Request
const activeSubagents = new Map();
const handledTools = new Set();
emitter.emit('agent_started', {
id: currentAgentId,
name: 'Main',
type: useMode,
model: useModel,
});
// AUTO-Modus: effektiven Modus bestimmen
let effectiveMode = useMode;
if (useMode === 'auto') {
effectiveMode = chooseAutoMode(text);
}
// Orchestrator-Prompt fuer nicht-Solo Modi
let fullPrompt = text;
if (effectiveMode !== 'solo' && ORCHESTRATOR_PROMPTS[effectiveMode]) {
fullPrompt = `${ORCHESTRATOR_PROMPTS[effectiveMode]}\n\n---\n\n${text}`;
}
const startTime = Date.now();
let fullText = '';
let usedModel = useModel;
try {
// Query-Optionen
const queryOptions = {
model: useModel,
maxTurns: 25,
abortController: activeAbort,
tools: { type: 'preset', preset: 'claude_code' },
allowedTools: ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob', 'Write', 'Edit', 'Bash'],
};
// Claude-Session-ID fuer Fortsetzung
if (session?.claudeSessionId) {
queryOptions.resume = session.claudeSessionId;
}
let conversation = query({
prompt: fullPrompt,
options: queryOptions,
});
// Tool-Use handhaben
function handleToolUse(ev) {
const toolId = ev.tool_use_id || ev.id || randomUUID();
if (handledTools.has(toolId)) return;
handledTools.add(toolId);
const toolName = ev.name || 'unknown';
const toolInput = ev.input || {};
// Subagent-Erkennung
if (SUBAGENT_TOOLS.includes(toolName)) {
const subagentId = randomUUID();
const subagentType = getSubagentType(toolName, toolInput);
const subagentTask = toolInput.description || toolInput.prompt || toolInput.task || '';
activeSubagents.set(toolId, {
agentId: subagentId,
type: subagentType,
task: subagentTask,
});
emitter.emit('agent_started', {
id: subagentId,
name: subagentType,
type: subagentType,
});
}
emitter.emit('tool_call', {
name: toolName,
args: toolInput,
id: toolId,
summary: summarizeToolInput(toolName, toolInput),
});
}
// Tool-Result handhaben
function handleToolResult(ev) {
const toolId = ev.tool_use_id || '';
if (activeSubagents.has(toolId)) {
const subagent = activeSubagents.get(toolId);
emitter.emit('agent_stopped', {
id: subagent.agentId,
success: !ev.is_error,
});
activeSubagents.delete(toolId);
}
emitter.emit('tool_result', {
id: toolId,
success: !ev.is_error,
});
}
// Iteration mit Fallback bei ungueltiger Session-ID
async function* iterateWithRetry() {
try {
for await (const ev of conversation) yield ev;
} catch (err) {
if (queryOptions.resume) {
console.log('[bridge] Resume fehlgeschlagen, starte neue Session:', err.message);
delete queryOptions.resume;
conversation = query({ prompt: fullPrompt, options: queryOptions });
for await (const ev of conversation) yield ev;
} else {
throw err;
}
}
}
for await (const event of iterateWithRetry()) {
switch (event.type) {
case 'assistant':
if (event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'text' && block.text) {
fullText += block.text;
emitter.emit('text', { content: block.text });
} else if (block.type === 'tool_use') {
handleToolUse(block);
}
}
}
if (event.message?.model) {
usedModel = event.message.model;
}
break;
case 'tool_use':
handleToolUse(event);
break;
case 'tool_result':
handleToolResult(event);
break;
case 'user':
if (event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'tool_result') {
handleToolResult(block);
}
}
}
break;
case 'result': {
const durationMs = Date.now() - startTime;
const usage = event.usage || {};
const inputTokens = usage.input_tokens || 0;
const cacheRead = usage.cache_read_input_tokens || 0;
const cacheCreation = usage.cache_creation_input_tokens || 0;
const contextTokens = inputTokens + cacheRead + cacheCreation;
const outputTokens = usage.output_tokens || 0;
const cost = event.total_cost_usd || 0;
// Claude-Session-ID speichern fuer spaetere Fortsetzung
if (session && event.session_id) {
session.claudeSessionId = event.session_id;
}
// Antwort in Session speichern
if (session) {
session.messages.push({
role: 'assistant',
content: fullText,
timestamp: new Date().toISOString(),
model: usedModel,
cost,
});
}
emitter.emit('result', {
content: fullText,
cost,
tokens: {
input: contextTokens,
output: outputTokens,
cache_read: cacheRead,
cache_creation: cacheCreation,
},
session_id: event.session_id || '',
duration_ms: durationMs,
model: usedModel,
});
break;
}
default:
break;
}
}
} catch (err) {
if (err.name === 'AbortError') {
emitter.emit('result', {
content: fullText || '(Abgebrochen)',
cost: 0,
tokens: { input: 0, output: 0 },
session_id: '',
duration_ms: Date.now() - startTime,
model: usedModel,
aborted: true,
});
} else {
console.error('[bridge] Fehler:', err.message);
emitter.emit('error', { message: err.message || String(err) });
}
} finally {
// Verbleibende Subagents beenden
for (const [toolId, subagent] of activeSubagents) {
emitter.emit('agent_stopped', {
id: subagent.agentId,
success: false,
});
}
activeSubagents.clear();
emitter.emit('agent_stopped', { id: currentAgentId, success: true });
currentAgentId = null;
activeAbort = null;
}
}
// ============ Steuerungs-Funktionen ============
/** Stoppt alle aktiven Agents */
export function stopAll() {
if (activeAbort) {
activeAbort.abort();
return true;
}
return false;
}
/** Aktueller Status */
export function getStatus() {
return {
processing: !!currentAgentId,
model: currentModel,
mode: agentMode,
agentId: currentAgentId,
uptime: Math.floor(process.uptime()),
};
}
/** Verfuegbare Modelle */
export function listModels() {
return {
current: currentModel,
available: AVAILABLE_MODELS,
};
}
/** Modell wechseln */
export function setModel(model) {
const validIds = AVAILABLE_MODELS.map(m => m.id);
if (!validIds.includes(model)) {
throw new Error(`Ungueltiges Modell: ${model}. Verfuegbar: ${validIds.join(', ')}`);
}
currentModel = model;
return { model: currentModel, status: 'Modell geaendert' };
}
/** Modus wechseln */
export function setMode(mode) {
const validModes = ['solo', 'handlanger', 'experten', 'auto'];
if (!validModes.includes(mode)) {
throw new Error(`Ungueltiger Modus: ${mode}. Verfuegbar: ${validModes.join(', ')}`);
}
agentMode = mode;
return { mode: agentMode, status: 'Modus geaendert' };
}
// ============ Session-Management ============
/** Neue Session erstellen */
export function createSession(title) {
const id = randomUUID();
const session = {
id,
title: title || `Session ${sessions.size + 1}`,
createdAt: new Date().toISOString(),
messages: [],
claudeSessionId: null,
};
sessions.set(id, session);
return session;
}
/** Session-Liste (ohne Messages fuer Uebersicht) */
export function listSessions() {
return Array.from(sessions.values()).map(s => ({
id: s.id,
title: s.title,
createdAt: s.createdAt,
messageCount: s.messages.length,
}));
}
/** Einzelne Session mit Messages */
export function getSession(id) {
const session = sessions.get(id);
if (!session) return null;
return { ...session };
}