diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index e38a11e..2ebf0c9 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -39,6 +39,17 @@ pub struct ChatMessage { pub timestamp: String, } +/// Block C: Suchresultat fuer Cross-Session-Recall (FTS5 auf messages) +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PastMessageMatch { + pub id: String, + pub session_id: String, + pub role: String, + pub snippet: String, // bis zu 240 Zeichen Anriss + pub timestamp: String, + pub session_title: Option, +} + /// Ein Monitor-Event (System-Log) #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MonitorEvent { @@ -195,6 +206,26 @@ impl Database { ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp); + -- Block C: FTS5-Volltextsuche fuer Cross-Session-Recall. + -- Externer content-Modus: kein doppelter Speicher, FTS hat nur + -- die rowid + indizierten Felder. Sync via Trigger. + CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + content, + tokenize='unicode61 remove_diacritics 2' + ); + CREATE TRIGGER IF NOT EXISTS messages_fts_ai + AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content); + END; + CREATE TRIGGER IF NOT EXISTS messages_fts_ad + AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + END; + CREATE TRIGGER IF NOT EXISTS messages_fts_au + AFTER UPDATE ON messages BEGIN + UPDATE messages_fts SET content = new.content WHERE rowid = old.rowid; + END; + -- Monitor-Events (System-Log) CREATE TABLE IF NOT EXISTS monitor_events ( id TEXT PRIMARY KEY, @@ -717,6 +748,86 @@ impl Database { Ok(()) } + /// Block C: Cross-Session-Recall — Volltext-Suche ueber alle Sessions + /// liefert die top-N relevanten Assistant-Antworten aus der Vergangenheit. + /// Nutzt FTS5-Index `messages_fts`. Der current_session_id wird ausgeschlossen + /// damit das Recall sich nicht selbst trifft. + pub fn search_past_messages( + &self, + query: &str, + current_session_id: Option<&str>, + limit: usize, + ) -> SqlResult> { + // FTS5-Query: bei leerem Query nichts. Auch sehr kurze Queries (<3 Zeichen) + // ueberspringen — bringen nur Rauschen. + if query.trim().len() < 3 { + return Ok(Vec::new()); + } + + // Sanitize: FTS5 mag keine ' und " in der Query + let safe = query + .replace('"', " ") + .replace('\'', " ") + .split_whitespace() + .filter(|w| w.len() >= 2) + .take(8) + .collect::>() + .join(" OR "); + if safe.is_empty() { + return Ok(Vec::new()); + } + + let mut stmt = self.conn.prepare( + "SELECT m.id, m.session_id, m.role, m.content, m.timestamp, s.title + FROM messages_fts + JOIN messages m ON m.rowid = messages_fts.rowid + LEFT JOIN sessions s ON s.id = m.session_id + WHERE messages_fts MATCH ?1 + AND m.role = 'assistant' + AND length(m.content) > 100 + AND (?2 IS NULL OR m.session_id != ?2) + ORDER BY rank + LIMIT ?3" + )?; + + let rows = stmt.query_map( + params![safe, current_session_id, limit as i64], + |row| { + let content: String = row.get(3)?; + Ok(PastMessageMatch { + id: row.get(0)?, + session_id: row.get(1)?, + role: row.get(2)?, + snippet: if content.len() > 240 { format!("{}…", &content[..240]) } else { content }, + timestamp: row.get(4)?, + session_title: row.get(5)?, + }) + } + )?.collect::>>()?; + + Ok(rows) + } + + /// Einmalige Migration: bestehende messages in den FTS5-Index spielen + /// (falls die Tabelle vor Block C bereits Eintraege hatte). + pub fn rebuild_messages_fts(&self) -> SqlResult { + // Prefcheck: ist FTS leer aber messages voll? + let fts_count: i64 = self.conn + .query_row("SELECT COUNT(*) FROM messages_fts", [], |r| r.get(0)) + .unwrap_or(0); + let msg_count: i64 = self.conn + .query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))?; + if fts_count >= msg_count { return Ok(0); } + + self.conn.execute( + "INSERT INTO messages_fts(rowid, content) + SELECT rowid, content FROM messages + WHERE rowid NOT IN (SELECT rowid FROM messages_fts)", + [] + )?; + Ok((msg_count - fts_count) as usize) + } + /// Zählt Nachrichten einer Session pub fn count_messages(&self, session_id: &str) -> SqlResult { self.conn.query_row( @@ -1208,6 +1319,31 @@ pub async fn clear_messages(app: AppHandle, session_id: String) -> Result<(), St db.clear_messages(&session_id).map_err(|e| e.to_string()) } +/// Block C: Cross-Session-Recall — sucht in alten Sessions nach aehnlichen +/// Assistant-Antworten. Frontend ruft das beim ersten claude-text auf, damit +/// User sieht "🕒 Schon mal beantwortet" wenn er etwas Aehnliches frueher fragte. +#[tauri::command] +pub async fn search_past_messages( + app: AppHandle, + query: String, + current_session_id: Option, + limit: Option, +) -> Result, String> { + let state = app.state::(); + let db = state.lock().unwrap(); + db.search_past_messages(&query, current_session_id.as_deref(), limit.unwrap_or(3)) + .map_err(|e| e.to_string()) +} + +/// Block C: einmalige Migration — bestehende messages in den FTS5-Index spielen. +/// Frontend ruft das einmal beim App-Start auf (idempotent — tut nichts wenn schon synced). +#[tauri::command] +pub async fn rebuild_messages_fts(app: AppHandle) -> Result { + let state = app.state::(); + let db = state.lock().unwrap(); + db.rebuild_messages_fts().map_err(|e| e.to_string()) +} + /// Session kompaktieren — fasst alte Nachrichten zusammen #[tauri::command] pub async fn compact_session( diff --git a/src-tauri/src/knowledge.rs b/src-tauri/src/knowledge.rs index 107fedf..33ed7fb 100644 --- a/src-tauri/src/knowledge.rs +++ b/src-tauri/src/knowledge.rs @@ -399,6 +399,7 @@ async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &O ORDER BY CASE WHEN tags LIKE ? THEN 1 ELSE 0 END DESC, priority DESC, + usage_count DESC, relevance DESC LIMIT ?"#, (search_query, search_query, &proj_pattern, fetch_limit), @@ -413,7 +414,7 @@ async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &O FROM knowledge WHERE status = 'active' AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) - ORDER BY priority DESC, relevance DESC + ORDER BY priority DESC, usage_count DESC, relevance DESC LIMIT ?"#, (search_query, search_query, fetch_limit), ).await.map_err(|e| e.to_string())? @@ -522,7 +523,7 @@ pub async fn search_knowledge_by_query(search_query: &str, limit: i32) -> Result FROM knowledge WHERE status = 'active' AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) - ORDER BY priority DESC, relevance DESC + ORDER BY priority DESC, usage_count DESC, relevance DESC LIMIT ?"#, (search_query, search_query, limit), ).await.map_err(|e| e.to_string())?; @@ -684,7 +685,7 @@ pub async fn search_knowledge( WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) AND category = ? AND status = 'active' - ORDER BY priority DESC, relevance DESC + ORDER BY priority DESC, usage_count DESC, relevance DESC LIMIT ?"#, (&query, &query, &cat, limit), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance): @@ -709,7 +710,7 @@ pub async fn search_knowledge( FROM knowledge WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) AND status = 'active' - ORDER BY priority DESC, relevance DESC + ORDER BY priority DESC, usage_count DESC, relevance DESC LIMIT ?"#, (&query, &query, limit), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance): @@ -1086,3 +1087,19 @@ pub async fn get_kb_session_status() -> Result { topic.last_project.as_deref().unwrap_or("none") )) } + +/// Block B: Usage-Tracking — markiert einen KB-Eintrag als hilfreich/genutzt. +/// Wird beim Tool-Erfolg (tool-end mit success=true) aufgerufen fuer alle Hints +/// die zur laufenden Tool-Phase geladen wurden. Async, fire-and-forget. +#[tauri::command] +pub async fn track_knowledge_hit(id: i64) -> Result<(), String> { + let pool = create_pool(); + let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?; + conn.exec_drop( + "UPDATE knowledge SET usage_count = usage_count + 1, last_used = NOW() WHERE id = ?", + (id,), + ).await.map_err(|e| e.to_string())?; + drop(conn); + let _ = pool.disconnect().await; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39fb7f9..c6d4a3b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -109,6 +109,8 @@ pub fn run() { db::load_messages, db::clear_messages, db::compact_session, + db::search_past_messages, + db::rebuild_messages_fts, // Monitor-Events db::save_monitor_event, db::load_monitor_events, @@ -142,6 +144,8 @@ pub fn run() { // Phase 3.1: Smart Hints v2 — Session-Management knowledge::reset_kb_session, knowledge::get_kb_session_status, + // Block B: Usage-Tracking — markiert KB-Treffer als hilfreich + knowledge::track_knowledge_hit, // Context-Management context::get_sticky_context, context::set_sticky_context, diff --git a/src/app.css b/src/app.css index 41d6eb0..28b1d2b 100644 --- a/src/app.css +++ b/src/app.css @@ -164,6 +164,25 @@ input, textarea, select { transition: border-color var(--dur-fast) var(--ease); } +/* WebKitGTK ignoriert haeufig CSS-Background bei nativen updateChatAppearance({ fontFamily: (e.currentTarget as HTMLSelectElement).value })} + > + + + + + + +
+
+ Schriftgroesse + {$chatAppearance.fontSize} px +
+ updateChatAppearance({ fontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })} + /> +
+
+
+ Zeilenhoehe + {$chatAppearance.lineHeight.toFixed(2)} +
+ updateChatAppearance({ lineHeight: parseFloat((e.currentTarget as HTMLInputElement).value) })} + /> +
+
+
+ Code-Bloecke + Schriftgroesse fuer Code: {$chatAppearance.codeFontSize} px +
+ updateChatAppearance({ codeFontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })} + /> +
+ +
+
Vorschau
+

So sieht eine Nachricht aus. Die Schrift uebernimmt deine Einstellungen live.

+
const beispiel = "Code-Block";
+
+ +
+ + {DEFAULT_APPEARANCE.fontSize}px / {DEFAULT_APPEARANCE.lineHeight} / System +
+ + {/if} + {/if} + {#if activeCategory === 'model' || (searchQuery && availableModels.some(m => m.name.toLowerCase().includes(searchQuery.toLowerCase())))}
@@ -687,6 +760,52 @@ .setting-desc { font-size: 0.65rem; color: var(--text-secondary); } .setting-val { font-size: 0.8rem; color: var(--text-secondary); font-family: var(--font-mono); } + .setting-control { + min-width: 180px; + font-size: 0.8rem; + } + .setting-row input[type="range"] { + min-width: 180px; + max-width: 240px; + } + + .btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border, var(--bg-tertiary)); + border-radius: var(--radius-sm); + padding: 4px 12px; + font-size: 0.75rem; + cursor: pointer; + } + .btn-secondary:hover { + background: var(--bg-secondary); + } + + .appearance-preview { + margin-top: 12px; + padding: 12px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border, var(--bg-tertiary)); + border-radius: var(--radius-sm); + color: var(--text-primary); + } + .appearance-preview .preview-label { + font-size: 0.65rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; + } + .appearance-preview p { margin: 0 0 6px 0; } + .appearance-preview pre { + margin: 0; + padding: 6px 8px; + background: var(--bg-input); + border-radius: 3px; + overflow-x: auto; + } + /* Commands */ .command-list { display: flex; diff --git a/src/lib/components/ToolCallCard.svelte b/src/lib/components/ToolCallCard.svelte index e7cf276..4145e54 100644 --- a/src/lib/components/ToolCallCard.svelte +++ b/src/lib/components/ToolCallCard.svelte @@ -46,13 +46,16 @@ } -
+ +