Compare commits

..

8 commits

Author SHA1 Message Date
Eddy
9d837efae6 Phase 7: UI-Verbesserungen — Code-Copy, Edit, Regenerate
- Code-Blöcke mit Copy-Button (📋) und Sprach-Label
- Nachrichten bearbeiten (✏️) mit Speichern & Senden
- Antwort regenerieren (🔄) für letzte Assistant-Nachricht
- Custom marked-Renderer für Code-Block-Wrapper
- MutationObserver für Streaming-kompatible Copy-Buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 12:44:50 +02:00
Eddy
adb11fd121 Phase 16: System-Monitor (Debug-Panel)
Neue Komponenten:
- MonitorPanel.svelte: Live-Event-Stream mit Farbcodierung
- MonitorEvent Store in app.ts (Ringbuffer max 1000)

Features:
- 🔵 API-Events (Request/Response, Token, Kosten, Latenz)
- 🟡 Tool-Events (Name, Parameter, Dauer)
- 🟠 Agent-Events (Start/Stop)
- 🔴 Error-Events
- Filter nach Event-Typ
- Auto-Scroll toggle
- Detail-Ansicht bei Klick
- Copy-Button für Details
- Statistik-Anzeige (API-Calls, Errors, Avg Latency)

claude-bridge.js:
- sendMonitorEvent() Funktion
- summarizeToolInput() für kompakte Zusammenfassung
- Events bei API-Start, API-Ende, Tool-Start, Fehler

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 12:40:29 +02:00
Eddy
6cfcdb2c79 Phase 5: Subagent-Hierarchie + ROADMAP erweitert
Subagent-Hierarchie:
- Agent Interface erweitert: parentAgentId, depth, model
- claude-bridge.js: Erkennt Task-Tool als Subagent-Start
- events.ts: Listener für subagent-started/stopped
- AgentView.svelte: Baumansicht mit Einrückung + Collapse

ROADMAP erweitert (Phase 5-16):
- Phase 5: Subagent-Hierarchie 
- Phase 6-9: Session, UI, Claude-DB, Context
- Phase 10: Sprach-Interface
- Phase 11: Multi-Agent-Modi (Solo/Handlanger/Experten)
- Phase 12: Hook-System
- Phase 13: VSCodium Integration
- Phase 14: Programm-Steuerung (Playwright, D-Bus)
- Phase 15: Schulungsmodus (Mermaid, animierter Code)
- Phase 16: System-Monitor (Debug-Panel)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 12:35:29 +02:00
Eddy
18c8ef2f4f Drei Agent-Modi: Solo / Handlanger / Experten
- Solo: Main macht alles, keine Subagents (für Triviales)
- Handlanger: Main denkt, Subs führen exakt aus (Koordination)
- Experten: Jeder Agent plant selbst (parallelisierbar)

Einstellbar in UI:
- Settings: Agent-Modus Dropdown
- Auto-Modus wählt basierend auf Komplexität
- Footer zeigt aktiven Modus

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 12:22:50 +02:00
Eddy
f640b18f47 Phase 11: Multi-Agent-Architektur für Context-Einsparung
Orchestrator + spezialisierte Sub-Agents statt einem Main-Agent:
- Orchestrator: Plant, delegiert, entscheidet (kleiner Context)
- Research Agent: Durchsucht Code, gibt Zusammenfassung
- Implement Agent: Schreibt Code nach Spec
- Test Agent: Führt Tests aus, berichtet Ergebnisse
- Review Agent: Prüft Code-Qualität

Vorteile:
- Main Context bleibt klein (~10k statt 200k)
- Compacting selten nötig
- Parallele Arbeit möglich
- Spezialisierung = bessere Ergebnisse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 12:20:18 +02:00
Eddy
25617dc76f Enforcement-Mechanismen für Context-Nutzung
- Position: Schicht 1 im System Prompt, Schicht 2 als letzter Reminder
- Explizite Anweisungen mit <critical-context> Tags
- validateResponse() für Kontext-Ignorierung erkennen
- Auto-Retry mit Korrektur-Hinweis
- context_failures Tabelle für Feedback-Loop
- UI-Warnung bei Regel-Verletzung

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 12:18:08 +02:00
Eddy
35872f8679 ROADMAP.md mit vollständigem Entwicklungsplan
Phase 5: Subagent-Hierarchie (Baumansicht)
Phase 6: Session-Management (Auto-Load, agentId)
Phase 7: UI (Edit/Regenerate/Copy/Diff)
Phase 8: Claude-DB Integration (Wissensbasis)
Phase 9: Context-Management (Drei-Schichten-Gedächtnis)
Phase 10: Sprach-Interface (Whisper/TTS)
Phase 11: Hook-System (Automatisierung)

Enthält DB-Schema für:
- project_context (Schicht 2)
- credentials (verschlüsselt)
- compacting_archive
- concept_cache

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 12:15:45 +02:00
Eddy
3c6da3b3d5 Tray-Icon mit Menü (Zeigen/Minimieren/Beenden)
- Tray-Icon zeigt App im System-Tray
- Kontextmenü: Fenster zeigen, Minimieren, Beenden
- Klick auf Tray-Icon zeigt Fenster
- Capabilities für Window-Operationen ergänzt
- Icon-Konfiguration in tauri.conf.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 11:57:15 +02:00
12 changed files with 2913 additions and 64 deletions

1300
ROADMAP.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,10 @@ let activeAbort = null;
let currentAgentId = null; let currentAgentId = null;
let currentModel = process.env.CLAUDE_MODEL || 'opus'; let currentModel = process.env.CLAUDE_MODEL || 'opus';
// Subagent-Tracking
// Map: toolUseId → { agentId, parentId, type, task, depth }
const activeSubagents = new Map();
// Verfügbare Modelle // Verfügbare Modelle
const AVAILABLE_MODELS = [ const AVAILABLE_MODELS = [
{ id: 'haiku', name: 'Claude Haiku', description: 'Schnell & günstig' }, { id: 'haiku', name: 'Claude Haiku', description: 'Schnell & günstig' },
@ -26,6 +30,26 @@ const AVAILABLE_MODELS = [
{ id: 'opus', name: 'Claude Opus', description: 'Leistungsstark' }, { id: 'opus', name: 'Claude Opus', description: 'Leistungsstark' },
]; ];
// Tools die Subagents spawnen
const SUBAGENT_TOOLS = ['Task', 'Agent', 'spawn_agent', 'launch_agent'];
// 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();
// Fallback basierend auf description/prompt
const desc = (input?.description || input?.prompt || '').toLowerCase();
if (desc.includes('explore') || desc.includes('search') || desc.includes('find')) return 'explore';
if (desc.includes('plan') || desc.includes('design')) return 'plan';
if (desc.includes('bash') || desc.includes('command') || desc.includes('terminal')) return 'bash';
if (desc.includes('code') || desc.includes('implement') || desc.includes('write')) return 'code';
if (desc.includes('test') || desc.includes('verify')) return 'test';
if (desc.includes('review') || desc.includes('check')) return 'review';
return 'explore'; // Default
}
// ============ Kommunikation mit Tauri ============ // ============ Kommunikation mit Tauri ============
function sendToTauri(msg) { function sendToTauri(msg) {
@ -44,6 +68,52 @@ function sendError(id, error) {
sendToTauri({ type: 'response', id, error }); sendToTauri({ type: 'response', id, error });
} }
// ============ Monitor-Events ============
// Sendet ein Event für den System-Monitor
function sendMonitorEvent(type, summary, details = {}, options = {}) {
sendEvent('monitor', {
type, // 'api' | 'hook' | 'tool' | 'mcp' | 'agent' | 'error' | 'debug'
summary, // Einzeiler für Kompakt-Ansicht
details, // Vollständige Daten
agentId: options.agentId || currentAgentId,
durationMs: options.durationMs,
error: options.error,
});
}
// Tool-Input für Logging kürzen (sensitive Daten maskieren)
function summarizeToolInput(tool, input) {
if (!input) return '';
// Bestimmte Tools speziell behandeln
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 || '';
}
// Default: Erstes String-Feld nehmen
const firstString = Object.values(input).find(v => typeof v === 'string');
if (firstString) {
return firstString.length > 50 ? firstString.substring(0, 50) + '...' : firstString;
}
return '';
}
// ============ Claude Agent SDK ============ // ============ Claude Agent SDK ============
async function sendMessage(message, requestId, model = null) { async function sendMessage(message, requestId, model = null) {
@ -60,6 +130,20 @@ async function sendMessage(message, requestId, model = null) {
model: useModel, model: useModel,
}); });
// Monitor: Agent gestartet
sendMonitorEvent('agent', `Main Agent gestartet (${useModel})`, {
agentId: currentAgentId,
model: useModel,
task: message.substring(0, 100),
});
// Monitor: API-Request
sendMonitorEvent('api', `${useModel}`, {
model: useModel,
promptLength: message.length,
maxTurns: 25,
});
sendResponse(requestId, { agentId: currentAgentId, status: 'gestartet', model: useModel }); sendResponse(requestId, { agentId: currentAgentId, status: 'gestartet', model: useModel });
const startTime = Date.now(); const startTime = Date.now();
@ -93,35 +177,109 @@ async function sendMessage(message, requestId, model = null) {
} }
break; break;
case 'tool_use': case 'tool_use': {
const toolId = event.tool_use_id || randomUUID();
const toolName = event.name || 'unknown';
const toolInput = event.input || {};
// Prüfen ob dieses Tool einen Subagent startet
if (SUBAGENT_TOOLS.includes(toolName)) {
const subagentId = randomUUID();
const subagentType = getSubagentType(toolName, toolInput);
const subagentTask = toolInput.description || toolInput.prompt || toolInput.task || 'Subagent-Aufgabe';
const subagentModel = toolInput.model || useModel;
// Tiefe berechnen (Main = 0, erster Sub = 1, etc.)
// Für jetzt: immer depth 1 (direkter Subagent vom Main)
const depth = 1;
activeSubagents.set(toolId, {
agentId: subagentId,
parentId: currentAgentId,
type: subagentType,
task: subagentTask,
depth,
model: subagentModel,
});
sendEvent('subagent-started', {
id: subagentId,
parentAgentId: currentAgentId,
type: subagentType,
task: subagentTask.substring(0, 100),
depth,
model: subagentModel,
toolUseId: toolId,
});
}
sendEvent('tool-start', { sendEvent('tool-start', {
id: event.tool_use_id || randomUUID(), id: toolId,
tool: event.name || 'unknown', tool: toolName,
input: event.input || {}, input: toolInput,
agentId: currentAgentId,
});
// Monitor: Tool gestartet
const toolSummary = summarizeToolInput(toolName, toolInput);
sendMonitorEvent('tool', `${toolName} ${toolSummary}`, {
toolId,
tool: toolName,
input: toolInput,
}); });
break; break;
}
case 'tool_result': case 'tool_result': {
sendEvent('tool-end', { const toolId = event.tool_use_id || '';
id: event.tool_use_id || '',
// Prüfen ob dieser Tool-Call ein Subagent war
if (activeSubagents.has(toolId)) {
const subagent = activeSubagents.get(toolId);
sendEvent('subagent-stopped', {
id: subagent.agentId,
parentAgentId: subagent.parentId,
success: !event.is_error, success: !event.is_error,
toolUseId: toolId,
});
activeSubagents.delete(toolId);
}
sendEvent('tool-end', {
id: toolId,
success: !event.is_error,
agentId: currentAgentId,
}); });
break; break;
}
case 'result': case 'result': {
// Endergebnis // Endergebnis
const durationMs = Date.now() - startTime;
const inputTokens = event.usage?.input_tokens || 0;
const outputTokens = event.usage?.output_tokens || 0;
const cost = event.total_cost_usd || 0;
sendEvent('result', { sendEvent('result', {
text: fullText, text: fullText,
cost: event.total_cost_usd || 0, cost,
tokens: { tokens: { input: inputTokens, output: outputTokens },
input: event.usage?.input_tokens || 0,
output: event.usage?.output_tokens || 0,
},
session_id: event.session_id || '', session_id: event.session_id || '',
duration_ms: Date.now() - startTime, duration_ms: durationMs,
model: usedModel, model: usedModel,
}); });
// Monitor: API-Response
const tokenK = ((inputTokens + outputTokens) / 1000).toFixed(1);
sendMonitorEvent('api', `${usedModel} [${durationMs}ms] ${tokenK}k tok $${cost.toFixed(4)}`, {
model: usedModel,
inputTokens,
outputTokens,
cost,
sessionId: event.session_id,
}, { durationMs });
break; break;
}
default: default:
// Andere Events still ignorieren // Andere Events still ignorieren
@ -131,10 +289,29 @@ async function sendMessage(message, requestId, model = null) {
} catch (err) { } catch (err) {
if (err.name === 'AbortError') { if (err.name === 'AbortError') {
// Abgebrochen — kein Fehler // Abgebrochen — kein Fehler
sendMonitorEvent('agent', 'Abgebrochen (User)', { reason: 'abort' });
} else { } else {
sendEvent('text', { text: `\n\n**Fehler:** ${err.message || err}` }); sendEvent('text', { text: `\n\n**Fehler:** ${err.message || err}` });
// Monitor: Fehler
sendMonitorEvent('error', `${err.message || err}`, {
name: err.name,
message: err.message,
stack: err.stack,
}, { error: err.message || String(err) });
} }
} finally { } finally {
// Alle noch aktiven Subagents stoppen
for (const [toolId, subagent] of activeSubagents) {
sendEvent('subagent-stopped', {
id: subagent.agentId,
parentAgentId: subagent.parentId,
success: false, // Vorzeitig beendet
toolUseId: toolId,
});
}
activeSubagents.clear();
sendEvent('agent-stopped', { id: currentAgentId, code: 0 }); sendEvent('agent-stopped', { id: currentAgentId, code: 0 });
sendEvent('all-stopped'); sendEvent('all-stopped');
currentAgentId = null; currentAgentId = null;

View file

@ -5,6 +5,12 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-close",
"core:menu:default",
"core:tray:default",
"shell:allow-open", "shell:allow-open",
"shell:allow-execute", "shell:allow-execute",
"shell:allow-spawn", "shell:allow-spawn",

View file

@ -2,7 +2,11 @@
// Hauptmodul für die Rust-Seite der App // Hauptmodul für die Rust-Seite der App
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::Manager; use tauri::{
Manager,
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
mod audit; mod audit;
mod claude; mod claude;
@ -97,6 +101,57 @@ pub fn run() {
println!("🧠 Initialisiere Gedächtnis-System..."); println!("🧠 Initialisiere Gedächtnis-System...");
}); });
// Tray-Icon einrichten
let show_item = MenuItem::with_id(app, "show", "Fenster zeigen", true, None::<&str>)?;
let hide_item = MenuItem::with_id(app, "hide", "Minimieren", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
let tray_menu = Menu::with_items(app, &[&show_item, &hide_item, &quit_item])?;
// App-Icon für Tray verwenden
let icon = app.default_window_icon()
.cloned()
.expect("Kein App-Icon konfiguriert");
let tray_icon = TrayIconBuilder::new()
.icon(icon)
.menu(&tray_menu)
.show_menu_on_left_click(false)
.tooltip("Claude Desktop")
.on_menu_event(|app, event| {
match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"hide" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
"quit" => {
app.exit(0);
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
// Doppelklick auf Tray-Icon zeigt das Fenster
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
if let Some(window) = tray.app_handle().get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
// Tray-Icon Handle speichern (optional für späteren Zugriff)
app.manage(tray_icon);
println!("🔲 Tray-Icon eingerichtet");
Ok(()) Ok(())
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View file

@ -31,7 +31,10 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["appimage", "deb"] "targets": ["appimage", "deb"],
"icon": [
"icons/icon.png"
]
}, },
"plugins": { "plugins": {
"shell": { "shell": {

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { agents, selectedAgentId, agentCount } from '$lib/stores/app'; import { agents, selectedAgentId, agentCount, agentTree, type AgentTreeNode } from '$lib/stores/app';
import type { Agent } from '$lib/stores/app'; import type { Agent } from '$lib/stores/app';
// Status-Icons // Status-Icons
@ -10,14 +10,40 @@
stopped: '🔴' stopped: '🔴'
}; };
// Typ-Namen // Typ-Namen (erweitert für alle Agent-Typen)
const typeNames: Record<Agent['type'], string> = { const typeNames: Record<Agent['type'], string> = {
main: 'Main Agent', main: 'Main Agent',
explore: 'Explore', explore: 'Explore',
plan: 'Plan', plan: 'Plan',
bash: 'Bash' bash: 'Bash',
code: 'Code',
test: 'Test',
review: 'Review'
}; };
// Typ-Icons
const typeIcons: Record<Agent['type'], string> = {
main: '🤖',
explore: '🔍',
plan: '📋',
bash: '💻',
code: '✏️',
test: '🧪',
review: '👀'
};
// Collapsed State für Baumknoten
let collapsedNodes = new Set<string>();
function toggleCollapse(id: string) {
if (collapsedNodes.has(id)) {
collapsedNodes.delete(id);
} else {
collapsedNodes.add(id);
}
collapsedNodes = collapsedNodes; // Trigger reactivity
}
function selectAgent(id: string) { function selectAgent(id: string) {
$selectedAgentId = $selectedAgentId === id ? null : id; $selectedAgentId = $selectedAgentId === id ? null : id;
} }
@ -31,13 +57,84 @@
} }
return `${seconds}s`; return `${seconds}s`;
} }
// Rekursives Rendern von Baum-Knoten
function hasChildren(node: AgentTreeNode): boolean {
return node.children.length > 0;
}
</script> </script>
<!-- Rekursive Komponente für Baum-Knoten -->
{#snippet agentNode(node: AgentTreeNode, depth: number)}
{@const agent = node.agent}
{@const isCollapsed = collapsedNodes.has(agent.id)}
{@const hasKids = hasChildren(node)}
<div class="agent-node" style="--depth: {depth}">
<!-- Verbindungslinie -->
{#if depth > 0}
<div class="tree-line"></div>
{/if}
<button
class="agent-item"
class:selected={$selectedAgentId === agent.id}
class:active={agent.status === 'active'}
class:is-subagent={depth > 0}
on:click={() => selectAgent(agent.id)}
>
<!-- Collapse-Toggle wenn Kinder vorhanden -->
{#if hasKids}
<button
class="collapse-toggle"
on:click|stopPropagation={() => toggleCollapse(agent.id)}
title={isCollapsed ? 'Aufklappen' : 'Zuklappen'}
>
{isCollapsed ? '▶' : '▼'}
</button>
{:else}
<span class="collapse-spacer"></span>
{/if}
<div class="agent-content">
<div class="agent-main">
<span class="agent-status">{statusIcons[agent.status]}</span>
<span class="agent-type-icon" title={typeNames[agent.type]}>{typeIcons[agent.type]}</span>
<span class="agent-type">{typeNames[agent.type]}</span>
{#if agent.model}
<span class="agent-model">{agent.model}</span>
{/if}
<span class="agent-duration">({formatDuration(agent.startedAt)})</span>
</div>
<div class="agent-task">{agent.task}</div>
<div class="agent-meta">
<span class="agent-tools">🔧 {agent.toolCalls.length}</span>
{#if depth > 0}
<span class="agent-depth">Ebene {depth}</span>
{/if}
</div>
</div>
</button>
<!-- Kinder rekursiv rendern -->
{#if hasKids && !isCollapsed}
<div class="agent-children">
{#each node.children as child}
{@render agentNode(child, depth + 1)}
{/each}
</div>
{/if}
</div>
{/snippet}
<div class="agent-view"> <div class="agent-view">
<div class="agent-header"> <div class="agent-header">
<h2>🤖 Agents & Sub-Agents</h2> <h2>🤖 Agents</h2>
<div class="agent-summary"> <div class="agent-summary">
{$agentCount.total} gesamt | {$agentCount.active} aktiv {$agentCount.total} gesamt |
{$agentCount.mainAgents} Main |
{$agentCount.subAgents} Sub |
{$agentCount.active} aktiv
</div> </div>
</div> </div>
@ -47,24 +144,9 @@
<p class="hint">Agents erscheinen hier wenn Claude arbeitet.</p> <p class="hint">Agents erscheinen hier wenn Claude arbeitet.</p>
</div> </div>
{:else} {:else}
<div class="agent-list"> <div class="agent-tree">
{#each $agents as agent} {#each $agentTree as rootNode}
<button {@render agentNode(rootNode, 0)}
class="agent-item"
class:selected={$selectedAgentId === agent.id}
class:active={agent.status === 'active'}
on:click={() => selectAgent(agent.id)}
>
<div class="agent-main">
<span class="agent-status">{statusIcons[agent.status]}</span>
<span class="agent-type">{typeNames[agent.type]}</span>
<span class="agent-duration">({formatDuration(agent.startedAt)})</span>
</div>
<div class="agent-task">{agent.task}</div>
<div class="agent-tools">
Tools: {agent.toolCalls.length} Aufrufe
</div>
</button>
{/each} {/each}
</div> </div>
@ -73,7 +155,8 @@
{@const selectedAgent = $agents.find((a) => a.id === $selectedAgentId)} {@const selectedAgent = $agents.find((a) => a.id === $selectedAgentId)}
{#if selectedAgent} {#if selectedAgent}
<div class="agent-details"> <div class="agent-details">
<h3>Details: {typeNames[selectedAgent.type]}</h3> <h3>{typeIcons[selectedAgent.type]} {typeNames[selectedAgent.type]}</h3>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">Status:</span> <span class="detail-label">Status:</span>
<span class="detail-value">{statusIcons[selectedAgent.status]} {selectedAgent.status}</span> <span class="detail-value">{statusIcons[selectedAgent.status]} {selectedAgent.status}</span>
@ -82,6 +165,12 @@
<span class="detail-label">Aufgabe:</span> <span class="detail-label">Aufgabe:</span>
<span class="detail-value">{selectedAgent.task}</span> <span class="detail-value">{selectedAgent.task}</span>
</div> </div>
{#if selectedAgent.model}
<div class="detail-row">
<span class="detail-label">Modell:</span>
<span class="detail-value">{selectedAgent.model}</span>
</div>
{/if}
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">Gestartet:</span> <span class="detail-label">Gestartet:</span>
<span class="detail-value">{selectedAgent.startedAt.toLocaleTimeString('de-DE')}</span> <span class="detail-value">{selectedAgent.startedAt.toLocaleTimeString('de-DE')}</span>
@ -90,6 +179,18 @@
<span class="detail-label">Laufzeit:</span> <span class="detail-label">Laufzeit:</span>
<span class="detail-value">{formatDuration(selectedAgent.startedAt)}</span> <span class="detail-value">{formatDuration(selectedAgent.startedAt)}</span>
</div> </div>
{#if selectedAgent.parentAgentId}
<div class="detail-row">
<span class="detail-label">Parent:</span>
<span class="detail-value parent-link" on:click={() => selectAgent(selectedAgent.parentAgentId!)}>
{selectedAgent.parentAgentId.substring(0, 8)}...
</span>
</div>
{/if}
<div class="detail-row">
<span class="detail-label">Tiefe:</span>
<span class="detail-value">{selectedAgent.depth}</span>
</div>
<h4>Tool-Aufrufe ({selectedAgent.toolCalls.length})</h4> <h4>Tool-Aufrufe ({selectedAgent.toolCalls.length})</h4>
<div class="tool-list"> <div class="tool-list">
@ -150,13 +251,34 @@
margin-top: var(--spacing-sm); margin-top: var(--spacing-sm);
} }
.agent-list { /* Baum-Ansicht */
.agent-tree {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: var(--spacing-sm); padding: var(--spacing-sm);
} }
.agent-node {
position: relative;
margin-left: calc(var(--depth) * 1.5rem);
}
/* Verbindungslinie für Subagents */
.tree-line {
position: absolute;
left: -1rem;
top: 0;
bottom: 50%;
width: 1rem;
border-left: 2px solid var(--bg-tertiary);
border-bottom: 2px solid var(--bg-tertiary);
border-bottom-left-radius: 4px;
}
.agent-item { .agent-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-xs);
width: 100%; width: 100%;
text-align: left; text-align: left;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
@ -180,15 +302,57 @@
border-left: 3px solid var(--success); border-left: 3px solid var(--success);
} }
.agent-item.is-subagent {
background: var(--bg-primary);
border-style: dashed;
}
.collapse-toggle {
padding: 2px 4px;
font-size: 0.625rem;
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
flex-shrink: 0;
}
.collapse-toggle:hover {
color: var(--text-primary);
}
.collapse-spacer {
width: 18px;
flex-shrink: 0;
}
.agent-content {
flex: 1;
min-width: 0;
}
.agent-main { .agent-main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); gap: var(--spacing-xs);
margin-bottom: var(--spacing-xs); flex-wrap: wrap;
}
.agent-type-icon {
font-size: 0.875rem;
} }
.agent-type { .agent-type {
font-weight: 600; font-weight: 600;
font-size: 0.8rem;
}
.agent-model {
font-size: 0.625rem;
padding: 1px 4px;
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
} }
.agent-duration { .agent-duration {
@ -202,14 +366,27 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
margin-top: 2px;
} }
.agent-tools { .agent-meta {
display: flex;
gap: var(--spacing-sm);
font-size: 0.625rem; font-size: 0.625rem;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
} }
.agent-depth {
padding: 1px 4px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
.agent-children {
margin-top: var(--spacing-xs);
}
/* Detail-Ansicht */ /* Detail-Ansicht */
.agent-details { .agent-details {
padding: var(--spacing-md); padding: var(--spacing-md);
@ -242,6 +419,15 @@
min-width: 80px; min-width: 80px;
} }
.parent-link {
cursor: pointer;
color: var(--accent);
}
.parent-link:hover {
text-decoration: underline;
}
.tool-list { .tool-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -1,11 +1,22 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, type Message } from '$lib/stores/app'; import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, type Message } from '$lib/stores/app';
import { marked } from 'marked'; import { marked, type Tokens } from 'marked';
import { tick, onDestroy } from 'svelte'; import { tick, onDestroy } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
marked.setOptions({ breaks: true, gfm: true }); // Custom Renderer für Code-Blöcke mit Wrapper
const renderer = new marked.Renderer();
renderer.code = function ({ text, lang }: Tokens.Code): string {
const language = lang || '';
const escapedCode = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<div class="code-block-wrapper" data-lang="${language}"><pre><code class="language-${language}">${escapedCode}</code></pre></div>`;
};
marked.setOptions({ breaks: true, gfm: true, renderer });
function renderMarkdown(text: string): string { function renderMarkdown(text: string): string {
try { try {
@ -15,8 +26,67 @@
} }
} }
// Svelte Action: Copy-Buttons zu Code-Blöcken hinzufügen
function addCopyButtons(node: HTMLElement) {
function processCodeBlocks() {
const wrappers = node.querySelectorAll('.code-block-wrapper:not([data-copy-added])');
wrappers.forEach((wrapper) => {
wrapper.setAttribute('data-copy-added', 'true');
const lang = wrapper.getAttribute('data-lang') || '';
const codeEl = wrapper.querySelector('code');
const codeText = codeEl?.textContent || '';
// Header mit Sprache und Copy-Button erstellen
const header = document.createElement('div');
header.className = 'code-header';
if (lang) {
const langSpan = document.createElement('span');
langSpan.className = 'code-lang';
langSpan.textContent = lang;
header.appendChild(langSpan);
}
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.title = 'Code kopieren';
copyBtn.innerHTML = '📋';
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(codeText);
copyBtn.innerHTML = '<span class="copied"></span>';
setTimeout(() => (copyBtn.innerHTML = '📋'), 2000);
} catch (err) {
console.error('Kopieren fehlgeschlagen:', err);
}
};
header.appendChild(copyBtn);
wrapper.insertBefore(header, wrapper.firstChild);
});
}
// Initial verarbeiten
processCodeBlocks();
// MutationObserver für dynamische Inhalte (Streaming)
const observer = new MutationObserver(processCodeBlocks);
observer.observe(node, { childList: true, subtree: true });
return {
destroy() {
observer.disconnect();
}
};
}
let messagesContainer: HTMLDivElement; let messagesContainer: HTMLDivElement;
// Edit-Modus State
let editingMessageId: string | null = $state(null);
let editingContent: string = $state('');
async function scrollToBottom() { async function scrollToBottom() {
await tick(); await tick();
if (messagesContainer) { if (messagesContainer) {
@ -94,6 +164,107 @@
sendMessage(); sendMessage();
} }
} }
// Edit-Funktionen
function startEdit(message: Message) {
editingMessageId = message.id;
editingContent = message.content;
}
function cancelEdit() {
editingMessageId = null;
editingContent = '';
}
async function confirmEdit() {
if (!editingMessageId || !editingContent.trim()) return;
const msgIndex = $messages.findIndex((m) => m.id === editingMessageId);
if (msgIndex === -1) return;
const updatedMessage = {
...$messages[msgIndex],
content: editingContent.trim(),
timestamp: new Date()
};
// Nachricht aktualisieren und alle folgenden entfernen
// (da sie auf der alten Nachricht basieren)
messages.update((msgs) => {
const newMsgs = [...msgs.slice(0, msgIndex), updatedMessage];
return newMsgs;
});
// Nachricht in DB aktualisieren
await saveMessageToDb(updatedMessage);
// Edit-Modus beenden
const newContent = editingContent.trim();
editingMessageId = null;
editingContent = '';
// Nachricht neu an Claude senden
$isProcessing = true;
try {
await invoke('send_message', { message: newContent });
} catch (err) {
console.error('Fehler beim Senden:', err);
addMessage('system', `Fehler: ${err}`);
$isProcessing = false;
}
}
function handleEditKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
confirmEdit();
} else if (event.key === 'Escape') {
cancelEdit();
}
}
// Regenerate: Antwort neu generieren
async function regenerateResponse(assistantMsgIndex: number) {
if ($isProcessing) return;
// Vorherige User-Nachricht finden
let userMsg: Message | null = null;
for (let i = assistantMsgIndex - 1; i >= 0; i--) {
if ($messages[i].role === 'user') {
userMsg = $messages[i];
break;
}
}
if (!userMsg) {
console.error('Keine User-Nachricht zum Regenerieren gefunden');
return;
}
// Alle Nachrichten ab der Assistant-Nachricht entfernen
messages.update((msgs) => msgs.slice(0, assistantMsgIndex));
// User-Nachricht erneut senden
$isProcessing = true;
try {
await invoke('send_message', { message: userMsg.content });
} catch (err) {
console.error('Fehler beim Regenerieren:', err);
addMessage('system', `Fehler: ${err}`);
$isProcessing = false;
}
}
// Prüfen ob eine Nachricht die letzte Assistant-Nachricht ist
function isLastAssistantMessage(index: number): boolean {
// Finde die letzte Assistant-Nachricht
for (let i = $messages.length - 1; i >= 0; i--) {
if ($messages[i].role === 'assistant' && $messages[i].content) {
return i === index;
}
}
return false;
}
</script> </script>
<div class="chat-panel"> <div class="chat-panel">
@ -102,7 +273,7 @@
<span class="msg-count">{$messages.length} Nachrichten</span> <span class="msg-count">{$messages.length} Nachrichten</span>
</div> </div>
<div class="chat-messages" bind:this={messagesContainer}> <div class="chat-messages" bind:this={messagesContainer} use:addCopyButtons>
{#if $messages.length === 0} {#if $messages.length === 0}
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon">🤖</div> <div class="empty-icon">🤖</div>
@ -110,8 +281,8 @@
<p class="hint">Enter = Senden, Shift+Enter = Neue Zeile, Escape = Stopp</p> <p class="hint">Enter = Senden, Shift+Enter = Neue Zeile, Escape = Stopp</p>
</div> </div>
{:else} {:else}
{#each $messages as message} {#each $messages as message, index}
<div class="message" class:user={message.role === 'user'} class:assistant={message.role === 'assistant'} class:system={message.role === 'system'}> <div class="message" class:user={message.role === 'user'} class:assistant={message.role === 'assistant'} class:system={message.role === 'system'} class:editing={editingMessageId === message.id}>
<div class="message-header"> <div class="message-header">
<span class="message-role"> <span class="message-role">
{#if message.role === 'user'} {#if message.role === 'user'}
@ -122,12 +293,33 @@
⚙️ System ⚙️ System
{/if} {/if}
</span> </span>
<div class="message-actions">
{#if message.role === 'user' && !$isProcessing && editingMessageId !== message.id}
<button class="action-btn" onclick={() => startEdit(message)} title="Bearbeiten">✏️</button>
{/if}
{#if message.role === 'assistant' && !$isProcessing && isLastAssistantMessage(index) && message.content}
<button class="action-btn" onclick={() => regenerateResponse(index)} title="Antwort neu generieren">🔄</button>
{/if}
<span class="message-time"> <span class="message-time">
{message.timestamp.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} {message.timestamp.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</span> </span>
</div> </div>
</div>
<div class="message-content"> <div class="message-content">
{#if message.role === 'assistant'} {#if editingMessageId === message.id}
<textarea
class="edit-textarea"
bind:value={editingContent}
onkeydown={handleEditKeydown}
rows="3"
></textarea>
<div class="edit-actions">
<button class="edit-btn cancel" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn confirm" onclick={confirmEdit} disabled={!editingContent.trim()}>
Speichern & Senden
</button>
</div>
{:else if message.role === 'assistant'}
{@html renderMarkdown(message.content)} {@html renderMarkdown(message.content)}
{:else} {:else}
{message.content} {message.content}
@ -268,11 +460,101 @@
font-weight: 600; font-weight: 600;
} }
.message-actions {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.action-btn {
padding: 0.15rem 0.3rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
font-size: 0.7rem;
}
.message:hover .action-btn {
opacity: 0.6;
}
.action-btn:hover {
opacity: 1 !important;
background: var(--bg-tertiary);
}
.message-time { .message-time {
font-size: 0.6rem; font-size: 0.6rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Edit-Modus */
.message.editing {
border: 1px solid var(--accent);
}
.edit-textarea {
width: 100%;
min-height: 60px;
padding: var(--spacing-sm);
font-size: 0.85rem;
line-height: 1.5;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
resize: vertical;
font-family: inherit;
}
.edit-textarea:focus {
outline: none;
border-color: var(--accent);
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
.edit-btn {
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s ease;
}
.edit-btn.cancel {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.edit-btn.cancel:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.edit-btn.confirm {
background: var(--accent);
border: 1px solid var(--accent);
color: white;
}
.edit-btn.confirm:hover:not(:disabled) {
background: var(--accent-hover);
}
.edit-btn.confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.message-content { .message-content {
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.6; line-height: 1.6;
@ -299,7 +581,68 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.message-content :global(pre) { /* Code-Block mit Header und Copy-Button */
.message-content :global(.code-block-wrapper) {
margin: 0.5em 0;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
overflow: hidden;
}
.message-content :global(.code-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0.75rem;
background: rgba(0, 0, 0, 0.15);
font-size: 0.65rem;
gap: 0.5rem;
}
.message-content :global(.code-lang) {
color: var(--text-secondary);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message-content :global(.copy-btn) {
margin-left: auto;
padding: 0.2rem 0.5rem;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s ease;
}
.message-content :global(.copy-btn:hover) {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--text-secondary);
}
.message-content :global(.copy-btn .copied) {
color: var(--success);
}
.message-content :global(.code-block-wrapper pre) {
margin: 0;
padding: var(--spacing-sm) var(--spacing-md);
overflow-x: auto;
font-size: 0.75rem;
line-height: 1.5;
}
.message-content :global(.code-block-wrapper pre code) {
padding: 0;
background: none;
}
/* Fallback für inline pre (ohne wrapper) */
.message-content :global(pre:not(.code-block-wrapper pre)) {
margin: 0.5em 0; margin: 0.5em 0;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-tertiary); background: var(--bg-tertiary);
@ -309,7 +652,7 @@
line-height: 1.4; line-height: 1.4;
} }
.message-content :global(pre code) { .message-content :global(pre:not(.code-block-wrapper pre) code) {
padding: 0; padding: 0;
background: none; background: none;
} }

View file

@ -0,0 +1,96 @@
<script lang="ts">
// CodeBlock — Code mit Syntax-Highlight und Copy-Button
// Standalone-Komponente für manuellen Einsatz
interface Props {
code: string;
language?: string;
}
let { code, language = '' }: Props = $props();
let copied = $state(false);
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(code);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (err) {
console.error('Kopieren fehlgeschlagen:', err);
}
}
</script>
<div class="code-block">
<div class="code-header">
{#if language}
<span class="code-lang">{language}</span>
{/if}
<button class="copy-btn" onclick={copyToClipboard} title="Code kopieren">
{#if copied}
<span class="copied">✓ Kopiert</span>
{:else}
<span>📋</span>
{/if}
</button>
</div>
<pre><code class="language-{language}">{code}</code></pre>
</div>
<style>
.code-block {
position: relative;
margin: 0.5em 0;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
overflow: hidden;
}
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.75rem;
background: rgba(0, 0, 0, 0.2);
font-size: 0.65rem;
}
.code-lang {
color: var(--text-secondary);
font-family: var(--font-mono);
text-transform: uppercase;
}
.copy-btn {
padding: 0.2rem 0.5rem;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s ease;
}
.copy-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.copied {
color: var(--success);
}
pre {
margin: 0;
padding: var(--spacing-sm) var(--spacing-md);
overflow-x: auto;
font-size: 0.75rem;
line-height: 1.5;
}
code {
font-family: var(--font-mono);
}
</style>

View file

@ -0,0 +1,438 @@
<script lang="ts">
import {
filteredMonitorEvents,
monitorFilter,
monitorAutoScroll,
selectedMonitorEventId,
monitorStats,
monitorEventColors,
clearMonitorEvents,
type MonitorEvent,
type MonitorEventType
} from '$lib/stores/app';
import { tick } from 'svelte';
// Event-Liste Container für Auto-Scroll
let eventListEl: HTMLDivElement;
// Auto-Scroll bei neuen Events
$effect(() => {
if ($monitorAutoScroll && $filteredMonitorEvents.length > 0) {
tick().then(() => {
if (eventListEl) {
eventListEl.scrollTop = eventListEl.scrollHeight;
}
});
}
});
// Event auswählen
function selectEvent(id: string) {
$selectedMonitorEventId = $selectedMonitorEventId === id ? null : id;
}
// Zeit formatieren
function formatTime(date: Date): string {
return date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
}
// Details formatieren
function formatDetails(details: Record<string, unknown>): string {
return JSON.stringify(details, null, 2);
}
// Event-Typ Labels
const typeLabels: Record<MonitorEventType, string> = {
api: 'API',
hook: 'HOOK',
tool: 'TOOL',
mcp: 'MCP',
agent: 'AGENT',
error: 'ERROR',
debug: 'DEBUG',
};
// Filter-Optionen
const filterOptions: Array<{ value: MonitorEventType | 'all'; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 'api', label: '🔵 API' },
{ value: 'tool', label: '🟡 Tools' },
{ value: 'agent', label: '🟠 Agents' },
{ value: 'mcp', label: '🟣 MCP' },
{ value: 'hook', label: '🟢 Hooks' },
{ value: 'error', label: '🔴 Fehler' },
{ value: 'debug', label: '⚪ Debug' },
];
</script>
<div class="monitor-panel">
<!-- Header mit Stats und Filter -->
<div class="monitor-header">
<div class="monitor-title">
<h2>📊 System-Monitor</h2>
<div class="monitor-stats">
<span class="stat" title="Alle Events">{$monitorStats.totalEvents}</span>
<span class="stat api" title="API-Calls">{$monitorStats.apiCalls} API</span>
{#if $monitorStats.errors > 0}
<span class="stat error" title="Fehler">{$monitorStats.errors} Err</span>
{/if}
{#if $monitorStats.avgLatencyMs > 0}
<span class="stat latency" title="Durchschnittliche Latenz">{$monitorStats.avgLatencyMs}ms</span>
{/if}
</div>
</div>
<div class="monitor-controls">
<select bind:value={$monitorFilter} class="filter-select">
{#each filterOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<label class="auto-scroll-toggle" title="Auto-Scroll">
<input type="checkbox" bind:checked={$monitorAutoScroll} />
<span></span>
</label>
<button class="clear-btn" on:click={clearMonitorEvents} title="Events löschen">
🗑️
</button>
</div>
</div>
<!-- Event-Liste -->
<div class="event-list" bind:this={eventListEl}>
{#if $filteredMonitorEvents.length === 0}
<div class="empty-state">
<p>Keine Events.</p>
<p class="hint">Events erscheinen hier wenn Claude arbeitet.</p>
</div>
{:else}
{#each $filteredMonitorEvents as event (event.id)}
<button
class="event-item"
class:selected={$selectedMonitorEventId === event.id}
class:error={event.type === 'error'}
on:click={() => selectEvent(event.id)}
>
<span class="event-time">{formatTime(event.timestamp)}</span>
<span class="event-icon">{monitorEventColors[event.type]}</span>
<span class="event-type">{typeLabels[event.type]}</span>
<span class="event-summary">{event.summary}</span>
{#if event.durationMs}
<span class="event-duration">[{event.durationMs}ms]</span>
{/if}
</button>
{/each}
{/if}
</div>
<!-- Detail-Ansicht -->
{#if $selectedMonitorEventId}
{@const selectedEvent = $filteredMonitorEvents.find((e) => e.id === $selectedMonitorEventId)}
{#if selectedEvent}
<div class="event-details">
<div class="details-header">
<h3>
{monitorEventColors[selectedEvent.type]} {typeLabels[selectedEvent.type]}
</h3>
<button class="copy-btn" on:click={() => navigator.clipboard.writeText(formatDetails(selectedEvent.details))} title="Kopieren">
📋
</button>
</div>
<div class="details-row">
<span class="label">Zeit:</span>
<span class="value">{selectedEvent.timestamp.toLocaleString('de-DE')}</span>
</div>
<div class="details-row">
<span class="label">Summary:</span>
<span class="value">{selectedEvent.summary}</span>
</div>
{#if selectedEvent.durationMs}
<div class="details-row">
<span class="label">Dauer:</span>
<span class="value">{selectedEvent.durationMs}ms</span>
</div>
{/if}
{#if selectedEvent.agentId}
<div class="details-row">
<span class="label">Agent:</span>
<span class="value mono">{selectedEvent.agentId.substring(0, 8)}...</span>
</div>
{/if}
{#if selectedEvent.error}
<div class="details-row error">
<span class="label">Fehler:</span>
<span class="value">{selectedEvent.error}</span>
</div>
{/if}
<h4>Details</h4>
<pre class="details-json">{formatDetails(selectedEvent.details)}</pre>
</div>
{/if}
{/if}
</div>
<style>
.monitor-panel {
display: flex;
flex-direction: column;
height: 100%;
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
}
.monitor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
border-bottom: 1px solid var(--bg-tertiary);
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.monitor-title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.monitor-title h2 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.monitor-stats {
display: flex;
gap: var(--spacing-xs);
}
.stat {
padding: 2px 6px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
font-size: 0.625rem;
}
.stat.api {
color: #4a9eff;
}
.stat.error {
background: var(--error);
color: white;
}
.stat.latency {
color: var(--text-secondary);
}
.monitor-controls {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.filter-select {
padding: 4px 8px;
font-size: 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--bg-tertiary);
border-radius: var(--radius-sm);
color: var(--text-primary);
}
.auto-scroll-toggle {
display: flex;
align-items: center;
cursor: pointer;
}
.auto-scroll-toggle input {
display: none;
}
.auto-scroll-toggle span {
padding: 4px 8px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
opacity: 0.5;
}
.auto-scroll-toggle input:checked + span {
opacity: 1;
background: var(--accent);
color: white;
}
.clear-btn {
padding: 4px 8px;
background: var(--bg-tertiary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.clear-btn:hover {
background: var(--error);
}
.event-list {
flex: 1;
overflow-y: auto;
padding: var(--spacing-xs);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
text-align: center;
}
.empty-state .hint {
font-size: 0.625rem;
margin-top: var(--spacing-xs);
}
.event-item {
display: flex;
align-items: center;
gap: var(--spacing-xs);
width: 100%;
text-align: left;
padding: 4px var(--spacing-sm);
margin-bottom: 2px;
background: var(--bg-secondary);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.1s ease;
}
.event-item:hover {
background: var(--bg-tertiary);
}
.event-item.selected {
border-color: var(--accent);
}
.event-item.error {
background: rgba(239, 68, 68, 0.1);
}
.event-time {
color: var(--text-secondary);
font-size: 0.625rem;
flex-shrink: 0;
}
.event-icon {
flex-shrink: 0;
}
.event-type {
font-weight: 600;
flex-shrink: 0;
min-width: 50px;
}
.event-summary {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
}
.event-duration {
font-size: 0.625rem;
color: var(--text-secondary);
flex-shrink: 0;
}
/* Detail-Ansicht */
.event-details {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
border-top: 1px solid var(--bg-tertiary);
max-height: 40%;
overflow-y: auto;
}
.details-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-sm);
}
.details-header h3 {
font-size: 0.875rem;
margin: 0;
}
.copy-btn {
padding: 4px 8px;
background: var(--bg-tertiary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.copy-btn:hover {
background: var(--accent);
}
.details-row {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
.details-row .label {
color: var(--text-secondary);
min-width: 60px;
}
.details-row .value {
flex: 1;
}
.details-row .mono {
font-family: var(--font-mono, monospace);
}
.details-row.error .value {
color: var(--error);
}
.event-details h4 {
font-size: 0.75rem;
margin: var(--spacing-sm) 0 var(--spacing-xs);
}
.details-json {
padding: var(--spacing-sm);
background: var(--bg-primary);
border-radius: var(--radius-sm);
overflow-x: auto;
font-size: 0.625rem;
max-height: 200px;
overflow-y: auto;
}
</style>

View file

@ -5,11 +5,15 @@ import { writable, derived } from 'svelte/store';
// Typen // Typen
export interface Agent { export interface Agent {
id: string; id: string;
type: 'main' | 'explore' | 'plan' | 'bash'; type: 'main' | 'explore' | 'plan' | 'bash' | 'code' | 'test' | 'review';
status: 'active' | 'waiting' | 'idle' | 'stopped'; status: 'active' | 'waiting' | 'idle' | 'stopped';
task: string; task: string;
startedAt: Date; startedAt: Date;
toolCalls: ToolCall[]; toolCalls: ToolCall[];
// Subagent-Hierarchie
parentAgentId?: string; // undefined = Main Agent
depth: number; // 0 = Main, 1 = direkter Subagent, etc.
model?: string; // Welches Modell nutzt dieser Agent
} }
export interface ToolCall { export interface ToolCall {
@ -74,9 +78,34 @@ export const agentCount = derived(agents, ($agents) => ({
total: $agents.length, total: $agents.length,
active: $agents.filter((a) => a.status === 'active').length, active: $agents.filter((a) => a.status === 'active').length,
waiting: $agents.filter((a) => a.status === 'waiting').length, waiting: $agents.filter((a) => a.status === 'waiting').length,
idle: $agents.filter((a) => a.status === 'idle').length idle: $agents.filter((a) => a.status === 'idle').length,
mainAgents: $agents.filter((a) => !a.parentAgentId).length,
subAgents: $agents.filter((a) => a.parentAgentId).length,
})); }));
// Agent-Baum Typen und Builder (muss vor agentTree Store sein)
export interface AgentTreeNode {
agent: Agent;
children: AgentTreeNode[];
}
export function buildAgentTree(agentsList: Agent[]): AgentTreeNode[] {
// Nur Root-Agents (ohne Parent)
const roots = agentsList.filter((a) => !a.parentAgentId);
function buildNode(agent: Agent): AgentTreeNode {
const children = agentsList
.filter((a) => a.parentAgentId === agent.id)
.map(buildNode);
return { agent, children };
}
return roots.map(buildNode);
}
// Agent-Baum als reaktiver Store
export const agentTree = derived(agents, ($agents) => buildAgentTree($agents));
// Aktionen // Aktionen
export function addMessage(role: Message['role'], content: string, agentId?: string) { export function addMessage(role: Message['role'], content: string, agentId?: string) {
messages.update((msgs) => [ messages.update((msgs) => [
@ -91,8 +120,27 @@ export function addMessage(role: Message['role'], content: string, agentId?: str
]); ]);
} }
export function addAgent(type: Agent['type'], task: string): string { export interface AddAgentOptions {
const id = crypto.randomUUID(); id?: string;
parentAgentId?: string;
model?: string;
}
export function addAgent(type: Agent['type'], task: string, options?: AddAgentOptions): string {
const id = options?.id || crypto.randomUUID();
const parentAgentId = options?.parentAgentId;
// Tiefe berechnen: Parent-Tiefe + 1 (oder 0 wenn kein Parent)
let depth = 0;
if (parentAgentId) {
agents.subscribe((ags) => {
const parent = ags.find((a) => a.id === parentAgentId);
if (parent) {
depth = parent.depth + 1;
}
})();
}
agents.update((ags) => [ agents.update((ags) => [
...ags, ...ags,
{ {
@ -101,12 +149,30 @@ export function addAgent(type: Agent['type'], task: string): string {
status: 'active', status: 'active',
task, task,
startedAt: new Date(), startedAt: new Date(),
toolCalls: [] toolCalls: [],
parentAgentId,
depth,
model: options?.model,
} }
]); ]);
return id; return id;
} }
// Subagent hinzufügen (Kurzform)
export function addSubAgent(
parentId: string,
type: Agent['type'],
task: string,
options?: Omit<AddAgentOptions, 'parentAgentId'>
): string {
return addAgent(type, task, { ...options, parentAgentId: parentId });
}
// Alle Kinder eines Agents finden
export function getChildAgents(parentId: string, agentsList: Agent[]): Agent[] {
return agentsList.filter((a) => a.parentAgentId === parentId);
}
export function updateAgentStatus(id: string, status: Agent['status']) { export function updateAgentStatus(id: string, status: Agent['status']) {
agents.update((ags) => agents.update((ags) =>
ags.map((a) => (a.id === id ? { ...a, status } : a)) ags.map((a) => (a.id === id ? { ...a, status } : a))
@ -195,3 +261,108 @@ export function dbToMessage(db: DbMessage): Message {
export function setMessagesFromDb(dbMessages: DbMessage[]) { export function setMessagesFromDb(dbMessages: DbMessage[]) {
messages.set(dbMessages.map(dbToMessage)); messages.set(dbMessages.map(dbToMessage));
} }
// ============ System-Monitor ============
export type MonitorEventType = 'api' | 'hook' | 'tool' | 'mcp' | 'agent' | 'error' | 'debug';
export interface MonitorEvent {
id: string;
timestamp: Date;
type: MonitorEventType;
summary: string; // Einzeiler für Kompakt-Ansicht
details: Record<string, unknown>; // Vollständige Daten
sessionId?: string;
agentId?: string;
durationMs?: number;
error?: string;
}
// Farbcodierung für Event-Typen
export const monitorEventColors: Record<MonitorEventType, string> = {
api: '🔵',
hook: '🟢',
tool: '🟡',
mcp: '🟣',
agent: '🟠',
error: '🔴',
debug: '⚪',
};
// Monitor Store — Ringbuffer mit max 1000 Events
const MAX_MONITOR_EVENTS = 1000;
export const monitorEvents = writable<MonitorEvent[]>([]);
// Filter für Monitor-Ansicht
export const monitorFilter = writable<MonitorEventType | 'all'>('all');
export const monitorAutoScroll = writable(true);
export const selectedMonitorEventId = writable<string | null>(null);
// Gefilterte Events
export const filteredMonitorEvents = derived(
[monitorEvents, monitorFilter],
([$events, $filter]) => {
if ($filter === 'all') return $events;
return $events.filter((e) => e.type === $filter);
}
);
// Monitor-Statistiken
export const monitorStats = derived(monitorEvents, ($events) => {
const last100 = $events.slice(-100);
const apiEvents = last100.filter((e) => e.type === 'api');
const errorEvents = last100.filter((e) => e.type === 'error');
const avgLatency = apiEvents.length > 0
? apiEvents.reduce((sum, e) => sum + (e.durationMs || 0), 0) / apiEvents.length
: 0;
return {
totalEvents: $events.length,
apiCalls: apiEvents.length,
errors: errorEvents.length,
avgLatencyMs: Math.round(avgLatency),
};
});
// Monitor-Event hinzufügen
export function addMonitorEvent(
type: MonitorEventType,
summary: string,
details: Record<string, unknown> = {},
options?: Partial<Omit<MonitorEvent, 'id' | 'timestamp' | 'type' | 'summary' | 'details'>>
) {
const event: MonitorEvent = {
id: crypto.randomUUID(),
timestamp: new Date(),
type,
summary,
details,
...options,
};
monitorEvents.update((events) => {
const updated = [...events, event];
// Ringbuffer: Alte Events entfernen wenn zu viele
if (updated.length > MAX_MONITOR_EVENTS) {
return updated.slice(-MAX_MONITOR_EVENTS);
}
return updated;
});
return event.id;
}
// Monitor leeren
export function clearMonitorEvents() {
monitorEvents.set([]);
}
// Sensitive Daten maskieren
export function maskSensitive(data: string): string {
return data
.replace(/password[=:]\s*\S+/gi, 'password=***')
.replace(/api[_-]?key[=:]\s*\S+/gi, 'api_key=***')
.replace(/bearer\s+\S+/gi, 'Bearer ***')
.replace(/sk-[a-zA-Z0-9]+/g, 'sk-***')
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, '***@***.***');
}

View file

@ -11,6 +11,7 @@ import {
isProcessing, isProcessing,
addMessage, addMessage,
addAgent, addAgent,
addSubAgent,
updateAgentStatus, updateAgentStatus,
addToolCall, addToolCall,
completeToolCall, completeToolCall,
@ -19,7 +20,10 @@ import {
sessionStats, sessionStats,
currentSessionId, currentSessionId,
messageToDb, messageToDb,
type Message addMonitorEvent,
type Message,
type Agent,
type MonitorEventType
} from './app'; } from './app';
// Event-Typen vom Backend // Event-Typen vom Backend
@ -28,6 +32,18 @@ interface AgentEvent {
type?: string; type?: string;
task?: string; task?: string;
code?: number; code?: number;
model?: string;
}
interface SubagentEvent {
id: string;
parentAgentId: string;
type?: string;
task?: string;
depth?: number;
model?: string;
toolUseId?: string;
success?: boolean;
} }
interface ToolEvent { interface ToolEvent {
@ -52,6 +68,15 @@ interface ResultEvent {
model?: string; model?: string;
} }
interface MonitorEventPayload {
type: MonitorEventType;
summary: string;
details: Record<string, unknown>;
agentId?: string;
durationMs?: number;
error?: string;
}
// Listener-Handles // Listener-Handles
let listeners: UnlistenFn[] = []; let listeners: UnlistenFn[] = [];
@ -137,6 +162,30 @@ export async function initEventListeners(): Promise<void> {
}) })
); );
// 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 // Tool Start
listeners.push( listeners.push(
await listen<ToolEvent>('tool-start', (event) => { await listen<ToolEvent>('tool-start', (event) => {
@ -229,6 +278,19 @@ export async function initEventListeners(): Promise<void> {
}) })
); );
// 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'); console.log('✅ Event-Listener initialisiert');
} }
@ -241,17 +303,25 @@ export async function cleanupEventListeners(): Promise<void> {
} }
// Agent-Typ mappen // Agent-Typ mappen
function mapAgentType(type: string): 'main' | 'explore' | 'plan' | 'bash' { function mapAgentType(type: string): Agent['type'] {
const typeMap: Record<string, 'main' | 'explore' | 'plan' | 'bash'> = { const typeMap: Record<string, Agent['type']> = {
main: 'main', main: 'main',
'Main Agent': 'main', 'Main Agent': 'main',
Main: 'main', Main: 'main',
explore: 'explore', explore: 'explore',
Explore: 'explore', Explore: 'explore',
'general-purpose': 'explore',
plan: 'plan', plan: 'plan',
Plan: 'plan', Plan: 'plan',
bash: 'bash', bash: 'bash',
Bash: 'bash' Bash: 'bash',
code: 'code',
Code: 'code',
implement: 'code',
test: 'test',
Test: 'test',
review: 'review',
Review: 'review',
}; };
return typeMap[type] || 'main'; return typeMap[type] || 'explore';
} }

View file

@ -8,12 +8,14 @@
import AuditLog from '$lib/components/AuditLog.svelte'; import AuditLog from '$lib/components/AuditLog.svelte';
import GuardRailsPanel from '$lib/components/GuardRailsPanel.svelte'; import GuardRailsPanel from '$lib/components/GuardRailsPanel.svelte';
import SettingsPanel from '$lib/components/SettingsPanel.svelte'; import SettingsPanel from '$lib/components/SettingsPanel.svelte';
import MonitorPanel from '$lib/components/MonitorPanel.svelte';
let activeMiddleTab = 'activity'; let activeMiddleTab = 'activity';
let activeRightTab = 'agents'; let activeRightTab = 'agents';
const middleTabs = [ const middleTabs = [
{ id: 'activity', label: 'Aktivität', icon: '📋' }, { id: 'activity', label: 'Aktivität', icon: '📋' },
{ id: 'monitor', label: 'Monitor', icon: '📊' },
{ id: 'memory', label: 'Gedächtnis', icon: '🧠' }, { id: 'memory', label: 'Gedächtnis', icon: '🧠' },
{ id: 'audit', label: 'Historie', icon: '📝' }, { id: 'audit', label: 'Historie', icon: '📝' },
]; ];
@ -60,6 +62,8 @@
<div class="panel-content"> <div class="panel-content">
{#if activeMiddleTab === 'activity'} {#if activeMiddleTab === 'activity'}
<ActivityPanel /> <ActivityPanel />
{:else if activeMiddleTab === 'monitor'}
<MonitorPanel />
{:else if activeMiddleTab === 'memory'} {:else if activeMiddleTab === 'memory'}
<MemoryPanel /> <MemoryPanel />
{:else if activeMiddleTab === 'audit'} {:else if activeMiddleTab === 'audit'}