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:
parent
433e2de2b6
commit
4ba14a53e1
6 changed files with 250 additions and 12 deletions
|
|
@ -27,6 +27,17 @@ pub struct Session {
|
||||||
pub last_message: Option<String>,
|
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
|
/// Datenbank-Wrapper
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
|
|
@ -141,6 +152,18 @@ impl Database {
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
updated_at 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(())
|
Ok(())
|
||||||
|
|
@ -488,10 +511,58 @@ impl Database {
|
||||||
|
|
||||||
/// Löscht eine Session
|
/// Löscht eine Session
|
||||||
pub fn delete_session(&self, id: &str) -> SqlResult<()> {
|
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])?;
|
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])?;
|
||||||
Ok(())
|
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 ============
|
// ============ Settings ============
|
||||||
|
|
||||||
/// Speichert eine Einstellung
|
/// 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();
|
let db = state.lock().unwrap();
|
||||||
db.get_all_settings().map_err(|e| e.to_string())
|
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())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ pub fn run() {
|
||||||
session::resume_session,
|
session::resume_session,
|
||||||
session::get_active_session,
|
session::get_active_session,
|
||||||
session::set_claude_session_id,
|
session::set_claude_session_id,
|
||||||
|
// Messages
|
||||||
|
db::save_message,
|
||||||
|
db::load_messages,
|
||||||
|
db::clear_messages,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<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, currentSessionId, messageToDb, type Message } from '$lib/stores/app';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { tick } from 'svelte';
|
import { tick, onDestroy } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
marked.setOptions({ breaks: true, gfm: true });
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
|
|
||||||
|
|
@ -25,11 +26,56 @@
|
||||||
|
|
||||||
$: if ($messages.length) scrollToBottom();
|
$: 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() {
|
async function sendMessage() {
|
||||||
const text = $currentInput.trim();
|
const text = $currentInput.trim();
|
||||||
if (!text || $isProcessing) return;
|
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 = '';
|
$currentInput = '';
|
||||||
$isProcessing = true;
|
$isProcessing = true;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
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 {
|
interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -29,12 +29,28 @@
|
||||||
sessions = await invoke('list_sessions', { limit: 50 });
|
sessions = await invoke('list_sessions', { limit: 50 });
|
||||||
const active: Session | null = await invoke('get_active_session');
|
const active: Session | null = await invoke('get_active_session');
|
||||||
activeSessionId = active?.id || null;
|
activeSessionId = active?.id || null;
|
||||||
|
$currentSessionId = activeSessionId;
|
||||||
|
|
||||||
|
// Wenn aktive Session existiert, Nachrichten laden
|
||||||
|
if (activeSessionId) {
|
||||||
|
await loadSessionMessages(activeSessionId);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Laden der Sessions:', err);
|
console.error('Fehler beim Laden der Sessions:', err);
|
||||||
}
|
}
|
||||||
loading = false;
|
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(() => {
|
onMount(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
});
|
});
|
||||||
|
|
@ -47,6 +63,7 @@
|
||||||
workingDir: null,
|
workingDir: null,
|
||||||
});
|
});
|
||||||
activeSessionId = session.id;
|
activeSessionId = session.id;
|
||||||
|
$currentSessionId = session.id;
|
||||||
clearAll();
|
clearAll();
|
||||||
newTitle = '';
|
newTitle = '';
|
||||||
showNewForm = false;
|
showNewForm = false;
|
||||||
|
|
@ -61,8 +78,10 @@
|
||||||
try {
|
try {
|
||||||
const session: Session = await invoke('resume_session', { id });
|
const session: Session = await invoke('resume_session', { id });
|
||||||
activeSessionId = session.id;
|
activeSessionId = session.id;
|
||||||
|
$currentSessionId = session.id;
|
||||||
clearAll();
|
clearAll();
|
||||||
// TODO: Nachrichten aus Session-Historie laden
|
// Nachrichten aus DB laden
|
||||||
|
await loadSessionMessages(session.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler:', err);
|
console.error('Fehler:', err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export const isProcessing = writable(false);
|
||||||
export const currentInput = writable('');
|
export const currentInput = writable('');
|
||||||
export const selectedAgentId = writable<string | null>(null);
|
export const selectedAgentId = writable<string | null>(null);
|
||||||
export const currentModel = writable('');
|
export const currentModel = writable('');
|
||||||
|
export const currentSessionId = writable<string | null>(null);
|
||||||
|
|
||||||
// Session-Statistiken (kumuliert)
|
// Session-Statistiken (kumuliert)
|
||||||
export const sessionStats = writable({
|
export const sessionStats = writable({
|
||||||
|
|
@ -156,3 +157,41 @@ export function clearAll() {
|
||||||
messages.set([]);
|
messages.set([]);
|
||||||
isProcessing.set(false);
|
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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
// Empfängt Events vom Tauri-Backend und aktualisiert die Stores
|
// Empfängt Events vom Tauri-Backend und aktualisiert die Stores
|
||||||
|
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import {
|
import {
|
||||||
agents,
|
agents,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
|
@ -14,7 +16,10 @@ import {
|
||||||
completeToolCall,
|
completeToolCall,
|
||||||
clearAll,
|
clearAll,
|
||||||
currentModel,
|
currentModel,
|
||||||
sessionStats
|
sessionStats,
|
||||||
|
currentSessionId,
|
||||||
|
messageToDb,
|
||||||
|
type Message
|
||||||
} from './app';
|
} from './app';
|
||||||
|
|
||||||
// Event-Typen vom Backend
|
// Event-Typen vom Backend
|
||||||
|
|
@ -53,6 +58,20 @@ let listeners: UnlistenFn[] = [];
|
||||||
// Streaming: ID der aktuellen Live-Nachricht
|
// Streaming: ID der aktuellen Live-Nachricht
|
||||||
let streamingMessageId: string | null = null;
|
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
|
// Events initialisieren
|
||||||
export async function initEventListeners(): Promise<void> {
|
export async function initEventListeners(): Promise<void> {
|
||||||
console.log('🎧 Initialisiere Event-Listener...');
|
console.log('🎧 Initialisiere Event-Listener...');
|
||||||
|
|
@ -161,15 +180,31 @@ export async function initEventListeners(): Promise<void> {
|
||||||
|
|
||||||
// Ergebnis (Kosten, Token, Modell)
|
// Ergebnis (Kosten, Token, Modell)
|
||||||
listeners.push(
|
listeners.push(
|
||||||
await listen<ResultEvent>('claude-result', (event) => {
|
await listen<ResultEvent>('claude-result', async (event) => {
|
||||||
const { cost, tokens, session_id, model } = event.payload;
|
const { cost, tokens, session_id, model } = event.payload;
|
||||||
console.log('📊 Ergebnis:', { cost: cost ? `$${cost.toFixed(4)}` : '-', tokens, model });
|
console.log('📊 Ergebnis:', { cost: cost ? `$${cost.toFixed(4)}` : '-', tokens, model });
|
||||||
|
|
||||||
// Modell an die Streaming-Nachricht anhängen
|
// Modell an die Streaming-Nachricht anhängen und speichern
|
||||||
if (model && streamingMessageId) {
|
if (streamingMessageId) {
|
||||||
messages.update((msgs) =>
|
let finalMessage: Message | null = null;
|
||||||
msgs.map((m) => m.id === streamingMessageId ? { ...m, model } : m)
|
|
||||||
);
|
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);
|
currentModel.set(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue