feat: Projekt-Briefing Button — Claude über aktuellen Stand informieren [appimage]
Some checks failed
Build AppImage / build (push) Failing after 2m32s

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 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-05-02 23:00:44 +02:00
parent 60989cd44d
commit acc218c17c
3 changed files with 264 additions and 0 deletions

View file

@ -592,6 +592,222 @@ pub async fn list_sticky_context(
db.load_sticky_context().map_err(|e| e.to_string()) 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<String>,
}
/// 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<String>,
) -> Result<Briefing, String> {
let mut parts: Vec<String> = Vec::new();
let mut sections: Vec<String> = Vec::new();
let state = app.state::<DbState>();
// 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::<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),
_ => {}
}
}
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!(
"<session-summary>\n**Letzte Session** ({} Nachrichten, davon {} gezeigt):\n\n{}\n</session-summary>",
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!("<git-context>\n**Letzte Commits:**\n```\n{}\n```\n</git-context>", 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!("<project-docs>\n**CLAUDE.md:**\n{}\n</project-docs>", 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!(
"<project-briefing>\nDies ist ein automatisches Projekt-Briefing. Es fasst den aktuellen Stand zusammen.\n\n{}\n</project-briefing>",
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 ============ // ============ @-Mentions: Fuzzy File Search ============
/// Datei-Ergebnis fuer Frontend /// Datei-Ergebnis fuer Frontend

View file

@ -157,6 +157,7 @@ pub fn run() {
context::extract_context_before_compacting, context::extract_context_before_compacting,
context::log_context_failure, context::log_context_failure,
context::get_full_context, context::get_full_context,
context::generate_briefing,
context::list_sticky_context, context::list_sticky_context,
context::fuzzy_search_files, context::fuzzy_search_files,
context::resolve_file_path, context::resolve_file_path,

View file

@ -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() { async function sendMessage() {
const text = $currentInput.trim(); const text = $currentInput.trim();
if (!text) return; if (!text) return;
@ -1189,6 +1218,13 @@
Titlebar / StatusBar. Detach-Button als kleine Icon-Action im Tab- Titlebar / StatusBar. Detach-Button als kleine Icon-Action im Tab-
Bereich (oben rechts), realisiert ueber kompakte Toolbar. --> Bereich (oben rechts), realisiert ueber kompakte Toolbar. -->
<div class="chat-toolbar"> <div class="chat-toolbar">
<button
class="tool-btn"
class:active={briefingLoading}
onclick={sendBriefing}
disabled={briefingLoading || $isProcessing}
title="Projekt-Briefing senden — Claude über den aktuellen Stand informieren"
>📋</button>
{#if !detached} {#if !detached}
<button class="tool-btn" onclick={() => invoke('chat_window_open')} title="Chat herauslösen">&#x29C9;</button> <button class="tool-btn" onclick={() => invoke('chat_window_open')} title="Chat herauslösen">&#x29C9;</button>
{:else} {:else}
@ -1513,6 +1549,17 @@
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); 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 { .token-count {
font-size: 0.625rem; font-size: 0.625rem;