Session-Historie: Nachrichten werden persistent gespeichert

- Neue messages-Tabelle in SQLite für Chat-Nachrichten
- save_message, load_messages, clear_messages Tauri-Commands
- User-Nachrichten werden beim Senden sofort gespeichert
- Assistant-Nachrichten werden nach Abschluss gespeichert
- Beim Session-Wechsel werden Nachrichten aus DB geladen
- currentSessionId Store für Session-Tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-14 10:35:04 +02:00
parent 433e2de2b6
commit 4ba14a53e1
6 changed files with 250 additions and 12 deletions

View file

@ -27,6 +27,17 @@ pub struct Session {
pub last_message: Option<String>,
}
/// Eine Chat-Nachricht
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ChatMessage {
pub id: String,
pub session_id: String,
pub role: String, // "user", "assistant", "system"
pub content: String,
pub model: Option<String>,
pub timestamp: String,
}
/// Datenbank-Wrapper
pub struct Database {
conn: Connection,
@ -141,6 +152,18 @@ impl Database {
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Chat-Nachrichten
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
model TEXT,
timestamp TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
",
)?;
Ok(())
@ -488,10 +511,58 @@ impl Database {
/// Löscht eine Session
pub fn delete_session(&self, id: &str) -> SqlResult<()> {
// Erst Nachrichten löschen (wegen Foreign Key)
self.conn.execute("DELETE FROM messages WHERE session_id = ?1", params![id])?;
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])?;
Ok(())
}
// ============ Messages ============
/// Speichert eine Nachricht
pub fn save_message(&self, msg: &ChatMessage) -> SqlResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO messages (id, session_id, role, content, model, timestamp)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
msg.id,
msg.session_id,
msg.role,
msg.content,
msg.model,
msg.timestamp,
],
)?;
Ok(())
}
/// Lädt alle Nachrichten einer Session
pub fn load_messages(&self, session_id: &str) -> SqlResult<Vec<ChatMessage>> {
let mut stmt = self.conn.prepare(
"SELECT id, session_id, role, content, model, timestamp
FROM messages WHERE session_id = ?1 ORDER BY timestamp ASC"
)?;
let messages = stmt.query_map(params![session_id], |row| {
Ok(ChatMessage {
id: row.get(0)?,
session_id: row.get(1)?,
role: row.get(2)?,
content: row.get(3)?,
model: row.get(4)?,
timestamp: row.get(5)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(messages)
}
/// Löscht alle Nachrichten einer Session
pub fn clear_messages(&self, session_id: &str) -> SqlResult<()> {
self.conn.execute("DELETE FROM messages WHERE session_id = ?1", params![session_id])?;
Ok(())
}
// ============ Settings ============
/// Speichert eine Einstellung
@ -637,3 +708,27 @@ pub async fn get_all_settings(app: AppHandle) -> Result<Vec<(String, String)>, S
let db = state.lock().unwrap();
db.get_all_settings().map_err(|e| e.to_string())
}
/// Nachricht speichern
#[tauri::command]
pub async fn save_message(app: AppHandle, message: ChatMessage) -> Result<(), String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.save_message(&message).map_err(|e| e.to_string())
}
/// Nachrichten einer Session laden
#[tauri::command]
pub async fn load_messages(app: AppHandle, session_id: String) -> Result<Vec<ChatMessage>, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.load_messages(&session_id).map_err(|e| e.to_string())
}
/// Alle Nachrichten einer Session löschen
#[tauri::command]
pub async fn clear_messages(app: AppHandle, session_id: String) -> Result<(), String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.clear_messages(&session_id).map_err(|e| e.to_string())
}

View file

@ -57,6 +57,10 @@ pub fn run() {
session::resume_session,
session::get_active_session,
session::set_claude_session_id,
// Messages
db::save_message,
db::load_messages,
db::clear_messages,
])
.setup(|app| {
let handle = app.handle().clone();

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { messages, currentInput, isProcessing, addMessage } from '$lib/stores/app';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, type Message } from '$lib/stores/app';
import { marked } from 'marked';
import { tick } from 'svelte';
import { tick, onDestroy } from 'svelte';
import { get } from 'svelte/store';
marked.setOptions({ breaks: true, gfm: true });
@ -25,11 +26,56 @@
$: if ($messages.length) scrollToBottom();
// Nachricht in DB speichern
async function saveMessageToDb(msg: Message) {
const sessionId = get(currentSessionId);
if (!sessionId) return;
try {
const dbMsg = messageToDb(msg, sessionId);
await invoke('save_message', { message: dbMsg });
} catch (err) {
console.error('Fehler beim Speichern der Nachricht:', err);
}
}
// Neue Nachrichten automatisch speichern
let lastMessageCount = 0;
const unsubscribe = messages.subscribe(async (msgs) => {
if (msgs.length > lastMessageCount && lastMessageCount > 0) {
// Neue Nachricht(en) hinzugefügt
const newMessages = msgs.slice(lastMessageCount);
for (const msg of newMessages) {
// Nur speichern wenn Nachricht Content hat (nicht die leere Streaming-Nachricht)
if (msg.content && msg.content.trim()) {
await saveMessageToDb(msg);
}
}
}
lastMessageCount = msgs.length;
});
onDestroy(() => {
unsubscribe();
});
async function sendMessage() {
const text = $currentInput.trim();
if (!text || $isProcessing) return;
addMessage('user', text);
// Nachricht hinzufügen (wird durch den Store-Subscriber gespeichert)
const msgId = crypto.randomUUID();
const msg: Message = {
id: msgId,
role: 'user',
content: text,
timestamp: new Date(),
};
messages.update((msgs) => [...msgs, msg]);
// Sofort speichern (nicht auf Subscriber warten)
await saveMessageToDb(msg);
$currentInput = '';
$isProcessing = true;

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { messages, clearAll, isProcessing } from '$lib/stores/app';
import { messages, clearAll, isProcessing, currentSessionId, setMessagesFromDb, type DbMessage } from '$lib/stores/app';
interface Session {
id: string;
@ -29,12 +29,28 @@
sessions = await invoke('list_sessions', { limit: 50 });
const active: Session | null = await invoke('get_active_session');
activeSessionId = active?.id || null;
$currentSessionId = activeSessionId;
// Wenn aktive Session existiert, Nachrichten laden
if (activeSessionId) {
await loadSessionMessages(activeSessionId);
}
} catch (err) {
console.error('Fehler beim Laden der Sessions:', err);
}
loading = false;
}
async function loadSessionMessages(sessionId: string) {
try {
const dbMessages: DbMessage[] = await invoke('load_messages', { sessionId });
setMessagesFromDb(dbMessages);
console.log(`📨 ${dbMessages.length} Nachrichten geladen`);
} catch (err) {
console.error('Fehler beim Laden der Nachrichten:', err);
}
}
onMount(() => {
loadSessions();
});
@ -47,6 +63,7 @@
workingDir: null,
});
activeSessionId = session.id;
$currentSessionId = session.id;
clearAll();
newTitle = '';
showNewForm = false;
@ -61,8 +78,10 @@
try {
const session: Session = await invoke('resume_session', { id });
activeSessionId = session.id;
$currentSessionId = session.id;
clearAll();
// TODO: Nachrichten aus Session-Historie laden
// Nachrichten aus DB laden
await loadSessionMessages(session.id);
} catch (err) {
console.error('Fehler:', err);
}

View file

@ -51,6 +51,7 @@ export const isProcessing = writable(false);
export const currentInput = writable('');
export const selectedAgentId = writable<string | null>(null);
export const currentModel = writable('');
export const currentSessionId = writable<string | null>(null);
// Session-Statistiken (kumuliert)
export const sessionStats = writable({
@ -156,3 +157,41 @@ export function clearAll() {
messages.set([]);
isProcessing.set(false);
}
// DB-Nachricht Format (für Tauri)
export interface DbMessage {
id: string;
session_id: string;
role: string;
content: string;
model: string | null;
timestamp: string;
}
// Konvertierung: Store → DB
export function messageToDb(msg: Message, sessionId: string): DbMessage {
return {
id: msg.id,
session_id: sessionId,
role: msg.role,
content: msg.content,
model: msg.model || null,
timestamp: msg.timestamp.toISOString(),
};
}
// Konvertierung: DB → Store
export function dbToMessage(db: DbMessage): Message {
return {
id: db.id,
role: db.role as Message['role'],
content: db.content,
model: db.model || undefined,
timestamp: new Date(db.timestamp),
};
}
// Nachrichten aus DB in Store laden
export function setMessagesFromDb(dbMessages: DbMessage[]) {
messages.set(dbMessages.map(dbToMessage));
}

View file

@ -2,6 +2,8 @@
// Empfängt Events vom Tauri-Backend und aktualisiert die Stores
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { get } from 'svelte/store';
import {
agents,
toolCalls,
@ -14,7 +16,10 @@ import {
completeToolCall,
clearAll,
currentModel,
sessionStats
sessionStats,
currentSessionId,
messageToDb,
type Message
} from './app';
// Event-Typen vom Backend
@ -53,6 +58,20 @@ let listeners: UnlistenFn[] = [];
// Streaming: ID der aktuellen Live-Nachricht
let streamingMessageId: string | null = null;
// Nachricht in DB speichern
async function saveMessageToDb(msg: Message) {
const sessionId = get(currentSessionId);
if (!sessionId) return;
try {
const dbMsg = messageToDb(msg, sessionId);
await invoke('save_message', { message: dbMsg });
console.log('💾 Nachricht gespeichert:', msg.role);
} catch (err) {
console.error('Fehler beim Speichern der Nachricht:', err);
}
}
// Events initialisieren
export async function initEventListeners(): Promise<void> {
console.log('🎧 Initialisiere Event-Listener...');
@ -161,15 +180,31 @@ export async function initEventListeners(): Promise<void> {
// Ergebnis (Kosten, Token, Modell)
listeners.push(
await listen<ResultEvent>('claude-result', (event) => {
await listen<ResultEvent>('claude-result', async (event) => {
const { cost, tokens, session_id, model } = event.payload;
console.log('📊 Ergebnis:', { cost: cost ? `$${cost.toFixed(4)}` : '-', tokens, model });
// Modell an die Streaming-Nachricht anhängen
if (model && streamingMessageId) {
messages.update((msgs) =>
msgs.map((m) => m.id === streamingMessageId ? { ...m, model } : m)
);
// Modell an die Streaming-Nachricht anhängen und speichern
if (streamingMessageId) {
let finalMessage: Message | null = null;
messages.update((msgs) => {
return msgs.map((m) => {
if (m.id === streamingMessageId) {
finalMessage = { ...m, model: model || m.model };
return finalMessage;
}
return m;
});
});
// Nachricht in DB speichern (nur wenn Content vorhanden)
if (finalMessage && finalMessage.content && finalMessage.content.trim()) {
await saveMessageToDb(finalMessage);
}
}
if (model) {
currentModel.set(model);
}