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>
This commit is contained in:
parent
532c91c605
commit
f101661016
6 changed files with 716 additions and 14 deletions
|
|
@ -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" => {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,23 @@ use crate::audit::{AuditAction, AuditCategory, AuditEntry, AuditStats};
|
||||||
use crate::guard::{Permission, PermissionAction, PermissionType};
|
use crate::guard::{Permission, PermissionAction, PermissionType};
|
||||||
use crate::memory::{ContextCategory, MemoryEntry, Pattern};
|
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
|
/// Datenbank-Wrapper
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
|
|
@ -100,6 +117,24 @@ impl Database {
|
||||||
updated_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)
|
-- Einstellungen (Key-Value)
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
|
|
@ -344,6 +379,119 @@ impl Database {
|
||||||
Ok(patterns)
|
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 ============
|
// ============ Settings ============
|
||||||
|
|
||||||
/// Speichert eine Einstellung
|
/// Speichert eine Einstellung
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ mod claude;
|
||||||
mod db;
|
mod db;
|
||||||
mod guard;
|
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)]
|
||||||
|
|
@ -40,6 +41,15 @@ pub fn run() {
|
||||||
// Datenbank
|
// Datenbank
|
||||||
db::init_database,
|
db::init_database,
|
||||||
db::get_db_stats,
|
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();
|
||||||
|
|
|
||||||
155
src-tauri/src/session.rs
Normal file
155
src-tauri/src/session.rs
Normal 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(())
|
||||||
|
}
|
||||||
344
src/lib/components/SessionList.svelte
Normal file
344
src/lib/components/SessionList.svelte
Normal 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>
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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';
|
||||||
|
|
@ -25,12 +26,17 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panels">
|
<div class="panels">
|
||||||
<!-- Linkes Panel: Chat -->
|
<!-- Session-Sidebar (links) -->
|
||||||
|
<aside class="panel panel-sessions">
|
||||||
|
<SessionList />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Chat (Mitte-Links) -->
|
||||||
<section class="panel panel-chat">
|
<section class="panel panel-chat">
|
||||||
<ChatPanel />
|
<ChatPanel />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Mittleres Panel: Aktivität / Memory / Audit -->
|
<!-- Aktivität / Memory / Audit (Mitte-Rechts) -->
|
||||||
<section class="panel panel-activity">
|
<section class="panel panel-activity">
|
||||||
<div class="panel-tabs">
|
<div class="panel-tabs">
|
||||||
{#each middleTabs as tab}
|
{#each middleTabs as tab}
|
||||||
|
|
@ -54,7 +60,7 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Rechtes Panel: Agents / Guard-Rails -->
|
<!-- Agents / Guard-Rails (rechts) -->
|
||||||
<section class="panel panel-details">
|
<section class="panel panel-details">
|
||||||
<div class="panel-tabs">
|
<div class="panel-tabs">
|
||||||
{#each rightTabs as tab}
|
{#each rightTabs as tab}
|
||||||
|
|
@ -80,10 +86,10 @@
|
||||||
<style>
|
<style>
|
||||||
.panels {
|
.panels {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 220px 1fr 1fr 1fr;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg-tertiary);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
|
@ -93,28 +99,32 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-sessions {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-sm);
|
||||||
font-size: 0.8rem;
|
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;
|
border-bottom: 2px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.15s ease;
|
||||||
white-space: nowrap;
|
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 {
|
||||||
|
|
@ -128,21 +138,30 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1400px) {
|
||||||
.panels {
|
.panels {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 200px 1fr 1fr;
|
||||||
}
|
}
|
||||||
.panel-details {
|
.panel-details {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 1000px) {
|
||||||
.panels {
|
.panels {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 180px 1fr;
|
||||||
}
|
}
|
||||||
.panel-activity {
|
.panel-activity {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.panel-sessions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue