Compare commits

..

15 commits

Author SHA1 Message Date
Eddy
3e5021dbf0 Fix: Bridge --verbose Flag + korrekte Event-Extraktion
- --verbose ist Pflicht für --output-format stream-json
- total_cost_usd statt cost_usd für Kosten
- usage.input_tokens/output_tokens + Cache-Info
- Session-ID aus system.init Events
- rate_limit_event ignorieren
- allowedTools entfernt (Claude entscheidet selbst)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:32:32 +02:00
Eddy
e48519fff4 PaneForge statt eigener Resize-Logik — funktionierendes Drag
Eigene Resize-Implementierung komplett ersetzt durch PaneForge Library:
- Getestet, Svelte 5 kompatibel, funktioniert in WebKitGTK
- autoSaveId für automatische Persistierung in localStorage
- minSize/maxSize pro Panel in Prozent
- Kein eigener Pointer-Event-Code mehr nötig

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:26:20 +02:00
Eddy
f414e820e6 Fix: Rechtes Panel tatsächlich kleiner schiebbar
Alle Panels nutzen jetzt feste px-Werte, das letzte Panel bekommt
den Restplatz über JS-Berechnung statt CSS 1fr. Dadurch kann der
Drag-Handle die Breite tatsächlich reduzieren.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:24:00 +02:00
Eddy
8a6ed764c5 Fix: Rechtes Panel frei zusammenschiebbar (minmax 80px statt gespeichertem Wert)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:22:20 +02:00
Eddy
e16a9c720a Fix: Letztes Panel füllt Restbreite (kein toter Bereich rechts)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:21:39 +02:00
Eddy
fa60ade2fc Fix: applyWidths() generiert korrekt 7 Grid-Spalten mit Handle-Gaps
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:20:54 +02:00
Eddy
82f40b6ae2 Fix: Resizable Panels via Pointer Events + setPointerCapture
Komplett auf Vanilla JS Pointer Events umgestellt:
- setPointerCapture() fängt ALLE Events am Handle-Element (kein Overlay nötig)
- touch-action: none auf Handles (WebKitGTK Kompatibilität)
- addEventListener in onMount statt Svelte on: Syntax
- Handles als sichtbare 8px Grid-Spalten (nicht absolute)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:13:46 +02:00
Eddy
eb8e2ac1d7 Fix: Resize-Handles als absolute Overlays statt Grid-Spalten
Komplett neuer Ansatz: Handles sind absolut positionierte 8px-Elemente
über den Panel-Grenzen statt schmale Grid-Spalten. Behebt das Problem
dass WebKitGTK Mouse-Events auf engen Grid-Zellen nicht registriert.

- Handles position:absolute über kumulierten Panel-Breiten
- 8px breit, transparent, wird blau beim Hover
- Drag-Overlay (position:fixed) fängt Events während Drag
- document.addEventListener statt window für bessere Kompatibilität

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:27:32 +02:00
Eddy
a0ad11b66c Fix: Resizable Panels — Drag-Overlay verhindert Text-Selektion
- Unsichtbares Fullscreen-Overlay während Drag fängt alle Maus-Events
- document.body.style.userSelect blockiert sofort bei mousedown
- Bestehende Selektion wird beim Drag-Start gelöscht
- Handle 5px breit + unsichtbarer 15px Greifbereich (::before)
- Visueller Grip-Indikator beim Hover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:17:44 +02:00
Eddy
92353e2852 Fix: Grid-Template für 7 Spalten (4 Panels + 3 Handles)
Grid hatte nur 4 Spalten-Werte für 7 Kinder — Handles wurden
in die nächste Zeile umgebrochen. Jetzt korrekt: panel 3px panel 3px ...

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:16:15 +02:00
Eddy
56c967b618 Resizable Panels — alle Bereiche per Drag ziehbar
- 3 Resize-Handles zwischen den 4 Panels (Session/Chat/Aktivität/Agents)
- Mindestbreite 120px pro Panel
- Panel-Breiten werden in localStorage gespeichert und beim Neustart geladen
- Visuelles Feedback: Handle wird blau beim Hover/Drag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:13:50 +02:00
Eddy
f101661016 Phase 5: Session-Verwaltung + permanente Konversationen
- session.rs: Neues Modul mit 7 Tauri-Commands (CRUD, Resume, aktive Session)
- db.rs: Sessions-Tabelle + CRUD-Methoden (bleiben bis User sie löscht)
- claude.rs: Session-ID und Token/Kosten automatisch in DB speichern
- SessionList.svelte: Sidebar mit Session-Liste, Erstellen, Fortsetzen, Löschen
- +page.svelte: 4-Panel Layout (Sessions | Chat | Aktivität | Agents)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:11:17 +02:00
Eddy
532c91c605 AWL Dark Theme + Tauri 2 Config-Fix
- app.css: Komplett neues Farbschema basierend auf AWL Dark / KDE Breeze Dark
- tauri.conf.json: Shell-Plugin scope entfernt (Tauri 2 inkompatibel)
- capabilities/default.json: Tauri 2 Berechtigungen für Shell-Plugin
- StopButton + Layout: Hardcoded Farben durch CSS-Variablen ersetzt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:38:17 +02:00
Eddy
ff423e9d80 Phase 4: Tab-Switching, Markdown-Rendering, Guard-Rails UI
- +page.svelte: Echtes Tab-Switching (Aktivität/Gedächtnis/Historie + Agents/Guard-Rails)
- ChatPanel: Markdown-Rendering via marked, Auto-Scroll, verbessertes Layout
- GuardRailsPanel: Neue Komponente für Permission-Verwaltung (CRUD, blockierte Patterns)
- package.json: marked als Dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:31:33 +02:00
Eddy
f5ca5bca7c Phase 3: SQLite-Persistierung, Guard-Rails Integration + Claude Bridge
- db.rs: Vollständige SQLite-Schicht (Permissions, Audit, Memory, Patterns, Settings)
- guard.rs: Risiko-Klassifikation + Freigabe-Management in lib.rs integriert
- scripts/claude-bridge.js: Node.js Bridge für Claude CLI (stream-json, NDJSON)
- audit.rs + memory.rs: An SQLite angebunden statt In-Memory
- Frontend: MemoryPanel + AuditLog laden echte Daten via Tauri-Commands
- shell.nix: Rust-Toolchain aus nixpkgs statt rustup
- Build: cargo check + npm run build erfolgreich

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:28:35 +02:00
22 changed files with 2823 additions and 233 deletions

View file

@ -24,6 +24,8 @@
"dependencies": { "dependencies": {
"@anthropic-ai/claude-code": "^0.2.0", "@anthropic-ai/claude-code": "^0.2.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0" "@tauri-apps/plugin-shell": "^2.0.0",
"marked": "^18.0.0",
"paneforge": "^1.0.2"
} }
} }

324
scripts/claude-bridge.js Normal file
View file

@ -0,0 +1,324 @@
#!/usr/bin/env node
// Claude Desktop — Bridge zwischen Tauri-Backend und Claude CLI
//
// Kommunikation: stdin/stdout als NDJSON (eine JSON-Zeile pro Nachricht)
//
// Eingehend (von Tauri):
// { "command": "message", "id": "req-1", "message": "Fixe den Bug..." }
// { "command": "stop", "id": "req-2" }
//
// Ausgehend (an Tauri):
// { "type": "event", "event": "ready" }
// { "type": "event", "event": "agent-started", "payload": { "id": "...", "type": "Main" } }
// { "type": "event", "event": "text", "payload": { "text": "..." } }
// { "type": "event", "event": "tool-start", "payload": { "id": "...", "tool": "Read", "input": {...} } }
// { "type": "event", "event": "tool-end", "payload": { "id": "...", "success": true } }
// { "type": "event", "event": "result", "payload": { "cost": 0.01, "tokens": {...} } }
// { "type": "event", "event": "agent-stopped", "payload": { "id": "..." } }
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { randomUUID } from 'node:crypto';
// Aktive Claude-Prozesse
const activeProcesses = new Map();
// Session-ID für --resume
let currentSessionId = null;
// ============ Kommunikation mit Tauri ============
function sendToTauri(msg) {
process.stdout.write(JSON.stringify(msg) + '\n');
}
function sendEvent(event, payload = {}) {
sendToTauri({ type: 'event', event, payload });
}
function sendResponse(id, result) {
sendToTauri({ type: 'response', id, result });
}
function sendError(id, error) {
sendToTauri({ type: 'response', id, error });
}
// ============ Claude CLI aufrufen ============
function spawnClaude(message, requestId) {
const agentId = randomUUID();
const args = [
'-p', // Print-Modus (nicht interaktiv)
'--output-format', 'stream-json', // Streaming JSON Events
'--verbose', // Pflicht für stream-json
];
// Bei Fortsetzung letzte Session verwenden
if (currentSessionId) {
args.push('--resume', currentSessionId);
}
// Nachricht als Argument
args.push(message);
// Claude CLI Pfad — npx oder global installiert
const claudePath = process.env.CLAUDE_PATH || 'claude';
const proc = spawn(claudePath, args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
// Keine interaktive UI
CLAUDE_CODE_HEADLESS: '1',
},
});
activeProcesses.set(agentId, { proc, requestId });
sendEvent('agent-started', {
id: agentId,
type: 'Main',
task: message.substring(0, 100),
});
// NDJSON-Zeilen von stdout parsen
let fullText = '';
let toolCounter = 0;
const rl = createInterface({ input: proc.stdout });
rl.on('line', (line) => {
if (!line.trim()) return;
try {
const event = JSON.parse(line);
handleClaudeEvent(event, agentId, requestId, { fullText: () => fullText, addText: (t) => { fullText += t; } });
} catch {
// Nicht-JSON Zeile — als Text weiterleiten
fullText += line;
sendEvent('text', { text: line });
}
});
// stderr für Debug-Infos
const stderrRl = createInterface({ input: proc.stderr });
stderrRl.on('line', (line) => {
if (process.env.CLAUDE_DEBUG) {
process.stderr.write(`[claude-stderr] ${line}\n`);
}
});
proc.on('close', (code) => {
activeProcesses.delete(agentId);
sendEvent('agent-stopped', {
id: agentId,
code,
});
if (activeProcesses.size === 0) {
sendEvent('all-stopped');
}
});
proc.on('error', (err) => {
sendError(requestId, `Claude konnte nicht gestartet werden: ${err.message}`);
activeProcesses.delete(agentId);
});
return agentId;
}
// ============ Claude Stream-JSON Events verarbeiten ============
function handleClaudeEvent(event, agentId, requestId, textState) {
// Das stream-json Format hat verschiedene Event-Typen:
// { "type": "assistant", "message": { "content": [...], ... } }
// { "type": "content_block_start", "content_block": { "type": "text", "text": "..." } }
// { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "..." } }
// { "type": "content_block_start", "content_block": { "type": "tool_use", "name": "Read", ... } }
// { "type": "content_block_delta", "delta": { "type": "input_json_delta", ... } }
// { "type": "result", "result": "...", "cost_usd": 0.01, ... }
// { "type": "system", "subtype": "session_id", "session_id": "..." }
const type = event.type;
switch (type) {
case 'system':
if (event.session_id) {
currentSessionId = event.session_id;
}
break;
case 'rate_limit_event':
// Ignorieren
break;
case 'assistant':
// Vollständige Nachricht — Inhalt extrahieren
if (event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'text') {
sendEvent('text', { text: block.text });
}
}
}
break;
case 'content_block_start':
if (event.content_block?.type === 'text') {
// Text-Block startet
if (event.content_block.text) {
sendEvent('text', { text: event.content_block.text });
}
} else if (event.content_block?.type === 'tool_use') {
// Tool-Aufruf startet
sendEvent('tool-start', {
id: event.content_block.id || randomUUID(),
tool: event.content_block.name,
input: event.content_block.input || {},
});
}
break;
case 'content_block_delta':
if (event.delta?.type === 'text_delta' && event.delta.text) {
sendEvent('text', { text: event.delta.text });
}
break;
case 'content_block_stop':
// Block fertig — wenn es ein Tool war, Tool-Ende senden
break;
case 'tool_result':
case 'tool_use_result':
sendEvent('tool-end', {
id: event.tool_use_id || event.id || '',
success: !event.is_error,
output: typeof event.content === 'string'
? event.content
: JSON.stringify(event.content)?.substring(0, 500),
});
break;
case 'result':
// Endergebnis mit Kosten
sendEvent('result', {
text: event.result || '',
cost: event.total_cost_usd || event.cost_usd || 0,
tokens: {
input: event.usage?.input_tokens || 0,
output: event.usage?.output_tokens || 0,
cache_read: event.usage?.cache_read_input_tokens || 0,
cache_create: event.usage?.cache_creation_input_tokens || 0,
},
session_id: event.session_id || currentSessionId,
duration_ms: event.duration_ms || 0,
num_turns: event.num_turns || 0,
});
// Session-ID für Fortsetzung merken
if (event.session_id) {
currentSessionId = event.session_id;
}
break;
default:
// Unbekannte Events durchreichen
if (event.subagent_id || event.agent_id) {
// Sub-Agent Events
sendEvent('subagent-event', {
agentId: event.subagent_id || event.agent_id,
type,
data: event,
});
}
break;
}
}
// ============ Alle Prozesse stoppen ============
function stopAll() {
for (const [agentId, { proc }] of activeProcesses) {
try {
proc.kill('SIGTERM');
// Nach 2 Sekunden hart killen
setTimeout(() => {
try { proc.kill('SIGKILL'); } catch {}
}, 2000);
} catch {}
}
activeProcesses.clear();
sendEvent('all-stopped');
}
// ============ Eingehende Befehle verarbeiten ============
function handleCommand(msg) {
switch (msg.command) {
case 'message':
if (!msg.message) {
sendError(msg.id, 'Keine Nachricht angegeben');
return;
}
const agentId = spawnClaude(msg.message, msg.id);
sendResponse(msg.id, { agentId, status: 'gestartet' });
break;
case 'stop':
stopAll();
sendResponse(msg.id, { status: 'gestoppt' });
break;
case 'status':
const agents = [];
for (const [id, { proc }] of activeProcesses) {
agents.push({
id,
running: !proc.killed,
pid: proc.pid,
});
}
sendResponse(msg.id, { agents, sessionId: currentSessionId });
break;
case 'ping':
sendResponse(msg.id, { pong: true });
break;
default:
sendError(msg.id, `Unbekannter Befehl: ${msg.command}`);
}
}
// ============ Main ============
// stdin zeilenweise lesen
const rl = createInterface({ input: process.stdin });
rl.on('line', (line) => {
if (!line.trim()) return;
try {
const msg = JSON.parse(line);
handleCommand(msg);
} catch (err) {
process.stderr.write(`Ungültige Eingabe: ${err.message}\n`);
}
});
// Sauber beenden
process.on('SIGTERM', () => {
stopAll();
process.exit(0);
});
process.on('SIGINT', () => {
stopAll();
process.exit(0);
});
// Bereit-Signal senden
sendEvent('ready', { version: '0.1.0', pid: process.pid });

View file

@ -2,7 +2,11 @@
pkgs.mkShell { pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# Rust (wir nutzen rustup, aber brauchen den Linker) # Rust Toolchain aus nixpkgs
rustc
cargo
rustfmt
clippy
gcc gcc
pkg-config pkg-config
openssl openssl
@ -56,11 +60,9 @@ pkgs.mkShell {
pkgs.openssl pkgs.openssl
]}:$LD_LIBRARY_PATH" ]}:$LD_LIBRARY_PATH"
# Rust von rustup laden
source "$HOME/.cargo/env" 2>/dev/null || true
echo "🦀 Claude Desktop Entwicklungsumgebung geladen" echo "🦀 Claude Desktop Entwicklungsumgebung geladen"
echo " Rust: $(rustc --version 2>/dev/null || echo 'nicht gefunden')" echo " Rust: $(rustc --version 2>/dev/null || echo 'nicht gefunden')"
echo " Cargo: $(cargo --version 2>/dev/null || echo 'nicht gefunden')"
echo " Node: $(node --version 2>/dev/null || echo 'nicht gefunden')" echo " Node: $(node --version 2>/dev/null || echo 'nicht gefunden')"
''; '';
} }

View file

@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View file

@ -0,0 +1,14 @@
{
"$schema": "https://schema.tauri.app/config/2/capability",
"identifier": "default",
"description": "Claude Desktop Standardberechtigungen",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"shell:allow-execute",
"shell:allow-spawn",
"shell:allow-stdin-write",
"shell:allow-kill"
]
}

View file

@ -2,7 +2,10 @@
// Protokolliert alle Änderungen an Einstellungen, Guard-Rails, Hooks, Skills, etc. // Protokolliert alle Änderungen an Einstellungen, Guard-Rails, Hooks, Skills, etc.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::AppHandle; use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager};
use crate::db;
/// Kategorie der Änderung /// Kategorie der Änderung
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -105,17 +108,17 @@ pub struct AuditStats {
/// Holt die letzten Audit-Einträge /// Holt die letzten Audit-Einträge
#[tauri::command] #[tauri::command]
pub async fn get_audit_log(limit: Option<usize>) -> Result<Vec<AuditEntry>, String> { pub async fn get_audit_log(app: AppHandle, limit: Option<usize>) -> Result<Vec<AuditEntry>, String> {
let limit = limit.unwrap_or(50); let limit = limit.unwrap_or(50);
let state = app.state::<Arc<Mutex<db::Database>>>();
// TODO: Aus SQLite laden let db_lock = state.lock().unwrap();
db_lock.load_audit_log(limit).map_err(|e| e.to_string())
Ok(vec![])
} }
/// Fügt einen Audit-Eintrag hinzu /// Fügt einen Audit-Eintrag hinzu
#[tauri::command] #[tauri::command]
pub async fn add_audit_entry( pub async fn add_audit_entry(
app: AppHandle,
category: String, category: String,
action: String, action: String,
item_id: String, item_id: String,
@ -159,21 +162,20 @@ pub async fn add_audit_entry(
session_id: None, session_id: None,
}; };
// TODO: In SQLite speichern // In SQLite speichern
let state = app.state::<Arc<Mutex<db::Database>>>();
let db_lock = state.lock().unwrap();
db_lock.save_audit_entry(&entry).map_err(|e| e.to_string())?;
println!("📋 Audit-Eintrag hinzugefügt: {:?}", entry); println!("📋 Audit: {:?} {:?} - {}", entry.action, entry.category, entry.item_name);
Ok(()) Ok(())
} }
/// Holt Audit-Statistiken /// Holt Audit-Statistiken
#[tauri::command] #[tauri::command]
pub async fn get_audit_stats() -> Result<AuditStats, String> { pub async fn get_audit_stats(app: AppHandle) -> Result<AuditStats, String> {
// TODO: Echte Implementierung let state = app.state::<Arc<Mutex<db::Database>>>();
let db_lock = state.lock().unwrap();
Ok(AuditStats { db_lock.audit_stats().map_err(|e| e.to_string())
total: 0,
auto_corrected: 0,
today: 0,
})
} }

View file

@ -7,6 +7,8 @@ use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use crate::db;
/// Status eines Agents /// Status eines Agents
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStatus { pub struct AgentStatus {
@ -176,6 +178,30 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
let _ = app.emit("claude-text", &payload); let _ = app.emit("claude-text", &payload);
} }
"result" => { "result" => {
// Session-ID aus Result extrahieren und in DB speichern
if let Some(sid) = payload.get("session_id").and_then(|v| v.as_str()) {
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
let db_lock = db_state.lock().unwrap();
if let Ok(Some(active_id)) = db_lock.get_setting("active_session_id") {
if !active_id.is_empty() {
if let Ok(Some(mut session)) = db_lock.get_session(&active_id) {
session.claude_session_id = Some(sid.to_string());
session.message_count += 1;
if let Some(cost) = payload.get("cost").and_then(|v| v.as_f64()) {
session.cost_usd += cost;
}
if let Some(tin) = payload.get("tokens").and_then(|t| t.get("input")).and_then(|v| v.as_i64()) {
session.token_input += tin;
}
if let Some(tout) = payload.get("tokens").and_then(|t| t.get("output")).and_then(|v| v.as_i64()) {
session.token_output += tout;
}
let _ = db_lock.update_session(&session);
}
}
}
}
}
let _ = app.emit("claude-result", &payload); let _ = app.emit("claude-result", &payload);
} }
"all-stopped" => { "all-stopped" => {

606
src-tauri/src/db.rs Normal file
View file

@ -0,0 +1,606 @@
// Claude Desktop — SQLite Datenbankschicht
// Persistiert Guard-Rails, Audit-Log, Memory und Einstellungen
use rusqlite::{params, Connection, Result as SqlResult};
use std::path::Path;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager};
use crate::audit::{AuditAction, AuditCategory, AuditEntry, AuditStats};
use crate::guard::{Permission, PermissionAction, PermissionType};
use crate::memory::{ContextCategory, MemoryEntry, Pattern};
/// Eine Claude-Session
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Session {
pub id: String,
pub claude_session_id: Option<String>,
pub title: String,
pub working_dir: Option<String>,
pub message_count: i64,
pub token_input: i64,
pub token_output: i64,
pub cost_usd: f64,
pub status: String,
pub created_at: String,
pub updated_at: String,
pub last_message: Option<String>,
}
/// Datenbank-Wrapper
pub struct Database {
conn: Connection,
}
/// Datenbank-Statistiken
#[derive(Debug, serde::Serialize)]
pub struct DbStats {
pub permissions: usize,
pub audit_entries: usize,
pub memory_entries: usize,
pub patterns: usize,
pub db_size_kb: u64,
}
impl Database {
/// Öffnet oder erstellt die Datenbank
pub fn open(path: &Path) -> SqlResult<Self> {
let conn = Connection::open(path)?;
// WAL-Modus für bessere Performance
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
let db = Self { conn };
db.create_tables()?;
Ok(db)
}
/// Schema erstellen
fn create_tables(&self) -> SqlResult<()> {
self.conn.execute_batch(
"
-- Guard-Rails Permissions
CREATE TABLE IF NOT EXISTS permissions (
id TEXT PRIMARY KEY,
pattern TEXT NOT NULL,
tool TEXT,
path_pattern TEXT,
action TEXT NOT NULL DEFAULT 'allow',
created_at TEXT NOT NULL,
use_count INTEGER DEFAULT 0,
last_used TEXT
);
-- Audit-Log
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
category TEXT NOT NULL,
action TEXT NOT NULL,
item_id TEXT NOT NULL,
item_name TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
reason TEXT,
auto_corrected INTEGER DEFAULT 0,
session_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_audit_category ON audit_log(category);
-- Memory-Einträge
CREATE TABLE IF NOT EXISTS memory (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
sticky INTEGER DEFAULT 0,
auto_load INTEGER DEFAULT 0,
last_used TEXT,
use_count INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_memory_category ON memory(category);
CREATE INDEX IF NOT EXISTS idx_memory_sticky ON memory(sticky) WHERE sticky = 1;
-- Patterns (Vorgehensweisen)
CREATE TABLE IF NOT EXISTS patterns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
trigger_text TEXT,
old_approach TEXT,
new_approach TEXT,
reason TEXT,
occurrence_count INTEGER DEFAULT 1,
auto_corrected INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Sessions (Claude-Konversationen)
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
claude_session_id TEXT,
title TEXT NOT NULL,
working_dir TEXT,
message_count INTEGER DEFAULT 0,
token_input INTEGER DEFAULT 0,
token_output INTEGER DEFAULT 0,
cost_usd REAL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_message TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
-- Einstellungen (Key-Value)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
",
)?;
Ok(())
}
// ============ Permissions ============
/// Speichert eine Permission
pub fn save_permission(&self, perm: &Permission) -> SqlResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO permissions (id, pattern, tool, path_pattern, action, created_at, use_count, last_used)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
perm.id,
perm.pattern,
perm.tool,
perm.path_pattern,
format!("{:?}", perm.action).to_lowercase(),
perm.created_at,
perm.use_count,
perm.last_used,
],
)?;
Ok(())
}
/// Lädt alle permanenten Permissions
pub fn load_permissions(&self) -> SqlResult<Vec<Permission>> {
let mut stmt = self.conn.prepare(
"SELECT id, pattern, tool, path_pattern, action, created_at, use_count, last_used FROM permissions"
)?;
let perms = stmt.query_map([], |row| {
let action_str: String = row.get(4)?;
let action = match action_str.as_str() {
"deny" => PermissionAction::Deny,
_ => PermissionAction::Allow,
};
Ok(Permission {
id: row.get(0)?,
pattern: row.get(1)?,
tool: row.get(2)?,
path_pattern: row.get(3)?,
permission_type: PermissionType::Permanent,
action,
created_at: row.get(5)?,
use_count: row.get(6)?,
last_used: row.get(7)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(perms)
}
/// Löscht eine Permission
pub fn delete_permission(&self, id: &str) -> SqlResult<()> {
self.conn.execute("DELETE FROM permissions WHERE id = ?1", params![id])?;
Ok(())
}
// ============ Audit-Log ============
/// Speichert einen Audit-Eintrag
pub fn save_audit_entry(&self, entry: &AuditEntry) -> SqlResult<()> {
self.conn.execute(
"INSERT INTO audit_log (id, timestamp, category, action, item_id, item_name, old_value, new_value, reason, auto_corrected, session_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
entry.id,
entry.timestamp,
format!("{:?}", entry.category).to_lowercase(),
format!("{:?}", entry.action).to_lowercase(),
entry.item_id,
entry.item_name,
entry.old_value.as_ref().map(|v| v.to_string()),
entry.new_value.as_ref().map(|v| v.to_string()),
entry.reason,
entry.auto_corrected as i32,
entry.session_id,
],
)?;
Ok(())
}
/// Lädt die letzten N Audit-Einträge
pub fn load_audit_log(&self, limit: usize) -> SqlResult<Vec<AuditEntry>> {
let mut stmt = self.conn.prepare(
"SELECT id, timestamp, category, action, item_id, item_name, old_value, new_value, reason, auto_corrected, session_id
FROM audit_log ORDER BY timestamp DESC LIMIT ?1"
)?;
let entries = stmt.query_map(params![limit as i64], |row| {
let cat_str: String = row.get(2)?;
let act_str: String = row.get(3)?;
let old_val: Option<String> = row.get(6)?;
let new_val: Option<String> = row.get(7)?;
let auto_corr: i32 = row.get(9)?;
Ok(AuditEntry {
id: row.get(0)?,
timestamp: row.get(1)?,
category: parse_audit_category(&cat_str),
action: parse_audit_action(&act_str),
item_id: row.get(4)?,
item_name: row.get(5)?,
old_value: old_val.and_then(|s| serde_json::from_str(&s).ok()),
new_value: new_val.and_then(|s| serde_json::from_str(&s).ok()),
reason: row.get(8)?,
auto_corrected: auto_corr != 0,
session_id: row.get(10)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(entries)
}
/// Audit-Statistiken
pub fn audit_stats(&self) -> SqlResult<AuditStats> {
let total: usize = self.conn.query_row(
"SELECT COUNT(*) FROM audit_log", [], |row| row.get(0)
)?;
let auto_corrected: usize = self.conn.query_row(
"SELECT COUNT(*) FROM audit_log WHERE auto_corrected = 1", [], |row| row.get(0)
)?;
let today: usize = self.conn.query_row(
"SELECT COUNT(*) FROM audit_log WHERE timestamp LIKE ?1 || '%'",
params![chrono::Local::now().format("%Y-%m-%d").to_string()],
|row| row.get(0),
)?;
Ok(AuditStats { total, auto_corrected, today })
}
// ============ Memory ============
/// Speichert einen Memory-Eintrag
pub fn save_memory_entry(&self, entry: &MemoryEntry) -> SqlResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO memory (id, category, key, value, sticky, auto_load, last_used, use_count)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
entry.id,
format!("{:?}", entry.category),
entry.key,
entry.value.to_string(),
entry.sticky as i32,
entry.auto_load as i32,
entry.last_used,
entry.use_count,
],
)?;
Ok(())
}
/// Lädt alle Memory-Einträge
pub fn load_memory_entries(&self) -> SqlResult<Vec<MemoryEntry>> {
let mut stmt = self.conn.prepare(
"SELECT id, category, key, value, sticky, auto_load, last_used, use_count FROM memory"
)?;
let entries = stmt.query_map([], |row| {
let cat_str: String = row.get(1)?;
let val_str: String = row.get(3)?;
let sticky: i32 = row.get(4)?;
let auto_load: i32 = row.get(5)?;
Ok(MemoryEntry {
id: row.get(0)?,
category: parse_context_category(&cat_str),
key: row.get(2)?,
value: serde_json::from_str(&val_str).unwrap_or(serde_json::Value::String(val_str)),
sticky: sticky != 0,
auto_load: auto_load != 0,
last_used: row.get(6)?,
use_count: row.get(7)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(entries)
}
/// Löscht einen Memory-Eintrag
pub fn delete_memory_entry(&self, id: &str) -> SqlResult<()> {
self.conn.execute("DELETE FROM memory WHERE id = ?1", params![id])?;
Ok(())
}
// ============ Patterns ============
/// Speichert ein Pattern
pub fn save_pattern(&self, pattern: &Pattern) -> SqlResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO patterns (id, name, description, trigger_text, old_approach, new_approach, reason, occurrence_count, auto_corrected, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
pattern.id,
pattern.name,
pattern.description,
pattern.trigger,
pattern.old_approach,
pattern.new_approach,
pattern.reason,
pattern.occurrence_count,
pattern.auto_corrected as i32,
pattern.created_at,
pattern.updated_at,
],
)?;
Ok(())
}
/// Lädt alle Patterns
pub fn load_patterns(&self) -> SqlResult<Vec<Pattern>> {
let mut stmt = self.conn.prepare(
"SELECT id, name, description, trigger_text, old_approach, new_approach, reason, occurrence_count, auto_corrected, created_at, updated_at FROM patterns"
)?;
let patterns = stmt.query_map([], |row| {
let auto_corr: i32 = row.get(8)?;
Ok(Pattern {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
trigger: row.get(3)?,
old_approach: row.get(4)?,
new_approach: row.get(5)?,
reason: row.get(6)?,
occurrence_count: row.get(7)?,
auto_corrected: auto_corr != 0,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(patterns)
}
// ============ Sessions ============
/// Erstellt eine neue Session
pub fn create_session(&self, session: &Session) -> SqlResult<()> {
self.conn.execute(
"INSERT INTO sessions (id, claude_session_id, title, working_dir, message_count, token_input, token_output, cost_usd, status, created_at, updated_at, last_message)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
session.id,
session.claude_session_id,
session.title,
session.working_dir,
session.message_count,
session.token_input,
session.token_output,
session.cost_usd,
session.status,
session.created_at,
session.updated_at,
session.last_message,
],
)?;
Ok(())
}
/// Aktualisiert eine Session
pub fn update_session(&self, session: &Session) -> SqlResult<()> {
self.conn.execute(
"UPDATE sessions SET claude_session_id = ?2, title = ?3, message_count = ?4,
token_input = ?5, token_output = ?6, cost_usd = ?7, status = ?8,
updated_at = ?9, last_message = ?10
WHERE id = ?1",
params![
session.id,
session.claude_session_id,
session.title,
session.message_count,
session.token_input,
session.token_output,
session.cost_usd,
session.status,
chrono::Local::now().to_rfc3339(),
session.last_message,
],
)?;
Ok(())
}
/// Lädt alle Sessions (neueste zuerst)
pub fn load_sessions(&self, limit: usize) -> SqlResult<Vec<Session>> {
let mut stmt = self.conn.prepare(
"SELECT id, claude_session_id, title, working_dir, message_count,
token_input, token_output, cost_usd, status, created_at, updated_at, last_message
FROM sessions ORDER BY updated_at DESC LIMIT ?1"
)?;
let sessions = stmt.query_map(params![limit as i64], |row| {
Ok(Session {
id: row.get(0)?,
claude_session_id: row.get(1)?,
title: row.get(2)?,
working_dir: row.get(3)?,
message_count: row.get(4)?,
token_input: row.get(5)?,
token_output: row.get(6)?,
cost_usd: row.get(7)?,
status: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
last_message: row.get(11)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(sessions)
}
/// Holt eine Session nach ID
pub fn get_session(&self, id: &str) -> SqlResult<Option<Session>> {
let result = self.conn.query_row(
"SELECT id, claude_session_id, title, working_dir, message_count,
token_input, token_output, cost_usd, status, created_at, updated_at, last_message
FROM sessions WHERE id = ?1",
params![id],
|row| {
Ok(Session {
id: row.get(0)?,
claude_session_id: row.get(1)?,
title: row.get(2)?,
working_dir: row.get(3)?,
message_count: row.get(4)?,
token_input: row.get(5)?,
token_output: row.get(6)?,
cost_usd: row.get(7)?,
status: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
last_message: row.get(11)?,
})
},
);
match result {
Ok(s) => Ok(Some(s)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
/// Löscht eine Session
pub fn delete_session(&self, id: &str) -> SqlResult<()> {
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])?;
Ok(())
}
// ============ Settings ============
/// Speichert eine Einstellung
pub fn set_setting(&self, key: &str, value: &str) -> SqlResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3)",
params![key, value, chrono::Local::now().to_rfc3339()],
)?;
Ok(())
}
/// Liest eine Einstellung
pub fn get_setting(&self, key: &str) -> SqlResult<Option<String>> {
let result = self.conn.query_row(
"SELECT value FROM settings WHERE key = ?1",
params![key],
|row| row.get(0),
);
match result {
Ok(val) => Ok(Some(val)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
// ============ Statistiken ============
/// DB-Statistiken
pub fn stats(&self) -> SqlResult<DbStats> {
let permissions: usize = self.conn.query_row(
"SELECT COUNT(*) FROM permissions", [], |row| row.get(0)
)?;
let audit_entries: usize = self.conn.query_row(
"SELECT COUNT(*) FROM audit_log", [], |row| row.get(0)
)?;
let memory_entries: usize = self.conn.query_row(
"SELECT COUNT(*) FROM memory", [], |row| row.get(0)
)?;
let patterns: usize = self.conn.query_row(
"SELECT COUNT(*) FROM patterns", [], |row| row.get(0)
)?;
// DB-Größe ermitteln
let page_count: u64 = self.conn.query_row(
"PRAGMA page_count", [], |row| row.get(0)
)?;
let page_size: u64 = self.conn.query_row(
"PRAGMA page_size", [], |row| row.get(0)
)?;
let db_size_kb = (page_count * page_size) / 1024;
Ok(DbStats { permissions, audit_entries, memory_entries, patterns, db_size_kb })
}
}
// ============ Hilfsfunktionen ============
fn parse_audit_category(s: &str) -> AuditCategory {
match s {
"guardrail" | "guard_rail" => AuditCategory::GuardRail,
"pattern" => AuditCategory::Pattern,
"hook" => AuditCategory::Hook,
"skill" => AuditCategory::Skill,
"setting" => AuditCategory::Setting,
"mcp" => AuditCategory::MCP,
"memory" => AuditCategory::Memory,
_ => AuditCategory::Setting,
}
}
fn parse_audit_action(s: &str) -> AuditAction {
match s {
"create" => AuditAction::Create,
"update" => AuditAction::Update,
"delete" => AuditAction::Delete,
"enable" => AuditAction::Enable,
"disable" => AuditAction::Disable,
_ => AuditAction::Update,
}
}
fn parse_context_category(s: &str) -> ContextCategory {
match s {
"Critical" => ContextCategory::Critical,
"Pattern" => ContextCategory::Pattern,
"Preference" => ContextCategory::Preference,
"GuardRail" => ContextCategory::GuardRail,
"Hook" => ContextCategory::Hook,
"Skill" => ContextCategory::Skill,
_ => ContextCategory::Pattern,
}
}
// ============ Tauri Commands ============
pub type DbState = Arc<Mutex<Database>>;
/// DB initialisieren (falls Frontend es auslösen will)
#[tauri::command]
pub async fn init_database(app: AppHandle) -> Result<DbStats, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.stats().map_err(|e| e.to_string())
}
/// DB-Statistiken abrufen
#[tauri::command]
pub async fn get_db_stats(app: AppHandle) -> Result<DbStats, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.stats().map_err(|e| e.to_string())
}

470
src-tauri/src/guard.rs Normal file
View file

@ -0,0 +1,470 @@
// Claude Desktop — Guard-Rails System
// Risiko-Klassifikation und Freigabe-Management
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
/// Risiko-Level einer Aktion
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RiskLevel {
Safe, // Read, Glob, Grep → auto-approve
Moderate, // Write, Edit, Git commit → Hinweis in Statusbar
Critical, // Prod-Deploy, DB-Schema, Git push → Modal + Bestätigung
Blocked, // rm -rf, force push main → hart blockiert
}
/// Typ der Freigabe
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PermissionType {
Session, // Gilt nur für aktuelle Session
Permanent, // Dauerhaft gespeichert
}
/// Eine Freigabe-Regel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Permission {
pub id: String,
pub pattern: String, // z.B. "npm install *", "git commit -m *"
pub tool: Option<String>, // z.B. "Bash", "Edit", None = alle
pub path_pattern: Option<String>, // z.B. "/var/www/dolibarr/*"
pub permission_type: PermissionType,
pub action: PermissionAction,
pub created_at: String,
pub use_count: u32,
pub last_used: Option<String>,
}
/// Aktion einer Regel
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PermissionAction {
Allow,
Deny,
}
/// Anfrage zur Freigabe
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRequest {
pub id: String,
pub tool: String,
pub command: String,
pub args: Option<serde_json::Value>,
pub path: Option<String>,
pub risk_level: RiskLevel,
pub suggested_pattern: Option<String>,
}
/// Antwort auf Freigabe-Anfrage
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionResponse {
pub request_id: String,
pub allowed: bool,
pub save_as: Option<PermissionType>,
pub pattern: Option<String>,
}
/// Guard-Rails Manager
pub struct GuardRails {
permissions: Vec<Permission>,
session_permissions: Vec<Permission>,
blocked_patterns: Vec<String>,
}
impl Default for GuardRails {
fn default() -> Self {
Self::new()
}
}
impl GuardRails {
pub fn new() -> Self {
Self {
permissions: vec![],
session_permissions: vec![],
blocked_patterns: vec![
// Immer blockiert
"rm -rf /".to_string(),
"rm -rf /*".to_string(),
"rm -rf ~".to_string(),
"git push --force origin main".to_string(),
"git push --force origin master".to_string(),
"git push -f origin main".to_string(),
"DROP DATABASE".to_string(),
"DROP TABLE".to_string(),
"TRUNCATE TABLE".to_string(),
"> /dev/sda".to_string(),
"mkfs.".to_string(),
"dd if=/dev/zero".to_string(),
":(){:|:&};:".to_string(), // Fork bomb
],
}
}
/// Klassifiziert das Risiko einer Aktion
pub fn classify_risk(&self, tool: &str, command: &str, path: Option<&str>) -> RiskLevel {
// Erst prüfen ob blockiert
if self.is_blocked(command) {
return RiskLevel::Blocked;
}
// Tool-basierte Klassifikation
match tool {
// Sichere Tools
"Read" | "Glob" | "Grep" | "WebFetch" | "WebSearch" => RiskLevel::Safe,
// Moderate Tools
"Write" | "Edit" | "NotebookEdit" => {
// Pfad-basierte Eskalation
if let Some(p) = path {
if self.is_production_path(p) {
RiskLevel::Critical
} else {
RiskLevel::Moderate
}
} else {
RiskLevel::Moderate
}
}
// Bash braucht Command-Analyse
"Bash" => self.classify_bash_command(command, path),
// Task/Agent - abhängig vom Typ
"Task" => RiskLevel::Moderate,
// Unbekannt = Moderate
_ => RiskLevel::Moderate,
}
}
/// Klassifiziert Bash-Befehle
fn classify_bash_command(&self, command: &str, path: Option<&str>) -> RiskLevel {
let cmd_lower = command.to_lowercase();
// Sichere Befehle
if cmd_lower.starts_with("ls ")
|| cmd_lower.starts_with("pwd")
|| cmd_lower.starts_with("echo ")
|| cmd_lower.starts_with("cat ")
|| cmd_lower.starts_with("head ")
|| cmd_lower.starts_with("tail ")
|| cmd_lower.starts_with("wc ")
|| cmd_lower.starts_with("grep ")
|| cmd_lower.starts_with("find ")
|| cmd_lower.starts_with("which ")
|| cmd_lower.starts_with("whoami")
|| cmd_lower.starts_with("date")
|| cmd_lower.starts_with("uname")
{
return RiskLevel::Safe;
}
// Kritische Befehle
if cmd_lower.contains("--force")
|| cmd_lower.contains(" -f ")
|| cmd_lower.starts_with("sudo ")
|| cmd_lower.starts_with("su ")
|| cmd_lower.contains("systemctl")
|| cmd_lower.contains("service ")
|| cmd_lower.contains("docker ")
|| cmd_lower.contains("kubectl")
|| cmd_lower.starts_with("rm ")
|| cmd_lower.starts_with("mv ")
|| cmd_lower.contains("chmod ")
|| cmd_lower.contains("chown ")
{
// Prod-Pfad macht es noch kritischer
if let Some(p) = path {
if self.is_production_path(p) {
return RiskLevel::Critical;
}
}
// rm ohne -r ist nur Moderate
if cmd_lower.starts_with("rm ") && !cmd_lower.contains("-r") {
return RiskLevel::Moderate;
}
return RiskLevel::Critical;
}
// Moderate Befehle
if cmd_lower.starts_with("npm ")
|| cmd_lower.starts_with("cargo ")
|| cmd_lower.starts_with("git ")
|| cmd_lower.starts_with("mkdir ")
|| cmd_lower.starts_with("touch ")
|| cmd_lower.starts_with("cp ")
{
// git push ist kritisch
if cmd_lower.contains("git push") {
return RiskLevel::Critical;
}
return RiskLevel::Moderate;
}
// Default: Moderate
RiskLevel::Moderate
}
/// Prüft ob ein Befehl blockiert ist
fn is_blocked(&self, command: &str) -> bool {
let cmd_lower = command.to_lowercase();
self.blocked_patterns
.iter()
.any(|p| cmd_lower.contains(&p.to_lowercase()))
}
/// Prüft ob ein Pfad in Produktion liegt
fn is_production_path(&self, path: &str) -> bool {
let prod_patterns = [
"/var/www/prod",
"/var/www/production",
"/opt/prod",
"/home/prod",
"/srv/prod",
];
prod_patterns.iter().any(|p| path.starts_with(p))
}
/// Prüft ob eine Aktion erlaubt ist
pub fn check_permission(&self, tool: &str, command: &str, path: Option<&str>) -> Option<&Permission> {
// Erst Session-Permissions prüfen
for perm in &self.session_permissions {
if self.matches_permission(perm, tool, command, path) {
return Some(perm);
}
}
// Dann permanente Permissions
for perm in &self.permissions {
if self.matches_permission(perm, tool, command, path) {
return Some(perm);
}
}
None
}
/// Prüft ob eine Permission matcht
fn matches_permission(&self, perm: &Permission, tool: &str, command: &str, path: Option<&str>) -> bool {
// Tool prüfen
if let Some(ref perm_tool) = perm.tool {
if perm_tool != tool {
return false;
}
}
// Pfad prüfen
if let (Some(ref perm_path), Some(actual_path)) = (&perm.path_pattern, path) {
if !self.matches_pattern(perm_path, actual_path) {
return false;
}
}
// Command/Pattern prüfen
self.matches_pattern(&perm.pattern, command)
}
/// Einfacher Pattern-Matcher mit * Wildcard
fn matches_pattern(&self, pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let (prefix, suffix) = (parts[0], parts[1]);
return value.starts_with(prefix) && value.ends_with(suffix);
}
}
pattern == value
}
/// Fügt eine Permission hinzu
pub fn add_permission(&mut self, permission: Permission) {
match permission.permission_type {
PermissionType::Session => self.session_permissions.push(permission),
PermissionType::Permanent => self.permissions.push(permission),
}
}
/// Entfernt eine Permission
pub fn remove_permission(&mut self, id: &str) {
self.permissions.retain(|p| p.id != id);
self.session_permissions.retain(|p| p.id != id);
}
/// Session-Permissions löschen
pub fn clear_session(&mut self) {
self.session_permissions.clear();
}
/// Alle Permissions abrufen
pub fn get_all_permissions(&self) -> Vec<&Permission> {
self.permissions.iter().chain(self.session_permissions.iter()).collect()
}
/// Schlägt ein Pattern vor
pub fn suggest_pattern(&self, _tool: &str, command: &str) -> String {
// Für npm install: npm install *
if command.starts_with("npm install ") {
return "npm install *".to_string();
}
// Für git commit: git commit -m *
if command.starts_with("git commit ") {
return "git commit *".to_string();
}
// Für cargo: cargo *
if command.starts_with("cargo ") {
let parts: Vec<&str> = command.split_whitespace().collect();
if parts.len() >= 2 {
return format!("cargo {} *", parts[1]);
}
}
// Default: exakter Befehl
command.to_string()
}
}
// ============ Tauri Commands ============
use std::sync::{Arc, Mutex};
/// Globaler Guard-Rails State
pub type GuardState = Arc<Mutex<GuardRails>>;
/// Prüft eine Aktion und gibt Risiko-Level zurück
#[tauri::command]
pub async fn check_action(
app: AppHandle,
tool: String,
command: String,
path: Option<String>,
) -> Result<serde_json::Value, String> {
let state = app.state::<GuardState>();
let guard = state.lock().unwrap();
let risk = guard.classify_risk(&tool, &command, path.as_deref());
// Wenn blockiert, sofort ablehnen
if risk == RiskLevel::Blocked {
return Ok(serde_json::json!({
"allowed": false,
"risk": "blocked",
"reason": "Diese Aktion ist aus Sicherheitsgründen blockiert."
}));
}
// Permission prüfen
if let Some(perm) = guard.check_permission(&tool, &command, path.as_deref()) {
return Ok(serde_json::json!({
"allowed": perm.action == PermissionAction::Allow,
"risk": format!("{:?}", risk).to_lowercase(),
"matched_rule": perm.id
}));
}
// Kein Match - Frontend muss fragen
let suggested = guard.suggest_pattern(&tool, &command);
Ok(serde_json::json!({
"allowed": risk == RiskLevel::Safe,
"risk": format!("{:?}", risk).to_lowercase(),
"needs_confirmation": risk != RiskLevel::Safe,
"suggested_pattern": suggested
}))
}
/// Fügt eine Freigabe hinzu
#[tauri::command]
pub async fn add_permission(
app: AppHandle,
pattern: String,
tool: Option<String>,
path_pattern: Option<String>,
permission_type: String,
action: String,
) -> Result<String, String> {
let state = app.state::<GuardState>();
let mut guard = state.lock().unwrap();
let perm_type = match permission_type.as_str() {
"session" => PermissionType::Session,
"permanent" => PermissionType::Permanent,
_ => return Err("Ungültiger Permission-Typ".to_string()),
};
let perm_action = match action.as_str() {
"allow" => PermissionAction::Allow,
"deny" => PermissionAction::Deny,
_ => return Err("Ungültige Aktion".to_string()),
};
let id = uuid::Uuid::new_v4().to_string();
let permission = Permission {
id: id.clone(),
pattern,
tool,
path_pattern,
permission_type: perm_type,
action: perm_action,
created_at: chrono::Local::now().to_rfc3339(),
use_count: 0,
last_used: None,
};
guard.add_permission(permission.clone());
// Bei Permanent in SQLite speichern
if perm_type == PermissionType::Permanent {
let db_state = app.state::<Arc<Mutex<crate::db::Database>>>();
let db_lock = db_state.lock().unwrap();
db_lock.save_permission(&permission).map_err(|e| e.to_string())?;
}
println!("✅ Permission hinzugefügt: {}", id);
Ok(id)
}
/// Entfernt eine Freigabe
#[tauri::command]
pub async fn remove_permission(app: AppHandle, id: String) -> Result<(), String> {
let state = app.state::<GuardState>();
let mut guard = state.lock().unwrap();
guard.remove_permission(&id);
// Aus SQLite löschen
let db_state = app.state::<Arc<Mutex<crate::db::Database>>>();
let db_lock = db_state.lock().unwrap();
let _ = db_lock.delete_permission(&id);
println!("🗑️ Permission entfernt: {}", id);
Ok(())
}
/// Holt alle Freigaben
#[tauri::command]
pub async fn get_permissions(app: AppHandle) -> Result<Vec<Permission>, String> {
let state = app.state::<GuardState>();
let guard = state.lock().unwrap();
Ok(guard.get_all_permissions().into_iter().cloned().collect())
}
/// Holt blockierte Patterns
#[tauri::command]
pub async fn get_blocked_patterns(app: AppHandle) -> Result<Vec<String>, String> {
let state = app.state::<GuardState>();
let guard = state.lock().unwrap();
Ok(guard.blocked_patterns.clone())
}

View file

@ -6,7 +6,10 @@ use tauri::Manager;
mod audit; mod audit;
mod claude; mod claude;
mod db;
mod guard;
mod memory; mod memory;
mod session;
/// Initialisiert die App /// Initialisiert die App
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -14,6 +17,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.manage(Arc::new(Mutex::new(claude::ClaudeState::default()))) .manage(Arc::new(Mutex::new(claude::ClaudeState::default())))
.manage(guard::GuardState::new(Mutex::new(guard::GuardRails::new())))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
// Claude SDK // Claude SDK
claude::send_message, claude::send_message,
@ -28,21 +32,60 @@ pub fn run() {
audit::get_audit_log, audit::get_audit_log,
audit::add_audit_entry, audit::add_audit_entry,
audit::get_audit_stats, audit::get_audit_stats,
// Guard-Rails
guard::check_action,
guard::add_permission,
guard::remove_permission,
guard::get_permissions,
guard::get_blocked_patterns,
// Datenbank
db::init_database,
db::get_db_stats,
// Sessions
session::create_session,
session::update_session,
session::list_sessions,
session::get_session,
session::delete_session,
session::resume_session,
session::get_active_session,
session::set_claude_session_id,
]) ])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();
println!("🤖 Claude Desktop gestartet"); println!("🤖 Claude Desktop gestartet");
// Gedächtnis-System beim Start laden // Datenbank initialisieren
let app_dir = app.path().app_data_dir()
.expect("Konnte App-Datenverzeichnis nicht ermitteln");
std::fs::create_dir_all(&app_dir).ok();
let db_path = app_dir.join("claude-desktop.db");
println!("📦 Datenbank: {:?}", db_path);
let db = db::Database::open(&db_path)
.expect("Datenbank konnte nicht geöffnet werden");
app.manage(Arc::new(Mutex::new(db)));
// Guard-Rails: permanente Permissions aus DB laden
{
let db_state = handle.state::<Arc<Mutex<db::Database>>>();
let guard_state = handle.state::<guard::GuardState>();
let db_lock = db_state.lock().unwrap();
let mut guard_lock = guard_state.lock().unwrap();
if let Ok(perms) = db_lock.load_permissions() {
for p in perms {
guard_lock.add_permission(p);
}
println!("🛡️ {} permanente Permissions geladen", guard_lock.get_all_permissions().len());
}
}
// Gedächtnis-System laden
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
println!("🧠 Initialisiere Gedächtnis-System..."); println!("🧠 Initialisiere Gedächtnis-System...");
// TODO: memory::load_memory aufrufen
}); });
// Bridge optional beim Start starten (oder lazy bei erster Nachricht)
// let _ = claude::start_bridge(&app.handle());
Ok(()) Ok(())
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View file

@ -3,7 +3,10 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use tauri::AppHandle; use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager};
use crate::db;
/// Kategorien für Sticky Context (werden nie vergessen) /// Kategorien für Sticky Context (werden nie vergessen)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -121,44 +124,69 @@ pub struct Pattern {
pub async fn load_memory(app: AppHandle) -> Result<MemoryStats, String> { pub async fn load_memory(app: AppHandle) -> Result<MemoryStats, String> {
println!("🧠 Lade Gedächtnis-System..."); println!("🧠 Lade Gedächtnis-System...");
// TODO: Aus lokaler SQLite laden let state = app.state::<Arc<Mutex<db::Database>>>();
// TODO: Mit Remote claude-db synchronisieren let db_lock = state.lock().unwrap();
let entries = db_lock.load_memory_entries().map_err(|e| e.to_string())?;
let mut by_category: HashMap<String, usize> = HashMap::new();
let mut sticky_count = 0;
for entry in &entries {
let cat = format!("{:?}", entry.category);
*by_category.entry(cat).or_insert(0) += 1;
if entry.sticky {
sticky_count += 1;
}
}
println!("🧠 {} Einträge geladen ({} sticky)", entries.len(), sticky_count);
// Placeholder-Statistiken
Ok(MemoryStats { Ok(MemoryStats {
total: 0, total: entries.len(),
sticky: 0, sticky: sticky_count,
by_category: HashMap::new(), by_category,
}) })
} }
/// Holt den Sticky-Kontext für Claude /// Holt den Sticky-Kontext für Claude
#[tauri::command] #[tauri::command]
pub async fn get_sticky_context() -> Result<Vec<MemoryEntry>, String> { pub async fn get_sticky_context(app: AppHandle) -> Result<Vec<MemoryEntry>, String> {
// TODO: Echte Implementierung let state = app.state::<Arc<Mutex<db::Database>>>();
Ok(vec![]) let db_lock = state.lock().unwrap();
let entries = db_lock.load_memory_entries().map_err(|e| e.to_string())?;
Ok(entries.into_iter().filter(|e| e.sticky).collect())
} }
/// Speichert eine neue Vorgehensweise /// Speichert eine neue Vorgehensweise
#[tauri::command] #[tauri::command]
pub async fn save_pattern(pattern: Pattern) -> Result<(), String> { pub async fn save_pattern(app: AppHandle, pattern: Pattern) -> Result<(), String> {
println!("📝 Speichere Vorgehensweise: {}", pattern.name); println!("📝 Speichere Vorgehensweise: {}", pattern.name);
// TODO: In SQLite speichern let state = app.state::<Arc<Mutex<db::Database>>>();
// TODO: Mit claude-db synchronisieren let db_lock = state.lock().unwrap();
db_lock.save_pattern(&pattern).map_err(|e| e.to_string())
Ok(())
} }
/// Erkennt ein Problem und schlägt Korrektur vor /// Erkennt ein Problem und schlägt Korrektur vor
#[tauri::command] #[tauri::command]
pub async fn detect_issue( pub async fn detect_issue(
app: AppHandle,
error_message: String, error_message: String,
context: String, _context: String,
) -> Result<Option<Pattern>, String> { ) -> Result<Option<Pattern>, String> {
println!("🔍 Prüfe auf bekannte Probleme: {}", &error_message[..error_message.len().min(50)]); let preview_len = error_message.len().min(50);
println!("🔍 Prüfe auf bekannte Probleme: {}", &error_message[..preview_len]);
// TODO: Pattern-Matching gegen bekannte Probleme let state = app.state::<Arc<Mutex<db::Database>>>();
let db_lock = state.lock().unwrap();
let patterns = db_lock.load_patterns().map_err(|e| e.to_string())?;
// Einfaches Pattern-Matching: Trigger im Fehlertext suchen
let error_lower = error_message.to_lowercase();
for pattern in patterns {
if error_lower.contains(&pattern.trigger.to_lowercase()) {
return Ok(Some(pattern));
}
}
Ok(None) Ok(None)
} }

155
src-tauri/src/session.rs Normal file
View file

@ -0,0 +1,155 @@
// Claude Desktop — Session-Verwaltung
// Sessions bleiben permanent gespeichert bis der User sie löscht
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager};
use crate::db::{self, Session};
// ============ Tauri Commands ============
/// Neue Session erstellen
#[tauri::command]
pub async fn create_session(
app: AppHandle,
title: String,
working_dir: Option<String>,
) -> Result<Session, String> {
let session = Session {
id: uuid::Uuid::new_v4().to_string(),
claude_session_id: None,
title,
working_dir,
message_count: 0,
token_input: 0,
token_output: 0,
cost_usd: 0.0,
status: "active".to_string(),
created_at: chrono::Local::now().to_rfc3339(),
updated_at: chrono::Local::now().to_rfc3339(),
last_message: None,
};
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.create_session(&session).map_err(|e| e.to_string())?;
// Als aktive Session speichern
db.set_setting("active_session_id", &session.id).map_err(|e| e.to_string())?;
println!("📝 Neue Session: {} ({})", session.title, session.id);
Ok(session)
}
/// Session aktualisieren (nach Nachrichten, Token-Update, etc.)
#[tauri::command]
pub async fn update_session(
app: AppHandle,
session: Session,
) -> Result<(), String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.update_session(&session).map_err(|e| e.to_string())
}
/// Alle Sessions laden
#[tauri::command]
pub async fn list_sessions(
app: AppHandle,
limit: Option<usize>,
) -> Result<Vec<Session>, String> {
let limit = limit.unwrap_or(50);
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.load_sessions(limit).map_err(|e| e.to_string())
}
/// Session nach ID laden
#[tauri::command]
pub async fn get_session(
app: AppHandle,
id: String,
) -> Result<Option<Session>, String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.get_session(&id).map_err(|e| e.to_string())
}
/// Session löschen
#[tauri::command]
pub async fn delete_session(
app: AppHandle,
id: String,
) -> Result<(), String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.delete_session(&id).map_err(|e| e.to_string())?;
// Falls es die aktive Session war, Setting löschen
if let Ok(Some(active_id)) = db.get_setting("active_session_id") {
if active_id == id {
let _ = db.set_setting("active_session_id", "");
}
}
println!("🗑️ Session gelöscht: {}", id);
Ok(())
}
/// Session fortsetzen — setzt die aktive Session und gibt die claude_session_id zurück
#[tauri::command]
pub async fn resume_session(
app: AppHandle,
id: String,
) -> Result<Session, String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
let session = db.get_session(&id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Session {} nicht gefunden", id))?;
// Als aktive Session setzen
db.set_setting("active_session_id", &session.id).map_err(|e| e.to_string())?;
println!("▶️ Session fortgesetzt: {} (claude: {:?})", session.title, session.claude_session_id);
Ok(session)
}
/// Aktive Session holen (nach App-Start)
#[tauri::command]
pub async fn get_active_session(
app: AppHandle,
) -> Result<Option<Session>, String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
if let Ok(Some(id)) = db.get_setting("active_session_id") {
if !id.is_empty() {
return db.get_session(&id).map_err(|e| e.to_string());
}
}
Ok(None)
}
/// Claude Session-ID speichern (kommt von der Bridge nach erstem Request)
#[tauri::command]
pub async fn set_claude_session_id(
app: AppHandle,
session_id: String,
claude_session_id: String,
) -> Result<(), String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
if let Ok(Some(mut session)) = db.get_session(&session_id) {
session.claude_session_id = Some(claude_session_id.clone());
db.update_session(&session).map_err(|e| e.to_string())?;
println!("🔗 Claude Session-ID gesetzt: {}{}", session_id, claude_session_id);
}
Ok(())
}

View file

@ -34,19 +34,7 @@
}, },
"plugins": { "plugins": {
"shell": { "shell": {
"open": true, "open": true
"scope": [
{
"name": "node",
"cmd": "node",
"args": true
},
{
"name": "npm",
"cmd": "npm",
"args": true
}
]
} }
} }
} }

View file

@ -1,17 +1,29 @@
/* Claude Desktop — Basis-Styles */ /* Claude Desktop — Basis-Styles (AWL Dark Theme) */
:root { :root {
/* Farbschema */ /* AWL Dark Farbschema — basierend auf KDE Breeze Dark */
--bg-primary: #1a1a2e; --bg-primary: #202326; /* Body/Window BG */
--bg-secondary: #16213e; --bg-secondary: #272c31; /* Top-Menü / Header BG */
--bg-tertiary: #0f3460; --bg-tertiary: #292c30; /* Button/Tabellen-Header BG */
--text-primary: #eaeaea; --bg-input: #141618; /* Input-Felder / ungerade Zeilen */
--text-secondary: #a0a0a0; --bg-hover: #2a2e33; /* Hover-State */
--accent: #e94560; --bg-selected: #1e5774; /* Ausgewählte Elemente */
--accent-hover: #ff6b6b;
--success: #4ade80; --text-primary: #cccccc; /* Haupttext (VSCode Dark+ Niveau) */
--warning: #fbbf24; --text-secondary: #a1a9b1; /* Sekundärtext (ForegroundInactive) */
--error: #ef4444; --text-heading: #d4d4d4; /* Titel/Überschriften */
--accent: #3daee9; /* DecorationFocus (Breeze Blau) */
--accent-hover: #4dbdf9; /* Heller beim Hover */
--link: #1d99f3; /* ForegroundLink */
--success: #27ae60; /* Grün (KDE Breeze) */
--warning: #f67400; /* Orange (KDE Breeze) */
--error: #da4453; /* Rot (KDE Breeze) */
/* Rahmen */
--border: #3b3f44; /* Dezente Trennlinien */
--border-active: #3daee9; /* Aktive Elemente */
/* Abstände */ /* Abstände */
--spacing-xs: 0.25rem; --spacing-xs: 0.25rem;
@ -21,29 +33,18 @@
--spacing-xl: 2rem; --spacing-xl: 2rem;
/* Border-Radius */ /* Border-Radius */
--radius-sm: 4px; --radius-sm: 3px;
--radius-md: 8px; --radius-md: 6px;
--radius-lg: 12px; --radius-lg: 10px;
/* Schatten */ /* Schatten */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5); --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5);
/* Font */ /* Font */
--font-mono: 'JetBrains Mono', 'Fira Code', monospace; --font-mono: 'Hack', 'JetBrains Mono', 'Fira Code', monospace;
--font-sans: 'Inter', system-ui, sans-serif; --font-sans: 'Noto Sans', 'Inter', system-ui, sans-serif;
}
/* Light Mode */
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #f8f9fa;
--bg-secondary: #e9ecef;
--bg-tertiary: #dee2e6;
--text-primary: #212529;
--text-secondary: #6c757d;
}
} }
* { * {
@ -58,20 +59,21 @@ html, body {
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
overflow: hidden; overflow: hidden;
font-size: 14px;
} }
/* Scrollbar-Styling */ /* Scrollbar — KDE-Style, dezent */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: var(--bg-secondary); background: var(--bg-primary);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--bg-tertiary); background: var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
@ -89,34 +91,52 @@ button {
} }
/* Input-Reset */ /* Input-Reset */
input, textarea { input, textarea, select {
font-family: inherit; font-family: inherit;
border: none; border: 1px solid var(--border);
background: var(--bg-secondary); background: var(--bg-input);
color: var(--text-primary); color: var(--text-primary);
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md); border-radius: var(--radius-md);
transition: border-color 0.2s ease;
} }
input:focus, textarea:focus { input:focus, textarea:focus, select:focus {
outline: 2px solid var(--accent); outline: none;
outline-offset: 2px; border-color: var(--accent);
} }
/* Code-Blöcke */ /* Code-Blöcke */
code, pre { code, pre {
font-family: var(--font-mono); font-family: var(--font-mono);
background: var(--bg-secondary); background: var(--bg-input);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
code { code {
padding: 0.1em 0.3em; padding: 0.1em 0.3em;
font-size: 0.9em;
} }
pre { pre {
padding: var(--spacing-md); padding: var(--spacing-md);
overflow-x: auto; overflow-x: auto;
border: 1px solid var(--border);
}
/* Überschriften */
h1, h2, h3, h4 {
color: var(--text-heading);
}
/* Links */
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
} }
/* Animationen */ /* Animationen */
@ -137,3 +157,9 @@ pre {
.animate-spin { .animate-spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
/* Selection */
::selection {
background: var(--bg-selected);
color: var(--text-primary);
}

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
interface AuditEntry { interface AuditEntry {
id: string; id: string;
@ -20,6 +21,7 @@
// Kategorie-Icons // Kategorie-Icons
const categoryIcons: Record<string, string> = { const categoryIcons: Record<string, string> = {
guard_rail: '🛡️', guard_rail: '🛡️',
guardrail: '🛡️',
pattern: '📋', pattern: '📋',
hook: '🪝', hook: '🪝',
skill: '🎯', skill: '🎯',
@ -37,45 +39,17 @@
disable: '❌' disable: '❌'
}; };
onMount(async () => { async function loadEntries() {
// TODO: Echte Daten laden via Tauri try {
// const result = await invoke('get_audit_log', { limit: 50 }); entries = await invoke('get_audit_log', { limit: 50 });
} catch (err) {
// Placeholder-Daten console.error('Fehler beim Laden des Audit-Logs:', err);
entries = [
{
id: '1',
timestamp: new Date().toISOString(),
category: 'guard_rail',
action: 'update',
item_name: 'npm install *',
old_value: 'deny',
new_value: 'allow_permanent',
reason: 'Häufig verwendet, sicher',
auto_corrected: false
},
{
id: '2',
timestamp: new Date(Date.now() - 3600000).toISOString(),
category: 'pattern',
action: 'create',
item_name: 'Tauri in nix-shell starten',
old_value: 'cargo tauri dev',
new_value: 'nix-shell --run "cargo tauri dev"',
reason: 'libbz2 fehlte ohne nix-shell',
auto_corrected: true
},
{
id: '3',
timestamp: new Date(Date.now() - 7200000).toISOString(),
category: 'hook',
action: 'enable',
item_name: 'post-git-commit.sh',
reason: 'Changelog-Erinnerung aktiviert',
auto_corrected: false
} }
];
loading = false; loading = false;
}
onMount(() => {
loadEntries();
}); });
function formatTime(timestamp: string): string { function formatTime(timestamp: string): string {

View file

@ -1,20 +1,47 @@
<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 } from '$lib/stores/app'; import { messages, currentInput, isProcessing, addMessage } from '$lib/stores/app';
import { marked } from 'marked';
import { tick } from 'svelte';
// marked konfigurieren: kein sanitize nötig (lokale App, kein User-Input von extern)
marked.setOptions({
breaks: true,
gfm: true,
});
// Markdown zu HTML konvertieren
function renderMarkdown(text: string): string {
try {
return marked.parse(text) as string;
} catch {
return text;
}
}
// Auto-Scroll zum Ende
let messagesContainer: HTMLDivElement;
async function scrollToBottom() {
await tick();
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
// Bei neuen Nachrichten scrollen
$: if ($messages.length) scrollToBottom();
async function sendMessage() { async function sendMessage() {
const text = $currentInput.trim(); const text = $currentInput.trim();
if (!text || $isProcessing) return; if (!text || $isProcessing) return;
// Nachricht hinzufügen
addMessage('user', text); addMessage('user', text);
$currentInput = ''; $currentInput = '';
$isProcessing = true; $isProcessing = true;
try { try {
// An Claude senden via Tauri
await invoke('send_message', { message: text }); await invoke('send_message', { message: text });
// Antwort kommt über Events (claude-text, agent-stopped)
} catch (err) { } catch (err) {
console.error('Fehler beim Senden:', err); console.error('Fehler beim Senden:', err);
addMessage('system', `Fehler: ${err}`); addMessage('system', `Fehler: ${err}`);
@ -33,13 +60,15 @@
<div class="chat-panel"> <div class="chat-panel">
<div class="chat-header"> <div class="chat-header">
<h2>💬 Chat</h2> <h2>💬 Chat</h2>
<span class="msg-count">{$messages.length} Nachrichten</span>
</div> </div>
<div class="chat-messages"> <div class="chat-messages" bind:this={messagesContainer}>
{#if $messages.length === 0} {#if $messages.length === 0}
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon">🤖</div>
<p>Starte eine Konversation mit Claude.</p> <p>Starte eine Konversation mit Claude.</p>
<p class="hint">Drücke Enter zum Senden, Shift+Enter für neue Zeile.</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}
@ -59,14 +88,18 @@
</span> </span>
</div> </div>
<div class="message-content"> <div class="message-content">
{#if message.role === 'assistant'}
{@html renderMarkdown(message.content)}
{:else}
{message.content} {message.content}
{/if}
</div> </div>
</div> </div>
{/each} {/each}
{/if} {/if}
{#if $isProcessing} {#if $isProcessing}
<div class="message assistant"> <div class="message assistant typing-msg">
<div class="message-header"> <div class="message-header">
<span class="message-role">🤖 Claude</span> <span class="message-role">🤖 Claude</span>
</div> </div>
@ -92,7 +125,11 @@
on:click={sendMessage} on:click={sendMessage}
disabled={!$currentInput.trim() || $isProcessing} disabled={!$currentInput.trim() || $isProcessing}
> >
Senden {#if $isProcessing}
{:else}
{/if}
</button> </button>
</div> </div>
</div> </div>
@ -105,6 +142,9 @@
} }
.chat-header { .chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--bg-tertiary); border-bottom: 1px solid var(--bg-tertiary);
@ -115,6 +155,11 @@
font-weight: 600; font-weight: 600;
} }
.msg-count {
font-size: 0.625rem;
color: var(--text-secondary);
}
.chat-messages { .chat-messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@ -134,29 +179,39 @@
text-align: center; text-align: center;
} }
.empty-icon {
font-size: 3rem;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
.empty-state .hint { .empty-state .hint {
font-size: 0.75rem; font-size: 0.75rem;
margin-top: var(--spacing-sm); margin-top: var(--spacing-sm);
color: var(--text-secondary);
opacity: 0.7;
} }
.message { .message {
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--bg-secondary); background: var(--bg-secondary);
max-width: 85%;
} }
.message.user { .message.user {
background: var(--bg-tertiary); background: var(--bg-tertiary);
margin-left: var(--spacing-xl); margin-left: auto;
} }
.message.assistant { .message.assistant {
margin-right: var(--spacing-xl); margin-right: auto;
} }
.message.system { .message.system {
background: rgba(233, 69, 96, 0.1); background: rgba(233, 69, 96, 0.1);
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
max-width: 100%;
} }
.message-header { .message-header {
@ -167,19 +222,105 @@
} }
.message-role { .message-role {
font-size: 0.75rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
} }
.message-time { .message-time {
font-size: 0.625rem; font-size: 0.6rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
.message-content { .message-content {
font-size: 0.875rem; font-size: 0.85rem;
line-height: 1.5; line-height: 1.6;
white-space: pre-wrap; }
/* Markdown-Styles innerhalb von Nachrichten */
.message-content :global(p) {
margin: 0.3em 0;
}
.message-content :global(p:first-child) {
margin-top: 0;
}
.message-content :global(p:last-child) {
margin-bottom: 0;
}
.message-content :global(code) {
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 0.1em 0.35em;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
.message-content :global(pre) {
margin: 0.5em 0;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
overflow-x: auto;
font-size: 0.75rem;
line-height: 1.4;
}
.message-content :global(pre code) {
padding: 0;
background: none;
}
.message-content :global(ul), .message-content :global(ol) {
margin: 0.3em 0;
padding-left: 1.5em;
}
.message-content :global(li) {
margin: 0.15em 0;
}
.message-content :global(strong) {
color: var(--text-primary);
}
.message-content :global(a) {
color: var(--accent);
text-decoration: none;
}
.message-content :global(a:hover) {
text-decoration: underline;
}
.message-content :global(blockquote) {
border-left: 3px solid var(--bg-tertiary);
padding-left: var(--spacing-sm);
margin: 0.3em 0;
color: var(--text-secondary);
}
.message-content :global(h1), .message-content :global(h2), .message-content :global(h3) {
margin: 0.5em 0 0.2em;
font-size: 0.9rem;
}
.message-content :global(table) {
border-collapse: collapse;
margin: 0.3em 0;
font-size: 0.75rem;
width: 100%;
}
.message-content :global(th), .message-content :global(td) {
border: 1px solid var(--bg-tertiary);
padding: 0.3em 0.5em;
}
.message-content :global(th) {
background: var(--bg-secondary);
font-weight: 600;
} }
/* Typing-Animation */ /* Typing-Animation */
@ -208,7 +349,7 @@
.chat-input { .chat-input {
display: flex; display: flex;
gap: var(--spacing-sm); gap: var(--spacing-sm);
padding: var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary); background: var(--bg-secondary);
border-top: 1px solid var(--bg-tertiary); border-top: 1px solid var(--bg-tertiary);
} }
@ -216,25 +357,34 @@
.chat-input textarea { .chat-input textarea {
flex: 1; flex: 1;
resize: none; resize: none;
font-size: 0.875rem; font-size: 0.85rem;
line-height: 1.4;
} }
.send-button { .send-button {
padding: var(--spacing-sm) var(--spacing-md); width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent); background: var(--accent);
color: white; color: white;
font-size: 1.2rem;
font-weight: 600; font-weight: 600;
border-radius: var(--radius-md); border-radius: var(--radius-md);
transition: all 0.2s ease; transition: all 0.2s ease;
align-self: flex-end;
} }
.send-button:hover:not(:disabled) { .send-button:hover:not(:disabled) {
background: var(--accent-hover); background: var(--accent-hover);
transform: scale(1.05);
} }
.send-button:disabled { .send-button:disabled {
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-secondary); color: var(--text-secondary);
cursor: not-allowed; cursor: not-allowed;
transform: none;
} }
</style> </style>

View file

@ -0,0 +1,367 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
interface Permission {
id: string;
pattern: string;
tool: string | null;
path_pattern: string | null;
permission_type: string;
action: string;
created_at: string;
use_count: number;
last_used: string | null;
}
let permissions: Permission[] = [];
let blockedPatterns: string[] = [];
let loading = true;
let showAddForm = false;
// Formular-State
let newPattern = '';
let newTool = '';
let newAction: 'allow' | 'deny' = 'allow';
let newType: 'session' | 'permanent' = 'permanent';
async function loadData() {
try {
permissions = await invoke('get_permissions');
blockedPatterns = await invoke('get_blocked_patterns');
} catch (err) {
console.error('Fehler beim Laden:', err);
}
loading = false;
}
onMount(() => {
loadData();
});
async function addPermission() {
if (!newPattern.trim()) return;
try {
await invoke('add_permission', {
pattern: newPattern,
tool: newTool || null,
pathPattern: null,
permissionType: newType,
action: newAction,
});
newPattern = '';
newTool = '';
showAddForm = false;
await loadData();
} catch (err) {
console.error('Fehler:', err);
}
}
async function removePermission(id: string) {
try {
await invoke('remove_permission', { id });
await loadData();
} catch (err) {
console.error('Fehler:', err);
}
}
async function checkAction() {
if (!newPattern.trim()) return;
try {
const result = await invoke('check_action', {
tool: newTool || 'Bash',
command: newPattern,
path: null,
});
console.log('Check-Ergebnis:', result);
} catch (err) {
console.error('Fehler:', err);
}
}
</script>
<div class="guard-panel">
<div class="panel-header">
<h2>🛡️ Guard-Rails</h2>
<button class="btn-add" on:click={() => showAddForm = !showAddForm}>
{showAddForm ? '✕' : '+ Regel'}
</button>
</div>
<!-- Neue Regel hinzufügen -->
{#if showAddForm}
<div class="add-form">
<div class="form-row">
<input
type="text"
bind:value={newPattern}
placeholder="Pattern (z.B. npm install *)"
/>
</div>
<div class="form-row">
<input
type="text"
bind:value={newTool}
placeholder="Tool (optional, z.B. Bash)"
/>
<select bind:value={newAction}>
<option value="allow">Erlauben</option>
<option value="deny">Verweigern</option>
</select>
<select bind:value={newType}>
<option value="permanent">Dauerhaft</option>
<option value="session">Session</option>
</select>
</div>
<div class="form-actions">
<button class="btn-save" on:click={addPermission}>Speichern</button>
<button class="btn-test" on:click={checkAction}>Testen</button>
</div>
</div>
{/if}
{#if loading}
<div class="loading-state">Lade...</div>
{:else}
<!-- Aktive Regeln -->
<div class="section">
<h3>Aktive Regeln ({permissions.length})</h3>
{#if permissions.length === 0}
<div class="empty-hint">Keine Regeln definiert</div>
{:else}
<div class="rules-list">
{#each permissions as perm}
<div class="rule-item" class:deny={perm.action === 'Deny'}>
<div class="rule-main">
<span class="rule-action" class:allow={perm.action === 'Allow'} class:deny={perm.action === 'Deny'}>
{perm.action === 'Allow' ? '✅' : '🚫'}
</span>
<code class="rule-pattern">{perm.pattern}</code>
{#if perm.tool}
<span class="rule-tool">{perm.tool}</span>
{/if}
</div>
<div class="rule-meta">
<span class="rule-type">
{perm.permission_type === 'Permanent' ? '💾' : '⏱️'}
</span>
<span class="rule-count">{perm.use_count}x</span>
<button class="btn-delete" on:click={() => removePermission(perm.id)}>🗑️</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Blockierte Patterns -->
<div class="section">
<h3>🚫 Immer blockiert ({blockedPatterns.length})</h3>
<div class="blocked-list">
{#each blockedPatterns as pattern}
<div class="blocked-item">
<code>{pattern}</code>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.guard-panel {
padding: var(--spacing-md);
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.panel-header h2 {
font-size: 1rem;
font-weight: 600;
}
.btn-add {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
}
.btn-add:hover {
background: var(--accent-hover);
}
/* Formular */
.add-form {
padding: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.form-row {
display: flex;
gap: var(--spacing-sm);
}
.form-row input {
flex: 1;
font-size: 0.8rem;
}
.form-row select {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--bg-tertiary);
color: var(--text-primary);
border: none;
border-radius: var(--radius-sm);
font-size: 0.75rem;
}
.form-actions {
display: flex;
gap: var(--spacing-sm);
}
.form-actions button {
flex: 1;
padding: var(--spacing-sm);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
}
.btn-save {
background: var(--success);
color: var(--bg-primary);
}
.btn-test {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* Sektionen */
.section {
margin-bottom: var(--spacing-md);
}
.section h3 {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.empty-hint {
font-size: 0.75rem;
color: var(--text-secondary);
text-align: center;
padding: var(--spacing-md);
}
/* Regeln */
.rules-list {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.rule-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
border-left: 3px solid var(--success);
}
.rule-item.deny {
border-left-color: var(--error);
}
.rule-main {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex: 1;
min-width: 0;
}
.rule-pattern {
font-size: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rule-tool {
font-size: 0.625rem;
padding: 1px 4px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
color: var(--text-secondary);
}
.rule-meta {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.625rem;
color: var(--text-secondary);
}
.btn-delete {
font-size: 0.75rem;
padding: 2px;
opacity: 0.5;
transition: opacity 0.2s;
}
.btn-delete:hover {
opacity: 1;
}
/* Blockiert */
.blocked-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.blocked-item {
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(239, 68, 68, 0.1);
border-radius: var(--radius-sm);
font-size: 0.7rem;
}
.blocked-item code {
color: var(--error);
font-size: 0.7rem;
}
</style>

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
interface MemoryStats { interface MemoryStats {
total: number; total: number;
@ -27,33 +28,23 @@
MCP: '🔌' MCP: '🔌'
}; };
onMount(async () => { async function loadStats() {
// TODO: Echte Daten laden via Tauri try {
// const result = await invoke('load_memory'); stats = await invoke('load_memory');
lastSync = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
// Placeholder-Daten } catch (err) {
stats = { console.error('Fehler beim Laden:', err);
total: 95,
sticky: 23,
by_category: {
Critical: 8,
Pattern: 47,
Preference: 12,
GuardRail: 15,
Hook: 5,
Skill: 8
} }
};
lastSync = 'vor 2 Minuten';
loading = false; loading = false;
}
onMount(() => {
loadStats();
}); });
async function syncNow() { async function syncNow() {
loading = true; loading = true;
// TODO: Mit claude-db synchronisieren await loadStats();
await new Promise((r) => setTimeout(r, 1000));
lastSync = 'gerade eben';
loading = false;
} }
</script> </script>

View file

@ -0,0 +1,344 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { messages, clearAll, isProcessing } from '$lib/stores/app';
interface Session {
id: string;
claude_session_id: string | null;
title: string;
working_dir: string | null;
message_count: number;
token_input: number;
token_output: number;
cost_usd: number;
status: string;
created_at: string;
updated_at: string;
last_message: string | null;
}
let sessions: Session[] = [];
let activeSessionId: string | null = null;
let loading = true;
let showNewForm = false;
let newTitle = '';
async function loadSessions() {
try {
sessions = await invoke('list_sessions', { limit: 50 });
const active: Session | null = await invoke('get_active_session');
activeSessionId = active?.id || null;
} catch (err) {
console.error('Fehler beim Laden der Sessions:', err);
}
loading = false;
}
onMount(() => {
loadSessions();
});
async function createSession() {
const title = newTitle.trim() || `Session ${new Date().toLocaleDateString('de-DE')}`;
try {
const session: Session = await invoke('create_session', {
title,
workingDir: null,
});
activeSessionId = session.id;
clearAll();
newTitle = '';
showNewForm = false;
await loadSessions();
} catch (err) {
console.error('Fehler:', err);
}
}
async function resumeSession(id: string) {
if (id === activeSessionId) return;
try {
const session: Session = await invoke('resume_session', { id });
activeSessionId = session.id;
clearAll();
// TODO: Nachrichten aus Session-Historie laden
} catch (err) {
console.error('Fehler:', err);
}
}
async function deleteSession(id: string) {
try {
await invoke('delete_session', { id });
if (activeSessionId === id) {
activeSessionId = null;
clearAll();
}
await loadSessions();
} catch (err) {
console.error('Fehler:', err);
}
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return 'gerade eben';
if (diff < 3600000) return `vor ${Math.floor(diff / 60000)} Min`;
if (diff < 86400000) return `vor ${Math.floor(diff / 3600000)} Std`;
if (diff < 172800000) return 'gestern';
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
function formatCost(usd: number): string {
if (usd === 0) return '';
return `$${usd.toFixed(3)}`;
}
</script>
<div class="session-list">
<div class="session-header">
<h2>💬 Sessions</h2>
<button class="btn-new" on:click={() => showNewForm = !showNewForm}>
{showNewForm ? '✕' : '+ Neu'}
</button>
</div>
{#if showNewForm}
<div class="new-form">
<input
type="text"
bind:value={newTitle}
placeholder="Session-Titel (optional)"
on:keydown={(e) => e.key === 'Enter' && createSession()}
/>
<button class="btn-create" on:click={createSession}>Erstellen</button>
</div>
{/if}
{#if loading}
<div class="loading">Lade Sessions...</div>
{:else if sessions.length === 0}
<div class="empty">
<p>Keine Sessions vorhanden.</p>
<button class="btn-first" on:click={() => { showNewForm = true; }}>
Erste Session starten
</button>
</div>
{:else}
<div class="sessions">
{#each sessions as session}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="session-item"
class:active={session.id === activeSessionId}
on:click={() => resumeSession(session.id)}
>
<div class="session-main">
<span class="session-status">
{#if session.id === activeSessionId}
🟢
{:else if session.claude_session_id}
⏸️
{:else}
{/if}
</span>
<div class="session-info">
<span class="session-title">{session.title}</span>
<span class="session-meta">
{session.message_count} Nachrichten
{#if session.cost_usd > 0}
· {formatCost(session.cost_usd)}
{/if}
</span>
</div>
</div>
<div class="session-right">
<span class="session-time">{formatDate(session.updated_at)}</span>
<button
class="btn-delete"
on:click|stopPropagation={() => deleteSession(session.id)}
title="Session löschen"
>
🗑️
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.session-list {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--border);
background: var(--bg-primary);
}
.session-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(--border);
}
.session-header h2 {
font-size: 0.85rem;
font-weight: 600;
}
.btn-new {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 600;
}
.btn-new:hover {
background: var(--accent-hover);
}
/* Neues Session Formular */
.new-form {
display: flex;
gap: var(--spacing-xs);
padding: var(--spacing-sm);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.new-form input {
flex: 1;
font-size: 0.75rem;
padding: var(--spacing-xs) var(--spacing-sm);
}
.btn-create {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--success);
color: white;
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 600;
}
.loading, .empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 0.8rem;
padding: var(--spacing-md);
text-align: center;
}
.btn-first {
margin-top: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--accent);
color: white;
border-radius: var(--radius-md);
font-size: 0.8rem;
}
/* Session-Items */
.sessions {
flex: 1;
overflow-y: auto;
}
.session-item {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--bg-tertiary);
transition: background 0.15s ease;
cursor: pointer;
}
.session-item:hover {
background: var(--bg-hover);
}
.session-item.active {
background: var(--bg-tertiary);
border-left: 3px solid var(--accent);
}
.session-main {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex: 1;
min-width: 0;
}
.session-status {
font-size: 0.6rem;
flex-shrink: 0;
}
.session-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.session-title {
font-size: 0.8rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
font-size: 0.65rem;
color: var(--text-secondary);
}
.session-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex-shrink: 0;
}
.session-time {
font-size: 0.6rem;
color: var(--text-secondary);
}
.btn-delete {
font-size: 0.65rem;
opacity: 0;
padding: 2px;
transition: opacity 0.15s;
}
.session-item:hover .btn-delete {
opacity: 0.5;
}
.btn-delete:hover {
opacity: 1 !important;
}
</style>

View file

@ -32,28 +32,28 @@
justify-content: center; justify-content: center;
gap: var(--spacing-md); gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg); padding: var(--spacing-md) var(--spacing-lg);
background: linear-gradient(135deg, #dc2626, #b91c1c); background: linear-gradient(135deg, var(--error), #c0392b);
color: white; color: white;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 2px solid #ef4444; border: 2px solid var(--error);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4); box-shadow: 0 4px 15px rgba(218, 68, 83, 0.3);
} }
.stop-button:not(.disabled):hover { .stop-button:not(.disabled):hover {
background: linear-gradient(135deg, #ef4444, #dc2626); background: linear-gradient(135deg, #e74c3c, var(--error));
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.5); box-shadow: 0 6px 20px rgba(218, 68, 83, 0.4);
} }
.stop-button:not(.disabled):active { .stop-button:not(.disabled):active {
transform: translateY(0); transform: translateY(0);
box-shadow: 0 2px 10px rgba(220, 38, 38, 0.4); box-shadow: 0 2px 10px rgba(218, 68, 83, 0.3);
} }
.stop-button.disabled { .stop-button.disabled {
@ -80,7 +80,7 @@
} }
@keyframes border-pulse { @keyframes border-pulse {
0%, 100% { border-color: #ef4444; } 0%, 100% { border-color: var(--error); }
50% { border-color: #fca5a5; } 50% { border-color: #e88; }
} }
</style> </style>

View file

@ -161,8 +161,8 @@
} }
@keyframes glow { @keyframes glow {
0%, 100% { box-shadow: 0 -2px 10px rgba(233, 69, 96, 0.3); } 0%, 100% { box-shadow: 0 -2px 10px rgba(218, 68, 83, 0.2); }
50% { box-shadow: 0 -2px 20px rgba(233, 69, 96, 0.6); } 50% { box-shadow: 0 -2px 20px rgba(218, 68, 83, 0.4); }
} }
.footer-stats { .footer-stats {

View file

@ -1,96 +1,174 @@
<script lang="ts"> <script lang="ts">
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import SessionList from '$lib/components/SessionList.svelte';
import ChatPanel from '$lib/components/ChatPanel.svelte'; import ChatPanel from '$lib/components/ChatPanel.svelte';
import ActivityPanel from '$lib/components/ActivityPanel.svelte'; import ActivityPanel from '$lib/components/ActivityPanel.svelte';
import AgentView from '$lib/components/AgentView.svelte'; import AgentView from '$lib/components/AgentView.svelte';
import MemoryPanel from '$lib/components/MemoryPanel.svelte';
import AuditLog from '$lib/components/AuditLog.svelte';
import GuardRailsPanel from '$lib/components/GuardRailsPanel.svelte';
let activeMiddleTab = 'activity';
let activeRightTab = 'agents';
const middleTabs = [
{ id: 'activity', label: 'Aktivität', icon: '📋' },
{ id: 'memory', label: 'Gedächtnis', icon: '🧠' },
{ id: 'audit', label: 'Historie', icon: '📝' },
];
const rightTabs = [
{ id: 'agents', label: 'Agents', icon: '🤖' },
{ id: 'guards', label: 'Guard-Rails', icon: '🛡️' },
];
</script> </script>
<div class="panels"> <PaneGroup direction="horizontal" autoSaveId="claude-desktop-panels" class="pane-group">
<!-- Linkes Panel: Chat --> <!-- Sessions -->
<section class="panel panel-chat"> <Pane defaultSize={15} minSize={8} maxSize={30} class="panel">
<ChatPanel /> <SessionList />
</section> </Pane>
<!-- Mittleres Panel: Aktivität + Agents --> <PaneResizer class="resizer">
<section class="panel panel-activity"> <div class="resizer-line"></div>
</PaneResizer>
<!-- Chat -->
<Pane defaultSize={35} minSize={15} class="panel">
<ChatPanel />
</Pane>
<PaneResizer class="resizer">
<div class="resizer-line"></div>
</PaneResizer>
<!-- Aktivität / Memory / Audit -->
<Pane defaultSize={25} minSize={10} class="panel">
<div class="panel-tabs"> <div class="panel-tabs">
<button class="tab active">📋 Aktivität</button> {#each middleTabs as tab}
<button class="tab">🤖 Agents</button> <button
class="tab"
class:active={activeMiddleTab === tab.id}
on:click={() => activeMiddleTab = tab.id}
>
{tab.icon} {tab.label}
</button>
{/each}
</div> </div>
<div class="panel-content"> <div class="panel-content">
{#if activeMiddleTab === 'activity'}
<ActivityPanel /> <ActivityPanel />
{:else if activeMiddleTab === 'memory'}
<MemoryPanel />
{:else if activeMiddleTab === 'audit'}
<AuditLog />
{/if}
</div> </div>
</section> </Pane>
<!-- Rechtes Panel: Agent-Details / Präsentation --> <PaneResizer class="resizer">
<section class="panel panel-details"> <div class="resizer-line"></div>
</PaneResizer>
<!-- Agents / Guard-Rails -->
<Pane defaultSize={25} minSize={10} class="panel">
<div class="panel-tabs">
{#each rightTabs as tab}
<button
class="tab"
class:active={activeRightTab === tab.id}
on:click={() => activeRightTab = tab.id}
>
{tab.icon} {tab.label}
</button>
{/each}
</div>
<div class="panel-content">
{#if activeRightTab === 'agents'}
<AgentView /> <AgentView />
</section> {:else if activeRightTab === 'guards'}
</div> <GuardRailsPanel />
{/if}
</div>
</Pane>
</PaneGroup>
<style> <style>
.panels { /* PaneForge Container */
display: grid; :global(.pane-group) {
grid-template-columns: 1fr 1fr 1fr;
gap: 1px;
height: 100%; height: 100%;
background: var(--bg-tertiary);
} }
.panel { :global(.panel) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-primary); background: var(--bg-primary);
overflow: hidden; overflow: hidden;
} }
/* Resizer Handle */
:global(.resizer) {
width: 8px;
background: var(--border);
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
transition: background 0.15s ease;
}
:global(.resizer:hover),
:global(.resizer[data-state="drag"]) {
background: var(--accent);
}
.resizer-line {
width: 2px;
height: 24px;
border-radius: 1px;
background: var(--text-secondary);
opacity: 0;
transition: opacity 0.15s;
}
:global(.resizer:hover) .resizer-line,
:global(.resizer[data-state="drag"]) .resizer-line {
opacity: 0.5;
}
/* Tabs */
.panel-tabs { .panel-tabs {
display: flex; display: flex;
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--bg-tertiary); border-bottom: 1px solid var(--border);
flex-shrink: 0;
} }
.tab { .tab {
flex: 1; flex: 1;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm);
font-size: 0.875rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 2px solid transparent;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.15s ease;
white-space: nowrap;
} }
.tab:hover { .tab:hover {
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-tertiary); background: var(--bg-hover);
} }
.tab.active { .tab.active {
color: var(--accent); color: var(--accent);
border-bottom: 2px solid var(--accent); border-bottom-color: var(--accent);
} }
.panel-content { .panel-content {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
} }
/* Responsive: Auf kleineren Bildschirmen stapeln */
@media (max-width: 1200px) {
.panels {
grid-template-columns: 1fr 1fr;
}
.panel-details {
display: none;
}
}
@media (max-width: 800px) {
.panels {
grid-template-columns: 1fr;
}
.panel-activity {
display: none;
}
}
</style> </style>