feat: KB-Hints, Voice-Konversation, Chat-Darstellung, Cross-Session-Recall [appimage]
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>
This commit is contained in:
Eddy 2026-04-27 16:54:58 +02:00
parent 6d3a0d8740
commit fec8aea22c
16 changed files with 1403 additions and 54 deletions

View file

@ -39,6 +39,17 @@ pub struct ChatMessage {
pub timestamp: String,
}
/// Block C: Suchresultat fuer Cross-Session-Recall (FTS5 auf messages)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PastMessageMatch {
pub id: String,
pub session_id: String,
pub role: String,
pub snippet: String, // bis zu 240 Zeichen Anriss
pub timestamp: String,
pub session_title: Option<String>,
}
/// Ein Monitor-Event (System-Log)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MonitorEvent {
@ -195,6 +206,26 @@ impl Database {
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
-- Block C: FTS5-Volltextsuche fuer Cross-Session-Recall.
-- Externer content-Modus: kein doppelter Speicher, FTS hat nur
-- die rowid + indizierten Felder. Sync via Trigger.
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER IF NOT EXISTS messages_fts_ai
AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_ad
AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE rowid = old.rowid;
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_au
AFTER UPDATE ON messages BEGIN
UPDATE messages_fts SET content = new.content WHERE rowid = old.rowid;
END;
-- Monitor-Events (System-Log)
CREATE TABLE IF NOT EXISTS monitor_events (
id TEXT PRIMARY KEY,
@ -717,6 +748,86 @@ impl Database {
Ok(())
}
/// Block C: Cross-Session-Recall — Volltext-Suche ueber alle Sessions
/// liefert die top-N relevanten Assistant-Antworten aus der Vergangenheit.
/// Nutzt FTS5-Index `messages_fts`. Der current_session_id wird ausgeschlossen
/// damit das Recall sich nicht selbst trifft.
pub fn search_past_messages(
&self,
query: &str,
current_session_id: Option<&str>,
limit: usize,
) -> SqlResult<Vec<PastMessageMatch>> {
// FTS5-Query: bei leerem Query nichts. Auch sehr kurze Queries (<3 Zeichen)
// ueberspringen — bringen nur Rauschen.
if query.trim().len() < 3 {
return Ok(Vec::new());
}
// Sanitize: FTS5 mag keine ' und " in der Query
let safe = query
.replace('"', " ")
.replace('\'', " ")
.split_whitespace()
.filter(|w| w.len() >= 2)
.take(8)
.collect::<Vec<_>>()
.join(" OR ");
if safe.is_empty() {
return Ok(Vec::new());
}
let mut stmt = self.conn.prepare(
"SELECT m.id, m.session_id, m.role, m.content, m.timestamp, s.title
FROM messages_fts
JOIN messages m ON m.rowid = messages_fts.rowid
LEFT JOIN sessions s ON s.id = m.session_id
WHERE messages_fts MATCH ?1
AND m.role = 'assistant'
AND length(m.content) > 100
AND (?2 IS NULL OR m.session_id != ?2)
ORDER BY rank
LIMIT ?3"
)?;
let rows = stmt.query_map(
params![safe, current_session_id, limit as i64],
|row| {
let content: String = row.get(3)?;
Ok(PastMessageMatch {
id: row.get(0)?,
session_id: row.get(1)?,
role: row.get(2)?,
snippet: if content.len() > 240 { format!("{}", &content[..240]) } else { content },
timestamp: row.get(4)?,
session_title: row.get(5)?,
})
}
)?.collect::<SqlResult<Vec<_>>>()?;
Ok(rows)
}
/// Einmalige Migration: bestehende messages in den FTS5-Index spielen
/// (falls die Tabelle vor Block C bereits Eintraege hatte).
pub fn rebuild_messages_fts(&self) -> SqlResult<usize> {
// Prefcheck: ist FTS leer aber messages voll?
let fts_count: i64 = self.conn
.query_row("SELECT COUNT(*) FROM messages_fts", [], |r| r.get(0))
.unwrap_or(0);
let msg_count: i64 = self.conn
.query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))?;
if fts_count >= msg_count { return Ok(0); }
self.conn.execute(
"INSERT INTO messages_fts(rowid, content)
SELECT rowid, content FROM messages
WHERE rowid NOT IN (SELECT rowid FROM messages_fts)",
[]
)?;
Ok((msg_count - fts_count) as usize)
}
/// Zählt Nachrichten einer Session
pub fn count_messages(&self, session_id: &str) -> SqlResult<usize> {
self.conn.query_row(
@ -1208,6 +1319,31 @@ pub async fn clear_messages(app: AppHandle, session_id: String) -> Result<(), St
db.clear_messages(&session_id).map_err(|e| e.to_string())
}
/// Block C: Cross-Session-Recall — sucht in alten Sessions nach aehnlichen
/// Assistant-Antworten. Frontend ruft das beim ersten claude-text auf, damit
/// User sieht "🕒 Schon mal beantwortet" wenn er etwas Aehnliches frueher fragte.
#[tauri::command]
pub async fn search_past_messages(
app: AppHandle,
query: String,
current_session_id: Option<String>,
limit: Option<usize>,
) -> Result<Vec<PastMessageMatch>, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.search_past_messages(&query, current_session_id.as_deref(), limit.unwrap_or(3))
.map_err(|e| e.to_string())
}
/// Block C: einmalige Migration — bestehende messages in den FTS5-Index spielen.
/// Frontend ruft das einmal beim App-Start auf (idempotent — tut nichts wenn schon synced).
#[tauri::command]
pub async fn rebuild_messages_fts(app: AppHandle) -> Result<usize, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.rebuild_messages_fts().map_err(|e| e.to_string())
}
/// Session kompaktieren — fasst alte Nachrichten zusammen
#[tauri::command]
pub async fn compact_session(

View file

@ -399,6 +399,7 @@ async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &O
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),
@ -413,7 +414,7 @@ async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &O
FROM knowledge
WHERE status = 'active'
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY priority DESC, relevance DESC
ORDER BY priority DESC, usage_count DESC, relevance DESC
LIMIT ?"#,
(search_query, search_query, fetch_limit),
).await.map_err(|e| e.to_string())?
@ -522,7 +523,7 @@ pub async fn search_knowledge_by_query(search_query: &str, limit: i32) -> Result
FROM knowledge
WHERE status = 'active'
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY priority DESC, relevance DESC
ORDER BY priority DESC, usage_count DESC, relevance DESC
LIMIT ?"#,
(search_query, search_query, limit),
).await.map_err(|e| e.to_string())?;
@ -684,7 +685,7 @@ pub async fn search_knowledge(
WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
AND category = ?
AND status = 'active'
ORDER BY priority DESC, relevance DESC
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):
@ -709,7 +710,7 @@ pub async fn search_knowledge(
FROM knowledge
WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
AND status = 'active'
ORDER BY priority DESC, relevance DESC
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):
@ -1086,3 +1087,19 @@ pub async fn get_kb_session_status() -> Result<String, String> {
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(())
}

View file

@ -109,6 +109,8 @@ pub fn run() {
db::load_messages,
db::clear_messages,
db::compact_session,
db::search_past_messages,
db::rebuild_messages_fts,
// Monitor-Events
db::save_monitor_event,
db::load_monitor_events,
@ -142,6 +144,8 @@ pub fn run() {
// Phase 3.1: Smart Hints v2 — Session-Management
knowledge::reset_kb_session,
knowledge::get_kb_session_status,
// Block B: Usage-Tracking — markiert KB-Treffer als hilfreich
knowledge::track_knowledge_hit,
// Context-Management
context::get_sticky_context,
context::set_sticky_context,

View file

@ -164,6 +164,25 @@ input, textarea, select {
transition: border-color var(--dur-fast) var(--ease);
}
/* WebKitGTK ignoriert haeufig CSS-Background bei nativen <select> und nimmt
stattdessen das System-Theme (oft weiss). appearance:none zwingt CSS durch.
Eigener Chevron als SVG-Background damit das Element nicht "nackt" wirkt. */
select {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%23999' d='M0 0l5 6 5-6z'/></svg>");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 24px;
cursor: pointer;
}
/* Option-Eintraege werden in vielen Engines vom OS-Theme gerendert
color/background trotzdem setzen, manche Engines respektieren es. */
select option {
background: var(--bg-secondary, var(--bg-input));
color: var(--text-primary);
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--focus);

View file

@ -11,6 +11,8 @@
import FileMention from './FileMention.svelte';
import QuickActions from './QuickActions.svelte';
import MessageList from './MessageList.svelte';
import ConversationBanner from './ConversationBanner.svelte';
import { startConversation, stopConversation, conversationActive } from '$lib/voice/conversationEngine';
// ChatStatusBar entfernt (Phase 9): wandert in globale StatusBar im +layout
// Props
@ -686,6 +688,42 @@
}
}
// Mic-Button: zwei Modi
// - Kurzer Klick (<450ms loslassen) = Diktat (alt: Audio in Textbox)
// - Lang halten (>=450ms) = Konversationsmodus (TTS, Barge-In)
// Wenn Konversation bereits aktiv ist: jeder Klick beendet sie.
const LONG_PRESS_MS = 450;
let micPressTimer: ReturnType<typeof setTimeout> | null = null;
let micWasLongPress = false;
function onMicPointerDown(_e: PointerEvent) {
// Wenn Konversation laeuft → erster Klick beendet
if (get(conversationActive)) {
stopConversation();
return;
}
micWasLongPress = false;
micPressTimer = setTimeout(() => {
micWasLongPress = true;
micPressTimer = null;
// Falls gerade Diktat laeuft, vorher stoppen
if (isRecording) stopRecording();
startConversation().catch((err) => console.error('Konversation-Start fehlgeschlagen:', err));
}, LONG_PRESS_MS);
}
function onMicPointerUp(_e: PointerEvent) {
if (micPressTimer) {
clearTimeout(micPressTimer);
micPressTimer = null;
}
// Wenn nicht zum Long-Press eskaliert → Diktat
if (!micWasLongPress && !get(conversationActive)) {
toggleRecording();
}
micWasLongPress = false;
}
onDestroy(() => {
unsubscribe();
// Voice-Aufnahme stoppen falls aktiv
@ -1118,6 +1156,9 @@
</div>
{/if}
<!-- Konversations-Banner: nur sichtbar wenn Voice-Konversation laeuft -->
<ConversationBanner />
<!-- Phase 9: Lokaler Header entfernt — Brand+Stats sind in der globalen
Titlebar / StatusBar. Detach-Button als kleine Icon-Action im Tab-
Bereich (oben rechts), realisiert ueber kompakte Toolbar. -->
@ -1219,10 +1260,20 @@
<button
class="mic-button"
class:recording={isRecording}
onclick={toggleRecording}
title={isRecording ? 'Aufnahme stoppen' : 'Spracheingabe starten'}
class:conversation={$conversationActive}
onpointerdown={onMicPointerDown}
onpointerup={onMicPointerUp}
onpointercancel={onMicPointerUp}
onpointerleave={onMicPointerUp}
title={$conversationActive
? 'Konversation aktiv (Esc oder Klick = beenden)'
: isRecording
? 'Aufnahme stoppen'
: 'Klick: Diktat ins Textfeld · Lang drücken: Konversationsmodus'}
>
{#if isRecording}
{#if $conversationActive}
<span class="mic-icon conv">🎙️</span>
{:else if isRecording}
<span class="mic-icon recording"></span>
<div class="audio-level" style="height: {audioLevel}%"></div>
{:else}
@ -2095,10 +2146,20 @@
animation: pulse-recording 1.5s ease-in-out infinite;
}
.mic-button.conversation {
background: rgba(124, 140, 255, 0.18);
border-color: var(--accent, #7c8cff);
animation: pulse-conversation 1.6s ease-in-out infinite;
}
@keyframes pulse-recording {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}
@keyframes pulse-conversation {
0%, 100% { box-shadow: 0 0 0 0 rgba(124, 140, 255, 0.45); }
50% { box-shadow: 0 0 0 10px rgba(124, 140, 255, 0); }
}
.mic-icon {
z-index: 1;

View file

@ -0,0 +1,130 @@
<script lang="ts">
// Dezenter Banner ueber dem Chat: zeigt Konversations-State (listening/speaking/...)
// + Stop-Button. Erscheint nur wenn conversationActive=true.
import {
conversationActive,
conversationState,
currentVolume,
lastError,
stopConversation,
} from '$lib/voice/conversationEngine';
const stateLabel = $derived.by(() => {
switch ($conversationState) {
case 'connecting': return 'Verbinde Mikrofon…';
case 'listening': return 'Ich höre zu…';
case 'transcribing': return 'Verstehe…';
case 'waiting': return 'Claude denkt nach…';
case 'speaking': return 'Claude spricht — sprich rein um zu unterbrechen';
default: return 'Konversation';
}
});
const stateIcon = $derived.by(() => {
switch ($conversationState) {
case 'listening': return '🎤';
case 'speaking': return '🔊';
case 'waiting':
case 'transcribing':
case 'connecting': return '⋯';
default: return '🎙️';
}
});
// Volume-Bar (0..1) mit smoothing
const volumePct = $derived(Math.min(100, Math.max(0, $currentVolume * 800)));
</script>
{#if $conversationActive}
<div class="conv-banner" role="status" aria-live="polite">
<span class="icon" class:pulse={$conversationState === 'listening' || $conversationState === 'speaking'}>{stateIcon}</span>
<span class="label">{stateLabel}</span>
{#if $conversationState === 'listening' || $conversationState === 'speaking'}
<span class="vol-bar" aria-hidden="true">
<span class="vol-fill" style="width: {volumePct}%"></span>
</span>
{/if}
{#if $lastError}
<span class="err">{$lastError}</span>
{/if}
<span class="spacer"></span>
<button class="stop" onclick={stopConversation} title="Konversation beenden (Esc)">
✕ Beenden
</button>
</div>
{/if}
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape' && $conversationActive) {
stopConversation();
}
}}
/>
<style>
.conv-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
background: linear-gradient(90deg, var(--accent, #007acc) 0%, transparent 280px);
background-color: var(--bg-secondary, rgba(124, 140, 255, 0.08));
border-bottom: 1px solid var(--accent, #007acc);
color: var(--text-primary, #ddd);
font-size: 12px;
font-family: var(--font-mono, ui-monospace, monospace);
}
.icon {
font-size: 14px;
line-height: 1;
}
.icon.pulse {
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.55; transform: scale(1); }
50% { opacity: 1; transform: scale(1.15); }
}
.label {
font-weight: 500;
}
.vol-bar {
display: inline-block;
width: 80px;
height: 5px;
background: rgba(255,255,255,0.08);
border-radius: 2px;
overflow: hidden;
}
.vol-fill {
display: block;
height: 100%;
background: var(--accent, #7c8cff);
transition: width 80ms linear;
}
.err {
color: var(--vscode-errorForeground, #f48771);
font-size: 11px;
}
.spacer { flex: 1; }
.stop {
background: transparent;
border: 1px solid var(--text-secondary, #888);
color: var(--text-primary, #ddd);
padding: 3px 10px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
}
.stop:hover {
background: var(--vscode-errorForeground, #f48771);
border-color: var(--vscode-errorForeground, #f48771);
color: #fff;
}
</style>

View file

@ -0,0 +1,110 @@
<script lang="ts">
// Dezente Hint-Pille fuer KB-Treffer im Chat — analog zu ToolCallCard
// aber noch flacher (kein Expand-Body, nur ein Klick → Drawer/KB).
// Soll deutlich machen "Claude hat KB gelesen" ohne den Lesefluss zu stoeren.
import type { KnowledgeHint } from '$lib/stores';
interface Props { hint: KnowledgeHint; }
let { hint }: Props = $props();
// Recall-Hints (Cross-Session) haben category='recall' und negative ID —
// werden visuell etwas anders dargestellt, oeffnen die alte Session statt KB.
const isRecall = $derived(hint.category === 'recall');
const subtitle = $derived(
isRecall ? '' : (hint.tags ? hint.tags.split(',').slice(0, 2).join(' · ') : (hint.category || ''))
);
const icon = $derived(isRecall ? '🕒' : '💡');
function openInBrowser() {
if (isRecall) {
// hint.tags enthaelt die alte Session-ID — TODO: Session-Wechsel
console.log('Recall: alte Session', hint.tags);
return;
}
const url = `http://192.168.155.1:3080/#/entries/${hint.id}`;
// In Tauri-WebKitGTK oeffnet window.open ueber den System-Browser via
// xdg-open (sofern erlaubt). Falls nicht — Fallback im Drawer wird spaeter ergaenzt.
try { window.open(url, '_blank'); } catch { /* ignore */ }
}
</script>
<button class="kb-pill" class:recall={isRecall} onclick={openInBrowser} title={isRecall ? 'Alte Session ansehen' : 'In KB-Browser oeffnen'}>
<span class="icon">{icon}</span>
{#if !isRecall}
<span class="kb-id">#{hint.id}</span>
{/if}
<span class="kb-title">{hint.title}</span>
{#if subtitle}
<span class="kb-meta">{subtitle}</span>
{/if}
{#if !isRecall}
<span class="kb-prio" title="Prioritaet">P{hint.priority}</span>
{/if}
</button>
<style>
.kb-pill {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 22px;
padding: 3px 10px;
margin: 1px 0;
background: transparent;
border: 0;
border-left: 2px solid var(--accent, #7c8cff);
border-radius: 4px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11.5px;
color: var(--vscode-descriptionForeground, #888);
text-align: left;
opacity: 0.78;
transition: opacity 0.15s ease, background 0.15s ease;
cursor: pointer;
}
/* Cross-Session-Recall hat eigenen, gedaempften Akzent (Cyan/Tuerkis) */
.kb-pill.recall {
border-left-color: #7ad0c0;
}
.kb-pill.recall .kb-title {
color: var(--vscode-foreground, #ccc);
font-style: italic;
}
.kb-pill:hover {
opacity: 1;
background: var(--vscode-list-hoverBackground, rgba(255,255,255,0.04));
color: var(--vscode-editor-foreground, #ddd);
}
.icon { font-size: 11px; line-height: 1; opacity: 0.85; }
.kb-id {
color: var(--accent, #7c8cff);
font-size: 10.5px;
font-weight: 600;
flex-shrink: 0;
}
.kb-title {
color: var(--vscode-foreground, #ccc);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb-meta {
opacity: 0.7;
font-size: 10.5px;
max-width: 30%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.kb-prio {
font-size: 10px;
opacity: 0.6;
flex-shrink: 0;
}
</style>

View file

@ -7,10 +7,11 @@
// Hover-Actions rechts oben: Edit (User), Regenerate (letzte Assistant),
// Copy, Das-merken, Rewind (Assistant).
import { messages, type Message, type InlineToolCall } from '$lib/stores';
import { messages, type Message, type InlineToolCall, type KnowledgeHint } from '$lib/stores';
import { processingPhase } from '$lib/stores/events';
import { renderMarkdown } from '$lib/utils/markdown';
import ToolCardAuto from './ToolCardAuto.svelte';
import KnowledgeHintPill from './KnowledgeHintPill.svelte';
interface Props {
message: Message;
@ -77,6 +78,7 @@
const showRewind = $derived(message.role === 'assistant' && onRewind);
const toolCalls = $derived<InlineToolCall[]>(message.toolCalls || []);
const knowledgeHints = $derived<KnowledgeHint[]>(message.knowledgeHints || []);
// Copy-Buttons fuer Code-Bloecke (sicher: nur Effekt auf content-Aenderung,
// kein MutationObserver — verhindert OOM bei langem Streaming)
@ -152,6 +154,25 @@
</span>
</header>
<!-- Reihenfolge: erst die Hintergrund-Aktionen (KB-Hints, Tools, Gedanken),
dann die finale Antwort. Spiegelt den echten zeitlichen Ablauf:
Claude liest KB → ruft Tools → schreibt Antwort. -->
{#if knowledgeHints.length > 0}
<div class="kb-hints">
{#each knowledgeHints as hint (hint.id)}
<KnowledgeHintPill {hint} />
{/each}
</div>
{/if}
{#if toolCalls.length > 0}
<div class="tool-calls">
{#each toolCalls as call (call.id)}
<ToolCardAuto {call} />
{/each}
</div>
{/if}
{#if message.content}
<div class="content" bind:this={contentEl}>
{@html renderMarkdown(message.content)}
@ -162,14 +183,6 @@
{:else if isStreaming}
<div class="content faint"></div>
{/if}
{#if toolCalls.length > 0}
<div class="tool-calls">
{#each toolCalls as call (call.id)}
<ToolCardAuto {call} />
{/each}
</div>
{/if}
</div>
</article>
@ -179,8 +192,10 @@
grid-template-columns: 28px 1fr;
column-gap: 10px;
padding: 10px 14px;
font-size: 13px;
line-height: 1.55;
/* User-konfigurierbar via chatAppearance Store (Settings → Chat-Darstellung) */
font-family: var(--chat-font-family, inherit);
font-size: var(--chat-font-size, 13px);
line-height: var(--chat-line-height, 1.55);
color: var(--vscode-editor-foreground);
border-bottom: 1px solid transparent;
}
@ -337,14 +352,14 @@
padding: 6px 10px;
background: transparent;
border: 0;
font-size: 12px;
font-size: var(--chat-code-font-size, 12px);
line-height: 1.55;
overflow-x: auto;
}
.content :global(code) {
font-family: var(--font-mono);
background: rgba(255, 255, 255, 0.05);
font-size: 12.5px;
font-size: var(--chat-code-font-size, 12.5px);
}
.content :global(.thinking-inline) {
display: block;
@ -370,6 +385,16 @@
}
.tool-calls {
margin-top: 4px;
margin: 2px 0 8px 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.kb-hints {
margin: 2px 0 4px 0;
display: flex;
flex-direction: column;
gap: 1px;
}
</style>

View file

@ -7,6 +7,7 @@
import { messages, isProcessing, type Message as ChatMessage } from '$lib/stores';
import { tick } from 'svelte';
import MessageItem from './Message.svelte';
import WorkingIndicator from './WorkingIndicator.svelte';
interface Props {
streamingMessageId?: string | null;
@ -27,6 +28,16 @@
let userScrolledUp = $state(false);
let scrollPending = false;
// Working-Indicator: zeigen wenn Claude verarbeitet, aber noch keine
// Assistant-Tokens da sind (vor erstem Stream + waehrend Tool-Calls).
const showWorking = $derived.by(() => {
if (!$isProcessing) return false;
const last = $messages[$messages.length - 1];
if (!last) return true;
if (last.role !== 'assistant') return true;
return !last.content?.trim();
});
function checkScroll() {
if (!container) return;
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
@ -75,6 +86,10 @@
/>
{/each}
{#if showWorking}
<WorkingIndicator />
{/if}
{#if $messages.length === 0}
<div class="empty">
<p class="empty-title">✱ Claude Code</p>

View file

@ -3,6 +3,7 @@
import { invoke } from '@tauri-apps/api/core';
import { currentModel, agentMode, type AgentMode } from '$lib/stores/app';
import { updateCheckManual } from '$lib/stores/updateTrigger';
import { chatAppearance, FONT_PRESETS, DEFAULT_APPEARANCE, resetChatAppearance, updateChatAppearance } from '$lib/stores/chatAppearance';
// === Typen ===
interface ModelInfo { id: string; name: string; description: string; }
@ -43,6 +44,7 @@
// === Kategorien ===
const categories = [
{ id: 'general', label: 'Allgemein', icon: '⚙️' },
{ id: 'appearance', label: 'Chat-Darstellung', icon: '🎨' },
{ id: 'model', label: 'Modell', icon: '🤖' },
{ id: 'commands', label: 'Commands', icon: '⌨️' },
{ id: 'hooks', label: 'Hooks', icon: '🪝' },
@ -264,6 +266,77 @@
{/if}
{/if}
<!-- CHAT-DARSTELLUNG -->
{#if activeCategory === 'appearance' || searchQuery}
{#if !searchQuery || 'darstellung schrift schriftart schriftgroesse chat font'.includes(searchQuery.toLowerCase())}
<section class="section">
<h3>🎨 Chat-Schrift</h3>
<div class="setting-row">
<div class="setting-info">
<span class="setting-key">Schriftart</span>
<span class="setting-desc">Welche Schriftfamilie wird im Chat verwendet</span>
</div>
<select
class="setting-control"
value={$chatAppearance.fontFamily}
onchange={(e) => updateChatAppearance({ fontFamily: (e.currentTarget as HTMLSelectElement).value })}
>
<option value="system">System-Default</option>
<option value="inter">Inter (Sans)</option>
<option value="serif">Serif</option>
<option value="mono">Monospace</option>
</select>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-key">Schriftgroesse</span>
<span class="setting-desc">{$chatAppearance.fontSize} px</span>
</div>
<input
type="range" min="11" max="18" step="0.5"
value={$chatAppearance.fontSize}
oninput={(e) => updateChatAppearance({ fontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })}
/>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-key">Zeilenhoehe</span>
<span class="setting-desc">{$chatAppearance.lineHeight.toFixed(2)}</span>
</div>
<input
type="range" min="1.3" max="1.8" step="0.05"
value={$chatAppearance.lineHeight}
oninput={(e) => updateChatAppearance({ lineHeight: parseFloat((e.currentTarget as HTMLInputElement).value) })}
/>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-key">Code-Bloecke</span>
<span class="setting-desc">Schriftgroesse fuer Code: {$chatAppearance.codeFontSize} px</span>
</div>
<input
type="range" min="10" max="16" step="0.5"
value={$chatAppearance.codeFontSize}
oninput={(e) => updateChatAppearance({ codeFontSize: parseFloat((e.currentTarget as HTMLInputElement).value) })}
/>
</div>
<div class="appearance-preview" style="font-family: {FONT_PRESETS[$chatAppearance.fontFamily] ?? $chatAppearance.fontFamily}; font-size: {$chatAppearance.fontSize}px; line-height: {$chatAppearance.lineHeight}">
<div class="preview-label">Vorschau</div>
<p>So sieht eine Nachricht aus. Die Schrift uebernimmt deine Einstellungen live.</p>
<pre style="font-size: {$chatAppearance.codeFontSize}px"><code>const beispiel = "Code-Block";</code></pre>
</div>
<div class="setting-row">
<button class="btn-secondary" onclick={() => resetChatAppearance()}>
Auf Standard zuruecksetzen
</button>
<span class="setting-desc">{DEFAULT_APPEARANCE.fontSize}px / {DEFAULT_APPEARANCE.lineHeight} / System</span>
</div>
</section>
{/if}
{/if}
<!-- MODELL -->
{#if activeCategory === 'model' || (searchQuery && availableModels.some(m => m.name.toLowerCase().includes(searchQuery.toLowerCase())))}
<section class="section">
@ -687,6 +760,52 @@
.setting-desc { font-size: 0.65rem; color: var(--text-secondary); }
.setting-val { font-size: 0.8rem; color: var(--text-secondary); font-family: var(--font-mono); }
.setting-control {
min-width: 180px;
font-size: 0.8rem;
}
.setting-row input[type="range"] {
min-width: 180px;
max-width: 240px;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border, var(--bg-tertiary));
border-radius: var(--radius-sm);
padding: 4px 12px;
font-size: 0.75rem;
cursor: pointer;
}
.btn-secondary:hover {
background: var(--bg-secondary);
}
.appearance-preview {
margin-top: 12px;
padding: 12px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border, var(--bg-tertiary));
border-radius: var(--radius-sm);
color: var(--text-primary);
}
.appearance-preview .preview-label {
font-size: 0.65rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.appearance-preview p { margin: 0 0 6px 0; }
.appearance-preview pre {
margin: 0;
padding: 6px 8px;
background: var(--bg-input);
border-radius: 3px;
overflow-x: auto;
}
/* Commands */
.command-list {
display: flex;

View file

@ -46,13 +46,16 @@
}
</script>
<div class="tool-card vscode-card vscode-card-accent {statusClass(call.status)}">
<!-- Tool-Card: kompakte „Background-Aktion"-Pille. Bewusst dezenter als
Chat-Messages — kleinerer Font, monospaced, gedämpfte Farben.
Sobald done und nichts Spannendes drin → bleibt collapsed. -->
<div class="tool-card {statusClass(call.status)}" class:open={isOpen}>
<button class="card-header" onclick={toggle} aria-expanded={isOpen}>
<span class="chevron" class:open={isOpen}>▸</span>
<span class="chevron" class:open={isOpen}></span>
<span class="icon">{meta.icon}</span>
<span class="tool-name">{meta.label}</span>
{#if subtitle}
<span class="subtitle">· {subtitle}</span>
<span class="subtitle">{subtitle}</span>
{/if}
<span class="status-spacer"></span>
{#if call.status === 'running'}
@ -75,9 +78,27 @@
<style>
.tool-card {
margin: 0.5rem 0;
font-size: 12px;
margin: 3px 0;
font-size: 11.5px;
overflow: hidden;
border-radius: 4px;
border-left: 2px solid var(--vscode-input-border, #3c3c3c);
opacity: 0.78;
transition: opacity 0.15s ease;
}
.tool-card.running {
opacity: 1;
border-left-color: var(--vscode-progressBar-background, #3794ff);
}
.tool-card.error {
opacity: 1;
border-left-color: var(--vscode-errorForeground, #f48771);
}
.tool-card.open {
opacity: 1;
}
.tool-card:hover {
opacity: 1;
}
.card-header {
@ -85,83 +106,88 @@
align-items: center;
width: 100%;
gap: 6px;
padding: 6px 10px;
padding: 3px 8px;
background: transparent;
color: var(--vscode-editor-foreground);
color: var(--vscode-descriptionForeground, #888);
text-align: left;
font-size: 12px;
line-height: 1.4;
font-size: 11.5px;
line-height: 1.5;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
min-height: 22px;
}
.card-header:hover {
background: var(--vscode-list-hoverBackground);
background: var(--vscode-list-hoverBackground, rgba(255,255,255,0.04));
color: var(--vscode-editor-foreground, #ddd);
}
.chevron {
display: inline-block;
font-size: 10px;
width: 10px;
font-size: 12px;
width: 8px;
color: var(--vscode-descriptionForeground);
transition: transform 0.12s ease;
flex-shrink: 0;
}
.chevron.open {
transform: rotate(90deg);
}
.icon {
font-size: 13px;
font-size: 11px;
line-height: 1;
opacity: 0.85;
}
.tool-name {
font-weight: 600;
color: var(--vscode-editor-foreground);
font-weight: 500;
color: var(--vscode-foreground, #ccc);
font-size: 11.5px;
}
.subtitle {
color: var(--vscode-descriptionForeground);
font-family: var(--font-mono);
font-size: 11.5px;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
max-width: 55%;
opacity: 0.85;
}
.status-spacer {
flex: 1;
}
.status-spacer { flex: 1; }
.status-icon {
font-size: 11px;
font-size: 10px;
font-weight: 700;
}
.status-icon.ok { color: var(--vscode-successForeground); }
.status-icon.error { color: var(--vscode-errorForeground); }
.status-icon.ok { color: var(--vscode-successForeground, #89d185); opacity: 0.7; }
.status-icon.error { color: var(--vscode-errorForeground, #f48771); }
.status-dots {
display: inline-flex;
gap: 3px;
}
.status-dots span {
width: 4px;
height: 4px;
background: var(--vscode-progressBar-background);
width: 3px;
height: 3px;
background: var(--vscode-progressBar-background, #3794ff);
border-radius: 50%;
animation: dotPulse 1.2s ease-in-out infinite;
}
.status-dots span:nth-child(2) { animation-delay: 0.15s; }
.status-dots span:nth-child(3) { animation-delay: 0.3s; }
@keyframes dotPulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
40% { opacity: 1; transform: scale(1); }
40% { opacity: 1; transform: scale(1); }
}
.card-body {
padding: 8px 10px 10px 10px;
border-top: 1px solid var(--vscode-input-border);
background: var(--vscode-editor-background);
padding: 6px 10px 8px 18px;
font-size: 11.5px;
font-family: var(--font-mono, monospace);
color: var(--vscode-descriptionForeground);
background: var(--vscode-input-background, rgba(0,0,0,0.15));
border-top: 1px solid var(--vscode-input-border, #3c3c3c);
}
</style>

View file

@ -0,0 +1,141 @@
<script lang="ts">
// Animierter "Claude arbeitet"-Indikator unter der letzten Message.
// Rotiert ein zufaelliges deutsches Verb alle ~2.5s, daneben laeuft ein
// Braille-Spinner und ein Sekunden-Counter. Stil orientiert sich an
// Claude Code.
import { onMount, onDestroy } from 'svelte';
const VERBS = [
'Denke nach',
'Gruebele',
'Bruete',
'Sinniere',
'Tueftle',
'Werkle',
'Stoebere',
'Knirsche',
'Bastle',
'Mische',
'Brodelt',
'Kluegele',
'Wuehle',
'Krame',
'Spinne',
'Schmiede',
'Pruefe',
'Lese',
'Verdaue',
'Sortiere',
'Sammle',
'Suche',
'Forsche',
'Faedele ein',
'Knete',
'Falte die Stirn',
'Klopfe ab',
'Lege Hand an',
];
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let verb = $state(pickVerb());
let spinnerIdx = $state(0);
let elapsed = $state(0);
let verbTimer: ReturnType<typeof setInterval> | null = null;
let spinTimer: ReturnType<typeof setInterval> | null = null;
let secondsTimer: ReturnType<typeof setInterval> | null = null;
let lastVerb = '';
function pickVerb(): string {
// Nicht zweimal hintereinander dasselbe Wort
let next = VERBS[Math.floor(Math.random() * VERBS.length)];
if (next === lastVerb && VERBS.length > 1) {
next = VERBS[(VERBS.indexOf(next) + 1) % VERBS.length];
}
lastVerb = next;
return next;
}
onMount(() => {
const start = Date.now();
verbTimer = setInterval(() => { verb = pickVerb(); }, 2500);
spinTimer = setInterval(() => {
spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length;
}, 90);
secondsTimer = setInterval(() => {
elapsed = Math.floor((Date.now() - start) / 1000);
}, 1000);
});
onDestroy(() => {
if (verbTimer) clearInterval(verbTimer);
if (spinTimer) clearInterval(spinTimer);
if (secondsTimer) clearInterval(secondsTimer);
});
</script>
<div class="working" role="status" aria-live="polite">
<span class="spinner">{SPINNER_FRAMES[spinnerIdx]}</span>
<span class="verb">{verb}</span>
<span class="dots"><span>.</span><span>.</span><span>.</span></span>
{#if elapsed > 1}
<span class="elapsed">({elapsed}s)</span>
{/if}
</div>
<style>
.working {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px 14px 52px;
font-size: 13px;
color: var(--vscode-descriptionForeground, #888);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
opacity: 0;
animation: fade-in 200ms ease-out forwards;
}
.spinner {
color: var(--accent, var(--vscode-button-background, #007acc));
font-size: 15px;
line-height: 1;
display: inline-block;
min-width: 14px;
text-align: center;
}
.verb {
color: var(--text-primary, var(--vscode-foreground, #ddd));
font-style: italic;
min-width: 100px;
transition: opacity 200ms ease;
}
.dots {
display: inline-flex;
gap: 2px;
color: var(--text-primary, var(--vscode-foreground, #ddd));
}
.dots span {
animation: dot-pulse 1.4s ease-in-out infinite;
}
.dots span:nth-child(2) { animation-delay: 200ms; }
.dots span:nth-child(3) { animation-delay: 400ms; }
.elapsed {
color: var(--text-muted, #888);
font-size: 11px;
margin-left: 4px;
}
@keyframes fade-in {
to { opacity: 1; }
}
@keyframes dot-pulse {
0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-2px); }
}
</style>

View file

@ -48,6 +48,7 @@ export interface Message {
model?: string;
queued?: boolean; // Nachricht wartet in der Queue auf Dispatch
toolCalls?: InlineToolCall[]; // Inline gerenderte Tool-Karten (Phase 8)
knowledgeHints?: KnowledgeHint[]; // KB-Treffer die fuer diese Antwort herangezogen wurden
}
export interface Permission {

View file

@ -0,0 +1,80 @@
// Chat-Darstellungs-Einstellungen — persistiert in localStorage,
// angewendet via CSS-Variablen am <html>-Element.
//
// Werden in den Settings unter "Chat-Darstellung" verstellt und live im
// Chat-Fenster wirksam ohne App-Reload.
import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
export interface ChatAppearance {
fontFamily: string; // CSS font-family Wert (Stack zulaessig)
fontSize: number; // px
lineHeight: number; // unitless
codeFontSize: number; // px — Code-Bloecke separat
toolCardScale: number; // 0.85..1.15 — Multiplikator fuer Tool-/Hint-Pillen
}
export const DEFAULT_APPEARANCE: ChatAppearance = {
fontFamily: 'system',
fontSize: 13,
lineHeight: 1.5,
codeFontSize: 12,
toolCardScale: 1.0,
};
// Vordefinierte Schriftarten — "system" trifft die Plattform-Default-Sans
export const FONT_PRESETS: Record<string, string> = {
system: 'system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
inter: '"Inter", system-ui, sans-serif',
serif: 'Georgia, "Times New Roman", serif',
mono: 'ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace',
};
const STORAGE_KEY = 'claude-desktop:chat-appearance';
function loadFromStorage(): ChatAppearance {
if (!browser) return DEFAULT_APPEARANCE;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_APPEARANCE;
const parsed = JSON.parse(raw);
return { ...DEFAULT_APPEARANCE, ...parsed };
} catch {
return DEFAULT_APPEARANCE;
}
}
export const chatAppearance = writable<ChatAppearance>(loadFromStorage());
// CSS-Variablen am Document-Root setzen
function applyToDocument(a: ChatAppearance) {
if (!browser) return;
const root = document.documentElement;
const family = FONT_PRESETS[a.fontFamily] ?? a.fontFamily;
root.style.setProperty('--chat-font-family', family);
root.style.setProperty('--chat-font-size', `${a.fontSize}px`);
root.style.setProperty('--chat-line-height', String(a.lineHeight));
root.style.setProperty('--chat-code-font-size', `${a.codeFontSize}px`);
root.style.setProperty('--chat-tool-scale', String(a.toolCardScale));
}
if (browser) {
// Initialer Apply
applyToDocument(get(chatAppearance));
// Persistieren + Apply bei jeder Aenderung
chatAppearance.subscribe((a) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(a));
} catch { /* ignore */ }
applyToDocument(a);
});
}
export function resetChatAppearance() {
chatAppearance.set({ ...DEFAULT_APPEARANCE });
}
export function updateChatAppearance(patch: Partial<ChatAppearance>) {
chatAppearance.update((a) => ({ ...a, ...patch }));
}

View file

@ -159,6 +159,75 @@ let listeners: UnlistenFn[] = [];
// Streaming: ID der aktuellen Live-Nachricht
let streamingMessageId: string | null = null;
// Block C: Cross-Session-Recall — pro neuer Assistant-Antwort hoechstens einmal triggern.
// Wird zurueckgesetzt sobald eine neue Streaming-Message anlegt wird (agent-started).
let recallTriggeredForCurrent = false;
interface PastMessageMatch {
id: string;
session_id: string;
role: string;
snippet: string;
timestamp: string;
session_title?: string | null;
}
async function triggerCrossSessionRecall() {
if (recallTriggeredForCurrent) return;
recallTriggeredForCurrent = true;
// Letzte User-Message als Such-Query verwenden
const all = get(messages);
let query = '';
for (let i = all.length - 1; i >= 0; i--) {
if (all[i].role === 'user' && all[i].content?.trim()) {
query = all[i].content;
break;
}
}
if (!query || query.length < 10) return;
try {
const sessionId = get(currentSessionId) || undefined;
const matches = await invoke<PastMessageMatch[]>('search_past_messages', {
query,
currentSessionId: sessionId,
limit: 1, // bewusst nur ein Treffer — Pille soll nicht zumuellen
});
if (!matches || matches.length === 0) return;
// Top-Treffer als pseudo-Hint an aktuelle Assistant-Message anhaengen,
// markiert via Kategorie 'recall' damit die Pille als "Schon mal beantwortet" rendert.
const m = matches[0];
const date = m.timestamp ? new Date(m.timestamp).toLocaleDateString('de-DE') : '';
const title = m.session_title || `Session vom ${date}`;
messages.update((msgs) => {
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i];
if (msg.role !== 'assistant') {
if (msg.role === 'user') break;
continue;
}
const recallHint = {
id: -Math.abs(parseInt(m.id.replace(/\D/g, '').slice(0, 9) || '0', 10) || 1),
category: 'recall',
title: `🕒 Schon mal beantwortet · ${title}`,
content: m.snippet,
tags: m.session_id,
priority: 0,
};
const existing = msg.knowledgeHints || [];
if (existing.some((h) => h.id === recallHint.id)) return msgs;
const next = { ...msg, knowledgeHints: [...existing, recallHint] };
return [...msgs.slice(0, i), next, ...msgs.slice(i + 1)];
}
return msgs;
});
} catch (err) {
console.debug('Cross-Session-Recall fehlgeschlagen:', err);
}
}
// Nachricht in DB speichern
async function saveMessageToDb(msg: Message) {
const sessionId = get(currentSessionId);
@ -181,6 +250,12 @@ export async function initEventListeners(): Promise<void> {
// Monitor-Events aus DB laden (letzte Session)
await loadMonitorEventsFromDb(500);
// Block C: FTS5-Index fuer Cross-Session-Recall einmalig syncen
// (idempotent — tut nichts wenn der Index schon vollstaendig ist).
invoke<number>('rebuild_messages_fts').then((added) => {
if (added > 0) console.log(`🔍 FTS5: ${added} Messages indiziert`);
}).catch((err) => console.debug('rebuild_messages_fts fehlgeschlagen:', err));
// Bridge bereit
listeners.push(
await listen('bridge-ready', () => {
@ -216,6 +291,7 @@ export async function initEventListeners(): Promise<void> {
// Leere Streaming-Nachricht anlegen
streamingMessageId = crypto.randomUUID();
recallTriggeredForCurrent = false; // Block C: pro neuer Antwort 1x recall
messages.update((msgs) => [
...msgs,
{
@ -333,6 +409,26 @@ export async function initEventListeners(): Promise<void> {
if (hints && hints.length > 0) {
activeKnowledgeHints.set(hints);
console.log('💡 Wissens-Hints geladen:', hints.map(h => h.title));
// An die letzte Assistant-Message dranhaengen damit sie als
// Pille im Chat sichtbar werden — neue Hints werden mit
// vorhandenen merged (Dedup per ID).
messages.update((msgs) => {
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i];
if (m.role !== 'assistant') {
if (m.role === 'user') break;
continue;
}
const existing = m.knowledgeHints || [];
const seen = new Set(existing.map((h) => h.id));
const fresh = hints.filter((h) => !seen.has(h.id));
if (fresh.length === 0) return msgs;
const next = { ...m, knowledgeHints: [...existing, ...fresh] };
return [...msgs.slice(0, i), next, ...msgs.slice(i + 1)];
}
return msgs;
});
}
} catch (err) {
// Fehler beim Laden ignorieren — Hints sind optional
@ -353,6 +449,17 @@ export async function initEventListeners(): Promise<void> {
// Phase 8: Inline-Karte in der Assistant-Message finalisieren
finalizeInlineToolCall(id, output, !success);
// Block B: bei Tool-Erfolg die zuletzt geladenen KB-Hints als
// hilfreich markieren (fire-and-forget, async). Heuristik bewusst
// grob — Tool laeuft mit Hints durch ohne Fehler => Hints waren
// vermutlich relevant. Sortier-Boost bei naechster Suche.
if (success) {
const hints = get(activeKnowledgeHints);
for (const h of hints) {
invoke('track_knowledge_hit', { id: h.id }).catch(() => { /* ignore */ });
}
}
// Hook: post-tool-use (fire-and-forget, Fehler blockieren nicht)
invoke('fire_hook', {
event: 'PostToolUse',
@ -401,6 +508,8 @@ export async function initEventListeners(): Promise<void> {
)
);
}
// Block C: Beim allerersten Token einmal Cross-Session-Recall ausloesen
triggerCrossSessionRecall();
})
);

View file

@ -0,0 +1,356 @@
// Headless Voice-Conversation-Engine — kein UI, nur State + Audio-Logik.
//
// Nutzt:
// - Mikrofon (MediaRecorder + AnalyserNode fuer VAD und Barge-In)
// - transcribe_audio (Whisper, Tauri-Command)
// - send_message (an Claude, Tauri-Command)
// - text_to_speech (Piper, Tauri-Command)
//
// Bietet:
// - conversationState: Writable<'idle' | 'connecting' | 'listening' | 'transcribing' | 'waiting' | 'speaking'>
// - conversationActive: Writable<boolean>
// - currentVolume: Writable<number> (0..1, fuer UI-Pulse)
// - lastError: Writable<string>
// - startConversation(), stopConversation()
//
// VoicePanel.svelte und ChatPanel.svelte koennen beide darauf zugreifen.
// Der Conversation-Loop ist genau einmal aktiv (idempotent).
import { writable, get } from 'svelte/store';
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { messages, addMessage } from '$lib/stores/app';
export type ConversationState =
| 'idle'
| 'connecting'
| 'listening'
| 'transcribing'
| 'waiting'
| 'speaking';
export const conversationState = writable<ConversationState>('idle');
export const conversationActive = writable(false);
export const currentVolume = writable(0);
export const lastError = writable('');
// Privater Zustand (Modul-Singleton) ----------------------------------------
let audioContext: AudioContext | null = null;
let mediaStream: MediaStream | null = null;
let mediaRecorder: MediaRecorder | null = null;
let audioChunks: Blob[] = [];
let analyser: AnalyserNode | null = null;
let animationFrame: number | null = null;
let ttsAudio: HTMLAudioElement | null = null;
// VAD
const SILENCE_THRESHOLD = 0.03;
const SILENCE_DURATION = 1800;
const MIN_RECORDING = 500;
let silenceStart = 0;
let recordingStart = 0;
// ---------------------------------------------------------------------------
function setState(s: ConversationState) { conversationState.set(s); }
function isActive() { return get(conversationActive); }
export async function startConversation(): Promise<void> {
if (isActive()) return;
conversationActive.set(true);
lastError.set('');
setState('connecting');
console.log('🎙️ Konversation gestartet');
try {
await initMicrophone();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
lastError.set(msg);
conversationActive.set(false);
setState('idle');
cleanupAudio();
return;
}
startListening();
}
export function stopConversation() {
conversationActive.set(false);
stopSpeaking();
stopRecording();
cleanupAudio();
setState('idle');
console.log('🎙️ Konversation beendet');
}
// Mikrofon ------------------------------------------------------------------
async function initMicrophone(): Promise<void> {
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') {
throw new Error(
'Mikrofon-API nicht verfuegbar. Auf NixOS: PipeWire + gst-plugins-pipewire pruefen.'
);
}
const getMic = async (constraints: MediaStreamConstraints): Promise<MediaStream> => {
const timeout = new Promise<MediaStream>((_, reject) =>
setTimeout(() => reject(new Error('__MIC_TIMEOUT__')), 8000)
);
return Promise.race([navigator.mediaDevices.getUserMedia(constraints), timeout]);
};
try {
mediaStream = await getMic({
audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 },
});
} catch (err1: any) {
if (err1?.message === '__MIC_TIMEOUT__') {
throw new Error('Mikrofon antwortet nicht (Timeout 8s).');
}
try {
mediaStream = await getMic({ audio: true });
} catch (err2: any) {
const name = err2?.name || err1?.name || '';
if (name === 'NotAllowedError' || name === 'SecurityError') {
throw new Error('Mikrofon-Zugriff verweigert.');
}
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
throw new Error('Kein Mikrofon gefunden.');
}
throw new Error(`Mikrofon-Fehler: ${err2?.message || err1?.message || name || 'unbekannt'}`);
}
}
try {
audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(mediaStream);
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
} catch (err: any) {
cleanupAudio();
throw new Error(`AudioContext-Fehler: ${err?.message || err}.`);
}
}
function cleanupAudio() {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try { mediaRecorder.stop(); } catch { /* ignore */ }
}
mediaRecorder = null;
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop());
mediaStream = null;
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => { /* ignore */ });
}
audioContext = null;
analyser = null;
audioChunks = [];
}
// Listening + VAD -----------------------------------------------------------
function startListening() {
if (!isActive() || !mediaStream) return;
setState('listening');
audioChunks = [];
silenceStart = 0;
recordingStart = Date.now();
try {
mediaRecorder = new MediaRecorder(mediaStream, { mimeType: 'audio/webm;codecs=opus' });
} catch {
mediaRecorder = new MediaRecorder(mediaStream);
}
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = () => {
if (audioChunks.length > 0 && isActive()) processRecording();
};
mediaRecorder.start(100);
monitorVAD();
}
function monitorVAD() {
if (!analyser || get(conversationState) !== 'listening') return;
const buffer = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(buffer);
let sum = 0;
for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
const rms = Math.sqrt(sum / buffer.length);
currentVolume.set(rms);
const now = Date.now();
const dur = now - recordingStart;
if (rms < SILENCE_THRESHOLD) {
if (silenceStart === 0) silenceStart = now;
if (now - silenceStart > SILENCE_DURATION && dur > MIN_RECORDING) {
stopRecording();
return;
}
} else {
silenceStart = 0;
}
animationFrame = requestAnimationFrame(monitorVAD);
}
function stopRecording() {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
}
async function processRecording() {
if (!isActive()) return;
setState('transcribing');
const blob = new Blob(audioChunks, { type: 'audio/webm' });
audioChunks = [];
if (blob.size < 1000) {
if (isActive()) startListening();
return;
}
const base64 = await blobToBase64(blob);
try {
const text: string = await invoke('transcribe_audio', { audioBase64: base64, format: 'webm' });
const cleaned = text.trim();
if (!cleaned || cleaned.length < 2) {
if (isActive()) startListening();
return;
}
// User-Message in Chat eintragen
addMessage('user', cleaned);
// Claude fragen
setState('waiting');
await invoke('send_message', { message: cleaned });
const response = await waitForResponse();
if (response && isActive()) {
const ttsText = prepareTtsText(response);
setState('speaking');
await speakAndWait(ttsText);
}
if (isActive()) startListening();
} catch (err) {
console.error('Verarbeitung fehlgeschlagen:', err);
lastError.set(`Fehler: ${err}`);
setTimeout(() => lastError.set(''), 5000);
if (isActive()) startListening();
}
}
async function waitForResponse(): Promise<string | null> {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
if (unlisten) unlisten();
resolve(null);
}, 120_000);
let unlisten: UnlistenFn;
listen('all-stopped', () => {
clearTimeout(timeout);
if (unlisten) unlisten();
const all = get(messages);
const last = [...all].reverse().find((m) => m.role === 'assistant' && m.content.trim());
resolve(last ? last.content.trim() : null);
}).then((fn: UnlistenFn) => { unlisten = fn; });
});
}
// TTS + Barge-In ------------------------------------------------------------
function prepareTtsText(text: string): string {
let clean = text.replace(/```[\s\S]*?```/g, '');
clean = clean.replace(/`[^`]+`/g, '');
clean = clean.replace(/[*_~#]+/g, '');
clean = clean.replace(/https?:\/\/\S+/g, '');
clean = clean.replace(/\s+/g, ' ').trim();
if (clean.length > 600) {
const cut = clean.lastIndexOf('.', 600);
clean = cut > 200 ? clean.substring(0, cut + 1) : clean.substring(0, 600) + '…';
}
return clean;
}
async function speakAndWait(text: string): Promise<void> {
if (!text || !isActive()) return;
try {
const audioBase64: string = await invoke('text_to_speech', { text, voice: null });
return new Promise((resolve) => {
if (ttsAudio) { ttsAudio.pause(); ttsAudio = null; }
ttsAudio = new Audio(`data:audio/wav;base64,${audioBase64}`);
ttsAudio.onended = () => { ttsAudio = null; resolve(); };
ttsAudio.onerror = () => { ttsAudio = null; resolve(); };
ttsAudio.play().catch(() => resolve());
monitorInterrupt(resolve);
});
} catch (err) {
console.error('TTS fehlgeschlagen:', err);
}
}
function monitorInterrupt(onInterrupt: () => void) {
if (!analyser || get(conversationState) !== 'speaking') return;
const buffer = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(buffer);
let sum = 0;
for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
const rms = Math.sqrt(sum / buffer.length);
currentVolume.set(rms);
// Schwelle 3x normales Stille-Niveau, ueber 300ms gehalten
if (rms > SILENCE_THRESHOLD * 3) {
console.log('⚡ Barge-In erkannt');
stopSpeaking();
onInterrupt();
return;
}
if (get(conversationState) === 'speaking' && ttsAudio && !ttsAudio.paused) {
requestAnimationFrame(() => monitorInterrupt(onInterrupt));
}
}
function stopSpeaking() {
if (ttsAudio) {
ttsAudio.pause();
ttsAudio.currentTime = 0;
ttsAudio = null;
}
}
// Helpers -------------------------------------------------------------------
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
resolve(result.split(',')[1]); // strip "data:audio/webm;base64,"
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}