claude-desktop/src/lib/stores/app.ts
Eddy 71ab5ec830
All checks were successful
Build AppImage / build (push) Successful in 8m18s
feat: Schulungsmodus Datei-Animationen + Permission-Toggle + Chat-Scroll-Fix [appimage]
- AnimatedFileEdit.svelte: neue Komponente fuer animierte Datei-Aenderungen im Praesentation-Fenster
- Schulungsmodus: 5-Stufen-Speed-Regler (Lehrer 10cps bis Data-Modus instant+Glow)
- Schulungsmodus: Live-Catchup-Button, Auto-Weiter nach Slide-Abschluss
- ChatPanel: Permission-Mode-Toggle links vom Textfeld (default/acceptEdits/bypassPermissions)
- ApprovalBar: Floating-Card mit blauem Glow, Buttons umbenannt (Anwenden/Ablehnen)
- MessageList: Scroll-Guard mit scrollend-Event + 700ms-Fallback statt doppeltem rAF
- MessageList: User-Nachrichten scrollen sofort nach unten (requestAnimationFrame + force)
- Message.svelte: MessagePart[]-basiertes Rendering fuer chronologische Reihenfolge
- events.ts: file-change sendet Slide an Praesentation-Fenster wenn offen
- teaching.rs: presentation_send_slide_if_open Command
- claude.rs: set/get_permission_mode Commands mit DB-Persistenz

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:13:52 +02:00

612 lines
18 KiB
TypeScript

// Claude Desktop — App-State
import { writable, derived } from 'svelte/store';
import { invoke } from '@tauri-apps/api/core';
// Typen
export interface Agent {
id: string;
type: 'main' | 'explore' | 'plan' | 'bash' | 'code' | 'test' | 'review';
status: 'active' | 'waiting' | 'idle' | 'stopped';
task: string;
startedAt: Date;
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 {
id: string;
agentId: string;
tool: string;
args: Record<string, unknown>;
status: 'running' | 'completed' | 'failed';
startedAt: Date;
completedAt?: Date;
result?: unknown;
}
// Inline Tool-Call der einer Message angehaengt ist (fuer Inline-Karten im Chat)
export interface InlineToolCall {
id: string; // toolId aus dem Backend
tool: string; // Read, Edit, Write, Bash, Grep, Glob, WebFetch, Task, MCP, ...
input: Record<string, unknown>;
status: 'running' | 'done' | 'error';
result?: string; // Stringifizierte Tool-Ausgabe (optional, wird beim tool-end gesetzt)
startedAt: Date;
completedAt?: Date;
}
// Phase 11: Chronologisch sortierte Stream-Teile einer Assistant-Message.
// Loest die alte Trennung in `content`/`toolCalls`/`knowledgeHints` auf —
// damit Tools jetzt zwischen Text-Stuecken erscheinen koennen, in der Reihenfolge
// in der sie tatsaechlich passiert sind.
export type MessagePart =
| { type: 'text'; content: string }
| { type: 'tool'; call: InlineToolCall };
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string; // aggregierter Text — fuer DB-Persistenz, FTS-Suche, Copy
parts?: MessagePart[]; // chronologische Stream-Parts (Renderpfad)
timestamp: Date;
agentId?: string;
model?: string;
queued?: boolean; // Nachricht wartet in der Queue auf Dispatch
toolCalls?: InlineToolCall[]; // Legacy: einige Stellen lesen noch hier — wird parallel gepflegt
knowledgeHints?: KnowledgeHint[]; // KB-Treffer die fuer diese Antwort herangezogen wurden
}
// Hilfen fuer Stream-Handler in events.ts.
// Text an die parts anhaengen: letzten text-part erweitern oder neuen anlegen.
export function appendTextToParts(parts: MessagePart[] | undefined, text: string): MessagePart[] {
const list = parts ? [...parts] : [];
if (list.length > 0) {
const last = list[list.length - 1];
if (last.type === 'text') {
list[list.length - 1] = { type: 'text', content: last.content + text };
return list;
}
}
list.push({ type: 'text', content: text });
return list;
}
// Tool-Part anfuegen (status='running').
export function appendToolToParts(parts: MessagePart[] | undefined, call: InlineToolCall): MessagePart[] {
const list = parts ? [...parts] : [];
list.push({ type: 'tool', call });
return list;
}
// Tool-Part finalisieren (status, result, completedAt setzen).
export function updateToolInParts(
parts: MessagePart[] | undefined,
toolId: string,
patch: Partial<InlineToolCall>
): MessagePart[] | undefined {
if (!parts) return parts;
let changed = false;
const next = parts.map((p) => {
if (p.type === 'tool' && p.call.id === toolId) {
changed = true;
return { type: 'tool' as const, call: { ...p.call, ...patch } };
}
return p;
});
return changed ? next : parts;
}
// content-String aus parts aggregieren (fuer DB-Persistenz beim Result-Event).
export function partsToContent(parts: MessagePart[] | undefined): string {
if (!parts) return '';
return parts.filter((p) => p.type === 'text').map((p) => (p as { content: string }).content).join('');
}
export interface Permission {
id: string;
pattern: string;
type: 'session' | 'permanent';
action: 'allow' | 'deny';
createdAt: Date;
}
// Quick-Actions Typ (fuer QuickActions.svelte + ChatPanel.svelte)
export interface QuickAction {
id: string;
label: string;
description: string;
icon: string;
category: 'build' | 'git' | 'session' | 'navigation' | 'voice' | 'tools';
shortcut?: string;
command?: string; // Wird als Message an Claude gesendet
invoke?: string; // Tauri-Command direkt aufrufen
invokeArgs?: Record<string, unknown>;
}
// Pending File-Changes (Accept/Reject DiffView)
export interface FileChange {
toolId: string;
tool: string;
filePath: string;
contentBefore: string;
contentAfter: string;
timestamp: Date;
}
export const pendingChanges = writable<FileChange[]>([]);
// Stores
export const agents = writable<Agent[]>([]);
export const toolCalls = writable<ToolCall[]>([]);
export const messages = writable<Message[]>([]);
export const permissions = writable<Permission[]>([]);
// Phase 11: Approval-Modus.
// - 'default' = Approval-Bar fragt bei jedem Edit/Bash
// - 'acceptEdits' = Datei-Edits laufen automatisch durch, Bash bleibt Approval-pflichtig
// - 'bypassPermissions' = alles automatisch (Yolo) — Guard-Rails greifen nicht mehr
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
export const permissionMode = writable<PermissionMode>('default');
// UI-State
export const isProcessing = writable(false);
export const chatDetached = writable(false);
export const currentInput = writable('');
export const selectedAgentId = writable<string | null>(null);
export const currentModel = writable('');
export const currentSessionId = writable<string | null>(null);
// Message-Queue: Nachrichten die waehrend der Verarbeitung eingehen, werden hier
// gesammelt und nach Ende der aktuellen Antwort FIFO abgearbeitet.
// Jede Nachricht erscheint sofort im Chat als User-Message.
export const messageQueue = writable<string[]>([]);
// Abwaertskompatibel: queuedMessage zeigt die naechste wartende Nachricht
// (Legacy — wird in ChatPanel noch referenziert)
export const queuedMessage = writable<string | null>(null);
// Agent-Modus für Multi-Agent-Architektur
export type AgentMode = 'solo' | 'handlanger' | 'experten' | 'auto';
export const agentMode = writable<AgentMode>('solo');
// Session-Statistiken (kumuliert)
export const sessionStats = writable({
totalTokensIn: 0,
totalTokensOut: 0,
totalCost: 0,
messageCount: 0,
});
// Kontext-Auslastung (aktueller API-Call)
// inputTokens = was Claude bei diesem Request "gelesen" hat (System + Konversation)
export const contextUsage = writable({
inputTokens: 0, // Aktuelle Kontext-Tokens
outputTokens: 0, // Tokens der letzten Antwort
contextLimit: 200000, // Claude 3.5/Opus Context Window
});
// Abgeleitet: Prozent der Kontext-Auslastung
export const contextPercent = derived(contextUsage, ($ctx) =>
Math.round(($ctx.inputTokens / $ctx.contextLimit) * 100)
);
// Sticky Context Status (beim App-Start geladen)
export interface StickyContextInfo {
loaded: boolean;
entries: number;
estimatedTokens: number;
hasUserInfo: boolean;
hasProject: boolean;
credentialsCount: number;
rulesCount: number;
}
export const stickyContextInfo = writable<StickyContextInfo | null>(null);
// Wissens-Hints (aus claude-db)
export interface KnowledgeHint {
id: number;
category: string;
title: string;
content: string;
tags?: string;
priority: number;
}
export const activeKnowledgeHints = writable<KnowledgeHint[]>([]);
// Abgeleitete Stores
export const activeAgents = derived(agents, ($agents) =>
$agents.filter((a) => a.status === 'active')
);
export const recentToolCalls = derived(toolCalls, ($toolCalls) =>
$toolCalls.slice(-50).reverse()
);
export const agentCount = derived(agents, ($agents) => ({
total: $agents.length,
active: $agents.filter((a) => a.status === 'active').length,
waiting: $agents.filter((a) => a.status === 'waiting').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
export function addMessage(role: Message['role'], content: string, agentId?: string) {
messages.update((msgs) => [
...msgs,
{
id: crypto.randomUUID(),
role,
content,
parts: content ? [{ type: 'text', content }] : [],
timestamp: new Date(),
agentId
}
]);
}
export interface AddAgentOptions {
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) => [
...ags,
{
id,
type,
status: 'active',
task,
startedAt: new Date(),
toolCalls: [],
parentAgentId,
depth,
model: options?.model,
}
]);
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']) {
agents.update((ags) =>
ags.map((a) => (a.id === id ? { ...a, status } : a))
);
}
export function addToolCall(agentId: string, tool: string, args: Record<string, unknown>, fixedId?: string): string {
const id = fixedId || crypto.randomUUID();
const call: ToolCall = {
id,
agentId,
tool,
args,
status: 'running',
startedAt: new Date()
};
toolCalls.update((calls) => [...calls, call]);
// Auch im Agent speichern
agents.update((ags) =>
ags.map((a) =>
a.id === agentId ? { ...a, toolCalls: [...a.toolCalls, call] } : a
)
);
return id;
}
export function completeToolCall(id: string, result: unknown, failed = false) {
toolCalls.update((calls) => {
// Exakte ID-Suche
let found = calls.some((c) => c.id === id);
if (found) {
return calls.map((c) =>
c.id === id
? { ...c, status: (failed ? 'failed' : 'completed') as ToolCall['status'], completedAt: new Date(), result }
: c
);
}
// Fallback: Letzten laufenden Tool-Call abschließen (für Events ohne passende ID)
const lastRunning = [...calls].reverse().find((c) => c.status === 'running');
if (lastRunning) {
return calls.map((c) =>
c.id === lastRunning.id
? { ...c, status: (failed ? 'failed' : 'completed') as ToolCall['status'], completedAt: new Date(), result }
: c
);
}
return calls;
});
}
export function clearAll() {
agents.set([]);
toolCalls.set([]);
messages.set([]);
isProcessing.set(false);
}
// DB-Nachricht Format (für Tauri)
export interface DbMessage {
id: string;
session_id: string;
role: string;
content: string;
model: string | null;
agent_id: string | null; // Agent der die Nachricht erzeugt hat
timestamp: string;
}
// Konvertierung: Store → DB
export function messageToDb(msg: Message, sessionId: string): DbMessage {
return {
id: msg.id,
session_id: sessionId,
role: msg.role,
content: msg.content,
model: msg.model || null,
agent_id: msg.agentId || null,
timestamp: msg.timestamp.toISOString(),
};
}
// Konvertierung: DB → Store
export function dbToMessage(db: DbMessage): Message {
const role = db.role as Message['role'];
// Beim Reload aus der DB sind nur text-Parts wiederherstellbar — Tool-Calls
// werden nicht persistiert (waren sie vorher auch nicht). Der Renderer
// laeuft trotzdem ueber parts, damit die Logik in beiden Faellen gleich ist.
const parts: MessagePart[] | undefined =
role === 'assistant' && db.content ? [{ type: 'text', content: db.content }] : undefined;
return {
id: db.id,
role,
content: db.content,
parts,
model: db.model || undefined,
agentId: db.agent_id || undefined,
timestamp: new Date(db.timestamp),
};
}
// Nachrichten aus DB in Store laden
export function setMessagesFromDb(dbMessages: DbMessage[]) {
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 (mit Persistierung)
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;
});
// Asynchron in DB speichern (fire-and-forget)
saveMonitorEventToDb(event).catch((err) => {
console.warn('Monitor-Event konnte nicht gespeichert werden:', err);
});
return event.id;
}
// Monitor-Event in DB speichern
async function saveMonitorEventToDb(event: MonitorEvent): Promise<void> {
// Für DB-Speicherung: Timestamp als ISO-String, Details als JSON-String
const dbEvent = {
id: event.id,
timestamp: event.timestamp.toISOString(),
event_type: event.type,
summary: event.summary,
details: JSON.stringify(event.details),
agent_id: event.agentId ?? null,
session_id: null, // TODO: Aktuelle Session-ID übergeben
duration_ms: event.durationMs ?? null,
error: event.error ?? null,
};
await invoke('save_monitor_event', { event: dbEvent });
}
// Monitor-Events aus DB laden
export async function loadMonitorEventsFromDb(limit = 500): Promise<void> {
try {
interface DbMonitorEvent {
id: string;
timestamp: string;
event_type: string;
summary: string;
details: string | null;
agent_id: string | null;
session_id: string | null;
duration_ms: number | null;
error: string | null;
}
const dbEvents = await invoke<DbMonitorEvent[]>('load_monitor_events', { limit });
// DB-Events in Frontend-Format umwandeln (neueste zuerst → umkehren für chronologische Reihenfolge)
const events: MonitorEvent[] = dbEvents.reverse().map((e) => ({
id: e.id,
timestamp: new Date(e.timestamp),
type: e.event_type as MonitorEventType,
summary: e.summary,
details: e.details ? JSON.parse(e.details) : {},
agentId: e.agent_id ?? undefined,
durationMs: e.duration_ms ?? undefined,
error: e.error ?? undefined,
}));
monitorEvents.set(events);
console.log(`📊 ${events.length} Monitor-Events aus DB geladen`);
} catch (err) {
console.error('Fehler beim Laden der Monitor-Events:', err);
}
}
// Monitor leeren (auch in DB)
export async function clearMonitorEvents(): Promise<void> {
monitorEvents.set([]);
selectedMonitorEventId.set(null);
try {
const count = await invoke<number>('clear_all_monitor_events');
console.log(`🗑️ ${count} Monitor-Events aus DB gelöscht`);
} catch (err) {
console.warn('Monitor-Events konnten nicht aus DB gelöscht werden:', err);
}
}
// 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, '***@***.***');
}