claude-desktop/src-tauri/src/knowledge.rs
Eddy fec8aea22c
All checks were successful
Build AppImage / build (push) Successful in 8m8s
feat: KB-Hints, Voice-Konversation, Chat-Darstellung, Cross-Session-Recall [appimage]
- Block A: KB-Hint-Pillen im Chat (💡) über Tool-Cards, Klick öffnet KB-Browser
- Block B: KB-Usage-Tracking (usage_count/last_used), Sortier-Boost für bewährte Einträge
- Block C: Cross-Session-Recall per SQLite-FTS5 (🕒 Pille "Schon mal beantwortet")
- Block D: Voice-Konversationsmodus (Langes Halten = Loop mit Barge-In-Unterbrechung)
- Block F: Select-Button im Audit-Log (appearance:none + SVG-Chevron, WebKitGTK-Fix)
- Block G: Chat-Darstellungseinstellungen (Schriftart, -größe, Zeilenhöhe, Code-Größe)
- WorkingIndicator: Deutsche Animationstexte beim Verarbeiten

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:54:58 +02:00

1105 lines
41 KiB
Rust

// Claude Desktop — Wissensbasis (claude-db)
// Direkte MySQL-Anbindung zur zentralen Wissensdatenbank
// Phase 2.0: MySQL Pool als Managed State + Themen-Erkennung
// Phase 3.1: Smart Hints v2 — Session-Context-Aware KB-Hints
use mysql_async::{Pool, prelude::*};
use serde::{Deserialize, Serialize};
use chrono::NaiveDateTime;
use std::sync::Arc;
use std::sync::Mutex;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager};
// ============ KB-Cache (RAM) ============
// Cached KB-Suchergebnisse im RAM. Spart MySQL-Roundtrip pro Nachricht.
// Invalidierung: automatisch nach 60 Sekunden (TTL).
struct CacheEntry {
result: String,
created_at: Instant,
}
struct KbCache {
entries: HashMap<String, CacheEntry>,
ttl: Duration,
}
impl KbCache {
fn new(ttl_secs: u64) -> Self {
Self {
entries: HashMap::new(),
ttl: Duration::from_secs(ttl_secs),
}
}
fn get(&self, key: &str) -> Option<&str> {
if let Some(entry) = self.entries.get(key) {
if entry.created_at.elapsed() < self.ttl {
return Some(&entry.result);
}
}
None
}
fn insert(&mut self, key: String, result: String) {
// Max 100 Eintraege — aelteste raus wenn voll
if self.entries.len() >= 100 {
self.evict_oldest();
}
self.entries.insert(key, CacheEntry { result, created_at: Instant::now() });
}
fn evict_oldest(&mut self) {
if let Some(oldest_key) = self.entries.iter()
.min_by_key(|(_, v)| v.created_at)
.map(|(k, _)| k.clone())
{
self.entries.remove(&oldest_key);
}
}
#[allow(dead_code)]
fn invalidate_expired(&mut self) {
self.entries.retain(|_, v| v.created_at.elapsed() < self.ttl);
}
}
// Globaler Cache — 60 Sekunden TTL
static KB_CACHE: std::sync::LazyLock<Mutex<KbCache>> =
std::sync::LazyLock::new(|| Mutex::new(KbCache::new(60)));
// ============ Smart Hints v2: Session Topic Tracking ============
// Akkumuliert Keywords über die Session hinweg, verhindert Wiederholungen,
// und liefert nur alle 3 Nachrichten frische Hints.
#[derive(Default)]
struct SessionTopic {
keywords: Vec<String>, // Alle Keywords der Session (gewichtet durch Reihenfolge)
shown_ids: Vec<i64>, // Bereits gezeigte KB-Einträge (nicht wiederholen!)
message_count: u32, // Anzahl Nachrichten seit Session-Start
last_project: Option<String>, // Erkanntes Projekt (z.B. "claude-desktop", "dolibarr")
}
static SESSION_TOPIC: std::sync::LazyLock<Mutex<SessionTopic>> =
std::sync::LazyLock::new(|| Mutex::new(SessionTopic::default()));
/// Session-Topic zurücksetzen — aufrufen wenn eine neue Chat-Session beginnt
pub fn reset_session_topic() {
let mut topic = SESSION_TOPIC.lock().unwrap();
*topic = SessionTopic::default();
println!("🔄 Session-Topic zurückgesetzt (Smart Hints v2)");
}
/// Erkennt das aktuelle Projekt aus Nachrichteninhalt oder Dateipfaden
fn detect_project(message: &str) -> Option<String> {
let lower = message.to_lowercase();
let projects: &[(&str, &[&str])] = &[
("claude-desktop", &["claudedesktop", "claude desktop", "claude-desktop", "tauri", "svelte", "bridge"]),
("dolibarr", &["dolibarr", "custom module", "awl.", "dolibarr_test", "produktkarte", "stockkonversion", "importzugferd"]),
("videokonverter", &["videokonverter", "vk-", "ffmpeg", "docker.videokonverter"]),
("nixos", &["nixos", "configuration.nix", "nix-shell", "nixos-rebuild"]),
("homeassistant", &["home assistant", "home-assistant", ".storage/", "lovelace"]),
("leckerbuch", &["leckerbuch", "rezept"]),
("elektroplan", &["elektroplan", "eplan"]),
("vde-katalog", &["vde katalog", "vde-katalog", "normen"]),
];
for (name, triggers) in projects {
if triggers.iter().any(|t| lower.contains(t)) {
return Some(name.to_string());
}
}
// Dateipfad-basierte Erkennung: /mnt/.../Projekte/<Name>/...
if let Some(idx) = lower.find("projekte/") {
let rest = &message[idx + 9..];
if let Some(end) = rest.find('/') {
let project_from_path = rest[..end].to_lowercase()
.replace(' ', "-");
if project_from_path.len() >= 3 {
return Some(project_from_path);
}
}
}
None
}
/// Verbindungskonfiguration — aus ENV-Variablen, Fallback auf Defaults
const MYSQL_PORT: u16 = 3306;
fn mysql_host() -> String { std::env::var("CLAUDE_MYSQL_HOST").unwrap_or_else(|_| "192.168.155.11".to_string()) }
fn mysql_user() -> String { std::env::var("CLAUDE_MYSQL_USER").unwrap_or_else(|_| "claude".to_string()) }
fn mysql_pass() -> String { std::env::var("CLAUDE_MYSQL_PASS").unwrap_or_else(|_| "8715".to_string()) }
fn mysql_db() -> String { std::env::var("CLAUDE_MYSQL_DB").unwrap_or_else(|_| "claude".to_string()) }
/// Managed MySQL Pool — wird einmal beim App-Start erstellt
/// Alle Knowledge-Funktionen nutzen diesen Pool statt jedes Mal einen neuen zu erstellen
pub type MysqlPoolState = Arc<Option<Pool>>;
/// Erstellt den globalen MySQL Pool (einmal beim App-Start aufrufen)
pub fn create_managed_pool() -> MysqlPoolState {
let url = format!(
"mysql://{}:{}@{}:{}/{}",
mysql_user(), mysql_pass(), mysql_host(), MYSQL_PORT, mysql_db()
);
match Pool::new(url.as_str()) {
pool => {
println!("🗄️ MySQL Pool erstellt ({}:{})", mysql_host(), MYSQL_PORT);
Arc::new(Some(pool))
}
}
}
/// Pool aus AppHandle holen — Fallback auf neuen Pool wenn State nicht verfügbar
/// TODO: Bestehende Commands schrittweise auf get_pool(app) migrieren
#[allow(dead_code)]
fn get_pool(app: Option<&AppHandle>) -> Pool {
if let Some(app) = app {
if let Some(pool_state) = app.try_state::<MysqlPoolState>() {
if let Some(pool) = pool_state.as_ref() {
return pool.clone();
}
}
}
// Fallback: neuen Pool erstellen (für Aufrufe ohne AppHandle, z.B. search_knowledge_internal)
create_pool()
}
/// Deutsche Stoppwörter die bei der Themen-Erkennung gefiltert werden
const STOP_WORDS: &[&str] = &[
"der", "die", "das", "den", "dem", "des", "ein", "eine", "einer", "einem", "einen",
"und", "oder", "aber", "doch", "noch", "auch", "nur", "schon", "mal", "dann",
"ist", "sind", "war", "hat", "haben", "wird", "werden", "kann", "können", "soll",
"muss", "müssen", "darf", "will", "wollen", "möchte", "würde", "sollte",
"ich", "du", "er", "sie", "es", "wir", "ihr", "mein", "dein", "sein",
"mit", "für", "auf", "von", "aus", "bei", "nach", "über", "unter", "vor",
"wie", "was", "wer", "wo", "wann", "warum", "wieso", "weshalb",
"nicht", "kein", "keine", "keinen", "keinem",
"this", "that", "the", "and", "for", "with", "from", "into",
"bitte", "danke", "okay", "alles", "nächste", "mach", "zeig", "gib",
"mir", "dir", "uns", "hier", "dort", "jetzt", "gerade", "einfach",
"phase", "feature", "erstelle", "implementiere", "baue",
];
/// Extrahiert relevante Keywords aus einer User-Nachricht
/// Filtert Stoppwörter und kurze Wörter raus, gibt die besten Suchbegriffe zurück
/// Phase 3.1: Erkennt auch Projektnamen und technische Terme aus Dateipfaden
pub fn extract_keywords(message: &str) -> Vec<String> {
let mut unique: Vec<String> = Vec::new();
// Projekt-Name aus Pfad extrahieren (z.B. /mnt/.../Projekte/Leckerbuch/... → leckerbuch)
if let Some(proj) = detect_project(message) {
if !unique.contains(&proj) {
unique.push(proj);
}
}
// Technische Terme die als Ganzes erhalten bleiben sollen
let lower = message.to_lowercase();
let tech_terms = [
"mysql", "docker", "tauri", "svelte", "rust", "cargo", "nixos",
"forgejo", "portainer", "claude-bridge", "wissensbasis", "knowledge",
"webhook", "api", "cors", "jwt", "sse", "websocket",
];
for term in &tech_terms {
if lower.contains(term) && !unique.contains(&term.to_string()) {
unique.push(term.to_string());
}
}
// Normale Wort-Extraktion
let words: Vec<String> = message
.to_lowercase()
.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.', " ")
.split_whitespace()
.filter(|w| {
w.len() >= 3
&& !STOP_WORDS.contains(&w.as_ref())
&& !w.chars().all(|c| c.is_numeric())
})
.map(|w| w.to_string())
.collect();
// Deduplizieren und max 8 Keywords behalten (mehr als vorher wegen Session-Kontext)
for w in words {
if !unique.contains(&w) {
unique.push(w);
}
if unique.len() >= 8 {
break;
}
}
unique
}
/// Wissenseintrag aus der knowledge-Tabelle
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeEntry {
pub id: i64,
pub category: String,
pub title: String,
pub content: String,
pub tags: Option<String>,
pub priority: i32,
pub status: String,
pub related_ids: Option<String>,
pub source: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// Neuer Wissenseintrag (ohne id, timestamps)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewKnowledge {
pub category: String,
pub title: String,
pub content: String,
pub tags: Option<String>,
pub priority: Option<i32>,
pub source: Option<String>,
}
/// Suchergebnis mit Relevanz-Score
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub entry: KnowledgeEntry,
pub relevance: f64,
}
/// Verfügbare Kategorien
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategoryInfo {
pub name: String,
pub count: i64,
}
// ============ Hilfsfunktionen ============
/// Erstellt einen MySQL Connection-Pool
fn create_pool() -> Pool {
let url = format!(
"mysql://{}:{}@{}:{}/{}",
mysql_user(), mysql_pass(), mysql_host(), MYSQL_PORT, mysql_db()
);
Pool::new(url.as_str())
}
// ============ Interne Funktionen (kein Tauri-Command) ============
/// KB-Hints für eine Nachricht laden — fehlertolerant, gibt leeren String bei DB-Problemen
/// Wird von claude.rs aufgerufen bevor die Nachricht an die Bridge geht
/// Phase 3.1 (Smart Hints v2): Session-Context-Aware — akkumuliert Keywords,
/// filtert bereits gezeigte Einträge, liefert nur alle 3 Nachrichten Hints.
pub async fn search_knowledge_internal(query: &str, limit: i32) -> Result<String, String> {
let new_keywords = extract_keywords(query);
let detected_project = detect_project(query);
let (session_query, skip_this_round) = {
let mut topic = SESSION_TOPIC.lock().unwrap();
// Projekt-Wechsel erkennen → Reset
if let Some(ref proj) = detected_project {
if topic.last_project.as_ref() != Some(proj) {
println!("🔀 Projekt-Wechsel erkannt: {:?}{} — Reset Hints",
topic.last_project, proj);
topic.shown_ids.clear();
topic.message_count = 0;
topic.last_project = Some(proj.clone());
}
}
// Keywords zur Session hinzufügen (dedupliziert)
for kw in &new_keywords {
if !topic.keywords.contains(kw) {
topic.keywords.push(kw.clone());
}
}
// Max 20 Keywords behalten (neueste bevorzugt)
if topic.keywords.len() > 20 {
let start = topic.keywords.len() - 20;
topic.keywords = topic.keywords[start..].to_vec();
}
topic.message_count += 1;
// Nur alle 3 Nachrichten frische Hints liefern (nicht jedes Mal!)
// Erste Nachricht (count=1) bekommt immer Hints
let skip = topic.message_count > 1 && topic.message_count % 3 != 1;
// Session-Query bauen: letzte 6 Keywords für breiteren Kontext
let query = if topic.keywords.len() > 3 {
let start = topic.keywords.len().saturating_sub(6);
topic.keywords[start..].join(" ")
} else if new_keywords.is_empty() {
query[..query.len().min(100)].to_string()
} else {
new_keywords.join(" ")
};
(query, skip)
};
if skip_this_round {
return Ok(String::new());
}
// Projekt-Boost: wenn Projekt erkannt, an Query anhängen
let final_query = if let Some(ref proj) = detected_project {
format!("{} {}", session_query, proj)
} else {
session_query.clone()
};
// Direkt die session-aware Filterung nutzen (max 3 Hints pro Runde)
let filtered = search_knowledge_filtered(&final_query, (limit.min(3)) as usize, &detected_project).await?;
Ok(filtered)
}
/// Session-aware Suche mit Filterung bereits gezeigter Einträge
async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &Option<String>) -> Result<String, String> {
let pool = create_pool();
let conn_result = tokio::time::timeout(
std::time::Duration::from_secs(3),
pool.get_conn()
).await;
let mut conn = match conn_result {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
println!("⚠️ KB-Hints: DB-Verbindung fehlgeschlagen: {}", e);
return Ok(String::new());
}
Err(_) => {
println!("⚠️ KB-Hints: DB-Timeout (3s)");
return Ok(String::new());
}
};
// Mehr laden als nötig für Filterung
let fetch_limit = (limit + 10) as i32;
let results: Vec<(i64, String, String, String, Option<String>, i32, f64)> = if let Some(ref proj) = project {
// Mit Projekt-Boost: Einträge mit passendem Tag werden bevorzugt
let proj_pattern = format!("%{}%", proj);
conn.exec(
r#"SELECT
id, category, title,
SUBSTRING(content, 1, 300) as content_preview,
tags, priority,
MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance
FROM knowledge
WHERE status = 'active'
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
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),
).await.map_err(|e| e.to_string())?
} else {
conn.exec(
r#"SELECT
id, category, title,
SUBSTRING(content, 1, 300) as content_preview,
tags, priority,
MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance
FROM knowledge
WHERE status = 'active'
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY priority DESC, usage_count DESC, relevance DESC
LIMIT ?"#,
(search_query, search_query, fetch_limit),
).await.map_err(|e| e.to_string())?
};
drop(conn);
let _ = pool.disconnect().await;
if results.is_empty() {
return Ok(String::new());
}
// Bereits gezeigte IDs filtern
let filtered: Vec<_> = {
let topic = SESSION_TOPIC.lock().unwrap();
results.into_iter()
.filter(|(id, _, _, _, _, _, _)| !topic.shown_ids.contains(id))
.take(limit)
.collect()
};
if filtered.is_empty() {
return Ok(String::new());
}
// Gezeigte IDs merken
{
let mut topic = SESSION_TOPIC.lock().unwrap();
for (id, _, _, _, _, _, _) in &filtered {
topic.shown_ids.push(*id);
}
// Max 30 IDs behalten
if topic.shown_ids.len() > 30 {
let start = topic.shown_ids.len() - 30;
topic.shown_ids = topic.shown_ids[start..].to_vec();
}
}
// Als <knowledge-hints> Block formatieren
let mut hints = Vec::new();
hints.push("<knowledge-hints>".to_string());
hints.push(format!("Relevante KB-Einträge ({} Treffer):", filtered.len()));
for (id, category, title, content_preview, tags, _priority, _relevance) in &filtered {
hints.push(format!("\n**#{}** [{}] {}", id, category, title));
hints.push(content_preview.clone());
if let Some(t) = tags {
if !t.is_empty() {
hints.push(format!("Tags: {}", t));
}
}
}
hints.push("</knowledge-hints>".to_string());
let block = hints.join("\n");
println!("🔍 Smart Hints v2 für '{}': {} Treffer, {} Bytes",
&search_query[..search_query.len().min(40)], filtered.len(), block.len());
Ok(block)
}
/// Sucht in der KB mit einem vorbereiteten Query-String
/// Zentrale Suchfunktion die von search_knowledge_internal und proactive_session_hints genutzt wird
/// Phase 3: Mit RAM-Cache — gleiche Query innerhalb 60s wird instant aus dem Cache bedient
pub async fn search_knowledge_by_query(search_query: &str, limit: i32) -> Result<String, String> {
// Cache-Key: query + limit
let cache_key = format!("{}:{}", search_query, limit);
// Cache-Hit prüfen
{
let cache = KB_CACHE.lock().unwrap();
if let Some(cached) = cache.get(&cache_key) {
println!("⚡ KB-Cache HIT für '{}'", &search_query[..search_query.len().min(40)]);
return Ok(cached.to_string());
}
}
let pool = create_pool();
// Verbindung mit Timeout — DB nicht erreichbar soll nicht blockieren
let conn_result = tokio::time::timeout(
std::time::Duration::from_secs(3),
pool.get_conn()
).await;
let mut conn = match conn_result {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
println!("⚠️ KB-Hints: DB-Verbindung fehlgeschlagen: {}", e);
return Ok(String::new());
}
Err(_) => {
println!("⚠️ KB-Hints: DB-Timeout (3s)");
return Ok(String::new());
}
};
// Volltext-Suche — nur Titel und Zusammenfassung, kein ganzer Content
let results: Vec<(i64, String, String, String, Option<String>, f64)> = conn.exec(
r#"SELECT
id, category, title,
SUBSTRING(content, 1, 300) as content_preview,
tags,
MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance
FROM knowledge
WHERE status = 'active'
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY priority DESC, usage_count DESC, relevance DESC
LIMIT ?"#,
(search_query, search_query, limit),
).await.map_err(|e| e.to_string())?;
drop(conn);
let _ = pool.disconnect().await;
if results.is_empty() {
println!("🔍 KB-Hints für '{}': keine Treffer", &search_query[..search_query.len().min(40)]);
// Leere Ergebnisse auch cachen — verhindert wiederholte DB-Abfragen
let mut cache = KB_CACHE.lock().unwrap();
cache.insert(cache_key, String::new());
return Ok(String::new());
}
// Als <knowledge-hints> Block formatieren
let mut hints = Vec::new();
hints.push("<knowledge-hints>".to_string());
hints.push(format!("Relevante KB-Einträge ({} Treffer):", results.len()));
for (id, category, title, content_preview, tags, _relevance) in &results {
hints.push(format!("\n**#{}** [{}] {}", id, category, title));
hints.push(content_preview.clone());
if let Some(t) = tags {
if !t.is_empty() {
hints.push(format!("Tags: {}", t));
}
}
}
hints.push("</knowledge-hints>".to_string());
let block = hints.join("\n");
println!("🔍 KB-Hints für '{}': {} Treffer, {} Bytes (cached)",
&search_query[..search_query.len().min(40)], results.len(), block.len());
// In Cache speichern
{
let mut cache = KB_CACHE.lock().unwrap();
cache.insert(cache_key, block.clone());
}
Ok(block)
}
/// Phase 2.0: Proaktive KB-Abfrage bei SessionStart
/// Lädt relevante Einträge basierend auf Projekt-Kontext und letzter Aktivität
pub async fn proactive_session_hints(project_name: Option<&str>) -> Result<String, String> {
let mut search_terms = Vec::new();
// Projekt-bezogene Begriffe
if let Some(name) = project_name {
search_terms.push(name.to_string());
// Projekt-spezifische Zusatzbegriffe
let lower = name.to_lowercase();
if lower.contains("dolibarr") { search_terms.push("dolibarr".to_string()); }
if lower.contains("claude") { search_terms.push("claude desktop tauri".to_string()); }
if lower.contains("bericht") { search_terms.push("bericht modul".to_string()); }
if lower.contains("kunden") { search_terms.push("kundenkarte schaltplan".to_string()); }
}
// Allgemeine Begriffe für Session-Start (aktive Fehler, wichtige Patterns)
search_terms.push("fehler workaround aktiv".to_string());
let query = search_terms.join(" ");
println!("📋 Proaktive KB-Abfrage: '{}'", &query[..query.len().min(60)]);
search_knowledge_by_query(&query, 5).await
}
/// Phase 2.0: Auto-Fehler-Pattern in KB speichern
/// Wird aufgerufen wenn ein Fehler 3x aufgetreten ist
pub async fn save_error_pattern_to_kb(
error_hash: &str,
error_message: &str,
tool: &str,
occurrence_count: i32,
) -> Result<i64, String> {
let pool = create_pool();
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
// Prüfen ob für diesen Hash schon ein KB-Eintrag existiert
let existing: Option<i64> = conn.exec_first(
r#"SELECT id FROM knowledge
WHERE category = 'fehler'
AND tags LIKE CONCAT('%', ?, '%')
AND status = 'active'
LIMIT 1"#,
(error_hash,),
).await.map_err(|e| e.to_string())?;
if let Some(id) = existing {
// Schon vorhanden — nur Occurrence aktualisieren
conn.exec_drop(
r#"UPDATE knowledge SET
content = CONCAT(content, '\n\n---\nWeiteres Auftreten: ', NOW(), ' (', ?, 'x gesamt)'),
updated_at = NOW()
WHERE id = ?"#,
(occurrence_count, id),
).await.map_err(|e| e.to_string())?;
drop(conn);
let _ = pool.disconnect().await;
println!("📝 Fehler-Pattern #{} aktualisiert ({}x)", id, occurrence_count);
return Ok(id);
}
// Neuen KB-Eintrag erstellen
let title = format!("Auto-Pattern: {} Fehler in {}", &error_message[..error_message.len().min(60)], tool);
let content = format!(
"## Automatisch erkanntes Fehler-Pattern\n\n\
**Tool:** {}\n\
**Häufigkeit:** {}x aufgetreten\n\
**Fehlermeldung:**\n```\n{}\n```\n\n\
**Hash:** `{}`\n\n\
> Dieses Pattern wurde automatisch erstellt nachdem der Fehler {}x aufgetreten ist.\n\
> Bitte Lösung/Workaround ergänzen.",
tool, occurrence_count, &error_message[..error_message.len().min(500)], error_hash, occurrence_count
);
let tags = format!("auto-pattern,fehler,{},{}", tool.to_lowercase(), error_hash);
conn.exec_drop(
r#"INSERT INTO knowledge (category, title, content, tags, priority, status, source, created_at, updated_at)
VALUES ('fehler', ?, ?, ?, 2, 'active', 'auto-pattern', NOW(), NOW())"#,
(&title, &content, &tags),
).await.map_err(|e| e.to_string())?;
let id: i64 = conn.last_insert_id().ok_or("Keine Insert-ID")? as i64;
drop(conn);
let _ = pool.disconnect().await;
println!("🆕 Neues Fehler-Pattern in KB gespeichert: #{} ({}x {})", id, occurrence_count, tool);
Ok(id)
}
// ============ Tauri Commands ============
/// Wissensbasis durchsuchen (Volltext)
#[tauri::command]
pub async fn search_knowledge(
query: String,
category: Option<String>,
limit: Option<usize>,
) -> Result<Vec<SearchResult>, String> {
let pool = create_pool();
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
let limit = limit.unwrap_or(20);
// Volltext-Suche mit optionalem Kategorie-Filter
let results: Vec<SearchResult> = if let Some(cat) = category {
conn.exec_map(
r#"SELECT
id, category, title, content, tags, priority, status,
related_ids, source, created_at, updated_at,
MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance
FROM knowledge
WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
AND category = ?
AND status = 'active'
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):
(i64, String, String, String, Option<String>, i32, String, Option<String>, Option<String>, NaiveDateTime, NaiveDateTime, f64)| {
SearchResult {
entry: KnowledgeEntry {
id, category, title, content, tags, priority, status,
related_ids, source,
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
},
relevance,
}
}
).await.map_err(|e| e.to_string())?
} else {
conn.exec_map(
r#"SELECT
id, category, title, content, tags, priority, status,
related_ids, source, created_at, updated_at,
MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance
FROM knowledge
WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
AND status = 'active'
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):
(i64, String, String, String, Option<String>, i32, String, Option<String>, Option<String>, NaiveDateTime, NaiveDateTime, f64)| {
SearchResult {
entry: KnowledgeEntry {
id, category, title, content, tags, priority, status,
related_ids, source,
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
},
relevance,
}
}
).await.map_err(|e| e.to_string())?
};
drop(conn);
pool.disconnect().await.map_err(|e| e.to_string())?;
println!("🔍 Wissensbasis-Suche '{}': {} Treffer", query, results.len());
Ok(results)
}
/// Wissenseintrag nach ID laden
#[tauri::command]
pub async fn get_knowledge(id: i64) -> Result<Option<KnowledgeEntry>, String> {
let pool = create_pool();
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
let result: Option<KnowledgeEntry> = conn.exec_first(
r#"SELECT id, category, title, content, tags, priority, status,
related_ids, source, created_at, updated_at
FROM knowledge WHERE id = ?"#,
(id,)
).await.map_err(|e| e.to_string())?
.map(|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at):
(i64, String, String, String, Option<String>, i32, String, Option<String>, Option<String>, NaiveDateTime, NaiveDateTime)| {
KnowledgeEntry {
id, category, title, content, tags, priority, status,
related_ids, source,
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
}
});
drop(conn);
pool.disconnect().await.map_err(|e| e.to_string())?;
Ok(result)
}
/// Neuen Wissenseintrag speichern ("Das merken")
#[tauri::command]
pub async fn save_knowledge(entry: NewKnowledge) -> Result<i64, String> {
let pool = create_pool();
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
let priority = entry.priority.unwrap_or(2);
conn.exec_drop(
r#"INSERT INTO knowledge (category, title, content, tags, priority, status, source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, NOW(), NOW())"#,
(&entry.category, &entry.title, &entry.content, &entry.tags, priority, &entry.source)
).await.map_err(|e| e.to_string())?;
let id: i64 = conn.last_insert_id().ok_or("Keine Insert-ID")? as i64;
drop(conn);
pool.disconnect().await.map_err(|e| e.to_string())?;
println!("💾 Wissen gespeichert: {} (ID: {})", entry.title, id);
Ok(id)
}
/// Alle Kategorien mit Anzahl der Einträge
#[tauri::command]
pub async fn get_knowledge_categories() -> Result<Vec<CategoryInfo>, String> {
let pool = create_pool();
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
let categories: Vec<CategoryInfo> = conn.query_map(
r#"SELECT category, COUNT(*) as count
FROM knowledge
WHERE status = 'active'
GROUP BY category
ORDER BY count DESC"#,
|(name, count): (String, i64)| CategoryInfo { name, count }
).await.map_err(|e| e.to_string())?;
drop(conn);
pool.disconnect().await.map_err(|e| e.to_string())?;
Ok(categories)
}
/// Letzte N Wissenseinträge laden
#[tauri::command]
pub async fn get_recent_knowledge(
limit: Option<usize>,
category: Option<String>,
) -> Result<Vec<KnowledgeEntry>, String> {
let pool = create_pool();
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
let limit = limit.unwrap_or(10);
let entries: Vec<KnowledgeEntry> = if let Some(cat) = category {
conn.exec_map(
r#"SELECT id, category, title, content, tags, priority, status,
related_ids, source, created_at, updated_at
FROM knowledge
WHERE status = 'active' AND category = ?
ORDER BY priority DESC, updated_at DESC
LIMIT ?"#,
(&cat, limit),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at):
(i64, String, String, String, Option<String>, i32, String, Option<String>, Option<String>, NaiveDateTime, NaiveDateTime)| {
KnowledgeEntry {
id, category, title, content, tags, priority, status,
related_ids, source,
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
}
}
).await.map_err(|e| e.to_string())?
} else {
conn.exec_map(
r#"SELECT id, category, title, content, tags, priority, status,
related_ids, source, created_at, updated_at
FROM knowledge
WHERE status = 'active'
ORDER BY priority DESC, updated_at DESC
LIMIT ?"#,
(limit,),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at):
(i64, String, String, String, Option<String>, i32, String, Option<String>, Option<String>, NaiveDateTime, NaiveDateTime)| {
KnowledgeEntry {
id, category, title, content, tags, priority, status,
related_ids, source,
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
}
}
).await.map_err(|e| e.to_string())?
};
drop(conn);
pool.disconnect().await.map_err(|e| e.to_string())?;
Ok(entries)
}
/// Wissens-Hints für ein Tool/Kommando laden
/// Sucht relevante Einträge basierend auf Tool-Name und Kommando
#[tauri::command]
pub async fn get_tool_hints(
tool: String,
command: Option<String>,
context: Option<String>,
) -> Result<Vec<KnowledgeEntry>, String> {
let pool = create_pool();
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
// Suchbegriffe aus Tool + Command + Context zusammenbauen
let mut search_terms = vec![tool.clone()];
// Tool-spezifische Kategorien mappen
let category: Option<&str> = match tool.as_str() {
"Bash" => {
if let Some(ref cmd) = command {
// Relevante Begriffe aus Bash-Kommando extrahieren
if cmd.contains("npm") || cmd.contains("node") { search_terms.push("npm".to_string()); }
if cmd.contains("git") { search_terms.push("git".to_string()); }
if cmd.contains("docker") { search_terms.push("docker".to_string()); }
if cmd.contains("cargo") { search_terms.push("cargo".to_string()); search_terms.push("rust".to_string()); }
if cmd.contains("dolibarr") { search_terms.push("dolibarr".to_string()); }
if cmd.contains("mysql") { search_terms.push("mysql".to_string()); search_terms.push("sql".to_string()); }
}
None // Keine spezifische Kategorie
}
"Read" | "Write" | "Edit" => {
if let Some(ref cmd) = command {
// Aus Dateipfad relevante Begriffe extrahieren
if cmd.contains("dolibarr") { search_terms.push("dolibarr".to_string()); }
if cmd.contains(".php") { search_terms.push("php".to_string()); }
if cmd.contains(".rs") { search_terms.push("rust".to_string()); }
if cmd.contains(".ts") || cmd.contains(".svelte") { search_terms.push("svelte".to_string()); }
}
None
}
_ => None,
};
// Optional: Context-Begriffe hinzufügen
if let Some(ref ctx) = context {
// Wichtige Begriffe aus Context extrahieren (max 3)
for word in ctx.split_whitespace().take(10) {
if word.len() > 4 && !search_terms.contains(&word.to_lowercase()) {
search_terms.push(word.to_lowercase());
}
}
}
// Suchquery bauen
let query_string = search_terms.join(" ");
// Suche mit Volltext und optionalem Kategorie-Filter
let entries: Vec<KnowledgeEntry> = if let Some(cat) = category {
conn.exec_map(
r#"SELECT id, category, title, content, tags, priority, status,
related_ids, source, created_at, updated_at
FROM knowledge
WHERE status = 'active'
AND category = ?
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY priority DESC, updated_at DESC
LIMIT 3"#,
(&cat, &query_string),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at):
(i64, String, String, String, Option<String>, i32, String, Option<String>, Option<String>, NaiveDateTime, NaiveDateTime)| {
KnowledgeEntry {
id, category, title, content, tags, priority, status,
related_ids, source,
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
}
}
).await.map_err(|e| e.to_string())?
} else {
conn.exec_map(
r#"SELECT id, category, title, content, tags, priority, status,
related_ids, source, created_at, updated_at
FROM knowledge
WHERE status = 'active'
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY priority DESC, updated_at DESC
LIMIT 3"#,
(&query_string,),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at):
(i64, String, String, String, Option<String>, i32, String, Option<String>, Option<String>, NaiveDateTime, NaiveDateTime)| {
KnowledgeEntry {
id, category, title, content, tags, priority, status,
related_ids, source,
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
updated_at: updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
}
}
).await.map_err(|e| e.to_string())?
};
drop(conn);
pool.disconnect().await.map_err(|e| e.to_string())?;
if !entries.is_empty() {
println!("💡 {} Wissens-Hints geladen für Tool '{}': {:?}",
entries.len(),
tool,
entries.iter().map(|e| &e.title).collect::<Vec<_>>()
);
}
Ok(entries)
}
/// Wissens-Hints als formatierter Kontext-Block
#[tauri::command]
pub async fn format_tool_hints(
tool: String,
command: Option<String>,
context: Option<String>,
) -> Result<String, String> {
let entries = get_tool_hints(tool.clone(), command, context).await?;
if entries.is_empty() {
return Ok(String::new());
}
let mut hints = Vec::new();
hints.push("<knowledge-hints>".to_string());
hints.push(format!("Relevante Informationen für {}:", tool));
for entry in entries {
hints.push(format!("\n**{}** ({})", entry.title, entry.category));
// Content auf ~300 Zeichen kürzen (sicher an Char-Boundary)
let content = if entry.content.len() > 300 {
let end = entry.content.char_indices()
.take_while(|(i, _)| *i < 300)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(300.min(entry.content.len()));
format!("{}...", &entry.content[..end])
} else {
entry.content
};
hints.push(content);
}
hints.push("</knowledge-hints>".to_string());
Ok(hints.join("\n"))
}
/// Verbindung zur Wissensbasis testen
#[tauri::command]
pub async fn test_knowledge_connection() -> Result<String, String> {
let pool = create_pool();
let mut conn = pool.get_conn().await.map_err(|e| format!("Verbindungsfehler: {}", e))?;
let count: i64 = conn.query_first("SELECT COUNT(*) FROM knowledge")
.await
.map_err(|e| e.to_string())?
.unwrap_or(0);
drop(conn);
pool.disconnect().await.map_err(|e| e.to_string())?;
println!("✅ Wissensbasis verbunden: {} Einträge", count);
Ok(format!("Verbunden! {} Einträge in der Wissensbasis", count))
}
// ============ Phase 2.0: Neue Commands ============
/// Keywords aus einer Nachricht extrahieren (für Frontend-Debug/Anzeige)
#[tauri::command]
pub async fn extract_message_keywords(message: String) -> Result<Vec<String>, String> {
Ok(extract_keywords(&message))
}
/// Proaktive KB-Hints bei Session-Start laden
#[tauri::command]
pub async fn get_session_hints(project_name: Option<String>) -> Result<String, String> {
proactive_session_hints(project_name.as_deref()).await
}
/// Fehler-Pattern automatisch in KB speichern (aufgerufen von Frontend bei 3+ Occurrences)
#[tauri::command]
pub async fn auto_save_error_pattern(
error_hash: String,
error_message: String,
tool: String,
occurrence_count: i32,
) -> Result<i64, String> {
save_error_pattern_to_kb(&error_hash, &error_message, &tool, occurrence_count).await
}
/// Phase 3: KB-Cache invalidieren (alle Eintraege loeschen)
/// Aufrufen wenn neue KB-Eintraege gespeichert wurden
#[tauri::command]
pub async fn invalidate_kb_cache() -> Result<String, String> {
let mut cache = KB_CACHE.lock().unwrap();
let count = cache.entries.len();
cache.entries.clear();
println!("🗑️ KB-Cache invalidiert ({} Eintraege geloescht)", count);
Ok(format!("{} Cache-Eintraege geloescht", count))
}
/// Phase 3.1: Session-Topic manuell zurücksetzen (z.B. bei neuem Chat im Frontend)
#[tauri::command]
pub async fn reset_kb_session() -> Result<String, String> {
reset_session_topic();
Ok("Session-Topic zurückgesetzt".to_string())
}
/// Phase 3.1: Session-Status abfragen (Debug/Monitoring)
#[tauri::command]
pub async fn get_kb_session_status() -> Result<String, String> {
let topic = SESSION_TOPIC.lock().unwrap();
Ok(format!(
"Keywords: {} | Shown: {} | Messages: {} | Project: {}",
topic.keywords.len(),
topic.shown_ids.len(),
topic.message_count,
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(())
}