From acc218c17c188114943377cd5942ef05c55b716d Mon Sep 17 00:00:00 2001 From: Eddy Date: Sat, 2 May 2026 23:00:44 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Projekt-Briefing=20Button=20=E2=80=94?= =?UTF-8?q?=20Claude=20=C3=BCber=20aktuellen=20Stand=20informieren=20[appi?= =?UTF-8?q?mage]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer 📋-Button in der Chat-Toolbar. Sammelt automatisch: - Sticky Context (User, Projekt, Zugänge) - Archivierter Projekt-Kontext (Entscheidungen, TODOs) - Letzte 20 Session-Nachrichten als Zusammenfassung - Git-Log (letzte 10 Commits) - CLAUDE.md des Projekts - KB-Treffer zum Projekt Wird als Kontext-Nachricht an Claude gesendet. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/context.rs | 216 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/components/ChatPanel.svelte | 47 ++++++ 3 files changed, 264 insertions(+) diff --git a/src-tauri/src/context.rs b/src-tauri/src/context.rs index 86afae3..e3071e7 100644 --- a/src-tauri/src/context.rs +++ b/src-tauri/src/context.rs @@ -592,6 +592,222 @@ pub async fn list_sticky_context( db.load_sticky_context().map_err(|e| e.to_string()) } +// ============ Projekt-Briefing ============ + +/// Briefing-Daten für das Frontend +#[derive(Debug, Serialize)] +pub struct Briefing { + pub text: String, + pub token_estimate: usize, + pub sections: Vec, +} + +/// Generiert ein Projekt-Briefing aus allen verfügbaren Quellen: +/// - Sticky Context (Schicht 1) +/// - Projekt-Kontext / Archiv (Schicht 2) +/// - Letzte Session-Nachrichten (Zusammenfassung) +/// - Git-Log (letzte 10 Commits) +/// - KB-Treffer zum Projekt +#[tauri::command] +pub async fn generate_briefing( + app: AppHandle, + session_id: Option, +) -> Result { + let mut parts: Vec = Vec::new(); + let mut sections: Vec = Vec::new(); + + let state = app.state::(); + + // 1. Sticky Context (User-Info, Projekt, Zugänge, Regeln) + { + let db = state.lock().unwrap(); + let _ = db.create_context_tables(); + if let Ok(entries) = db.load_sticky_context() { + if !entries.is_empty() { + let mut sticky = StickyContext::default(); + for (key, value, _) 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::(&value) { + sticky.active_credentials.push(cred); + } + } + k if k.starts_with("project:") => { + if let Ok(proj) = serde_json::from_str::(&value) { + sticky.current_project = Some(proj); + } + } + k if k.starts_with("rule:") => sticky.critical_rules.push(value), + _ => {} + } + } + let rendered = sticky.render(); + if !rendered.is_empty() { + parts.push(rendered); + sections.push("sticky-context".to_string()); + } + } + } + } + + // 2. Archivierter Projekt-Kontext (Entscheidungen, TODOs, Erkenntnisse) + if let Some(ref sid) = session_id { + let db = state.lock().unwrap(); + if let Ok(Some(archived)) = db.load_archived_context(sid) { + let mut project = ProjectContext::default(); + project.decisions = archived.decisions; + project.key_insights = archived.key_insights; + project.open_todos = archived.open_questions; + let rendered = project.render(); + if !rendered.is_empty() { + parts.push(rendered); + sections.push("project-context".to_string()); + } + } + } + + // 3. Session-Zusammenfassung: letzte N Nachrichten der aktuellen/vorherigen Session + { + let db = state.lock().unwrap(); + let target_session = session_id.clone() + .or_else(|| db.get_active_session().ok().flatten().map(|s| s.id)); + + if let Some(ref sid) = target_session { + if let Ok(messages) = db.load_messages(sid) { + let msg_count = messages.len(); + if msg_count > 0 { + // Letzte 20 Nachrichten als kompakte Zusammenfassung + let recent: Vec<_> = messages.iter().rev().take(20).collect(); + let mut summary_parts = Vec::new(); + for msg in recent.iter().rev() { + let role_label = match msg.role.as_str() { + "user" => "👤", + "assistant" => "🤖", + "system" => "⚙️", + _ => "?", + }; + // Content kürzen auf 300 Zeichen + let content = if msg.content.len() > 300 { + let end = msg.content.char_indices() + .take_while(|(i, _)| *i < 300) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(300.min(msg.content.len())); + format!("{}…", &msg.content[..end]) + } else { + msg.content.clone() + }; + summary_parts.push(format!("{} {}", role_label, content)); + } + parts.push(format!( + "\n**Letzte Session** ({} Nachrichten, davon {} gezeigt):\n\n{}\n", + msg_count, + summary_parts.len(), + summary_parts.join("\n\n---\n\n") + )); + sections.push("session-summary".to_string()); + } + } + } + + // 3b. Falls keine aktive Session, vorherige Sessions scannen + if target_session.is_none() { + if let Ok(sessions) = db.load_sessions(5) { + for prev_session in sessions.iter().take(3) { + if let Some(ref last_msg) = prev_session.last_message { + parts.push(format!( + "**Session „{}"** ({}): {}", + prev_session.title, + &prev_session.updated_at[..10], + crate::strutil::safe_truncate(last_msg, 200) + )); + } + } + if !sessions.is_empty() { + sections.push("previous-sessions".to_string()); + } + } + } + } + + // 4. Git-Log (letzte 10 Commits des Arbeitsverzeichnisses) + { + let db = state.lock().unwrap(); + let working_dir = db.get_active_session().ok().flatten() + .and_then(|s| s.working_dir); + drop(db); // Lock freigeben für async Command + + if let Some(ref dir) = working_dir { + match std::process::Command::new("git") + .args(["log", "--oneline", "-10"]) + .current_dir(dir) + .output() + { + Ok(output) if output.status.success() => { + let log = String::from_utf8_lossy(&output.stdout).to_string(); + if !log.trim().is_empty() { + parts.push(format!("\n**Letzte Commits:**\n```\n{}\n```\n", log.trim())); + sections.push("git-log".to_string()); + } + } + _ => {} + } + + // CLAUDE.md des Projekts lesen (falls vorhanden) + let claude_md = std::path::Path::new(dir).join("CLAUDE.md"); + if claude_md.exists() { + if let Ok(content) = std::fs::read_to_string(&claude_md) { + // Nur die ersten 1500 Zeichen + let truncated = crate::strutil::safe_truncate(&content, 1500); + parts.push(format!("\n**CLAUDE.md:**\n{}\n", truncated)); + sections.push("claude-md".to_string()); + } + } + } + } + + // 5. KB-Treffer zum Projekt (fehlertolerant) + { + let db = state.lock().unwrap(); + let project_name = db.get_active_session().ok().flatten() + .and_then(|s| s.title.split_whitespace().next().map(|w| w.to_string())); + drop(db); + + if let Some(ref proj) = project_name { + match crate::knowledge::search_knowledge_internal(proj, 3).await { + Ok(hints) if !hints.is_empty() => { + parts.push(hints); + sections.push("kb-hints".to_string()); + } + _ => {} + } + } + } + + if parts.is_empty() { + return Ok(Briefing { + text: "Kein Kontext verfügbar. Erstelle eine Session und arbeite an einem Projekt.".to_string(), + token_estimate: 20, + sections: vec![], + }); + } + + let text = format!( + "\nDies ist ein automatisches Projekt-Briefing. Es fasst den aktuellen Stand zusammen.\n\n{}\n", + parts.join("\n\n") + ); + let token_estimate = text.len() / 4; + + println!("📋 Briefing generiert: {} Sektionen, ~{} Token", sections.len(), token_estimate); + + Ok(Briefing { + text, + token_estimate, + sections, + }) +} + // ============ @-Mentions: Fuzzy File Search ============ /// Datei-Ergebnis fuer Frontend diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a98be88..326f6d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -157,6 +157,7 @@ pub fn run() { context::extract_context_before_compacting, context::log_context_failure, context::get_full_context, + context::generate_briefing, context::list_sticky_context, context::fuzzy_search_files, context::resolve_file_path, diff --git a/src/lib/components/ChatPanel.svelte b/src/lib/components/ChatPanel.svelte index 457bf3b..e4eec26 100644 --- a/src/lib/components/ChatPanel.svelte +++ b/src/lib/components/ChatPanel.svelte @@ -841,6 +841,35 @@ } } + // ============ Projekt-Briefing ============ + let briefingLoading = $state(false); + + async function sendBriefing() { + if (briefingLoading || $isProcessing) return; + briefingLoading = true; + try { + const result = await invoke<{ text: string; token_estimate: number; sections: string[] }>( + 'generate_briefing', + { sessionId: get(currentSessionId) } + ); + if (!result.text) { + addMessage('system', 'Kein Briefing-Kontext verfügbar.'); + return; + } + + // Briefing als unsichtbare User-Nachricht senden (Claude sieht es als Kontext) + const briefingMsg = `[Automatisches Projekt-Briefing — bitte bestätige kurz dass du den Kontext verstanden hast]\n\n${result.text}`; + await dispatchMessage(briefingMsg); + + console.log(`📋 Briefing gesendet: ${result.sections.join(', ')} (~${result.token_estimate} Token)`); + } catch (err) { + console.error('Briefing-Fehler:', err); + addMessage('system', `Briefing-Fehler: ${err}`); + } finally { + briefingLoading = false; + } + } + async function sendMessage() { const text = $currentInput.trim(); if (!text) return; @@ -1189,6 +1218,13 @@ Titlebar / StatusBar. Detach-Button als kleine Icon-Action im Tab- Bereich (oben rechts), realisiert ueber kompakte Toolbar. -->
+ {#if !detached} {:else} @@ -1513,6 +1549,17 @@ background: var(--bg-hover); color: var(--text-primary); } + .tool-btn.active { + animation: pulse-btn 1s ease-in-out infinite; + } + .tool-btn:disabled { + opacity: 0.4; + cursor: default; + } + @keyframes pulse-btn { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } .token-count { font-size: 0.625rem;