claude-desktop/src/lib/stores/events.ts
Eddy 8a7e0d87f3
Some checks failed
Build AppImage / build (push) Has been cancelled
feat: collapsible messages, German cost format, stats persistence [appimage]
- Long messages (>25 lines) auto-collapse with expand/collapse button
- Cost display uses German format: "16,23$" instead of "$16.230"
- Session stats (tokens, cost, count) persist to DB after each response
  via new update_session_stats command — survives app restart
- Small costs shown as cents (e.g. "3,2¢")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:31:25 +02:00

548 lines
15 KiB
TypeScript

// Claude Desktop — Event-Bridge
// Empfängt Events vom Tauri-Backend und aktualisiert die Stores
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { get } from 'svelte/store';
import {
agents,
toolCalls,
messages,
isProcessing,
addMessage,
addAgent,
addSubAgent,
updateAgentStatus,
addToolCall,
completeToolCall,
currentModel,
sessionStats,
contextUsage,
currentSessionId,
messageToDb,
addMonitorEvent,
loadMonitorEventsFromDb,
activeKnowledgeHints,
agentMode,
type Message,
type Agent,
type MonitorEventType,
type KnowledgeHint,
type AgentMode
} from './app';
// Event-Typen vom Backend
interface AgentEvent {
id: string;
type?: string;
task?: string;
code?: number;
model?: string;
}
interface SubagentEvent {
id: string;
parentAgentId: string;
type?: string;
task?: string;
depth?: number;
model?: string;
toolUseId?: string;
success?: boolean;
}
interface ToolEvent {
id: string;
tool?: string;
input?: Record<string, unknown>;
output?: string;
success?: boolean;
}
interface TextEvent {
text: string;
}
interface ResultEvent {
cost?: number;
tokens?: {
input: number;
output: number;
};
session_id?: string;
model?: string;
text?: string;
}
interface MonitorEventPayload {
type: MonitorEventType;
summary: string;
details: Record<string, unknown>;
agentId?: string;
durationMs?: number;
error?: string;
}
// Listener-Handles
let listeners: UnlistenFn[] = [];
// Streaming: ID der aktuellen Live-Nachricht
let streamingMessageId: string | null = null;
// Nachricht in DB speichern
async function saveMessageToDb(msg: Message) {
const sessionId = get(currentSessionId);
if (!sessionId) return;
try {
const dbMsg = messageToDb(msg, sessionId);
await invoke('save_message', { message: dbMsg });
console.log('💾 Nachricht gespeichert:', msg.role);
} catch (err) {
console.error('Fehler beim Speichern der Nachricht:', err);
}
}
// Events initialisieren
export async function initEventListeners(): Promise<void> {
console.log('🎧 Initialisiere Event-Listener...');
await cleanupEventListeners();
// Monitor-Events aus DB laden (letzte Session)
await loadMonitorEventsFromDb(500);
// Bridge bereit
listeners.push(
await listen('bridge-ready', () => {
console.log('✅ Bridge bereit');
})
);
// Session erstellt — Hook feuern + proaktive KB-Hints laden (fire-and-forget)
listeners.push(
await listen<{ id: string }>('session-created', (event) => {
const { id } = event.payload;
console.log('📂 Session-Created Event empfangen:', id);
invoke('fire_hook', {
event: 'SessionStart',
summary: JSON.stringify({ sessionId: id })
}).catch((err) => console.debug('Hook session-start fehlgeschlagen:', err));
// Phase 2.0: Proaktive KB-Abfrage bei Session-Start
loadProactiveSessionHints();
})
);
// Agent gestartet
listeners.push(
await listen<AgentEvent>('agent-started', (event) => {
const { id, type, task, model } = event.payload;
console.log('🤖 Agent gestartet:', id, type);
// WICHTIG: id mitgeben, sonst stimmt parentAgentId der Sub-Agents nicht!
addAgent(mapAgentType(type || 'main'), task || 'Verarbeite...', { id, model });
isProcessing.set(true);
// Leere Streaming-Nachricht anlegen
streamingMessageId = crypto.randomUUID();
messages.update((msgs) => [
...msgs,
{
id: streamingMessageId!,
role: 'assistant',
content: '',
timestamp: new Date(),
agentId: id
}
]);
})
);
// Agent gestoppt
listeners.push(
await listen<AgentEvent>('agent-stopped', (event) => {
const { id } = event.payload;
console.log('⏹️ Agent gestoppt:', id);
updateAgentStatus(id, 'stopped');
streamingMessageId = null;
// Prüfen ob noch Agents aktiv
agents.update((ags) => {
const stillActive = ags.some((a) => a.status === 'active');
if (!stillActive) {
isProcessing.set(false);
}
return ags;
});
})
);
// Alle Agents gestoppt
listeners.push(
await listen('all-stopped', () => {
console.log('⏹️ Alle Agents gestoppt');
agents.update((ags) => ags.map((a) => ({ ...a, status: 'stopped' as const })));
isProcessing.set(false);
streamingMessageId = null;
})
);
// Subagent gestartet
listeners.push(
await listen<SubagentEvent>('subagent-started', (event) => {
const { id, parentAgentId, type, task, depth, model } = event.payload;
console.log('🤖 Subagent gestartet:', id, type, '(Parent:', parentAgentId, ')');
addSubAgent(
parentAgentId,
mapAgentType(type || 'explore'),
task || 'Subagent-Aufgabe',
{ id, model }
);
})
);
// Subagent gestoppt
listeners.push(
await listen<SubagentEvent>('subagent-stopped', (event) => {
const { id, success } = event.payload;
console.log('⏹️ Subagent gestoppt:', id, success ? 'OK' : 'FEHLER');
updateAgentStatus(id, 'stopped');
})
);
// Tool Start
listeners.push(
await listen<ToolEvent>('tool-start', async (event) => {
const { tool, input } = event.payload;
console.log('🔧 Tool Start:', tool);
agents.update((ags) => {
const activeAgent = ags.find((a) => a.status === 'active');
if (activeAgent) {
addToolCall(activeAgent.id, tool || 'unknown', input || {});
}
return ags;
});
// Hook: pre-tool-use (fire-and-forget, Fehler blockieren nicht)
invoke('fire_hook', {
event: 'PreToolUse',
summary: JSON.stringify({ tool: tool || 'unknown', input: input || {} })
}).catch((err) => console.debug('Hook pre-tool-use fehlgeschlagen:', err));
// Wissens-Hints aus claude-db laden
try {
// Command aus Input extrahieren (je nach Tool)
let command: string | undefined;
if (input && typeof input === 'object') {
// Bash: command, Read/Write/Edit: file_path
command = (input as Record<string, unknown>).command as string
|| (input as Record<string, unknown>).file_path as string
|| undefined;
}
const hints = await invoke<KnowledgeHint[]>('get_tool_hints', {
tool: tool || 'unknown',
command,
context: undefined
});
if (hints && hints.length > 0) {
activeKnowledgeHints.set(hints);
console.log('💡 Wissens-Hints geladen:', hints.map(h => h.title));
}
} catch (err) {
// Fehler beim Laden ignorieren — Hints sind optional
console.debug('Wissens-Hints nicht verfügbar:', err);
}
})
);
// Tool Ende
listeners.push(
await listen<ToolEvent>('tool-end', (event) => {
const { id, tool, success, output } = event.payload;
console.log('✅ Tool Ende:', id, success ? 'OK' : 'FEHLER');
completeToolCall(id, output, !success);
// Hook: post-tool-use (fire-and-forget, Fehler blockieren nicht)
invoke('fire_hook', {
event: 'PostToolUse',
summary: JSON.stringify({ tool: tool || 'unknown', success: !!success, hasOutput: !!output })
}).catch((err) => console.debug('Hook post-tool-use fehlgeschlagen:', err));
// Pattern-Detektion bei Tool-Fehlern (fire-and-forget)
if (!success && output) {
invoke<{ id: string; name: string; description: string; new_approach: string } | null>(
'detect_issue',
{ errorMessage: output, context: tool || 'unknown' }
).then((pattern) => {
if (pattern) {
console.log('🔍 Bekanntes Problem erkannt:', pattern.name);
addMonitorEvent('error', `Bekanntes Problem: ${pattern.name}`, {
patternId: pattern.id,
beschreibung: pattern.description,
loesung: pattern.new_approach,
toolId: id,
tool: tool || 'unknown',
fehlerAuszug: output.substring(0, 200),
});
}
}).catch((err) => {
// Pattern-Detektion ist optional — Fehler nur loggen
console.debug('Pattern-Detektion fehlgeschlagen:', err);
});
// Phase 2.0: Auto-Fehler-Tracking — Fehler hashen und zählen
trackErrorOccurrence(output, tool || 'unknown');
}
})
);
// Text-Streaming — live in die aktuelle Nachricht schreiben
listeners.push(
await listen<TextEvent>('claude-text', (event) => {
const { text } = event.payload;
if (streamingMessageId) {
messages.update((msgs) =>
msgs.map((m) =>
m.id === streamingMessageId
? { ...m, content: m.content + text }
: m
)
);
}
})
);
// Ergebnis (Kosten, Token, Modell)
listeners.push(
await listen<ResultEvent>('claude-result', async (event) => {
const { cost, tokens, session_id, model, text } = event.payload;
console.log('📊 Ergebnis:', { cost: cost ? `$${cost.toFixed(4)}` : '-', tokens, model, session_id });
// Modell an die Streaming-Nachricht anhängen und speichern
if (streamingMessageId) {
let finalMessage: Message | null = null;
messages.update((msgs) => {
return msgs.map((m) => {
if (m.id === streamingMessageId) {
// Fallback: wenn kein Streaming-Text kam, result.text nutzen
const content = m.content && m.content.trim() ? m.content : (text || '');
finalMessage = { ...m, content, model: model || m.model };
return finalMessage;
}
return m;
});
});
// Nachricht in DB speichern (nur wenn Content vorhanden)
if (finalMessage && finalMessage.content && finalMessage.content.trim()) {
await saveMessageToDb(finalMessage);
}
}
if (model) {
currentModel.set(model);
}
// Claude Session-ID speichern für Fortsetzung
if (session_id) {
const appSessionId = get(currentSessionId);
if (appSessionId) {
try {
await invoke('set_claude_session_id', {
sessionId: appSessionId,
claudeSessionId: session_id,
});
console.log('🔗 Claude Session-ID gespeichert:', session_id);
} catch (err) {
console.warn('Claude Session-ID konnte nicht gespeichert werden:', err);
}
}
}
// Session-Statistiken aktualisieren
if (tokens || cost) {
sessionStats.update((s) => ({
totalTokensIn: s.totalTokensIn + (tokens?.input || 0),
totalTokensOut: s.totalTokensOut + (tokens?.output || 0),
totalCost: s.totalCost + (cost || 0),
messageCount: s.messageCount + 1,
}));
// Kontext-Auslastung aktualisieren (input_tokens = aktuelle Kontext-Größe)
if (tokens?.input) {
contextUsage.update((ctx) => ({
...ctx,
inputTokens: tokens.input,
outputTokens: tokens.output || 0,
}));
}
// Session-Stats in DB persistieren (überlebt App-Neustart)
const appSessionId = get(currentSessionId);
if (appSessionId) {
const stats = get(sessionStats);
invoke('update_session_stats', {
sessionId: appSessionId,
tokenInput: stats.totalTokensIn,
tokenOutput: stats.totalTokensOut,
costUsd: stats.totalCost,
messageCount: stats.messageCount,
}).catch((err: unknown) => console.warn('Session-Stats speichern fehlgeschlagen:', err));
}
}
})
);
// STOPP-Signal — nur Agents stoppen, Messages/Session bleiben erhalten
listeners.push(
await listen('agents-stopped', () => {
console.log('🛑 STOPP-Signal empfangen');
streamingMessageId = null;
// Alle Agents auf "stopped" setzen, aber Messages NICHT löschen
agents.update((ags) => ags.map((a) => ({ ...a, status: 'stopped' as const })));
toolCalls.set([]);
isProcessing.set(false);
})
);
// Agent-Modus geändert (von Bridge bestätigt)
listeners.push(
await listen<{ mode: AgentMode }>('mode-changed', (event) => {
const { mode } = event.payload;
console.log('🔄 Agent-Modus geändert:', mode);
agentMode.set(mode);
})
);
// Monitor-Events — für System-Monitor Panel
listeners.push(
await listen<MonitorEventPayload>('monitor', (event) => {
const { type, summary, details, agentId, durationMs, error } = event.payload;
addMonitorEvent(type, summary, details, {
agentId,
durationMs,
error,
});
})
);
console.log('✅ Event-Listener initialisiert');
}
// Listener aufräumen
export async function cleanupEventListeners(): Promise<void> {
for (const unlisten of listeners) {
unlisten();
}
listeners = [];
}
// Phase 2.0: Fehler-Hash berechnen (einfacher Hash aus Fehlermeldung)
function hashError(errorMessage: string): string {
// Normalisierung: Zahlen, Pfade und UUIDs entfernen für besseres Grouping
const normalized = errorMessage
.substring(0, 200)
.replace(/\/[\w/.-]+/g, '<PATH>') // Pfade
.replace(/[0-9a-f]{8}-[0-9a-f]{4}/gi, '<UUID>') // UUIDs
.replace(/\d+/g, '<N>') // Zahlen
.replace(/\s+/g, ' ') // Whitespace
.trim()
.toLowerCase();
// Einfacher String-Hash
let hash = 0;
for (let i = 0; i < normalized.length; i++) {
const char = normalized.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 32-bit Integer
}
return 'err_' + Math.abs(hash).toString(36);
}
// Phase 2.0: Auto-Fehler-Tracking — Fehler zählen und bei 3+ automatisch Pattern in KB speichern
async function trackErrorOccurrence(errorMessage: string, tool: string) {
try {
const errorHash = hashError(errorMessage);
const [count, existingKbId] = await invoke<[number, number | null]>('track_error', {
errorHash,
errorMessage: errorMessage.substring(0, 1000),
tool,
});
console.log(`📊 Fehler-Tracking: ${errorHash}${count}x (KB: ${existingKbId || 'noch nicht'})`);
// Bei 3+ Occurrences und noch kein KB-Eintrag: automatisch speichern
if (count >= 3 && !existingKbId) {
console.log(`🆕 Auto-Pattern: Fehler ${count}x aufgetreten, speichere in KB...`);
const kbId = await invoke<number>('auto_save_error_pattern', {
errorHash,
errorMessage: errorMessage.substring(0, 1000),
tool,
occurrenceCount: count,
});
// KB-ID zurückschreiben
await invoke('set_error_kb_pattern', { errorHash, kbPatternId: kbId });
addMonitorEvent('hook', `Auto-Pattern erstellt: KB #${kbId} (${count}x ${tool})`, {
errorHash,
kbId,
occurrenceCount: count,
tool,
});
}
} catch (err) {
// Fehler-Tracking ist komplett optional — niemals die App blockieren
console.debug('Fehler-Tracking fehlgeschlagen:', err);
}
}
// Phase 2.0: Proaktive KB-Abfrage bei Session-Erstellung
export async function loadProactiveSessionHints(projectName?: string): Promise<void> {
try {
const hints = await invoke<string>('get_session_hints', {
projectName: projectName || null,
});
if (hints && hints.length > 0) {
console.log('📋 Proaktive Session-Hints geladen:', hints.length, 'Bytes');
addMonitorEvent('hook', `Proaktive KB-Hints geladen (~${Math.ceil(hints.length / 4)} Token)`, {
projectName,
hintSize: hints.length,
});
}
} catch (err) {
console.debug('Proaktive Session-Hints nicht verfügbar:', err);
}
}
// Agent-Typ mappen
function mapAgentType(type: string): Agent['type'] {
const typeMap: Record<string, Agent['type']> = {
main: 'main',
'Main Agent': 'main',
Main: 'main',
explore: 'explore',
Explore: 'explore',
'general-purpose': 'explore',
plan: 'plan',
Plan: 'plan',
bash: 'bash',
Bash: 'bash',
code: 'code',
Code: 'code',
implement: 'code',
test: 'test',
Test: 'test',
review: 'review',
Review: 'review',
};
return typeMap[type] || 'explore';
}