All checks were successful
Build AppImage / build (push) Successful in 8m8s
- 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>
1105 lines
41 KiB
Rust
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(())
|
|
}
|