From f1016610163a2154bf65ceafff031d6e0396c354 Mon Sep 17 00:00:00 2001 From: Eddy Date: Mon, 13 Apr 2026 19:11:17 +0200 Subject: [PATCH] Phase 5: Session-Verwaltung + permanente Konversationen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src-tauri/src/claude.rs | 26 ++ src-tauri/src/db.rs | 148 +++++++++++ src-tauri/src/lib.rs | 10 + src-tauri/src/session.rs | 155 ++++++++++++ src/lib/components/SessionList.svelte | 344 ++++++++++++++++++++++++++ src/routes/+page.svelte | 47 ++-- 6 files changed, 716 insertions(+), 14 deletions(-) create mode 100644 src-tauri/src/session.rs create mode 100644 src/lib/components/SessionList.svelte diff --git a/src-tauri/src/claude.rs b/src-tauri/src/claude.rs index bb9f86f..631542a 100644 --- a/src-tauri/src/claude.rs +++ b/src-tauri/src/claude.rs @@ -7,6 +7,8 @@ use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, Manager}; +use crate::db; + /// Status eines Agents #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStatus { @@ -176,6 +178,30 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) { let _ = app.emit("claude-text", &payload); } "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::>>() { + 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); } "all-stopped" => { diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 56803aa..2fe7483 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -10,6 +10,23 @@ 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, + pub title: String, + pub working_dir: Option, + 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, +} + /// Datenbank-Wrapper pub struct Database { conn: Connection, @@ -100,6 +117,24 @@ impl Database { 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, @@ -344,6 +379,119 @@ impl Database { 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> { + 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::>>()?; + + Ok(sessions) + } + + /// Holt eine Session nach ID + pub fn get_session(&self, id: &str) -> SqlResult> { + 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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c3d28a8..2a58a08 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod claude; mod db; mod guard; mod memory; +mod session; /// Initialisiert die App #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -40,6 +41,15 @@ pub fn run() { // 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| { let handle = app.handle().clone(); diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs new file mode 100644 index 0000000..dfaa1ce --- /dev/null +++ b/src-tauri/src/session.rs @@ -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, +) -> Result { + 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::>>(); + 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::>>(); + 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, +) -> Result, String> { + let limit = limit.unwrap_or(50); + let state = app.state::>>(); + 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, String> { + let state = app.state::>>(); + 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::>>(); + 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 { + let state = app.state::>>(); + 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, String> { + let state = app.state::>>(); + 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::>>(); + 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(()) +} diff --git a/src/lib/components/SessionList.svelte b/src/lib/components/SessionList.svelte new file mode 100644 index 0000000..a138ed6 --- /dev/null +++ b/src/lib/components/SessionList.svelte @@ -0,0 +1,344 @@ + + +
+
+

💬 Sessions

+ +
+ + {#if showNewForm} +
+ e.key === 'Enter' && createSession()} + /> + +
+ {/if} + + {#if loading} +
Lade Sessions...
+ {:else if sessions.length === 0} +
+

Keine Sessions vorhanden.

+ +
+ {:else} +
+ {#each sessions as session} + + +
resumeSession(session.id)} + > +
+ + {#if session.id === activeSessionId} + 🟢 + {:else if session.claude_session_id} + ⏸️ + {:else} + ⚪ + {/if} + +
+ {session.title} + + {session.message_count} Nachrichten + {#if session.cost_usd > 0} + · {formatCost(session.cost_usd)} + {/if} + +
+
+
+ {formatDate(session.updated_at)} + +
+
+ {/each} +
+ {/if} +
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6e90bc0..b75e3e0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,4 +1,5 @@
- + + + +
- +
{#each middleTabs as tab} @@ -54,7 +60,7 @@
- +
{#each rightTabs as tab} @@ -80,10 +86,10 @@