All checks were successful
Build AppImage / build (push) Has been skipped
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>
502 lines
15 KiB
JavaScript
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 };
|
|
}
|