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,
|
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)
|
/// Ein Monitor-Event (System-Log)
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct MonitorEvent {
|
pub struct MonitorEvent {
|
||||||
|
|
@ -195,6 +206,26 @@ impl Database {
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
|
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)
|
-- Monitor-Events (System-Log)
|
||||||
CREATE TABLE IF NOT EXISTS monitor_events (
|
CREATE TABLE IF NOT EXISTS monitor_events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|
@ -717,6 +748,86 @@ impl Database {
|
||||||
Ok(())
|
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
|
/// Zählt Nachrichten einer Session
|
||||||
pub fn count_messages(&self, session_id: &str) -> SqlResult<usize> {
|
pub fn count_messages(&self, session_id: &str) -> SqlResult<usize> {
|
||||||
self.conn.query_row(
|
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())
|
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
|
/// Session kompaktieren — fasst alte Nachrichten zusammen
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn compact_session(
|
pub async fn compact_session(
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,7 @@ async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &O
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN tags LIKE ? THEN 1 ELSE 0 END DESC,
|
CASE WHEN tags LIKE ? THEN 1 ELSE 0 END DESC,
|
||||||
priority DESC,
|
priority DESC,
|
||||||
|
usage_count DESC,
|
||||||
relevance DESC
|
relevance DESC
|
||||||
LIMIT ?"#,
|
LIMIT ?"#,
|
||||||
(search_query, search_query, &proj_pattern, fetch_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
|
FROM knowledge
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
|
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 ?"#,
|
LIMIT ?"#,
|
||||||
(search_query, search_query, fetch_limit),
|
(search_query, search_query, fetch_limit),
|
||||||
).await.map_err(|e| e.to_string())?
|
).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
|
FROM knowledge
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
|
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 ?"#,
|
LIMIT ?"#,
|
||||||
(search_query, search_query, limit),
|
(search_query, search_query, limit),
|
||||||
).await.map_err(|e| e.to_string())?;
|
).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)
|
WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
|
||||||
AND category = ?
|
AND category = ?
|
||||||
AND status = 'active'
|
AND status = 'active'
|
||||||
ORDER BY priority DESC, relevance DESC
|
ORDER BY priority DESC, usage_count DESC, relevance DESC
|
||||||
LIMIT ?"#,
|
LIMIT ?"#,
|
||||||
(&query, &query, &cat, limit),
|
(&query, &query, &cat, limit),
|
||||||
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance):
|
|(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
|
FROM knowledge
|
||||||
WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
|
WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
|
||||||
AND status = 'active'
|
AND status = 'active'
|
||||||
ORDER BY priority DESC, relevance DESC
|
ORDER BY priority DESC, usage_count DESC, relevance DESC
|
||||||
LIMIT ?"#,
|
LIMIT ?"#,
|
||||||
(&query, &query, limit),
|
(&query, &query, limit),
|
||||||
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance):
|
|(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")
|
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::load_messages,
|
||||||
db::clear_messages,
|
db::clear_messages,
|
||||||
db::compact_session,
|
db::compact_session,
|
||||||
|
db::search_past_messages,
|
||||||
|
db::rebuild_messages_fts,
|
||||||
// Monitor-Events
|
// Monitor-Events
|
||||||
db::save_monitor_event,
|
db::save_monitor_event,
|
||||||
db::load_monitor_events,
|
db::load_monitor_events,
|
||||||
|
|
@ -142,6 +144,8 @@ pub fn run() {
|
||||||
// Phase 3.1: Smart Hints v2 — Session-Management
|
// Phase 3.1: Smart Hints v2 — Session-Management
|
||||||
knowledge::reset_kb_session,
|
knowledge::reset_kb_session,
|
||||||
knowledge::get_kb_session_status,
|
knowledge::get_kb_session_status,
|
||||||
|
// Block B: Usage-Tracking — markiert KB-Treffer als hilfreich
|
||||||
|
knowledge::track_knowledge_hit,
|
||||||
// Context-Management
|
// Context-Management
|
||||||
context::get_sticky_context,
|
context::get_sticky_context,
|
||||||
context::set_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);
|
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 {
|
input:focus, textarea:focus, select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--focus);
|
border-color: var(--focus);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
import FileMention from './FileMention.svelte';
|
import FileMention from './FileMention.svelte';
|
||||||
import QuickActions from './QuickActions.svelte';
|
import QuickActions from './QuickActions.svelte';
|
||||||
import MessageList from './MessageList.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
|
// ChatStatusBar entfernt (Phase 9): wandert in globale StatusBar im +layout
|
||||||
|
|
||||||
// Props
|
// 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(() => {
|
onDestroy(() => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
// Voice-Aufnahme stoppen falls aktiv
|
// Voice-Aufnahme stoppen falls aktiv
|
||||||
|
|
@ -1118,6 +1156,9 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Konversations-Banner: nur sichtbar wenn Voice-Konversation laeuft -->
|
||||||
|
<ConversationBanner />
|
||||||
|
|
||||||
<!-- Phase 9: Lokaler Header entfernt — Brand+Stats sind in der globalen
|
<!-- Phase 9: Lokaler Header entfernt — Brand+Stats sind in der globalen
|
||||||
Titlebar / StatusBar. Detach-Button als kleine Icon-Action im Tab-
|
Titlebar / StatusBar. Detach-Button als kleine Icon-Action im Tab-
|
||||||
Bereich (oben rechts), realisiert ueber kompakte Toolbar. -->
|
Bereich (oben rechts), realisiert ueber kompakte Toolbar. -->
|
||||||
|
|
@ -1219,10 +1260,20 @@
|
||||||
<button
|
<button
|
||||||
class="mic-button"
|
class="mic-button"
|
||||||
class:recording={isRecording}
|
class:recording={isRecording}
|
||||||
onclick={toggleRecording}
|
class:conversation={$conversationActive}
|
||||||
title={isRecording ? 'Aufnahme stoppen' : 'Spracheingabe starten'}
|
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>
|
<span class="mic-icon recording">⏹</span>
|
||||||
<div class="audio-level" style="height: {audioLevel}%"></div>
|
<div class="audio-level" style="height: {audioLevel}%"></div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -2095,10 +2146,20 @@
|
||||||
animation: pulse-recording 1.5s ease-in-out infinite;
|
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 {
|
@keyframes pulse-recording {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
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); }
|
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 {
|
.mic-icon {
|
||||||
z-index: 1;
|
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),
|
// Hover-Actions rechts oben: Edit (User), Regenerate (letzte Assistant),
|
||||||
// Copy, Das-merken, Rewind (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 { processingPhase } from '$lib/stores/events';
|
||||||
import { renderMarkdown } from '$lib/utils/markdown';
|
import { renderMarkdown } from '$lib/utils/markdown';
|
||||||
import ToolCardAuto from './ToolCardAuto.svelte';
|
import ToolCardAuto from './ToolCardAuto.svelte';
|
||||||
|
import KnowledgeHintPill from './KnowledgeHintPill.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message;
|
message: Message;
|
||||||
|
|
@ -77,6 +78,7 @@
|
||||||
const showRewind = $derived(message.role === 'assistant' && onRewind);
|
const showRewind = $derived(message.role === 'assistant' && onRewind);
|
||||||
|
|
||||||
const toolCalls = $derived<InlineToolCall[]>(message.toolCalls || []);
|
const toolCalls = $derived<InlineToolCall[]>(message.toolCalls || []);
|
||||||
|
const knowledgeHints = $derived<KnowledgeHint[]>(message.knowledgeHints || []);
|
||||||
|
|
||||||
// Copy-Buttons fuer Code-Bloecke (sicher: nur Effekt auf content-Aenderung,
|
// Copy-Buttons fuer Code-Bloecke (sicher: nur Effekt auf content-Aenderung,
|
||||||
// kein MutationObserver — verhindert OOM bei langem Streaming)
|
// kein MutationObserver — verhindert OOM bei langem Streaming)
|
||||||
|
|
@ -152,6 +154,25 @@
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</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}
|
{#if message.content}
|
||||||
<div class="content" bind:this={contentEl}>
|
<div class="content" bind:this={contentEl}>
|
||||||
{@html renderMarkdown(message.content)}
|
{@html renderMarkdown(message.content)}
|
||||||
|
|
@ -162,14 +183,6 @@
|
||||||
{:else if isStreaming}
|
{:else if isStreaming}
|
||||||
<div class="content faint">▍</div>
|
<div class="content faint">▍</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if toolCalls.length > 0}
|
|
||||||
<div class="tool-calls">
|
|
||||||
{#each toolCalls as call (call.id)}
|
|
||||||
<ToolCardAuto {call} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
@ -179,8 +192,10 @@
|
||||||
grid-template-columns: 28px 1fr;
|
grid-template-columns: 28px 1fr;
|
||||||
column-gap: 10px;
|
column-gap: 10px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 13px;
|
/* User-konfigurierbar via chatAppearance Store (Settings → Chat-Darstellung) */
|
||||||
line-height: 1.55;
|
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);
|
color: var(--vscode-editor-foreground);
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
@ -337,14 +352,14 @@
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
font-size: 12px;
|
font-size: var(--chat-code-font-size, 12px);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.content :global(code) {
|
.content :global(code) {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
font-size: 12.5px;
|
font-size: var(--chat-code-font-size, 12.5px);
|
||||||
}
|
}
|
||||||
.content :global(.thinking-inline) {
|
.content :global(.thinking-inline) {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -370,6 +385,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-calls {
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { messages, isProcessing, type Message as ChatMessage } from '$lib/stores';
|
import { messages, isProcessing, type Message as ChatMessage } from '$lib/stores';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import MessageItem from './Message.svelte';
|
import MessageItem from './Message.svelte';
|
||||||
|
import WorkingIndicator from './WorkingIndicator.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
streamingMessageId?: string | null;
|
streamingMessageId?: string | null;
|
||||||
|
|
@ -27,6 +28,16 @@
|
||||||
let userScrolledUp = $state(false);
|
let userScrolledUp = $state(false);
|
||||||
let scrollPending = 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() {
|
function checkScroll() {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
|
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
|
@ -75,6 +86,10 @@
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if showWorking}
|
||||||
|
<WorkingIndicator />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if $messages.length === 0}
|
{#if $messages.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p class="empty-title">✱ Claude Code</p>
|
<p class="empty-title">✱ Claude Code</p>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { currentModel, agentMode, type AgentMode } from '$lib/stores/app';
|
import { currentModel, agentMode, type AgentMode } from '$lib/stores/app';
|
||||||
import { updateCheckManual } from '$lib/stores/updateTrigger';
|
import { updateCheckManual } from '$lib/stores/updateTrigger';
|
||||||
|
import { chatAppearance, FONT_PRESETS, DEFAULT_APPEARANCE, resetChatAppearance, updateChatAppearance } from '$lib/stores/chatAppearance';
|
||||||
|
|
||||||
// === Typen ===
|
// === Typen ===
|
||||||
interface ModelInfo { id: string; name: string; description: string; }
|
interface ModelInfo { id: string; name: string; description: string; }
|
||||||
|
|
@ -43,6 +44,7 @@
|
||||||
// === Kategorien ===
|
// === Kategorien ===
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'general', label: 'Allgemein', icon: '⚙️' },
|
{ id: 'general', label: 'Allgemein', icon: '⚙️' },
|
||||||
|
{ id: 'appearance', label: 'Chat-Darstellung', icon: '🎨' },
|
||||||
{ id: 'model', label: 'Modell', icon: '🤖' },
|
{ id: 'model', label: 'Modell', icon: '🤖' },
|
||||||
{ id: 'commands', label: 'Commands', icon: '⌨️' },
|
{ id: 'commands', label: 'Commands', icon: '⌨️' },
|
||||||
{ id: 'hooks', label: 'Hooks', icon: '🪝' },
|
{ id: 'hooks', label: 'Hooks', icon: '🪝' },
|
||||||
|
|
@ -264,6 +266,77 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/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 -->
|
<!-- MODELL -->
|
||||||
{#if activeCategory === 'model' || (searchQuery && availableModels.some(m => m.name.toLowerCase().includes(searchQuery.toLowerCase())))}
|
{#if activeCategory === 'model' || (searchQuery && availableModels.some(m => m.name.toLowerCase().includes(searchQuery.toLowerCase())))}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
|
@ -687,6 +760,52 @@
|
||||||
.setting-desc { font-size: 0.65rem; color: var(--text-secondary); }
|
.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-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 */
|
/* Commands */
|
||||||
.command-list {
|
.command-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,16 @@
|
||||||
}
|
}
|
||||||
</script>
|
</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}>
|
<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="icon">{meta.icon}</span>
|
||||||
<span class="tool-name">{meta.label}</span>
|
<span class="tool-name">{meta.label}</span>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
<span class="subtitle">· {subtitle}</span>
|
<span class="subtitle">{subtitle}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="status-spacer"></span>
|
<span class="status-spacer"></span>
|
||||||
{#if call.status === 'running'}
|
{#if call.status === 'running'}
|
||||||
|
|
@ -75,9 +78,27 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tool-card {
|
.tool-card {
|
||||||
margin: 0.5rem 0;
|
margin: 3px 0;
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
overflow: hidden;
|
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 {
|
.card-header {
|
||||||
|
|
@ -85,83 +106,88 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 10px;
|
padding: 3px 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--vscode-editor-foreground);
|
color: var(--vscode-descriptionForeground, #888);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||||
|
min-height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header:hover {
|
.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 {
|
.chevron {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
width: 10px;
|
width: 8px;
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
transition: transform 0.12s ease;
|
transition: transform 0.12s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron.open {
|
.chevron.open {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-name {
|
.tool-name {
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--vscode-editor-foreground);
|
color: var(--vscode-foreground, #ccc);
|
||||||
|
font-size: 11.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11.5px;
|
font-size: 11px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
max-width: 60%;
|
max-width: 55%;
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-spacer {
|
.status-spacer { flex: 1; }
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-icon {
|
.status-icon {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.status-icon.ok { color: var(--vscode-successForeground); }
|
.status-icon.ok { color: var(--vscode-successForeground, #89d185); opacity: 0.7; }
|
||||||
.status-icon.error { color: var(--vscode-errorForeground); }
|
.status-icon.error { color: var(--vscode-errorForeground, #f48771); }
|
||||||
|
|
||||||
.status-dots {
|
.status-dots {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
.status-dots span {
|
.status-dots span {
|
||||||
width: 4px;
|
width: 3px;
|
||||||
height: 4px;
|
height: 3px;
|
||||||
background: var(--vscode-progressBar-background);
|
background: var(--vscode-progressBar-background, #3794ff);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: dotPulse 1.2s ease-in-out infinite;
|
animation: dotPulse 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.status-dots span:nth-child(2) { animation-delay: 0.15s; }
|
.status-dots span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
.status-dots span:nth-child(3) { animation-delay: 0.3s; }
|
.status-dots span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
@keyframes dotPulse {
|
@keyframes dotPulse {
|
||||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
|
||||||
40% { opacity: 1; transform: scale(1); }
|
40% { opacity: 1; transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 8px 10px 10px 10px;
|
padding: 6px 10px 8px 18px;
|
||||||
border-top: 1px solid var(--vscode-input-border);
|
font-size: 11.5px;
|
||||||
background: var(--vscode-editor-background);
|
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>
|
</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;
|
model?: string;
|
||||||
queued?: boolean; // Nachricht wartet in der Queue auf Dispatch
|
queued?: boolean; // Nachricht wartet in der Queue auf Dispatch
|
||||||
toolCalls?: InlineToolCall[]; // Inline gerenderte Tool-Karten (Phase 8)
|
toolCalls?: InlineToolCall[]; // Inline gerenderte Tool-Karten (Phase 8)
|
||||||
|
knowledgeHints?: KnowledgeHint[]; // KB-Treffer die fuer diese Antwort herangezogen wurden
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Permission {
|
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
|
// Streaming: ID der aktuellen Live-Nachricht
|
||||||
let streamingMessageId: string | null = null;
|
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
|
// Nachricht in DB speichern
|
||||||
async function saveMessageToDb(msg: Message) {
|
async function saveMessageToDb(msg: Message) {
|
||||||
const sessionId = get(currentSessionId);
|
const sessionId = get(currentSessionId);
|
||||||
|
|
@ -181,6 +250,12 @@ export async function initEventListeners(): Promise<void> {
|
||||||
// Monitor-Events aus DB laden (letzte Session)
|
// Monitor-Events aus DB laden (letzte Session)
|
||||||
await loadMonitorEventsFromDb(500);
|
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
|
// Bridge bereit
|
||||||
listeners.push(
|
listeners.push(
|
||||||
await listen('bridge-ready', () => {
|
await listen('bridge-ready', () => {
|
||||||
|
|
@ -216,6 +291,7 @@ export async function initEventListeners(): Promise<void> {
|
||||||
|
|
||||||
// Leere Streaming-Nachricht anlegen
|
// Leere Streaming-Nachricht anlegen
|
||||||
streamingMessageId = crypto.randomUUID();
|
streamingMessageId = crypto.randomUUID();
|
||||||
|
recallTriggeredForCurrent = false; // Block C: pro neuer Antwort 1x recall
|
||||||
messages.update((msgs) => [
|
messages.update((msgs) => [
|
||||||
...msgs,
|
...msgs,
|
||||||
{
|
{
|
||||||
|
|
@ -333,6 +409,26 @@ export async function initEventListeners(): Promise<void> {
|
||||||
if (hints && hints.length > 0) {
|
if (hints && hints.length > 0) {
|
||||||
activeKnowledgeHints.set(hints);
|
activeKnowledgeHints.set(hints);
|
||||||
console.log('💡 Wissens-Hints geladen:', hints.map(h => h.title));
|
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) {
|
} catch (err) {
|
||||||
// Fehler beim Laden ignorieren — Hints sind optional
|
// 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
|
// Phase 8: Inline-Karte in der Assistant-Message finalisieren
|
||||||
finalizeInlineToolCall(id, output, !success);
|
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)
|
// Hook: post-tool-use (fire-and-forget, Fehler blockieren nicht)
|
||||||
invoke('fire_hook', {
|
invoke('fire_hook', {
|
||||||
event: 'PostToolUse',
|
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