claude-desktop/src/lib/stores/events.ts
Eddy 3993387977
All checks were successful
Build AppImage / build (push) Has been skipped
Security-Fixes + UI-Verbesserungen: Stop-Button, Textfeld, Agent-Filter
Backend:
- Credentials aus Code entfernt → ENV-Variablen mit Fallback
- File-Traversal in Update-Download verhindert (Path-Sanitization)
- CLI-Injection bei D-Bus mit Whitelist-Validierung abgesichert

Frontend:
- Stop-Button dezenter (kleinere Schrift, gedämpftes Rot, kein Pulsieren)
- Stop löscht keine Session/Messages mehr — nur Agents stoppen
- Textfeld nicht mehr blockiert während Claude arbeitet (Einwände möglich)
- Agent-Filter "Nur aktive" wird in localStorage persistent gespeichert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 03:18:39 +02:00

402 lines
9.8 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');
})
);
// 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;
});
// 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, success, output } = event.payload;
console.log('✅ Tool Ende:', id, success ? 'OK' : 'FEHLER');
completeToolCall(id, output, !success);
})
);
// 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,
}));
}
}
})
);
// 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 = [];
}
// 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';
}