claude-desktop/src-tauri/src/context.rs
Eddy f51241efa6 Phase 10 Sprach-Interface + Phase 9 Nacharbeiten
Voice (Phase 10):
- voice.rs: OpenAI Whisper (STT) + TTS Backend
- ChatPanel: Mikrofon-Button, VAD (Pause 1.5s), Live-Pegel
- SettingsPanel: OpenAI-Key Konfiguration

Phase 9 Nacharbeiten:
- Auto-Extract vor Compacting (Entscheidungen/TODOs/Insights)
- get_tool_hints() - relevante KB-Eintraege bei Tool-Start
- activeKnowledgeHints Store, Anzeige im KnowledgePanel

Tech-Schulden:
- Dead-Code in memory.rs entfernt (MemorySystem struct)
- cargo-check Warnings behoben

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:24:28 +02:00

593 lines
20 KiB
Rust

// Claude Desktop — Intelligentes Context-Management
// Drei-Schichten-Gedächtnis für kritischen Kontext
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::db::{Database, DbState};
/// Schicht 1: Immer präsent (~200 Token)
/// Wird bei JEDEM API-Call als System-Prompt gesendet
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StickyContext {
/// User-Infos (Name, Firma)
pub user_info: Option<String>,
/// Komprimierte Zugänge (ohne Passwörter!)
pub active_credentials: Vec<CredentialHint>,
/// Aktuelles Projekt
pub current_project: Option<ProjectInfo>,
/// Kritische Regeln
pub critical_rules: Vec<String>,
}
/// Zugangs-Hint (ohne sensible Daten!)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialHint {
pub name: String,
pub host: String,
pub db_or_path: Option<String>,
/// Wann automatisch injizieren (Regex-Pattern)
pub inject_pattern: Option<String>,
}
/// Projekt-Info (kompakt)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectInfo {
pub id: String,
pub name: String,
pub current_phase: Option<String>,
pub working_dir: Option<String>,
}
/// Schicht 2: Projekt-Kontext (~500 Token)
/// Wird nach Compacting neu injiziert
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectContext {
/// Komprimiertes CLAUDE.md
pub claude_md_summary: Option<String>,
/// Architektur-Entscheidungen
pub decisions: Vec<Decision>,
/// Offene TODOs
pub open_todos: Vec<String>,
/// Wichtige Erkenntnisse
pub key_insights: Vec<String>,
}
/// Eine Architektur-Entscheidung
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Decision {
pub topic: String,
pub decision: String,
pub reason: Option<String>,
}
/// Extrahierter Kontext vor Compacting
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedContext {
pub session_id: String,
pub extracted_at: String,
pub decisions: Vec<Decision>,
pub open_questions: Vec<String>,
pub key_insights: Vec<String>,
pub mentioned_files: Vec<String>,
pub mentioned_tools: Vec<String>,
}
/// Wissens-Hint (Schicht 3, on-demand) — für zukünftige Wissens-Hints
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeHint {
pub title: String,
pub content: String,
pub relevance: f64,
}
impl StickyContext {
/// Rendert Schicht 1 als System-Prompt-Teil
pub fn render(&self) -> String {
let mut parts = Vec::new();
if let Some(ref user) = self.user_info {
parts.push(format!("**User:** {}", user));
}
if let Some(ref proj) = self.current_project {
let phase = proj.current_phase.as_deref().unwrap_or("unbekannt");
parts.push(format!("**Projekt:** {} (Phase: {})", proj.name, phase));
if let Some(ref dir) = proj.working_dir {
parts.push(format!("**Arbeitsverzeichnis:** {}", dir));
}
}
if !self.active_credentials.is_empty() {
parts.push("**Verfügbare Zugänge (NICHT nachfragen!):**".to_string());
for cred in &self.active_credentials {
let db_part = cred.db_or_path.as_deref().map(|d| format!(" / {}", d)).unwrap_or_default();
parts.push(format!("- {}: {}{}", cred.name, cred.host, db_part));
}
}
if !self.critical_rules.is_empty() {
parts.push("**Kritische Regeln:**".to_string());
for rule in &self.critical_rules {
parts.push(format!("- {}", rule));
}
}
if parts.is_empty() {
return String::new();
}
format!("<critical-context>\n{}\n</critical-context>", parts.join("\n"))
}
/// Geschätzte Token-Anzahl
#[allow(dead_code)]
pub fn estimate_tokens(&self) -> usize {
// Grobe Schätzung: ~4 Zeichen pro Token
self.render().len() / 4
}
}
impl ProjectContext {
/// Rendert Schicht 2 als System-Reminder
pub fn render(&self) -> String {
let mut parts = Vec::new();
if let Some(ref summary) = self.claude_md_summary {
parts.push(format!("**Projekt-Kontext:**\n{}", summary));
}
if !self.decisions.is_empty() {
parts.push("**Getroffene Entscheidungen:**".to_string());
for dec in &self.decisions {
let reason = dec.reason.as_deref().map(|r| format!(" ({})", r)).unwrap_or_default();
parts.push(format!("- {}: {}{}", dec.topic, dec.decision, reason));
}
}
if !self.open_todos.is_empty() {
parts.push("**Offene TODOs:**".to_string());
for todo in &self.open_todos {
parts.push(format!("- [ ] {}", todo));
}
}
if !self.key_insights.is_empty() {
parts.push("**Wichtige Erkenntnisse:**".to_string());
for insight in &self.key_insights {
parts.push(format!("- {}", insight));
}
}
if parts.is_empty() {
return String::new();
}
format!("<project-context>\n{}\n</project-context>", parts.join("\n\n"))
}
/// Geschätzte Token-Anzahl
#[allow(dead_code)]
pub fn estimate_tokens(&self) -> usize {
self.render().len() / 4
}
}
// ============ Datenbank-Erweiterungen ============
impl Database {
/// Erstellt die Context-Tabellen
pub fn create_context_tables(&self) -> rusqlite::Result<()> {
self.conn.execute_batch(
"
-- Sticky Context (Schicht 1)
CREATE TABLE IF NOT EXISTS sticky_context (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
priority INTEGER DEFAULT 2,
updated_at TEXT NOT NULL
);
-- Compacting-Archiv
CREATE TABLE IF NOT EXISTS compacting_archive (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
extracted_at TEXT NOT NULL,
decisions TEXT,
open_questions TEXT,
key_insights TEXT,
mentioned_files TEXT,
mentioned_tools TEXT
);
CREATE INDEX IF NOT EXISTS idx_archive_session ON compacting_archive(session_id);
-- Context-Failures (für Prompt-Optimierung)
CREATE TABLE IF NOT EXISTS context_failures (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
context_type TEXT NOT NULL,
expected TEXT,
actual TEXT,
resolved INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_failures_session ON context_failures(session_id);
"
)?;
Ok(())
}
/// Speichert einen Sticky-Context-Eintrag
pub fn save_sticky_context(&self, key: &str, value: &str, priority: i32) -> rusqlite::Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO sticky_context (key, value, priority, updated_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![key, value, priority, chrono::Local::now().to_rfc3339()],
)?;
Ok(())
}
/// Lädt alle Sticky-Context-Einträge
pub fn load_sticky_context(&self) -> rusqlite::Result<Vec<(String, String, i32)>> {
let mut stmt = self.conn.prepare(
"SELECT key, value, priority FROM sticky_context ORDER BY priority ASC"
)?;
let entries = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
/// Löscht einen Sticky-Context-Eintrag
pub fn delete_sticky_context(&self, key: &str) -> rusqlite::Result<()> {
self.conn.execute("DELETE FROM sticky_context WHERE key = ?1", rusqlite::params![key])?;
Ok(())
}
/// Archiviert extrahierten Kontext vor Compacting
pub fn archive_context(&self, extracted: &ExtractedContext) -> rusqlite::Result<()> {
self.conn.execute(
"INSERT INTO compacting_archive (id, session_id, extracted_at, decisions, open_questions, key_insights, mentioned_files, mentioned_tools)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
uuid::Uuid::new_v4().to_string(),
extracted.session_id,
extracted.extracted_at,
serde_json::to_string(&extracted.decisions).ok(),
serde_json::to_string(&extracted.open_questions).ok(),
serde_json::to_string(&extracted.key_insights).ok(),
serde_json::to_string(&extracted.mentioned_files).ok(),
serde_json::to_string(&extracted.mentioned_tools).ok(),
],
)?;
Ok(())
}
/// Lädt archivierten Kontext einer Session
pub fn load_archived_context(&self, session_id: &str) -> rusqlite::Result<Option<ExtractedContext>> {
let result = self.conn.query_row(
"SELECT session_id, extracted_at, decisions, open_questions, key_insights, mentioned_files, mentioned_tools
FROM compacting_archive WHERE session_id = ?1 ORDER BY extracted_at DESC LIMIT 1",
rusqlite::params![session_id],
|row| {
let decisions: Option<String> = row.get(2)?;
let questions: Option<String> = row.get(3)?;
let insights: Option<String> = row.get(4)?;
let files: Option<String> = row.get(5)?;
let tools: Option<String> = row.get(6)?;
Ok(ExtractedContext {
session_id: row.get(0)?,
extracted_at: row.get(1)?,
decisions: decisions.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
open_questions: questions.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
key_insights: insights.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
mentioned_files: files.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
mentioned_tools: tools.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
})
},
);
match result {
Ok(ctx) => Ok(Some(ctx)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
/// Speichert einen Context-Failure
pub fn log_context_failure(
&self,
session_id: &str,
context_type: &str,
expected: &str,
actual: &str,
) -> rusqlite::Result<()> {
self.conn.execute(
"INSERT INTO context_failures (id, session_id, timestamp, context_type, expected, actual)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
uuid::Uuid::new_v4().to_string(),
session_id,
chrono::Local::now().to_rfc3339(),
context_type,
expected,
actual,
],
)?;
Ok(())
}
}
// ============ Tauri Commands ============
/// Sticky Context laden (Schicht 1)
#[tauri::command]
pub async fn get_sticky_context(app: AppHandle) -> Result<StickyContext, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
// Context-Tabellen erstellen falls nicht vorhanden
let _ = db.create_context_tables();
let entries = db.load_sticky_context().map_err(|e| e.to_string())?;
let mut ctx = StickyContext::default();
for (key, value, _priority) in entries {
match key.as_str() {
"user_info" => ctx.user_info = Some(value),
k if k.starts_with("cred:") => {
if let Ok(cred) = serde_json::from_str::<CredentialHint>(&value) {
ctx.active_credentials.push(cred);
}
}
k if k.starts_with("project:") => {
if let Ok(proj) = serde_json::from_str::<ProjectInfo>(&value) {
ctx.current_project = Some(proj);
}
}
k if k.starts_with("rule:") => {
ctx.critical_rules.push(value);
}
_ => {}
}
}
Ok(ctx)
}
/// Sticky Context Eintrag setzen
#[tauri::command]
pub async fn set_sticky_context(
app: AppHandle,
key: String,
value: String,
priority: Option<i32>,
) -> Result<(), String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
let _ = db.create_context_tables();
db.save_sticky_context(&key, &value, priority.unwrap_or(2))
.map_err(|e| e.to_string())?;
println!("📌 Sticky Context gesetzt: {}", key);
Ok(())
}
/// Sticky Context Eintrag löschen
#[tauri::command]
pub async fn remove_sticky_context(app: AppHandle, key: String) -> Result<(), String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.delete_sticky_context(&key).map_err(|e| e.to_string())?;
println!("🗑️ Sticky Context entfernt: {}", key);
Ok(())
}
/// Projekt-Kontext laden (Schicht 2)
#[tauri::command]
pub async fn get_project_context(
app: AppHandle,
session_id: String,
) -> Result<ProjectContext, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
let _ = db.create_context_tables();
// Versuche archivierten Kontext zu laden
if let Ok(Some(archived)) = db.load_archived_context(&session_id) {
let mut ctx = ProjectContext::default();
ctx.decisions = archived.decisions;
ctx.key_insights = archived.key_insights;
ctx.open_todos = archived.open_questions; // open_questions als TODOs verwenden
return Ok(ctx);
}
Ok(ProjectContext::default())
}
/// Kontext vor Compacting extrahieren
#[tauri::command]
pub async fn extract_context_before_compacting(
app: AppHandle,
session_id: String,
messages_json: String,
) -> Result<ExtractedContext, String> {
// Nachrichten parsen
let messages: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
.map_err(|e| e.to_string())?;
let mut extracted = ExtractedContext {
session_id: session_id.clone(),
extracted_at: chrono::Local::now().to_rfc3339(),
decisions: Vec::new(),
open_questions: Vec::new(),
key_insights: Vec::new(),
mentioned_files: Vec::new(),
mentioned_tools: Vec::new(),
};
// Einfache Extraktion aus Nachrichten
for msg in &messages {
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
// Entscheidungen finden (Muster: "Entscheidung:", "Wir nehmen", "Ich verwende")
if content.contains("Entscheidung:") || content.contains("Wir nehmen") || content.contains("Ich verwende") {
// Vereinfachte Extraktion
let lines: Vec<&str> = content.lines()
.filter(|l| l.contains("Entscheidung") || l.contains("verwende") || l.contains("nehmen"))
.take(3)
.collect();
for line in lines {
if line.len() > 10 && line.len() < 200 {
extracted.decisions.push(Decision {
topic: "Architektur".to_string(),
decision: line.trim().to_string(),
reason: None,
});
}
}
}
// Offene Fragen finden (Muster: "TODO", "FIXME", "?")
if content.contains("TODO") || content.contains("FIXME") {
let lines: Vec<&str> = content.lines()
.filter(|l| l.contains("TODO") || l.contains("FIXME"))
.take(5)
.collect();
for line in lines {
if line.len() > 5 && line.len() < 200 {
extracted.open_questions.push(line.trim().to_string());
}
}
}
// Dateien extrahieren (vereinfacht: Pfade mit /)
for word in content.split_whitespace() {
if word.contains('/') && word.contains('.') && word.len() < 100 {
let clean = word.trim_matches(|c| c == '"' || c == '\'' || c == '`' || c == '(' || c == ')');
if !extracted.mentioned_files.contains(&clean.to_string()) {
extracted.mentioned_files.push(clean.to_string());
}
}
}
}
// Archivieren
let state = app.state::<DbState>();
let db = state.lock().unwrap();
let _ = db.create_context_tables();
let _ = db.archive_context(&extracted);
println!("📦 Kontext extrahiert: {} Entscheidungen, {} TODOs, {} Dateien",
extracted.decisions.len(),
extracted.open_questions.len(),
extracted.mentioned_files.len()
);
Ok(extracted)
}
/// Context-Failure loggen (für Prompt-Optimierung)
#[tauri::command]
pub async fn log_context_failure(
app: AppHandle,
session_id: String,
context_type: String,
expected: String,
actual: String,
) -> Result<(), String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
let _ = db.create_context_tables();
db.log_context_failure(&session_id, &context_type, &expected, &actual)
.map_err(|e| e.to_string())?;
println!("⚠️ Context-Failure geloggt: {} (erwartet: {}, tatsächlich: {})",
context_type, expected, actual);
Ok(())
}
/// Kombinierter System-Prompt aus Schicht 1+2
#[tauri::command]
pub async fn get_full_context(
app: AppHandle,
session_id: Option<String>,
) -> Result<String, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
let _ = db.create_context_tables();
// Schicht 1: Sticky Context
let entries = db.load_sticky_context().map_err(|e| e.to_string())?;
let mut sticky = StickyContext::default();
for (key, value, _priority) in entries {
match key.as_str() {
"user_info" => sticky.user_info = Some(value),
k if k.starts_with("cred:") => {
if let Ok(cred) = serde_json::from_str::<CredentialHint>(&value) {
sticky.active_credentials.push(cred);
}
}
k if k.starts_with("project:") => {
if let Ok(proj) = serde_json::from_str::<ProjectInfo>(&value) {
sticky.current_project = Some(proj);
}
}
k if k.starts_with("rule:") => {
sticky.critical_rules.push(value);
}
_ => {}
}
}
// Schicht 2: Projekt-Kontext (falls Session angegeben)
let mut project = ProjectContext::default();
if let Some(ref sid) = session_id {
if let Ok(Some(archived)) = db.load_archived_context(sid) {
project.decisions = archived.decisions;
project.key_insights = archived.key_insights;
project.open_todos = archived.open_questions;
}
}
// Kombinieren
let sticky_rendered = sticky.render();
let project_rendered = project.render();
let mut full = String::new();
if !sticky_rendered.is_empty() {
full.push_str(&sticky_rendered);
}
if !project_rendered.is_empty() {
if !full.is_empty() {
full.push_str("\n\n");
}
full.push_str(&project_rendered);
}
Ok(full)
}
/// Liste alle Sticky-Context-Einträge auf
#[tauri::command]
pub async fn list_sticky_context(
app: AppHandle,
) -> Result<Vec<(String, String, i32)>, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
let _ = db.create_context_tables();
db.load_sticky_context().map_err(|e| e.to_string())
}