From 3dd879640a94b583e1c636d0f940bfbffbf68ca0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 29 Mar 2026 16:01:12 -0400 Subject: [PATCH 001/119] chore(fable-mcp): bump version to 0.2.0 Reflects all changes since initial 0.1.0 release: RSS tools, task log content/body fix, project and milestone status on create, and various other fixes. Auto-bump hook will handle patch increments from here. Co-Authored-By: Claude Sonnet 4.6 --- fable-mcp/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fable-mcp/pyproject.toml b/fable-mcp/pyproject.toml index 57b804d..7ca4929 100644 --- a/fable-mcp/pyproject.toml +++ b/fable-mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "fable-mcp" -version = "0.1.0" +version = "0.2.0" description = "MCP server for Fabled Assistant" requires-python = ">=3.12" dependencies = [ From 3581cc15824d5b9d97d88246d222c7d83e66a0d9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 29 Mar 2026 18:17:58 -0400 Subject: [PATCH 002/119] docs: add voice S2S design spec Covers STT (faster-whisper), TTS (Kokoro), per-user settings, all new/modified files, audio format decisions, and 4-phase implementation plan. Co-Authored-By: Claude Sonnet 4.6 --- docs/2026-03-29-voice-s2s-design.md | 200 ++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/2026-03-29-voice-s2s-design.md diff --git a/docs/2026-03-29-voice-s2s-design.md b/docs/2026-03-29-voice-s2s-design.md new file mode 100644 index 0000000..3e0e88d --- /dev/null +++ b/docs/2026-03-29-voice-s2s-design.md @@ -0,0 +1,200 @@ +# Speech-to-Speech (S2S) Design Spec + +**Branch:** `feature/voice-s2s` +**Date:** 2026-03-29 +**Status:** Approved for implementation + +--- + +## Decisions + +- **STT:** faster-whisper (in-process Python, model size configurable via `STT_MODEL` env var) +- **TTS:** Kokoro TTS (in-process Python, voice/speed/style configurable per-user) +- **Input mode:** Push-to-talk (Phase 1); VAD deferred +- **No browser STT/TTS fallbacks** — self-hosted only, data stays on-server +- **No Android app** — web only for this implementation + +--- + +## New Env Vars (`config.py`) + +| Var | Default | Description | +|---|---|---| +| `VOICE_ENABLED` | `false` | Feature flag — opt-in | +| `STT_BACKEND` | `faster-whisper` | Only supported value currently | +| `STT_MODEL` | `base.en` | `tiny.en` / `base.en` / `small.en` / `medium.en` | +| `TTS_BACKEND` | `kokoro` | Only supported value currently | + +--- + +## Per-User Settings (stored in `settings` table) + +| Key | Default | Description | +|---|---|---| +| `voice_tts_voice` | `af_heart` | Kokoro voice ID | +| `voice_tts_speed` | `1.0` | Speech rate, 0.7–1.3 | +| `voice_speech_style` | `conversational` | `conversational` / `concise` / `detailed` | + +--- + +## New Backend Files + +### `src/fabledassistant/services/stt.py` +Lazy singleton `WhisperModel` loader. Public API: +- `load_stt_model()` — called at startup via `asyncio.create_task` +- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text +- `stt_available() -> bool` + +### `src/fabledassistant/services/tts.py` +Lazy singleton `KPipeline` loader. Public API: +- `load_tts_model()` — called at startup +- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono) +- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels +- `tts_available() -> bool` + +### `src/fabledassistant/routes/voice.py` +Blueprint at `/api/voice`, all routes `@login_required`. + +| Endpoint | Method | Description | +|---|---|---| +| `/api/voice/status` | GET | STT/TTS availability; `enabled` false if `VOICE_ENABLED=false` | +| `/api/voice/voices` | GET | List available Kokoro voices | +| `/api/voice/transcribe` | POST | multipart `audio` field → `{"transcript": "...", "duration_ms": 123}` | +| `/api/voice/synthesise` | POST | `{"text", "voice", "speed"}` → WAV bytes | + +--- + +## Modified Backend Files + +### `src/fabledassistant/app.py` +- Register `voice_bp` blueprint +- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED` + +### `src/fabledassistant/config.py` +- Add 4 new env var attributes +- Add validation in `validate()` + +### `src/fabledassistant/services/llm.py` +- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()` +- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."* +- Append style modifier based on `voice_speech_style` + +### `src/fabledassistant/services/generation_task.py` +- Add `voice_mode: bool = False` to `run_generation()` +- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()` + +### `src/fabledassistant/routes/chat.py` +- Allow `"voice"` in `conversation_type` whitelist + +### `src/fabledassistant/services/chat.py` +- Exclude `conversation_type == "voice"` from auto-cleanup retention + +--- + +## New Frontend Files + +### `frontend/src/composables/useVoiceRecorder.ts` +Wraps `MediaRecorder`. Exports: `recording`, `error`, `isSupported`, `startRecording()`, `stopRecording() -> Promise`. + +### `frontend/src/composables/useVoiceAudio.ts` +Wraps `AudioContext`. Exports: `playing`, `isSupported`, `play(blob)`, `stop()`. + +### `frontend/src/components/VoiceOverlay.vue` +Floating PTT button (fixed bottom-right). Creates/reuses a `"voice"` conversation. Full flow: record → transcribe → send → stream → synthesise → play. Space bar hotkey (from `App.vue`). Mounted globally in `App.vue`. + +--- + +## Modified Frontend Files + +### `frontend/src/api/client.ts` +Add: `transcribeAudio(blob)`, `synthesiseSpeech(text, voice?, speed?)`, `getVoiceStatus()`, `getVoiceList()` + +### `frontend/src/views/BriefingView.vue` +- "Listen" button: reads latest assistant message aloud via TTS +- Mic button in input bar: PTT → transcribe → auto-fill input → send +- Auto-TTS on assistant response when in listen mode + +### `frontend/src/views/SettingsView.vue` +- New "Voice" tab: voice dropdown, speed slider, speech style radio +- Loads from `/api/settings`, saves via `PUT /api/settings` + +### `frontend/src/App.vue` +- Mount `` +- Space bar → `"voice:ptt-toggle"` custom event + +--- + +## Audio Format + +| Direction | Format | Rationale | +|---|---|---| +| Browser → Server | WebM/Opus | Native `MediaRecorder` output; no re-encoding | +| Server → Browser | WAV (24kHz, 16-bit mono) | Kokoro native; no re-encoding; `decodeAudioData` compatible | + +--- + +## Dependencies to Add (`pyproject.toml`) + +```toml +[project.optional-dependencies] +voice = [ + "faster-whisper>=1.0", + "kokoro>=0.9", + "soundfile>=0.12", +] +``` + +Install unconditionally in Docker (activated by `VOICE_ENABLED` at runtime): +```dockerfile +RUN pip install faster-whisper kokoro soundfile +``` + +--- + +## Database Migration + +No schema changes required. `conversation_type` is unconstrained TEXT. Voice settings use existing key-value `settings` table. Optional no-op migration `0034_voice_conversation_type.py` for audit trail. + +--- + +## Implementation Phases + +### Phase 1 — Backend services + routes +1. Add env vars to `config.py` +2. Create `services/stt.py` (faster-whisper) +3. Create `services/tts.py` (Kokoro) +4. Create `routes/voice.py` (4 endpoints) +5. Wire model loading into `app.py` startup +6. Add `voice_mode` to `build_context()` + `run_generation()` +7. Allow `"voice"` conversation type in chat route + cleanup exclusion + +### Phase 2 — BriefingView listen + voice follow-up +1. Create `useVoiceRecorder.ts` +2. Create `useVoiceAudio.ts` +3. Add voice API functions to `client.ts` +4. Add "Listen" button + mic button to `BriefingView.vue` + +### Phase 3 — VoiceOverlay for general voice chat +1. Create `VoiceOverlay.vue` +2. Mount in `App.vue` + Space bar hotkey + +### Phase 4 — Settings UI +1. Add "Voice" tab to `SettingsView.vue` + +--- + +## Kokoro Voice Reference + +| ID | Character | +|---|---| +| `af_heart` | American female, warm (recommended default) | +| `af_bella` | American female, expressive | +| `af_nicole` | American female, breathy/intimate | +| `af_sarah` | American female, clear | +| `af_sky` | American female, bright | +| `am_adam` | American male, neutral | +| `am_michael` | American male, deeper | +| `bf_emma` | British female | +| `bf_isabella` | British female, formal | +| `bm_george` | British male | +| `bm_lewis` | British male, casual | From 6f84d90dff83484f1778fd3b7d95c3aef0b50ffb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 29 Mar 2026 20:03:38 -0400 Subject: [PATCH 003/119] =?UTF-8?q?feat:=20voice=20S2S=20=E2=80=94=20faste?= =?UTF-8?q?r-whisper=20STT,=20Kokoro=20TTS,=20PTT=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full speech-to-speech pipeline (all 4 phases): Backend (Phase 1): - services/stt.py: lazy WhisperModel singleton, run_in_executor transcription - services/tts.py: lazy KPipeline singleton, WAV synthesis at 24kHz/16-bit - routes/voice.py: /api/voice/status, /voices, /transcribe, /synthesise - config.py: VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND env vars - app.py: load STT/TTS models at startup when VOICE_ENABLED=true - llm.py: voice_mode + voice_speech_style params inject speak-naturally prefix - generation_task.py: voice_mode passed through from chat route - chat.py: "voice" conversation type allowed + excluded from retention cleanup - pyproject.toml + Dockerfile: faster-whisper, kokoro, soundfile dependencies Frontend (Phases 2–4): - composables/useVoiceRecorder.ts: MediaRecorder PTT wrapper - composables/useVoiceAudio.ts: AudioContext WAV playback wrapper - BriefingView.vue: Listen button (TTS read-aloud), auto-TTS mode, mic PTT - VoiceOverlay.vue: global floating PTT button; creates/reuses voice conv; full record→transcribe→stream→TTS flow; Space bar hold-to-talk via App.vue - SettingsView.vue: Voice tab (status badge, speech style, voice/speed) - App.vue: mounts VoiceOverlay; Space keydown/keyup fires voice:ptt-toggle - api/client.ts: getVoiceStatus, getVoiceList, transcribeAudio, synthesiseSpeech Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 + frontend/src/App.vue | 29 + frontend/src/api/client.ts | 48 ++ frontend/src/components/VoiceOverlay.vue | 536 ++++++++++++++++++ frontend/src/composables/useVoiceAudio.ts | 60 ++ frontend/src/composables/useVoiceRecorder.ts | 92 +++ frontend/src/views/BriefingView.vue | 201 ++++++- frontend/src/views/SettingsView.vue | 210 ++++++- pyproject.toml | 5 + src/fabledassistant/app.py | 9 + src/fabledassistant/config.py | 12 + src/fabledassistant/routes/chat.py | 3 +- src/fabledassistant/routes/voice.py | 130 +++++ src/fabledassistant/services/chat.py | 3 +- .../services/generation_task.py | 9 + src/fabledassistant/services/llm.py | 15 + src/fabledassistant/services/stt.py | 72 +++ src/fabledassistant/services/tts.py | 94 +++ 18 files changed, 1524 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/VoiceOverlay.vue create mode 100644 frontend/src/composables/useVoiceAudio.ts create mode 100644 frontend/src/composables/useVoiceRecorder.ts create mode 100644 src/fabledassistant/routes/voice.py create mode 100644 src/fabledassistant/services/stt.py create mode 100644 src/fabledassistant/services/tts.py diff --git a/Dockerfile b/Dockerfile index c5d5342..1d5d203 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ WORKDIR /app COPY pyproject.toml . COPY src/ src/ RUN pip install --no-cache-dir . +# Voice dependencies (faster-whisper, Kokoro TTS, soundfile) — activated at runtime via VOICE_ENABLED +RUN pip install --no-cache-dir faster-whisper kokoro soundfile # Build the fable-mcp wheel so it can be served for download COPY fable-mcp/ fable-mcp/ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3021118..7220313 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref, watch } from "vue"; import { useRouter } from "vue-router"; import AppHeader from "@/components/AppHeader.vue"; import ToastNotification from "@/components/ToastNotification.vue"; +import VoiceOverlay from "@/components/VoiceOverlay.vue"; import { useTheme } from "@/composables/useTheme"; import { useShortcuts } from "@/composables/useShortcuts"; import { useAuthStore } from "@/stores/auth"; @@ -47,9 +48,21 @@ function clearPrefix() { prefixTimeout = null; } +let spaceHeld = false; + function onGlobalKeydown(e: KeyboardEvent) { if (!authStore.isAuthenticated) return; + // Space — PTT start (only when not typing, no modifiers, no repeat) + if (e.key === " " && !isInputActive() && !e.ctrlKey && !e.metaKey && !e.altKey && !e.repeat) { + e.preventDefault(); + if (!spaceHeld) { + spaceHeld = true; + document.dispatchEvent(new CustomEvent("voice:ptt-toggle")); + } + return; + } + // ? — toggle shortcuts overlay (only when not typing) if (e.key === "?" && !isInputActive() && !e.ctrlKey && !e.metaKey) { toggleShortcuts(); @@ -122,8 +135,16 @@ function onGlobalKeydown(e: KeyboardEvent) { } } +function onGlobalKeyup(e: KeyboardEvent) { + if (e.key === " " && spaceHeld) { + spaceHeld = false; + document.dispatchEvent(new CustomEvent("voice:ptt-toggle")); + } +} + onMounted(async () => { document.addEventListener("keydown", onGlobalKeydown); + document.addEventListener("keyup", onGlobalKeyup); await authStore.checkAuth(); if (authStore.isAuthenticated) { startAppServices(); @@ -149,6 +170,7 @@ watch( onUnmounted(() => { document.removeEventListener("keydown", onGlobalKeydown); + document.removeEventListener("keyup", onGlobalKeyup); stopAppServices(); }); @@ -164,6 +186,9 @@ onUnmounted(() => {
v{{ appVersion }}
+ + +
@@ -268,6 +293,10 @@ onUnmounted(() => { Enter New line
+
+ Space + Hold to speak (voice, when enabled) +
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8af972c..9e1b2e7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -619,3 +619,51 @@ export function getNewsItems(params: GetNewsItemsParams = {}) { `/api/briefing/news?${p}` ) } + +// ─── Voice ──────────────────────────────────────────────────────────────────── + +export interface VoiceStatusResult { + enabled: boolean + stt: boolean + tts: boolean + stt_model?: string + tts_backend?: string +} + +export interface VoiceEntry { + id: string + label: string +} + +export const getVoiceStatus = () => apiGet('/api/voice/status') + +export const getVoiceList = () => + apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices) + +export async function transcribeAudio(blob: Blob): Promise<{ transcript: string; duration_ms: number }> { + const form = new FormData() + form.append('audio', blob, 'audio.webm') + const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) + throw new ApiError(res.status, err) + } + return res.json() +} + +export async function synthesiseSpeech( + text: string, + voice?: string, + speed?: number +): Promise { + const res = await fetch('/api/voice/synthesise', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, voice: voice ?? 'af_heart', speed: speed ?? 1.0 }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) + throw new ApiError(res.status, err) + } + return res.blob() +} diff --git a/frontend/src/components/VoiceOverlay.vue b/frontend/src/components/VoiceOverlay.vue new file mode 100644 index 0000000..7445a30 --- /dev/null +++ b/frontend/src/components/VoiceOverlay.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/frontend/src/composables/useVoiceAudio.ts b/frontend/src/composables/useVoiceAudio.ts new file mode 100644 index 0000000..6645f65 --- /dev/null +++ b/frontend/src/composables/useVoiceAudio.ts @@ -0,0 +1,60 @@ +import { ref, readonly } from 'vue' + +/** + * Audio playback composable wrapping the Web Audio API. + * + * Usage: + * const { playing, isSupported, play, stop } = useVoiceAudio() + * await play(wavBlob) + */ +export function useVoiceAudio() { + const playing = ref(false) + const isSupported = typeof AudioContext !== 'undefined' || typeof (window as unknown as Record).webkitAudioContext !== 'undefined' + + let audioCtx: AudioContext | null = null + let currentSource: AudioBufferSourceNode | null = null + + function _getCtx(): AudioContext { + if (!audioCtx || audioCtx.state === 'closed') { + const Ctx = window.AudioContext ?? (window as unknown as Record).webkitAudioContext + audioCtx = new Ctx() + } + return audioCtx + } + + async function play(blob: Blob): Promise { + stop() + const ctx = _getCtx() + if (ctx.state === 'suspended') await ctx.resume() + + const arrayBuffer = await blob.arrayBuffer() + const audioBuffer = await ctx.decodeAudioData(arrayBuffer) + + const source = ctx.createBufferSource() + source.buffer = audioBuffer + source.connect(ctx.destination) + currentSource = source + playing.value = true + + source.onended = () => { + playing.value = false + currentSource = null + } + source.start(0) + } + + function stop(): void { + if (currentSource) { + try { currentSource.stop() } catch { /* already stopped */ } + currentSource = null + } + playing.value = false + } + + return { + playing: readonly(playing), + isSupported, + play, + stop, + } +} diff --git a/frontend/src/composables/useVoiceRecorder.ts b/frontend/src/composables/useVoiceRecorder.ts new file mode 100644 index 0000000..2b55a57 --- /dev/null +++ b/frontend/src/composables/useVoiceRecorder.ts @@ -0,0 +1,92 @@ +import { ref, readonly } from 'vue' + +/** + * Push-to-talk recorder wrapping the browser MediaRecorder API. + * + * Usage: + * const { recording, error, isSupported, startRecording, stopRecording } = useVoiceRecorder() + * await startRecording() + * const blob = await stopRecording() // resolves with the recorded audio Blob + */ +export function useVoiceRecorder() { + const recording = ref(false) + const error = ref(null) + const isSupported = typeof MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia + + let mediaRecorder: MediaRecorder | null = null + let chunks: Blob[] = [] + let stream: MediaStream | null = null + let resolveStop: ((blob: Blob) => void) | null = null + let rejectStop: ((err: Error) => void) | null = null + + async function startRecording(): Promise { + error.value = null + if (!isSupported) { + error.value = 'Audio recording is not supported in this browser' + return + } + if (recording.value) return + + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch (e) { + error.value = 'Microphone access denied' + return + } + + chunks = [] + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : MediaRecorder.isTypeSupported('audio/webm') + ? 'audio/webm' + : '' + + mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream) + + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) chunks.push(e.data) + } + + mediaRecorder.onstop = () => { + const blob = new Blob(chunks, { type: mediaRecorder?.mimeType ?? 'audio/webm' }) + chunks = [] + stream?.getTracks().forEach((t) => t.stop()) + stream = null + recording.value = false + resolveStop?.(blob) + resolveStop = null + rejectStop = null + } + + mediaRecorder.onerror = () => { + recording.value = false + error.value = 'Recording error' + rejectStop?.(new Error('MediaRecorder error')) + resolveStop = null + rejectStop = null + } + + mediaRecorder.start(100) // collect in 100ms chunks + recording.value = true + } + + function stopRecording(): Promise { + return new Promise((resolve, reject) => { + if (!mediaRecorder || !recording.value) { + reject(new Error('Not recording')) + return + } + resolveStop = resolve + rejectStop = reject + mediaRecorder.stop() + }) + } + + return { + recording: readonly(recording), + error: readonly(error), + isSupported, + startRecording, + stopRecording, + } +} diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index ff05bc9..97f83cb 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -1,6 +1,8 @@ @@ -281,6 +368,29 @@ onMounted(async () => { + + + + + + + +
@@ -3528,4 +3676,60 @@ FABLE_API_KEY=<your-api-key> word-break: break-all; overflow-x: auto; } + +/* Voice tab */ +.voice-status-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} +.status-badge { + font-size: 0.75rem; + font-weight: 600; + padding: 0.15rem 0.55rem; + border-radius: 999px; +} +.status-on { + background: color-mix(in srgb, var(--color-success, #22c55e) 15%, transparent); + color: var(--color-success, #22c55e); + border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent); +} +.status-off { + background: color-mix(in srgb, var(--color-text-muted) 10%, transparent); + color: var(--color-text-muted); + border: 1px solid var(--color-border); +} +.radio-group { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin-top: 0.35rem; +} +.radio-option { + display: flex; + align-items: flex-start; + gap: 0.55rem; + cursor: pointer; +} +.radio-option input[type="radio"] { margin-top: 0.2rem; flex-shrink: 0; } +.radio-option span { display: flex; flex-direction: column; gap: 0.15rem; } +.radio-option strong { font-size: 0.875rem; } +.range-input { + width: 100%; + max-width: 360px; + accent-color: var(--color-primary); + margin: 0.35rem 0 0.2rem; +} +.range-labels { + display: flex; + justify-content: space-between; + max-width: 360px; + font-size: 0.75rem; + color: var(--color-text-muted); +} +.form-actions { + margin-top: 1rem; +} diff --git a/pyproject.toml b/pyproject.toml index 085493d..f097880 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,11 @@ dev = [ "pytest-asyncio>=0.23", "ruff>=0.6", ] +voice = [ + "faster-whisper>=1.0", + "kokoro>=0.9", + "soundfile>=0.12", +] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 4bac5db..3d9062b 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -31,6 +31,7 @@ from fabledassistant.routes.users import users_bp from fabledassistant.routes.api_keys import api_keys_bp from fabledassistant.routes.events import events_bp from fabledassistant.routes.search import search_bp +from fabledassistant.routes.voice import voice_bp STATIC_DIR = Path(__file__).parent / "static" logger = logging.getLogger(__name__) @@ -92,6 +93,7 @@ def create_app() -> Quart: app.register_blueprint(api_keys_bp) app.register_blueprint(events_bp) app.register_blueprint(search_bp) + app.register_blueprint(voice_bp) @app.before_request async def before_request(): @@ -264,6 +266,13 @@ def create_app() -> Quart: from fabledassistant.services.briefing_scheduler import start_briefing_scheduler await start_briefing_scheduler(asyncio.get_running_loop()) + # Voice model loading (only when feature is enabled) + if Config.VOICE_ENABLED: + from fabledassistant.services.stt import load_stt_model + from fabledassistant.services.tts import load_tts_model + asyncio.create_task(load_stt_model()) + asyncio.create_task(load_tts_model()) + @app.after_serving async def shutdown(): from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py index f8b08b4..e15a364 100644 --- a/src/fabledassistant/config.py +++ b/src/fabledassistant/config.py @@ -66,6 +66,12 @@ class Config: VAPID_PUBLIC_KEY: str = os.environ.get("VAPID_PUBLIC_KEY", "") VAPID_CLAIMS_SUB: str = os.environ.get("VAPID_CLAIMS_SUB", "mailto:admin@fabledassistant.local") + # Voice (Speech-to-Speech) feature + VOICE_ENABLED: bool = os.environ.get("VOICE_ENABLED", "").lower() in ("1", "true", "yes") + STT_BACKEND: str = os.environ.get("STT_BACKEND", "faster-whisper") + STT_MODEL: str = os.environ.get("STT_MODEL", "base.en") + TTS_BACKEND: str = os.environ.get("TTS_BACKEND", "kokoro") + @classmethod def oidc_enabled(cls) -> bool: return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET) @@ -93,5 +99,11 @@ class Config: "SECRET_KEY is set to the insecure default but SECURE_COOKIES=true indicates " "a production deployment. Set SECRET_KEY or SECRET_KEY_FILE before starting." ) + _valid_stt_models = {"tiny.en", "base.en", "small.en", "medium.en"} + if cls.VOICE_ENABLED and cls.STT_MODEL not in _valid_stt_models: + errors.append( + f"STT_MODEL='{cls.STT_MODEL}' is not supported. " + f"Valid values: {', '.join(sorted(_valid_stt_models))}" + ) if errors: raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors)) diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index 7883d60..5949308 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -76,7 +76,7 @@ async def create_conversation_route(): model = data.get("model", Config.OLLAMA_MODEL) conversation_type = data.get("conversation_type", "chat") # Only allow known types to prevent accidental misuse - if conversation_type not in ("chat", "mcp"): + if conversation_type not in ("chat", "mcp", "voice"): conversation_type = "chat" conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type) return jsonify(conv.to_dict()), 201 @@ -187,6 +187,7 @@ async def send_message_route(conv_id: int): rag_project_id=effective_rag_project_id, workspace_project_id=workspace_project_id, user_timezone=user_timezone, + voice_mode=(conv.conversation_type == "voice"), )) return jsonify({ diff --git a/src/fabledassistant/routes/voice.py b/src/fabledassistant/routes/voice.py new file mode 100644 index 0000000..10e18b5 --- /dev/null +++ b/src/fabledassistant/routes/voice.py @@ -0,0 +1,130 @@ +"""Voice (Speech-to-Speech) routes at /api/voice.""" +import logging +import time + +from quart import Blueprint, jsonify, request + +from fabledassistant.auth import login_required +from fabledassistant.config import Config + +logger = logging.getLogger(__name__) + +voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice") + + +@voice_bp.route("/status", methods=["GET"]) +@login_required +async def voice_status(): + """Return availability of STT and TTS services.""" + if not Config.VOICE_ENABLED: + return jsonify({"enabled": False, "stt": False, "tts": False}) + + from fabledassistant.services.stt import stt_available + from fabledassistant.services.tts import tts_available + + return jsonify({ + "enabled": True, + "stt": stt_available(), + "tts": tts_available(), + "stt_model": Config.STT_MODEL, + "tts_backend": Config.TTS_BACKEND, + }) + + +@voice_bp.route("/voices", methods=["GET"]) +@login_required +async def list_voices(): + """Return available Kokoro voice IDs and labels.""" + if not Config.VOICE_ENABLED: + return jsonify({"error": "Voice feature is disabled"}), 503 + + from fabledassistant.services.tts import list_voices, tts_available + + if not tts_available(): + return jsonify({"error": "TTS not available"}), 503 + + return jsonify({"voices": list_voices()}) + + +@voice_bp.route("/transcribe", methods=["POST"]) +@login_required +async def transcribe_audio(): + """Accept a multipart audio file and return the transcript. + + Request: multipart/form-data with field 'audio' (WebM/Opus blob) + Response: {"transcript": "...", "duration_ms": 123} + """ + if not Config.VOICE_ENABLED: + return jsonify({"error": "Voice feature is disabled"}), 503 + + from fabledassistant.services.stt import stt_available, transcribe + + if not stt_available(): + return jsonify({"error": "STT not available — model may still be loading"}), 503 + + files = await request.files + audio_file = files.get("audio") + if audio_file is None: + return jsonify({"error": "No audio file provided"}), 400 + + audio_bytes = audio_file.read() + if not audio_bytes: + return jsonify({"error": "Empty audio file"}), 400 + + if len(audio_bytes) > 25 * 1024 * 1024: # 25 MB hard cap + return jsonify({"error": "Audio file too large (max 25 MB)"}), 413 + + mime_type = audio_file.content_type or "audio/webm" + + t0 = time.monotonic() + try: + transcript = await transcribe(audio_bytes, mime_type) + except Exception: + logger.exception("STT transcription failed") + return jsonify({"error": "Transcription failed"}), 500 + + duration_ms = round((time.monotonic() - t0) * 1000) + return jsonify({"transcript": transcript, "duration_ms": duration_ms}) + + +@voice_bp.route("/synthesise", methods=["POST"]) +@login_required +async def synthesise_speech(): + """Convert text to speech and return WAV bytes. + + Request body: {"text": "...", "voice": "af_heart", "speed": 1.0} + Response: audio/wav bytes + """ + if not Config.VOICE_ENABLED: + return jsonify({"error": "Voice feature is disabled"}), 503 + + from fabledassistant.services.tts import synthesise, tts_available + + if not tts_available(): + return jsonify({"error": "TTS not available — model may still be loading"}), 503 + + data = await request.get_json() + if not data: + return jsonify({"error": "JSON body required"}), 400 + + text = str(data.get("text", "")).strip() + if not text: + return jsonify({"error": "text is required"}), 400 + + if len(text) > 8000: + return jsonify({"error": "text too long (max 8000 characters)"}), 400 + + voice = str(data.get("voice", "af_heart")) + try: + speed = float(data.get("speed", 1.0)) + except (TypeError, ValueError): + speed = 1.0 + + try: + wav_bytes = await synthesise(text, voice=voice, speed=speed) + except Exception: + logger.exception("TTS synthesis failed") + return jsonify({"error": "Synthesis failed"}), 500 + + from quart import Response + return Response(wav_bytes, mimetype="audio/wav") diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py index 0e5cff8..de00cd9 100644 --- a/src/fabledassistant/services/chat.py +++ b/src/fabledassistant/services/chat.py @@ -131,7 +131,8 @@ async def cleanup_old_conversations(user_id: int, days: int) -> int: .where( Conversation.user_id == user_id, Conversation.updated_at < cutoff, - Conversation.conversation_type != "mcp", # preserve MCP audit trail + Conversation.conversation_type != "mcp", # preserve MCP audit trail + Conversation.conversation_type != "voice", # voice convs managed separately ) .returning(Conversation.id) ) diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index c696cac..4f26c51 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -152,6 +152,7 @@ async def run_generation( rag_project_id: int | None = None, workspace_project_id: int | None = None, user_timezone: str | None = None, + voice_mode: bool = False, ) -> None: """Stream LLM response into buffer with periodic DB flushes.""" MAX_TOOL_ROUNDS = 5 @@ -177,6 +178,12 @@ async def run_generation( # Phase 3: Build context and wait for model in parallel. model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=180.0)) + # Fetch voice_speech_style from user settings when voice_mode is active. + voice_speech_style = "conversational" + if voice_mode: + from fabledassistant.services.settings import get_setting + voice_speech_style = await get_setting(user_id, "voice_speech_style", "conversational") + context_task = asyncio.create_task(build_context( user_id, history_to_use, context_note_id, user_content, history_summary=history_summary, @@ -186,6 +193,8 @@ async def run_generation( workspace_project_id=workspace_project_id, user_timezone=user_timezone, conv_id=conv_id, + voice_mode=voice_mode, + voice_speech_style=voice_speech_style, )) messages, context_meta = await context_task diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 9903f96..935cf68 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -454,6 +454,8 @@ async def build_context( workspace_project_id: int | None = None, user_timezone: str | None = None, conv_id: int | None = None, + voice_mode: bool = False, + voice_speech_style: str = "conversational", ) -> tuple[list[dict], dict]: """Build messages array for Ollama with system prompt and context. @@ -516,6 +518,19 @@ async def build_context( f"{tool_guidance}" ] + if voice_mode: + _style_hints = { + "conversational": "Be warm, natural, and conversational — like speaking to a friend.", + "concise": "Be brief and to the point. One or two sentences maximum unless detail is essential.", + "detailed": "Give thorough, informative responses as if narrating an explanation aloud.", + } + style_hint = _style_hints.get(voice_speech_style, _style_hints["conversational"]) + system_parts.insert(0, + "VOICE MODE: Respond naturally as if speaking aloud. " + "No markdown, bullet points, headers, or code blocks. Complete sentences only. " + f"{style_hint}\n\n" + ) + context_meta: dict = { "context_note_id": None, "context_note_title": None, diff --git a/src/fabledassistant/services/stt.py b/src/fabledassistant/services/stt.py new file mode 100644 index 0000000..38283be --- /dev/null +++ b/src/fabledassistant/services/stt.py @@ -0,0 +1,72 @@ +"""Speech-to-text service using faster-whisper (in-process, CPU/GPU).""" +import asyncio +import logging +import tempfile +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from faster_whisper import WhisperModel + +logger = logging.getLogger(__name__) + +_model: "WhisperModel | None" = None +_model_lock = asyncio.Lock() +_load_error: str | None = None + + +async def load_stt_model() -> None: + """Load the Whisper model. Called once at startup when VOICE_ENABLED=true.""" + global _model, _load_error + from fabledassistant.config import Config + + if not Config.VOICE_ENABLED: + return + + async with _model_lock: + if _model is not None: + return + try: + from faster_whisper import WhisperModel + + logger.info("Loading Whisper STT model '%s'...", Config.STT_MODEL) + loop = asyncio.get_running_loop() + _model = await loop.run_in_executor( + None, + lambda: WhisperModel(Config.STT_MODEL, device="cpu", compute_type="int8"), + ) + logger.info("Whisper STT model '%s' loaded", Config.STT_MODEL) + except Exception: + _load_error = f"Failed to load Whisper model '{Config.STT_MODEL}'" + logger.exception(_load_error) + + +def stt_available() -> bool: + return _model is not None + + +async def transcribe(audio_bytes: bytes, mime_type: str = "audio/webm") -> str: + """Transcribe audio bytes to text. Runs the model in a thread executor.""" + if _model is None: + raise RuntimeError("STT model not loaded") + + suffix = ".webm" + if "ogg" in mime_type: + suffix = ".ogg" + elif "wav" in mime_type: + suffix = ".wav" + elif "mp4" in mime_type or "m4a" in mime_type: + suffix = ".mp4" + + def _run() -> str: + with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as f: + f.write(audio_bytes) + f.flush() + t0 = time.monotonic() + segments, _ = _model.transcribe(f.name, beam_size=5) # type: ignore[union-attr] + text = " ".join(seg.text.strip() for seg in segments).strip() + logger.debug("STT transcription took %.2fs", time.monotonic() - t0) + return text + + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, _run) diff --git a/src/fabledassistant/services/tts.py b/src/fabledassistant/services/tts.py new file mode 100644 index 0000000..3a20a4a --- /dev/null +++ b/src/fabledassistant/services/tts.py @@ -0,0 +1,94 @@ +"""Text-to-speech service using Kokoro TTS (in-process).""" +import asyncio +import io +import logging +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from kokoro import KPipeline + +logger = logging.getLogger(__name__) + +_pipeline: "KPipeline | None" = None +_pipeline_lock = asyncio.Lock() +_load_error: str | None = None + +# Static list of supported Kokoro voice IDs and display labels +_VOICES: list[dict] = [ + {"id": "af_heart", "label": "Heart (American Female, warm)"}, + {"id": "af_bella", "label": "Bella (American Female, expressive)"}, + {"id": "af_nicole", "label": "Nicole (American Female, intimate)"}, + {"id": "af_sarah", "label": "Sarah (American Female, clear)"}, + {"id": "af_sky", "label": "Sky (American Female, bright)"}, + {"id": "am_adam", "label": "Adam (American Male, neutral)"}, + {"id": "am_michael", "label": "Michael (American Male, deep)"}, + {"id": "bf_emma", "label": "Emma (British Female)"}, + {"id": "bf_isabella", "label": "Isabella (British Female, formal)"}, + {"id": "bm_george", "label": "George (British Male)"}, + {"id": "bm_lewis", "label": "Lewis (British Male, casual)"}, +] + + +async def load_tts_model() -> None: + """Load the Kokoro pipeline. Called once at startup when VOICE_ENABLED=true.""" + global _pipeline, _load_error + from fabledassistant.config import Config + + if not Config.VOICE_ENABLED: + return + + async with _pipeline_lock: + if _pipeline is not None: + return + try: + from kokoro import KPipeline + + logger.info("Loading Kokoro TTS pipeline...") + loop = asyncio.get_running_loop() + _pipeline = await loop.run_in_executor( + None, + lambda: KPipeline(lang_code="a"), # "a" = American English + ) + logger.info("Kokoro TTS pipeline loaded") + except Exception: + _load_error = "Failed to load Kokoro TTS pipeline" + logger.exception(_load_error) + + +def tts_available() -> bool: + return _pipeline is not None + + +def list_voices() -> list[dict]: + return _VOICES + + +async def synthesise(text: str, voice: str = "af_heart", speed: float = 1.0) -> bytes: + """Synthesise text to WAV bytes (24kHz, 16-bit mono). Runs in executor.""" + if _pipeline is None: + raise RuntimeError("TTS pipeline not loaded") + + speed = max(0.7, min(1.3, speed)) + + def _run() -> bytes: + import numpy as np + import soundfile as sf + + t0 = time.monotonic() + audio_chunks: list = [] + for _, _, audio in _pipeline(text, voice=voice, speed=speed): # type: ignore[misc] + if audio is not None: + audio_chunks.append(audio) + + if not audio_chunks: + return b"" + + combined = np.concatenate(audio_chunks) + buf = io.BytesIO() + sf.write(buf, combined, samplerate=24000, format="WAV", subtype="PCM_16") + logger.debug("TTS synthesis took %.2fs for %d chars", time.monotonic() - t0, len(text)) + return buf.getvalue() + + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, _run) From eaf70500b8421435efe44cc291a2fd96c7ab125b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 29 Mar 2026 20:22:18 -0400 Subject: [PATCH 004/119] feat: move voice enable/model config to admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace VOICE_ENABLED env var gate with DB-backed admin setting. - services/voice_config.py: reads voice_enabled + voice_stt_model from admin user's settings row (falls back to env var defaults) - routes/admin.py: GET/PUT /api/admin/voice for admin configuration - routes/voice.py, services/stt.py, services/tts.py: read enabled/model from DB via voice_config instead of Config directly - app.py: always schedule model loaders at startup; they self-gate on the DB setting so no conditional needed at the call site - SettingsView.vue: Voice section in Admin → Config tab (enable toggle + STT model dropdown); user Voice tab now points to admin panel when disabled No env var required to test — enable via Settings → Admin → Config → Voice. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/SettingsView.vue | 67 +++++++++++++++++++- src/fabledassistant/app.py | 11 ++-- src/fabledassistant/routes/admin.py | 28 ++++++++ src/fabledassistant/routes/voice.py | 24 ++++--- src/fabledassistant/services/stt.py | 15 +++-- src/fabledassistant/services/tts.py | 6 +- src/fabledassistant/services/voice_config.py | 40 ++++++++++++ 7 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 src/fabledassistant/services/voice_config.py diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index fa4a011..10df3ca 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -445,6 +445,29 @@ const baseUrl = ref(""); const savingBaseUrl = ref(false); const baseUrlSaved = ref(false); +// Voice config (admin only) +const adminVoiceEnabled = ref(false); +const adminVoiceSttModel = ref("base.en"); +const savingAdminVoice = ref(false); +const adminVoiceSaved = ref(false); + +async function saveAdminVoice() { + savingAdminVoice.value = true; + adminVoiceSaved.value = false; + try { + await apiPut("/api/admin/voice", { + voice_enabled: adminVoiceEnabled.value, + voice_stt_model: adminVoiceSttModel.value, + }); + adminVoiceSaved.value = true; + setTimeout(() => { adminVoiceSaved.value = false; }, 2000); + // Refresh the user-facing voice status after toggling + voiceTabLoaded.value = false; + } finally { + savingAdminVoice.value = false; + } +} + // Search test (SearXNG) const searxngConfigured = ref(false); const searxngUrl = ref(""); @@ -565,6 +588,13 @@ onMounted(async () => { } catch { // base URL not configured yet } + try { + const vc = await apiGet>("/api/admin/voice"); + adminVoiceEnabled.value = vc.voice_enabled === "true"; + adminVoiceSttModel.value = vc.voice_stt_model ?? "base.en"; + } catch { + // voice not configured yet + } } _loadTabContent(activeTab.value); }); @@ -1888,9 +1918,8 @@ function formatUserDate(iso: string): string {
- Set VOICE_ENABLED=true in your server environment to activate voice features. - Optionally set STT_MODEL (tiny.en / base.en / small.en / medium.en) and - install pip install fabledassistant[voice]. + Voice is disabled. An administrator can enable it under + Admin → Config → Voice.
@@ -2146,6 +2175,38 @@ FABLE_API_KEY=<your-api-key> +
+

Voice (Speech-to-Speech)

+

+ Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS). + Models load at startup — a server restart is required after changing these settings. + Install dependencies first: pip install faster-whisper kokoro soundfile +

+
+ +

Shows the PTT button and voice settings to all users.

+
+
+ + +

Larger models are more accurate but use more RAM and take longer to load.

+
+
+ + Restart the server to apply model changes. +
+
+ diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 3d9062b..8ab854f 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -266,12 +266,11 @@ def create_app() -> Quart: from fabledassistant.services.briefing_scheduler import start_briefing_scheduler await start_briefing_scheduler(asyncio.get_running_loop()) - # Voice model loading (only when feature is enabled) - if Config.VOICE_ENABLED: - from fabledassistant.services.stt import load_stt_model - from fabledassistant.services.tts import load_tts_model - asyncio.create_task(load_stt_model()) - asyncio.create_task(load_tts_model()) + # Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var) + from fabledassistant.services.stt import load_stt_model + from fabledassistant.services.tts import load_tts_model + asyncio.create_task(load_stt_model()) + asyncio.create_task(load_tts_model()) @app.after_serving async def shutdown(): diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py index cdd23fc..abd20cd 100644 --- a/src/fabledassistant/routes/admin.py +++ b/src/fabledassistant/routes/admin.py @@ -19,6 +19,7 @@ from fabledassistant.services.backup import ( restore_full_backup, ) from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email +from fabledassistant.services.voice_config import get_voice_config from fabledassistant.services.logging import get_logs, get_log_stats, log_audit from fabledassistant.services.notifications import send_invitation_email from fabledassistant.services.settings import set_setting, set_settings_batch @@ -200,6 +201,33 @@ async def update_base_url(): return jsonify({"status": "ok"}) +@admin_bp.route("/voice", methods=["GET"]) +@admin_required +async def get_voice_config_route(): + config = await get_voice_config() + return jsonify(config) + + +@admin_bp.route("/voice", methods=["PUT"]) +@admin_required +async def update_voice_config(): + data = await request.get_json() + uid = get_current_user_id() + valid_models = {"tiny.en", "base.en", "small.en", "medium.en"} + settings: dict[str, str] = {} + if "voice_enabled" in data: + settings["voice_enabled"] = "true" if data["voice_enabled"] else "false" + if "voice_stt_model" in data: + model = str(data["voice_stt_model"]) + if model not in valid_models: + return jsonify({"error": f"Invalid STT model. Choose from: {', '.join(sorted(valid_models))}"}), 400 + settings["voice_stt_model"] = model + if settings: + await set_settings_batch(uid, settings) + await log_audit("voice_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details=settings) + return jsonify({"status": "ok"}) + + @admin_bp.route("/invitations", methods=["POST"]) @admin_required async def create_invite(): diff --git a/src/fabledassistant/routes/voice.py b/src/fabledassistant/routes/voice.py index 10e18b5..377f6a6 100644 --- a/src/fabledassistant/routes/voice.py +++ b/src/fabledassistant/routes/voice.py @@ -5,7 +5,6 @@ import time from quart import Blueprint, jsonify, request from fabledassistant.auth import login_required -from fabledassistant.config import Config logger = logging.getLogger(__name__) @@ -16,18 +15,22 @@ voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice") @login_required async def voice_status(): """Return availability of STT and TTS services.""" - if not Config.VOICE_ENABLED: - return jsonify({"enabled": False, "stt": False, "tts": False}) - + from fabledassistant.services.voice_config import get_voice_config from fabledassistant.services.stt import stt_available from fabledassistant.services.tts import tts_available + config = await get_voice_config() + enabled = config.get("voice_enabled", "false").lower() in ("1", "true", "yes") + + if not enabled: + return jsonify({"enabled": False, "stt": False, "tts": False}) + return jsonify({ "enabled": True, "stt": stt_available(), "tts": tts_available(), - "stt_model": Config.STT_MODEL, - "tts_backend": Config.TTS_BACKEND, + "stt_model": config.get("voice_stt_model", "base.en"), + "tts_backend": "kokoro", }) @@ -35,7 +38,8 @@ async def voice_status(): @login_required async def list_voices(): """Return available Kokoro voice IDs and labels.""" - if not Config.VOICE_ENABLED: + from fabledassistant.services.voice_config import is_voice_enabled + if not await is_voice_enabled(): return jsonify({"error": "Voice feature is disabled"}), 503 from fabledassistant.services.tts import list_voices, tts_available @@ -54,7 +58,8 @@ async def transcribe_audio(): Request: multipart/form-data with field 'audio' (WebM/Opus blob) Response: {"transcript": "...", "duration_ms": 123} """ - if not Config.VOICE_ENABLED: + from fabledassistant.services.voice_config import is_voice_enabled + if not await is_voice_enabled(): return jsonify({"error": "Voice feature is disabled"}), 503 from fabledassistant.services.stt import stt_available, transcribe @@ -95,7 +100,8 @@ async def synthesise_speech(): Request body: {"text": "...", "voice": "af_heart", "speed": 1.0} Response: audio/wav bytes """ - if not Config.VOICE_ENABLED: + from fabledassistant.services.voice_config import is_voice_enabled + if not await is_voice_enabled(): return jsonify({"error": "Voice feature is disabled"}), 503 from fabledassistant.services.tts import synthesise, tts_available diff --git a/src/fabledassistant/services/stt.py b/src/fabledassistant/services/stt.py index 38283be..91969c9 100644 --- a/src/fabledassistant/services/stt.py +++ b/src/fabledassistant/services/stt.py @@ -16,11 +16,11 @@ _load_error: str | None = None async def load_stt_model() -> None: - """Load the Whisper model. Called once at startup when VOICE_ENABLED=true.""" + """Load the Whisper model. Called once at startup when voice is enabled.""" global _model, _load_error - from fabledassistant.config import Config + from fabledassistant.services.voice_config import get_stt_model, is_voice_enabled - if not Config.VOICE_ENABLED: + if not await is_voice_enabled(): return async with _model_lock: @@ -29,15 +29,16 @@ async def load_stt_model() -> None: try: from faster_whisper import WhisperModel - logger.info("Loading Whisper STT model '%s'...", Config.STT_MODEL) + model_name = await get_stt_model() + logger.info("Loading Whisper STT model '%s'...", model_name) loop = asyncio.get_running_loop() _model = await loop.run_in_executor( None, - lambda: WhisperModel(Config.STT_MODEL, device="cpu", compute_type="int8"), + lambda: WhisperModel(model_name, device="cpu", compute_type="int8"), ) - logger.info("Whisper STT model '%s' loaded", Config.STT_MODEL) + logger.info("Whisper STT model '%s' loaded", model_name) except Exception: - _load_error = f"Failed to load Whisper model '{Config.STT_MODEL}'" + _load_error = "Failed to load Whisper STT model" logger.exception(_load_error) diff --git a/src/fabledassistant/services/tts.py b/src/fabledassistant/services/tts.py index 3a20a4a..5f7b2af 100644 --- a/src/fabledassistant/services/tts.py +++ b/src/fabledassistant/services/tts.py @@ -31,11 +31,11 @@ _VOICES: list[dict] = [ async def load_tts_model() -> None: - """Load the Kokoro pipeline. Called once at startup when VOICE_ENABLED=true.""" + """Load the Kokoro pipeline. Called once at startup when voice is enabled.""" global _pipeline, _load_error - from fabledassistant.config import Config + from fabledassistant.services.voice_config import is_voice_enabled - if not Config.VOICE_ENABLED: + if not await is_voice_enabled(): return async with _pipeline_lock: diff --git a/src/fabledassistant/services/voice_config.py b/src/fabledassistant/services/voice_config.py new file mode 100644 index 0000000..546a792 --- /dev/null +++ b/src/fabledassistant/services/voice_config.py @@ -0,0 +1,40 @@ +"""System-wide voice configuration stored in the admin user's settings row. + +Falls back to Config env vars so operators can still seed defaults via the +environment without touching the UI. +""" +from sqlalchemy import select + +from fabledassistant.config import Config +from fabledassistant.models import async_session +from fabledassistant.models.setting import Setting +from fabledassistant.models.user import User + +VOICE_SETTING_KEYS = ("voice_enabled", "voice_stt_model") + + +async def get_voice_config() -> dict[str, str]: + """Return voice config, preferring DB values over env-var defaults.""" + config: dict[str, str] = { + "voice_enabled": "true" if Config.VOICE_ENABLED else "false", + "voice_stt_model": Config.STT_MODEL, + } + async with async_session() as session: + result = await session.execute( + select(Setting) + .join(User, Setting.user_id == User.id) + .where(User.role == "admin", Setting.key.in_(VOICE_SETTING_KEYS)) + ) + for setting in result.scalars().all(): + config[setting.key] = setting.value + return config + + +async def is_voice_enabled() -> bool: + config = await get_voice_config() + return config.get("voice_enabled", "false").lower() in ("1", "true", "yes") + + +async def get_stt_model() -> str: + config = await get_voice_config() + return config.get("voice_stt_model", Config.STT_MODEL) From f146485df3818b2dd1f09a1e3a718c1cc02f153a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 29 Mar 2026 20:52:07 -0400 Subject: [PATCH 005/119] feat: hot-reload voice models without server restart Voice enabled/STT model are now DB-backed (admin settings), not env vars. Added reload_stt_model()/reload_tts_model() that clear singletons under lock and re-trigger loading. POST /api/admin/voice/reload triggers both in background tasks. Settings UI polls /api/voice/status every 2.5s until models are ready, with spinner feedback. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 2 +- frontend/src/views/SettingsView.vue | 69 +++++++++++++++++++++++++++-- src/fabledassistant/routes/admin.py | 11 +++++ src/fabledassistant/services/stt.py | 9 ++++ src/fabledassistant/services/tts.py | 9 ++++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5cf9bb6..7be1606 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: environment: DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}" OLLAMA_URL: "http://ollama:11434" - OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}" + OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:8B}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" # Uncomment and set to enable web research and image search via SearXNG: # SEARXNG_URL: "http://searxng:8080" diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 10df3ca..68059bc 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -450,6 +450,7 @@ const adminVoiceEnabled = ref(false); const adminVoiceSttModel = ref("base.en"); const savingAdminVoice = ref(false); const adminVoiceSaved = ref(false); +const voiceLoadingModels = ref(false); async function saveAdminVoice() { savingAdminVoice.value = true; @@ -461,13 +462,39 @@ async function saveAdminVoice() { }); adminVoiceSaved.value = true; setTimeout(() => { adminVoiceSaved.value = false; }, 2000); - // Refresh the user-facing voice status after toggling voiceTabLoaded.value = false; + if (adminVoiceEnabled.value) { + await reloadVoiceModels(); + } } finally { savingAdminVoice.value = false; } } +async function reloadVoiceModels() { + voiceLoadingModels.value = true; + try { + await apiPost("/api/admin/voice/reload", {}); + // Poll /api/voice/status until both STT and TTS are ready (max 3 min) + const deadline = Date.now() + 3 * 60 * 1000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2500)); + try { + const status = await apiGet<{ enabled: boolean; stt: boolean; tts: boolean }>("/api/voice/status"); + if (status.stt && status.tts) { + voiceStatus.value = status; + voiceTabLoaded.value = true; + break; + } + } catch { /* keep polling */ } + } + } catch { + toastStore.show("Failed to trigger model reload", "error"); + } finally { + voiceLoadingModels.value = false; + } +} + // Search test (SearXNG) const searxngConfigured = ref(false); const searxngUrl = ref(""); @@ -2200,10 +2227,22 @@ FABLE_API_KEY=<your-api-key>

Larger models are more accurate but use more RAM and take longer to load.

- - Restart the server to apply model changes. + + + Loading models… this may take a minute. + + + +
@@ -3793,4 +3832,26 @@ FABLE_API_KEY=<your-api-key> .form-actions { margin-top: 1rem; } + +.voice-admin-spinner { + display: inline-flex; + gap: 3px; + align-items: center; + margin-left: 0.4rem; + vertical-align: middle; +} +.voice-admin-spinner span { + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--color-text-muted); + animation: va-dot-bounce 1.2s ease-in-out infinite; +} +.voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; } +.voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; } +@keyframes va-dot-bounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py index abd20cd..e891ae4 100644 --- a/src/fabledassistant/routes/admin.py +++ b/src/fabledassistant/routes/admin.py @@ -228,6 +228,17 @@ async def update_voice_config(): return jsonify({"status": "ok"}) +@admin_bp.route("/voice/reload", methods=["POST"]) +@admin_required +async def reload_voice_models(): + """Reload STT and TTS models in the background without a server restart.""" + from fabledassistant.services.stt import reload_stt_model + from fabledassistant.services.tts import reload_tts_model + asyncio.create_task(reload_stt_model()) + asyncio.create_task(reload_tts_model()) + return jsonify({"status": "loading"}) + + @admin_bp.route("/invitations", methods=["POST"]) @admin_required async def create_invite(): diff --git a/src/fabledassistant/services/stt.py b/src/fabledassistant/services/stt.py index 91969c9..2de2f41 100644 --- a/src/fabledassistant/services/stt.py +++ b/src/fabledassistant/services/stt.py @@ -42,6 +42,15 @@ async def load_stt_model() -> None: logger.exception(_load_error) +async def reload_stt_model() -> None: + """Unload the current model and reload with the current config. Safe to call at runtime.""" + global _model, _load_error + async with _model_lock: + _model = None + _load_error = None + await load_stt_model() + + def stt_available() -> bool: return _model is not None diff --git a/src/fabledassistant/services/tts.py b/src/fabledassistant/services/tts.py index 5f7b2af..cf5495c 100644 --- a/src/fabledassistant/services/tts.py +++ b/src/fabledassistant/services/tts.py @@ -56,6 +56,15 @@ async def load_tts_model() -> None: logger.exception(_load_error) +async def reload_tts_model() -> None: + """Unload and reload the TTS pipeline. Safe to call at runtime.""" + global _pipeline, _load_error + async with _pipeline_lock: + _pipeline = None + _load_error = None + await load_tts_model() + + def tts_available() -> bool: return _pipeline is not None From dd304bb5565938e49c8d52b014560549d774d0d8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 29 Mar 2026 21:58:20 -0400 Subject: [PATCH 006/119] feat: unify voice PTT across briefing and chat views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatView: add PTT mic button in input bar (hold to speak → transcribe → send) - BriefingView: restyle input bar to match ChatView (floating pill, circle send) - BriefingView: move listen/mic controls into input bar, remove from header - BriefingView: consistent icon-button style for speaker/mic matching ChatView Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/BriefingView.vue | 213 +++++++++++++--------------- frontend/src/views/ChatView.vue | 77 +++++++++- 2 files changed, 176 insertions(+), 114 deletions(-) diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index 97f83cb..9ac88b2 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -368,29 +368,6 @@ onMounted(async () => { - - - + + - + @@ -483,11 +488,7 @@ onMounted(async () => { @click="send" :disabled="!input.trim() || chatStore.streaming || sending" aria-label="Send" - > - - - - + >↑ @@ -675,46 +676,53 @@ onMounted(async () => { .briefing-input-bar { display: flex; + align-items: center; gap: 0.5rem; - align-items: flex-end; - padding: 0.75rem 1rem 1rem; - border-top: 1px solid var(--color-border); + padding: 0.5rem 0.5rem 0.5rem 0.75rem; + margin: 0 0.75rem 1rem; + background: var(--color-input-bar-bg); + border-radius: 20px; + box-shadow: 0 2px 12px var(--color-shadow); flex-shrink: 0; } -.briefing-input { +.briefing-textarea { flex: 1; - padding: 0.6rem 0.8rem; - border: 1px solid var(--color-border); - border-radius: 10px; - background: var(--color-bg-card); - color: var(--color-text); - font-size: 0.9rem; resize: none; - outline: none; + padding: 0.4rem 0.5rem; + border: none; + border-radius: 12px; font-family: inherit; - line-height: 1.4; + font-size: 0.95rem; + background: transparent; + color: var(--color-input-bar-text); + outline: none; max-height: 120px; overflow-y: auto; - transition: border-color 0.15s; + line-height: 1.4; } -.briefing-input:focus { border-color: var(--color-primary); } +.briefing-textarea::placeholder { color: var(--color-input-bar-placeholder); } +.briefing-textarea:disabled { opacity: 0.5; } .btn-send { - padding: 0.55rem 0.75rem; - background: linear-gradient(135deg, #6366f1, #4f46e5); - color: #fff; - border: none; - border-radius: 10px; - cursor: pointer; + width: 34px; + min-width: 34px; + height: 34px; + padding: 0; display: flex; align-items: center; justify-content: center; - transition: opacity 0.15s; + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: #fff; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 1.1rem; flex-shrink: 0; + transition: box-shadow 0.15s; } -.btn-send:disabled { opacity: 0.4; cursor: not-allowed; } -.btn-send:hover:not(:disabled) { opacity: 0.9; } +.btn-send:not(:disabled):hover { box-shadow: 0 0 14px rgba(129, 140, 248, 0.5); } +.btn-send:disabled { opacity: 0.35; cursor: default; } /* ─── Right column (News) ────────────────────────────────────────────────── */ @@ -837,72 +845,53 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); } background: color-mix(in srgb, var(--color-primary) 12%, transparent); } -/* ─── Voice buttons ──────────────────────────────────────────────────────── */ +/* ─── Icon buttons (input bar) ───────────────────────────────────────────── */ -.btn-voice-header { - display: flex; - align-items: center; - gap: 0.35rem; - padding: 0.35rem 0.75rem; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-bg-card); - color: var(--color-text-muted); - font-size: 0.8rem; - cursor: pointer; - white-space: nowrap; - transition: all 0.15s; - font-family: inherit; -} -.btn-voice-header:hover:not(:disabled) { - border-color: var(--color-primary); - color: var(--color-primary); -} -.btn-voice-header:disabled { opacity: 0.5; cursor: not-allowed; } -.btn-voice-active { - border-color: var(--color-primary) !important; - color: var(--color-primary) !important; - background: color-mix(in srgb, var(--color-primary) 10%, transparent) !important; -} -.btn-voice-busy { - border-color: var(--color-primary); - color: var(--color-primary); - animation: pulse-border 1.2s ease-in-out infinite; -} -@keyframes pulse-border { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } -} - -.btn-mic { - padding: 0.55rem 0.65rem; - background: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: 10px; - color: var(--color-text-muted); +.btn-icon { + background: none; + border: none; cursor: pointer; + color: var(--color-input-bar-text); + opacity: 0.5; + padding: 0.25rem; display: flex; align-items: center; justify-content: center; flex-shrink: 0; - transition: all 0.15s; + transition: opacity 0.15s, color 0.15s; +} +.btn-icon:hover:not(:disabled) { opacity: 1; } +.btn-icon:disabled { opacity: 0.25; cursor: default; } +.btn-icon-active { + color: var(--color-primary) !important; + opacity: 1 !important; +} +.btn-icon-busy { + color: var(--color-primary) !important; + opacity: 0.8 !important; + animation: icon-pulse 1.2s ease-in-out infinite; +} +.btn-icon-danger { + color: #ef4444 !important; + opacity: 0.8 !important; +} + +.btn-mic-ptt { touch-action: none; user-select: none; } -.btn-mic:hover:not(:disabled) { - border-color: var(--color-primary); - color: var(--color-primary); -} -.btn-mic:disabled { opacity: 0.4; cursor: not-allowed; } -.btn-mic-active { - background: color-mix(in srgb, #ef4444 15%, transparent) !important; - border-color: #ef4444 !important; +.btn-mic-ptt.mic-recording { color: #ef4444 !important; - animation: pulse-border 0.8s ease-in-out infinite; + opacity: 1 !important; + animation: icon-pulse 0.8s ease-in-out infinite; } -.btn-mic-busy { - border-color: var(--color-primary); - color: var(--color-primary); +.btn-mic-ptt.mic-transcribing { + color: var(--color-primary) !important; + opacity: 0.7 !important; +} +@keyframes icon-pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } } /* ─── Responsive ─────────────────────────────────────────────────────────── */ diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index 6361237..b51284d 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -3,8 +3,9 @@ import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useChatStore } from "@/stores/chat"; import { useSettingsStore } from "@/stores/settings"; -import { apiGet } from "@/api/client"; +import { apiGet, getVoiceStatus, transcribeAudio } from "@/api/client"; import { renderMarkdown } from "@/utils/markdown"; +import { useVoiceRecorder } from "@/composables/useVoiceRecorder"; import ChatMessage from "@/components/ChatMessage.vue"; import ToolCallCard from "@/components/ToolCallCard.vue"; import ToolConfirmCard from "@/components/ToolConfirmCard.vue"; @@ -22,6 +23,38 @@ const sending = ref(false); const summarizing = ref(false); const sidebarOpen = ref(false); +// Voice PTT +const voiceEnabled = ref(false); +const transcribingVoice = ref(false); +const recorder = useVoiceRecorder(); + +async function checkVoice() { + try { + const status = await getVoiceStatus(); + voiceEnabled.value = status.enabled && status.stt; + } catch { /* voice absent */ } +} + +async function startPtt() { + if (!voiceEnabled.value || recorder.recording.value) return; + await recorder.startRecording(); +} + +async function stopPtt() { + if (!recorder.recording.value) return; + transcribingVoice.value = true; + try { + const blob = await recorder.stopRecording(); + const { transcript } = await transcribeAudio(blob); + if (transcript.trim()) { + messageInput.value = transcript.trim(); + autoResize(); + await sendMessage(); + } + } catch { /* silent */ } + finally { transcribingVoice.value = false; } +} + // Research modal state const showResearchModal = ref(false); const researchTopic = ref(""); @@ -145,7 +178,7 @@ const inputPlaceholder = computed(() => { onMounted(async () => { document.addEventListener("keydown", onGlobalKeydown); - await Promise.all([store.fetchConversations(), loadProjects()]); + await Promise.all([store.fetchConversations(), loadProjects(), checkVoice()]); if (convId.value) { if (store.currentConversation?.id !== convId.value) { await store.fetchConversation(convId.value); @@ -847,6 +880,27 @@ onUnmounted(() => { + + + + + From c31cf11767f4fd919c20112305aee7e638377f60 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 31 Mar 2026 12:55:07 -0400 Subject: [PATCH 019/119] fix: use AudioContext for voice previews to bypass browser autoplay policy new Audio().play() after an async await loses the user gesture context and is silently blocked. Creating AudioContext synchronously before the fetch preserves the permission, then decode/play through it after the await. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/SettingsView.vue | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index edc02d7..dd1b408 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -524,7 +524,6 @@ const voiceBlend = ref([ ]); const blendPreviewText = ref("Hello! I'm your assistant. How can I help you today?"); const blendPreviewing = ref(false); -let _blendAudioUrl = ""; function addBlendSlot() { voiceBlend.value.push({ voice: "af_heart", weight: 1.0 }); @@ -537,6 +536,8 @@ function removeBlendSlot(idx: number) { async function previewBlend() { if (blendPreviewing.value || !blendPreviewText.value.trim()) return; blendPreviewing.value = true; + // Create AudioContext synchronously in user-gesture context to bypass autoplay policy + const ctx = new AudioContext(); try { const blob = await synthesiseSpeech( blendPreviewText.value, @@ -544,10 +545,11 @@ async function previewBlend() { voiceTtsSpeed.value, voiceBlend.value, ); - if (_blendAudioUrl) URL.revokeObjectURL(_blendAudioUrl); - _blendAudioUrl = URL.createObjectURL(blob); - const audio = new Audio(_blendAudioUrl); - audio.play(); + const buf = await ctx.decodeAudioData(await blob.arrayBuffer()); + const src = ctx.createBufferSource(); + src.buffer = buf; + src.connect(ctx.destination); + src.start(0); } catch { toastStore.show("Preview failed — TTS may not be ready", "error"); } finally { @@ -572,20 +574,23 @@ async function loadVoiceTab() { } const voicePreviewing = ref(false); -let _voicePreviewUrl = ""; async function previewVoice() { if (voicePreviewing.value) return; voicePreviewing.value = true; + // Create AudioContext synchronously in user-gesture context to bypass autoplay policy + const ctx = new AudioContext(); try { const blob = await synthesiseSpeech( "Hello! I'm your assistant. How can I help you today?", voiceTtsVoice.value, voiceTtsSpeed.value, ); - if (_voicePreviewUrl) URL.revokeObjectURL(_voicePreviewUrl); - _voicePreviewUrl = URL.createObjectURL(blob); - new Audio(_voicePreviewUrl).play(); + const buf = await ctx.decodeAudioData(await blob.arrayBuffer()); + const src = ctx.createBufferSource(); + src.buffer = buf; + src.connect(ctx.destination); + src.start(0); } catch { toastStore.show("Preview failed — TTS may not be ready", "error"); } finally { From c1fcb1e287b6a442c08e5b435836361b5cecf891 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 31 Mar 2026 12:57:25 -0400 Subject: [PATCH 020/119] =?UTF-8?q?feat:=20discuss=20article=20in=20briefi?= =?UTF-8?q?ng=20chat=20via=20=F0=9F=92=AC=20button=20on=20news=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking 💬 on a news card in the briefing panel pre-fills the briefing chat input with the article title, snippet, and source so the user can ask the briefing LLM to summarize or discuss it directly. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/BriefingView.vue | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index f911cd8..4d558fe 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -174,6 +174,15 @@ async function send() { } } +function discussArticle(item: NewsItem) { + const snippet = item.snippet ? `\n\n"${item.snippet.slice(0, 400)}${item.snippet.length > 400 ? '…' : ''}"` : '' + input.value = `Can you summarize and discuss this article for me?\n\n**${item.title}**${snippet}\n\nSource: ${item.source}` + // Scroll the chat panel into view on narrow layouts + nextTick(() => { + document.querySelector('.briefing-chat')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }) +} + function onKeydown(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() @@ -525,6 +534,12 @@ onMounted(async () => { @click="handleReaction(item.id, 'down')" title="Not interested" >👎 + From ea23f16bd772a5dd734bce2078f3cde37160d151 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 31 Mar 2026 13:02:36 -0400 Subject: [PATCH 021/119] =?UTF-8?q?feat:=20weather=20card=20=E2=80=94=20pr?= =?UTF-8?q?ecip=20probability=20%,=20condition=20text,=20unit-aware=20wind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fetch precipitation_probability_max from Open-Meteo (replaces precip_sum in the card display — probability is more useful at a glance than mm) - Show WMO condition description text on each forecast day - Convert wind speed to mph when temp unit is F; pass wind_unit in response - Display 💧 X% chance of rain; 💨 X mph/km/h wind per day Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/WeatherCard.vue | 18 ++++++++++++++---- frontend/src/views/BriefingView.vue | 3 ++- src/fabledassistant/services/weather.py | 20 ++++++++++++++------ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/WeatherCard.vue b/frontend/src/components/WeatherCard.vue index 4434652..7e52c06 100644 --- a/frontend/src/components/WeatherCard.vue +++ b/frontend/src/components/WeatherCard.vue @@ -6,7 +6,7 @@ interface ForecastDay { condition: string high: number low: number - precip_mm: number + precip_probability: number | null windspeed_max: number } @@ -19,6 +19,7 @@ interface WeatherData { today_low: number | null yesterday_high: number | null yesterday_low: number | null + wind_unit?: string forecast: ForecastDay[] } @@ -87,10 +88,11 @@ const fetchedAtLabel = computed(() => {
{{ day.day }} {{ weatherIcon(day.condition) }} + {{ day.condition }} {{ day.high }}° / {{ day.low }}° - 💧 {{ day.precip_mm }}mm - - 💨 {{ day.windspeed_max }}km/h + 💧 {{ day.precip_probability }}% + 💧 — + 💨 {{ day.windspeed_max }} {{ weather.wind_unit ?? 'km/h' }}
@@ -186,6 +188,14 @@ const fetchedAtLabel = computed(() => { line-height: 1; } +.forecast-condition { + font-size: 0.7rem; + color: var(--color-text-muted); + text-align: center; + line-height: 1.2; + word-break: break-word; +} + .forecast-temps { white-space: nowrap; } diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index 4d558fe..34dbb35 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -35,7 +35,8 @@ interface WeatherData { today_low: number | null yesterday_high: number | null yesterday_low: number | null - forecast: { day: string; condition: string; high: number; low: number; precip_mm: number; windspeed_max: number }[] + wind_unit?: string + forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; windspeed_max: number }[] } const chatStore = useChatStore() diff --git a/src/fabledassistant/services/weather.py b/src/fabledassistant/services/weather.py index 9b08d49..0525693 100644 --- a/src/fabledassistant/services/weather.py +++ b/src/fabledassistant/services/weather.py @@ -15,7 +15,7 @@ NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" OPEN_METEO_DAILY = ( "temperature_2m_max,temperature_2m_min,precipitation_sum," - "weathercode,windspeed_10m_max" + "precipitation_probability_max,weathercode,windspeed_10m_max" ) # WMO weather code → description (subset; covers the most common codes) @@ -42,12 +42,14 @@ def parse_forecast(raw: dict) -> list[dict]: """Convert Open-Meteo daily response into a clean list of day dicts.""" daily = raw.get("daily", {}) dates = daily.get("time", []) + precip_prob = daily.get("precipitation_probability_max", []) return [ { "date": dates[i], "temp_max": daily.get("temperature_2m_max", [])[i], "temp_min": daily.get("temperature_2m_min", [])[i], "precip_mm": daily.get("precipitation_sum", [])[i], + "precip_probability": precip_prob[i] if i < len(precip_prob) else None, "weathercode": daily.get("weathercode", [])[i], "description": describe_weathercode(daily.get("weathercode", [])[i]), "windspeed_max": daily.get("windspeed_10m_max", [])[i], @@ -119,10 +121,13 @@ def parse_weather_card_data( yesterday_day = next((d for d in days if d["date"] == yesterday_str), None) future_days = [d for d in days if d["date"] > today_str][:5] + imperial = temp_unit == "F" + def to_temp(c: float) -> int: - if temp_unit == "F": - return round(c * 9 / 5 + 32) - return round(c) + return round(c * 9 / 5 + 32) if imperial else round(c) + + def to_wind(kmh: float) -> int: + return round(kmh * 0.621371) if imperial else round(kmh) def day_label(date_str: str) -> str: from datetime import date as _date @@ -131,6 +136,8 @@ def parse_weather_card_data( except ValueError: return date_str + wind_unit = "mph" if imperial else "km/h" + return { "location": getattr(cache_row, "location_label", ""), "fetched_at": cache_row.fetched_at.isoformat(), @@ -140,14 +147,15 @@ def parse_weather_card_data( "today_low": to_temp(today_day["temp_min"]) if today_day else None, "yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None, "yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None, + "wind_unit": wind_unit, "forecast": [ { "day": day_label(d["date"]), "condition": d["description"], "high": to_temp(d["temp_max"]), "low": to_temp(d["temp_min"]), - "precip_mm": round(d["precip_mm"], 1), - "windspeed_max": round(d["windspeed_max"]), + "precip_probability": d["precip_probability"], + "windspeed_max": to_wind(d["windspeed_max"]), } for d in future_days ], From ab397e78f333b5ee82b97839d4b7aaad6c266cc7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 31 Mar 2026 13:46:52 -0400 Subject: [PATCH 022/119] fix: briefing TTS now uses saved voice/speed/blend settings synthesiseSpeech() called without explicit params now omits voice/speed/ blend from the request body. The backend detects this and auto-loads all three from the user's saved settings (voice_tts_voice, voice_tts_speed, voice_tts_blend), so briefing listen mode respects the voice the user configured in Settings. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/client.ts | 8 ++++++-- src/fabledassistant/routes/voice.py | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d9ffc25..c97b290 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -666,11 +666,15 @@ export async function synthesiseSpeech( speed?: number, voiceBlend?: VoiceBlendEntry[] ): Promise { - const body: Record = { text, speed: speed ?? 1.0 } + // Only send voice/speed/blend when explicitly provided — omitting them lets + // the server auto-load the user's saved voice settings (voice, speed, blend). + const body: Record = { text } if (voiceBlend && voiceBlend.length >= 2) { body.voice_blend = voiceBlend - } else { + if (speed !== undefined) body.speed = speed + } else if (voice !== undefined || speed !== undefined) { body.voice = voice ?? 'af_heart' + body.speed = speed ?? 1.0 } const res = await fetch('/api/voice/synthesise', { method: 'POST', diff --git a/src/fabledassistant/routes/voice.py b/src/fabledassistant/routes/voice.py index 1cce243..e4bb98b 100644 --- a/src/fabledassistant/routes/voice.py +++ b/src/fabledassistant/routes/voice.py @@ -130,16 +130,25 @@ async def synthesise_speech(): if not isinstance(voice_blend, list) or len(voice_blend) < 2: voice_blend = None - # Auto-load blend from user settings when no explicit voice/blend provided - if voice_blend is None and "voice" not in data and "voice_blend" not in data: + # When no explicit voice/blend/speed provided, load all voice settings from the user's profile + if "voice" not in data and "voice_blend" not in data and "speed" not in data: from fabledassistant.services.settings import get_setting from fabledassistant.auth import get_current_user_id import json as _json try: uid = get_current_user_id() - stored = await get_setting(uid, "voice_tts_blend", "") - if stored: - parsed = _json.loads(stored) + saved_voice = await get_setting(uid, "voice_tts_voice", "") + if saved_voice: + voice = saved_voice + saved_speed = await get_setting(uid, "voice_tts_speed", "") + if saved_speed: + try: + speed = float(saved_speed) + except ValueError: + pass + saved_blend = await get_setting(uid, "voice_tts_blend", "") + if saved_blend: + parsed = _json.loads(saved_blend) if isinstance(parsed, list) and len(parsed) >= 2: voice_blend = parsed except Exception: From baeb0b14e5c650be2e80441b9f47a0b16cc6d24d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 31 Mar 2026 16:52:11 -0400 Subject: [PATCH 023/119] feat: listen mode + volume knob in chat; briefing discuss auto-send; fix LLM proactive note search - ChatView: listen mode toggle (auto-reads new responses via TTS), volume popup with range slider persisted per-device in localStorage via GainNode - useVoiceAudio: shared module-level _volume ref with localStorage persistence, GainNode for volume control, exported setVoiceVolume() - tts.py: pre-warm all Kokoro voices at pipeline load to eliminate HuggingFace HEAD requests at synthesis time (reduces TTS latency) - BriefingView: discuss article button now auto-sends instead of just filling input; prompt capped to 15 sentences; send() accepts optional overrideText - llm.py: instruct LLM not to proactively search notes or comment on note absence Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/composables/useVoiceAudio.ts | 25 +++-- frontend/src/views/BriefingView.vue | 15 +-- frontend/src/views/ChatView.vue | 131 +++++++++++++++++++++- src/fabledassistant/services/llm.py | 3 +- src/fabledassistant/services/tts.py | 17 ++- 5 files changed, 169 insertions(+), 22 deletions(-) diff --git a/frontend/src/composables/useVoiceAudio.ts b/frontend/src/composables/useVoiceAudio.ts index 6645f65..be490f7 100644 --- a/frontend/src/composables/useVoiceAudio.ts +++ b/frontend/src/composables/useVoiceAudio.ts @@ -1,12 +1,10 @@ import { ref, readonly } from 'vue' -/** - * Audio playback composable wrapping the Web Audio API. - * - * Usage: - * const { playing, isSupported, play, stop } = useVoiceAudio() - * await play(wavBlob) - */ +const VOLUME_KEY = 'fa_voice_volume' + +// Shared volume across all instances — persisted to localStorage per device +const _volume = ref(parseFloat(localStorage.getItem(VOLUME_KEY) ?? '1.0')) + export function useVoiceAudio() { const playing = ref(false) const isSupported = typeof AudioContext !== 'undefined' || typeof (window as unknown as Record).webkitAudioContext !== 'undefined' @@ -32,7 +30,12 @@ export function useVoiceAudio() { const source = ctx.createBufferSource() source.buffer = audioBuffer - source.connect(ctx.destination) + + const gain = ctx.createGain() + gain.gain.value = _volume.value + source.connect(gain) + gain.connect(ctx.destination) + currentSource = source playing.value = true @@ -53,8 +56,14 @@ export function useVoiceAudio() { return { playing: readonly(playing), + volume: _volume, isSupported, play, stop, } } + +export function setVoiceVolume(v: number) { + _volume.value = Math.max(0, Math.min(1, v)) + localStorage.setItem(VOLUME_KEY, String(_volume.value)) +} diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index 34dbb35..d842fe9 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -160,8 +160,8 @@ watch(() => chatStore.streaming, async (streaming) => { const input = ref('') const sending = ref(false) -async function send() { - const text = input.value.trim() +async function send(overrideText?: string) { + const text = (overrideText ?? input.value).trim() if (!text || !todayConvId.value || chatStore.streaming || sending.value) return if (chatStore.currentConversation?.id !== todayConvId.value) { await chatStore.fetchConversation(todayConvId.value) @@ -175,13 +175,14 @@ async function send() { } } -function discussArticle(item: NewsItem) { +async function discussArticle(item: NewsItem) { const snippet = item.snippet ? `\n\n"${item.snippet.slice(0, 400)}${item.snippet.length > 400 ? '…' : ''}"` : '' - input.value = `Can you summarize and discuss this article for me?\n\n**${item.title}**${snippet}\n\nSource: ${item.source}` - // Scroll the chat panel into view on narrow layouts - nextTick(() => { + const text = `Please give me a brief summary and key takeaways for this article (15 sentences or fewer):\n\n**${item.title}**${snippet}\n\nSource: ${item.source}` + // Scroll the chat panel into view on narrow layouts, then send + await nextTick(() => { document.querySelector('.briefing-chat')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }) + await send(text) } function onKeydown(e: KeyboardEvent) { @@ -490,7 +491,7 @@ onMounted(async () => { diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index b8e0ebf..e3c1c08 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -3,9 +3,10 @@ import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useChatStore } from "@/stores/chat"; import { useSettingsStore } from "@/stores/settings"; -import { apiGet, transcribeAudio } from "@/api/client"; +import { apiGet, transcribeAudio, synthesiseSpeech } from "@/api/client"; import { renderMarkdown } from "@/utils/markdown"; import { useVoiceRecorder } from "@/composables/useVoiceRecorder"; +import { useVoiceAudio, setVoiceVolume } from "@/composables/useVoiceAudio"; import ChatMessage from "@/components/ChatMessage.vue"; import ToolCallCard from "@/components/ToolCallCard.vue"; import ToolConfirmCard from "@/components/ToolConfirmCard.vue"; @@ -26,10 +27,46 @@ const sidebarOpen = ref(false); // Voice PTT — use store so status is globally reactive const transcribingVoice = ref(false); const recorder = useVoiceRecorder(); +const audio = useVoiceAudio(); const voiceEnabled = computed(() => settingsStore.voiceSttReady); +const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady); +const listenMode = ref(false); +const synthesising = ref(false); +const showVolumeSlider = ref(false); + +async function speakLastAssistantMessage() { + const messages = store.currentConversation?.messages ?? []; + const last = [...messages].reverse().find((m) => m.role === "assistant"); + if (!last?.content) return; + const plain = last.content + .replace(/```[\s\S]*?```/g, "") + .replace(/`[^`]+`/g, (m: string) => m.slice(1, -1)) + .replace(/#{1,6}\s+/g, "") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/\n{2,}/g, " ") + .trim(); + if (!plain) return; + synthesising.value = true; + try { + const blob = await synthesiseSpeech(plain); + await audio.play(blob); + } catch { /* TTS failure non-critical */ } + finally { synthesising.value = false; } +} + +watch(() => store.streaming, async (streaming) => { + if (!streaming && listenMode.value && voiceTtsEnabled.value) { + await new Promise((r) => setTimeout(r, 200)); + await speakLastAssistantMessage(); + } +}); async function startPtt() { if (!voiceEnabled.value || recorder.recording.value) return; + audio.stop(); await recorder.startRecording(); } @@ -873,6 +910,55 @@ onUnmounted(() => { + + + - +
+ +
@@ -458,40 +457,47 @@ onUnmounted(() => { - +
- - - +
+ + + +
@@ -879,10 +885,14 @@ onUnmounted(() => { transition: color 0.15s; } .btn-icon-sm:hover { color: var(--color-text); } -.graph-iframe { +.graph-embed { flex: 1; - border: none; - width: 100%; + overflow: hidden; + position: relative; +} +/* Override GraphView's 100vh height so it fills the panel instead */ +.graph-embed :deep(.graph-page) { + height: 100%; } /* ── Mini-chat ───────────────────────────────────────────── */ @@ -956,58 +966,84 @@ onUnmounted(() => { .minichat-input-row { display: flex; - align-items: flex-end; - gap: 6px; + align-items: center; + gap: 8px; padding: 10px 14px; } -.minichat-input { +/* Reuse the shared chat-input-bar pattern */ +.chat-input-bar { + flex: 1; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.5rem 0.5rem 0.75rem; + background: var(--color-input-bar-bg); + border-radius: 20px; + box-shadow: 0 2px 12px var(--color-shadow); +} +.chat-input-bar textarea { flex: 1; resize: none; - padding: 8px 12px; - border-radius: 10px; - border: 1px solid var(--color-border, rgba(255,255,255,0.1)); - background: var(--color-bg-tertiary, rgba(255,255,255,0.04)); - color: var(--color-text); - font-size: 0.9rem; + padding: 0.4rem 0.5rem; + border: none; + border-radius: 12px; font-family: inherit; - line-height: 1.45; + font-size: 0.95rem; + background: transparent; + color: var(--color-input-bar-text); outline: none; - min-height: 38px; max-height: 120px; - transition: border-color 0.15s; + overflow-y: auto; } -.minichat-input:focus { border-color: var(--color-primary, #6366f1); } -.minichat-btn { - flex-shrink: 0; - width: 34px; - height: 34px; - border-radius: 8px; +.chat-input-bar textarea::placeholder { color: var(--color-input-bar-placeholder); } +.chat-input-bar textarea:disabled { opacity: 0.5; } +.btn-attach { + background: none; border: none; cursor: pointer; + color: var(--color-input-bar-text); + opacity: 0.6; + padding: 0.25rem; display: flex; align-items: center; justify-content: center; - font-size: 0.9rem; - transition: all 0.15s; } +.btn-attach:hover { opacity: 1; } +.btn-attach:disabled { opacity: 0.3; cursor: default; } +.btn-mic-ptt { transition: color 0.15s, opacity 0.15s; } +.btn-mic-ptt.mic-recording { color: #ef4444 !important; opacity: 1 !important; } +.btn-mic-ptt.mic-transcribing { color: var(--color-primary); opacity: 1 !important; } .btn-send { - background: linear-gradient(135deg, #6366f1, #4f46e5); + width: 34px; + min-width: 34px; + height: 34px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-primary); color: #fff; - font-weight: 700; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 1.1rem; + flex-shrink: 0; } -.btn-send:disabled { opacity: 0.4; cursor: not-allowed; } -.btn-send:not(:disabled):hover { box-shadow: 0 0 12px rgba(99,102,241,0.5); } -.btn-mic { - background: rgba(255,255,255,0.05); +.btn-send:disabled { opacity: 0.35; cursor: default; } +.btn-close-chat { + flex-shrink: 0; + background: none; border: 1px solid var(--color-border, rgba(255,255,255,0.1)); + border-radius: 8px; color: var(--color-muted); + cursor: pointer; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + transition: color 0.15s; } -.btn-mic.recording { color: #ef4444; border-color: #ef4444; } -.btn-close { - background: rgba(255,255,255,0.05); - border: 1px solid var(--color-border, rgba(255,255,255,0.1)); - color: var(--color-muted); - font-size: 0.78rem; -} -.btn-close:hover { color: var(--color-text); } +.btn-close-chat:hover { color: var(--color-text); } From 5924e565b13d36b2ca8f53540d0523d202e579ab Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 1 Apr 2026 09:04:47 -0400 Subject: [PATCH 030/119] fix: floating mini-chat overlay style + weather precip fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KnowledgeView mini-chat: replace harsh border-top with upward box-shadow and rounded top corners (16px); remove padding-bottom from content area so widget truly overlays cards without pushing layout; add collapse toggle (chevron) that hides messages without closing the conversation - WeatherCard: show precip_mm as fallback when precipitation_probability_max is null but actual rainfall is expected (Open-Meteo omits probability for some forecast days even when rain is shown) - Pass precip_mm through weather service → frontend type definitions Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/WeatherCard.vue | 8 +++++++- frontend/src/views/BriefingView.vue | 2 +- frontend/src/views/KnowledgeView.vue | 27 ++++++++++++++++++------- src/fabledassistant/services/weather.py | 1 + 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/WeatherCard.vue b/frontend/src/components/WeatherCard.vue index 7e52c06..c25cebd 100644 --- a/frontend/src/components/WeatherCard.vue +++ b/frontend/src/components/WeatherCard.vue @@ -7,6 +7,7 @@ interface ForecastDay { high: number low: number precip_probability: number | null + precip_mm: number | null windspeed_max: number } @@ -90,7 +91,12 @@ const fetchedAtLabel = computed(() => { {{ weatherIcon(day.condition) }} {{ day.condition }} {{ day.high }}° / {{ day.low }}° - 💧 {{ day.precip_probability }}% + + 💧 {{ day.precip_probability }}% + + + 💧 {{ day.precip_mm.toFixed(1) }}mm + 💧 — 💨 {{ day.windspeed_max }} {{ weather.wind_unit ?? 'km/h' }}
diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index aa39caa..674dec3 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -36,7 +36,7 @@ interface WeatherData { yesterday_high: number | null yesterday_low: number | null wind_unit?: string - forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; windspeed_max: number }[] + forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; precip_mm: number | null; windspeed_max: number }[] } const chatStore = useChatStore() diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index 906d1e6..8c8c65d 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -136,6 +136,7 @@ function toggleGraph() { const chatInput = ref(""); const chatOpen = ref(false); +const chatCollapsed = ref(false); const chatConvId = ref(null); const chatSending = ref(false); const chatMessages = computed(() => { @@ -437,8 +438,8 @@ onUnmounted(() => {
- -
+ +
@@ -495,6 +496,17 @@ onUnmounted(() => { title="Send" >↑
+