feat: Projekt-Briefing Button — Claude über aktuellen Stand informieren [appimage]
Some checks failed
Build AppImage / build (push) Failing after 2m32s
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:
parent
60989cd44d
commit
acc218c17c
3 changed files with 264 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">⧉</button>
|
<button class="tool-btn" onclick={() => invoke('chat_window_open')} title="Chat herauslösen">⧉</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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue