feat: KB-Hints, Voice-Konversation, Chat-Darstellung, Cross-Session-Recall [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m8s
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:
parent
6d3a0d8740
commit
fec8aea22c
16 changed files with 1403 additions and 54 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
19
src/app.css
19
src/app.css
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
130
src/lib/components/ConversationBanner.svelte
Normal file
130
src/lib/components/ConversationBanner.svelte
Normal 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>
|
||||
110
src/lib/components/KnowledgeHintPill.svelte
Normal file
110
src/lib/components/KnowledgeHintPill.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
141
src/lib/components/WorkingIndicator.svelte
Normal file
141
src/lib/components/WorkingIndicator.svelte
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
80
src/lib/stores/chatAppearance.ts
Normal file
80
src/lib/stores/chatAppearance.ts
Normal 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 }));
|
||||
}
|
||||
|
|
@ -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();
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
356
src/lib/voice/conversationEngine.ts
Normal file
356
src/lib/voice/conversationEngine.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue