diff --git a/backend/api/routers/capture.py b/backend/api/routers/capture.py
index 23d18fd5..117e46cc 100644
--- a/backend/api/routers/capture.py
+++ b/backend/api/routers/capture.py
@@ -30,9 +30,16 @@ logger = logging.getLogger("omnivoice.capture")
async def transcribe_audio(
audio: UploadFile = File(...),
language: Optional[str] = Form(None),
+ model: Optional[str] = Form(None),
):
"""Transcribe an audio file to text.
+ Args:
+ audio: The audio file to transcribe.
+ language: Optional language hint (not currently used; auto-detected).
+ model: Whisper model size ('tiny', 'base', 'small', 'medium', 'large-v3').
+ Defaults to the server's configured ASR model.
+
Returns:
{
"text": "full transcription",
@@ -63,12 +70,16 @@ async def transcribe_audio(
tmp.write(content)
tmp.close()
+ chosen_model = model # captured in closure
+
def _run():
from services.asr_backend import get_active_asr_backend
backend = get_active_asr_backend(asr_pipe=_model._asr_pipe)
- # Note: WhisperXBackend.transcribe() auto-detects language;
- # the `language` form field is reserved for future use.
+ # If user chose a specific model and the backend supports it,
+ # override the default. Otherwise fall through to the default.
+ if chosen_model and hasattr(backend, 'model_size'):
+ backend.model_size = chosen_model
result = backend.transcribe(tmp.name)
return result
diff --git a/backend/api/routers/system.py b/backend/api/routers/system.py
index 5ac10222..69ac2120 100644
--- a/backend/api/routers/system.py
+++ b/backend/api/routers/system.py
@@ -29,6 +29,92 @@ def model_status():
return get_model_status()
+@router.get("/model/loaded")
+def loaded_models():
+ """Return details about all currently loaded models for the flush dropdown.
+
+ Returns a list of models with name, type, device, and estimated VRAM usage.
+ """
+ import services.model_manager as mm
+
+ models = []
+
+ # 1. TTS model (OmniVoice)
+ if mm.model is not None:
+ device = "unknown"
+ vram_mb = 0
+ try:
+ device = str(next(mm.model.parameters()).device) if hasattr(mm.model, 'parameters') else get_best_device()
+ except Exception:
+ device = get_best_device()
+ try:
+ torch = mm._lazy_torch()
+ if torch.cuda.is_available():
+ vram_mb = torch.cuda.memory_allocated() / (1024 ** 2)
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
+ driver = getattr(torch.mps, "driver_allocated_memory", None)
+ if driver:
+ vram_mb = driver() / (1024 ** 2)
+ except Exception:
+ pass
+ models.append({
+ "id": "tts",
+ "name": "OmniVoice TTS",
+ "checkpoint": os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice"),
+ "device": device,
+ "vram_mb": round(vram_mb, 1),
+ "unloadable": True,
+ })
+
+ # 2. ASR model (WhisperX)
+ if mm.model is not None and hasattr(mm.model, '_asr_pipe') and mm.model._asr_pipe is not None:
+ models.append({
+ "id": "asr",
+ "name": "WhisperX ASR",
+ "checkpoint": os.environ.get("ASR_MODEL", "Systran/faster-whisper-large-v3"),
+ "device": "cpu",
+ "vram_mb": 0,
+ "unloadable": False, # tied to TTS model lifecycle
+ })
+
+ # 3. Diarization pipeline
+ if mm._diar_pipeline is not None:
+ models.append({
+ "id": "diarization",
+ "name": "Pyannote Diarization",
+ "checkpoint": "pyannote/speaker-diarization-3.1",
+ "device": get_best_device(),
+ "vram_mb": 0,
+ "unloadable": True,
+ })
+
+ return {"models": models, "count": len(models)}
+
+
+@router.post("/model/unload/{model_id}")
+async def unload_model(model_id: str):
+ """Unload a specific model by ID."""
+ import services.model_manager as mm
+
+ if model_id == "tts":
+ async with mm._model_lock:
+ if mm.model is not None:
+ mm.model = None
+ mm.free_vram()
+ return {"unloaded": "tts", "success": True}
+ return {"unloaded": "tts", "success": False, "reason": "not loaded"}
+
+ elif model_id == "diarization":
+ if mm._diar_pipeline is not None:
+ mm._diar_pipeline = None
+ mm.free_vram()
+ return {"unloaded": "diarization", "success": True}
+ return {"unloaded": "diarization", "success": False, "reason": "not loaded"}
+
+ else:
+ raise HTTPException(status_code=400, detail=f"Unknown model id: {model_id}")
+
+
@router.get("/system/info", response_model=SystemInfoResponse)
def system_info():
"""Settings page system info — model, tokens, data dir, timeout.
@@ -421,12 +507,11 @@ async def set_env_var(body: dict):
Currently supports:
- HF_TOKEN: HuggingFace access token
- TRANSLATE_API_KEY: Translation API key
- - ELEVENLABS_API_KEY: ElevenLabs API key
The value is set on os.environ for the running process.
For persistence across restarts, users should set it in their shell profile.
"""
- ALLOWED_KEYS = {"HF_TOKEN", "TRANSLATE_API_KEY", "ELEVENLABS_API_KEY"}
+ ALLOWED_KEYS = {"HF_TOKEN", "TRANSLATE_API_KEY"}
key = body.get("key", "")
value = body.get("value", "")
diff --git a/frontend/src/components/CaptureButton.css b/frontend/src/components/CaptureButton.css
index 1c7a5129..5d6c1cb1 100644
--- a/frontend/src/components/CaptureButton.css
+++ b/frontend/src/components/CaptureButton.css
@@ -218,3 +218,71 @@
font-size: inherit;
margin: 0 1px;
}
+
+/* ── Model selector ──────────────────────────────────────────────────── */
+.capture-panel__model-row {
+ display: flex;
+ align-items: center;
+ padding: 4px 0;
+}
+
+.capture-panel__model-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 8px;
+ border-radius: 6px;
+ border: 1px solid color-mix(in srgb, var(--chrome-border, #3c3836) 60%, transparent);
+ background: color-mix(in srgb, var(--chrome-fg, #ebdbb2) 4%, transparent);
+ color: var(--chrome-fg-muted, #a89984);
+ font-size: 10px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.capture-panel__model-btn:hover {
+ background: color-mix(in srgb, var(--chrome-fg, #ebdbb2) 8%, transparent);
+ color: var(--chrome-fg, #ebdbb2);
+}
+
+.capture-panel__model-dropdown {
+ position: absolute;
+ bottom: calc(100% + 4px);
+ left: 0;
+ width: 220px;
+ background: var(--color-bg-elev-1, #282828);
+ border: 1px solid var(--color-border, #3c3836);
+ border-radius: 10px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+ padding: 4px;
+ z-index: 100;
+ animation: capture-slide-up 0.15s ease-out;
+}
+
+.capture-panel__model-option {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 6px 8px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--chrome-fg, #ebdbb2);
+ font-size: 11px;
+ cursor: pointer;
+ text-align: left;
+ transition: background 0.1s;
+}
+.capture-panel__model-option:hover { background: rgba(255, 255, 255, 0.06); }
+.capture-panel__model-option.is-active {
+ background: color-mix(in srgb, var(--color-brand, #d3869b) 12%, transparent);
+}
+
+.capture-panel__model-icon { font-size: 12px; flex-shrink: 0; }
+.capture-panel__model-label { font-weight: 600; min-width: 52px; }
+.capture-panel__model-desc {
+ font-size: 9px;
+ color: var(--chrome-fg-dim, #665c54);
+ flex: 1;
+}
diff --git a/frontend/src/components/CaptureButton.jsx b/frontend/src/components/CaptureButton.jsx
index e4ce4707..06ca7bb5 100644
--- a/frontend/src/components/CaptureButton.jsx
+++ b/frontend/src/components/CaptureButton.jsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { Mic, MicOff, Clipboard, X, Loader } from 'lucide-react';
+import { Mic, MicOff, Clipboard, X, Loader, ChevronDown } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { useAppStore } from '../store';
import './CaptureButton.css';
@@ -7,6 +7,16 @@ import './CaptureButton.css';
import { API as API_BASE } from '../api/client';
import { addTranscription } from '../pages/Transcriptions';
+const WHISPER_MODELS = [
+ { id: 'tiny', label: 'Tiny', desc: 'Fastest · lowest accuracy', icon: '⚡' },
+ { id: 'base', label: 'Base', desc: 'Fast · basic accuracy', icon: '🔵' },
+ { id: 'small', label: 'Small', desc: 'Balanced speed & quality', icon: '🟢' },
+ { id: 'medium', label: 'Medium', desc: 'Good accuracy · slower', icon: '🟡' },
+ { id: 'large-v3', label: 'Large V3', desc: 'Best accuracy · slowest', icon: '🔴' },
+];
+
+const LS_WHISPER_MODEL = 'omni_whisper_model';
+
/**
* CaptureButton — global dictation / voice capture widget.
*
@@ -24,6 +34,10 @@ export default function CaptureButton() {
const [transcript, setTranscript] = useState('');
const [duration, setDuration] = useState(0);
const [expanded, setExpanded] = useState(false);
+ const [whisperModel, setWhisperModel] = useState(() =>
+ localStorage.getItem(LS_WHISPER_MODEL) || 'small'
+ );
+ const [showModelPicker, setShowModelPicker] = useState(false);
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
@@ -101,6 +115,7 @@ export default function CaptureButton() {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
const formData = new FormData();
formData.append('audio', blob, 'capture.webm');
+ formData.append('model', whisperModel);
try {
const res = await fetch(`${API_BASE}/transcribe`, {
@@ -204,6 +219,40 @@ export default function CaptureButton() {
)}
+ {/* Model selector */}
+
+
+
+ {showModelPicker && (
+
+ {WHISPER_MODELS.map(m => (
+
+ ))}
+
+ )}
+
+
+
{navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}+⇧+Space
diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx
index 30a174cc..92d33033 100644
--- a/frontend/src/components/Header.jsx
+++ b/frontend/src/components/Header.jsx
@@ -1,5 +1,5 @@
-import React, { useState } from 'react';
-import { Globe, Fingerprint, Wand2, Film, FolderOpen, RefreshCw, Settings2, ChevronRight, Zap, Building2, Library, FileText } from 'lucide-react';
+import React, { useState, useRef, useEffect } from 'react';
+import { Globe, Fingerprint, Wand2, Film, FolderOpen, RefreshCw, Settings2, ChevronRight, ChevronDown, Zap, Building2, Library, FileText, Trash2 } from 'lucide-react';
import { Button, Badge } from '../ui';
import NotificationPanel from './NotificationPanel';
@@ -41,8 +41,51 @@ export default function Header({
activeProjectName, onFlushMemory,
}) {
const [flushing, setFlushing] = useState(false);
+ const [flushOpen, setFlushOpen] = useState(false);
+ const [loadedModels, setLoadedModels] = useState([]);
+ const [unloading, setUnloading] = useState(null);
+ const flushRef = useRef(null);
const view = VIEW_META[mode] || VIEW_META.launchpad;
const ViewIcon = view.Icon;
+
+ // Fetch loaded models when dropdown opens
+ useEffect(() => {
+ if (!flushOpen) return;
+ const fetchModels = async () => {
+ try {
+ const { API } = await import('../api/client');
+ const res = await fetch(`${API}/model/loaded`);
+ if (res.ok) {
+ const data = await res.json();
+ setLoadedModels(data.models || []);
+ }
+ } catch {}
+ };
+ fetchModels();
+ }, [flushOpen]);
+
+ // Click outside to close
+ useEffect(() => {
+ if (!flushOpen) return;
+ const handler = (e) => {
+ if (flushRef.current && !flushRef.current.contains(e.target)) setFlushOpen(false);
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, [flushOpen]);
+
+ const unloadModel = async (modelId) => {
+ setUnloading(modelId);
+ try {
+ const { API } = await import('../api/client');
+ const res = await fetch(`${API}/model/unload/${modelId}`, { method: 'POST' });
+ if (res.ok) {
+ setLoadedModels(prev => prev.filter(m => m.id !== modelId));
+ }
+ } catch {} finally {
+ setUnloading(null);
+ }
+ };
// Dynamic accent color must stay inline — it's driven by the current view.
const dotStyle = { background: view.accent, boxShadow: `0 0 10px ${view.accent}90` };
const labelStyle = { color: view.accent };
@@ -121,20 +164,69 @@ export default function Header({
{onFlushMemory && (
- }
- onClick={async (e) => {
- setFlushing(true);
- try { await onFlushMemory(e.altKey); } finally { setFlushing(false); }
- }}
- className="hq-flush-btn"
- >
- Flush
-
+
+
}
+ trailing={
}
+ onClick={() => setFlushOpen(o => !o)}
+ className="hq-flush-btn"
+ >
+ Flush
+
+ {flushOpen && (
+
+
Loaded Models
+ {loadedModels.length === 0 ? (
+
No models loaded
+ ) : (
+ loadedModels.map(m => (
+
+
+ {m.name}
+
+ {m.device} {m.vram_mb > 0 ? `· ${m.vram_mb.toFixed(0)} MB` : ''}
+
+
+ {m.unloadable && (
+
+ )}
+
+ ))
+ )}
+
+
+
+
+ )}
+
)}
)}
diff --git a/frontend/src/components/LogsFooter.css b/frontend/src/components/LogsFooter.css
index 586cc5f2..cdcda477 100644
--- a/frontend/src/components/LogsFooter.css
+++ b/frontend/src/components/LogsFooter.css
@@ -332,3 +332,100 @@
}
.logs-footer__line--error .logs-footer__line-text { color: #fb4934; }
.logs-footer__line--warn .logs-footer__line-text { color: #fabd2f; }
+
+/* ── Notification panel in footer ────────────────────────────────── */
+
+.logs-footer__notif-body {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 6px 8px;
+}
+
+.logs-footer__notif-item {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ padding: 8px 10px;
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid transparent;
+}
+.logs-footer__notif-item:hover { background: rgba(255, 255, 255, 0.04); }
+
+.logs-footer__notif-item--warn { border-left: 2px solid #fabd2f; }
+.logs-footer__notif-item--error { border-left: 2px solid #fb4934; }
+.logs-footer__notif-item--info { border-left: 2px solid #83a598; }
+
+.logs-footer__notif-icon {
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.logs-footer__notif-content {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+ min-width: 0;
+}
+.logs-footer__notif-content strong {
+ font-size: 12px;
+ color: var(--color-fg);
+}
+.logs-footer__notif-msg {
+ font-size: 11px;
+ color: var(--color-fg-muted);
+ line-height: 1.5;
+}
+
+.logs-footer__notif-link {
+ font-size: 11px;
+ color: var(--color-brand);
+ text-decoration: none;
+ margin-top: 2px;
+}
+.logs-footer__notif-link:hover { text-decoration: underline; }
+
+.logs-footer__notif-hf {
+ display: flex;
+ gap: 4px;
+ margin-top: 4px;
+}
+.logs-footer__notif-hf-input {
+ flex: 1;
+ max-width: 280px;
+ background: var(--color-bg-elev-2);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ color: var(--color-fg);
+ font-size: 11px;
+ font-family: var(--font-mono);
+ padding: 3px 8px;
+}
+.logs-footer__notif-hf-input:focus {
+ border-color: var(--color-brand);
+ outline: none;
+}
+.logs-footer__notif-hf-btn {
+ background: var(--color-brand);
+ color: #1d2021;
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 10px;
+ cursor: pointer;
+}
+.logs-footer__notif-hf-btn:hover { opacity: 0.85; }
+
+.logs-footer__notif-item--clickable { cursor: pointer; }
+.logs-footer__notif-item--clickable:hover { background: rgba(255, 255, 255, 0.06); }
+
+.logs-footer__notif-action {
+ flex-shrink: 0;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--color-brand);
+ white-space: nowrap;
+}
diff --git a/frontend/src/components/LogsFooter.jsx b/frontend/src/components/LogsFooter.jsx
index def71c07..36ac7baa 100644
--- a/frontend/src/components/LogsFooter.jsx
+++ b/frontend/src/components/LogsFooter.jsx
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ChevronUp, ChevronDown, RefreshCw, Trash2, Copy, Bug, X,
- AlertTriangle, AlertCircle, Info, FileText, Heart,
+ AlertTriangle, AlertCircle, Info, FileText, Heart, Bell,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { clearSystemLogs, clearTauriLogs } from '../api/system';
@@ -23,6 +23,7 @@ const SOURCES = [
{ id: 'backend', label: 'Backend', icon: FileText },
{ id: 'frontend', label: 'Frontend', icon: FileText },
{ id: 'tauri', label: 'Tauri', icon: FileText },
+ { id: 'notifications', label: 'Notifications', icon: Bell },
];
const LS_HEIGHT = 'omnivoice.logs.height';
@@ -192,6 +193,8 @@ export default function LogsFooter() {
// comes from the in-process ring buffer in consoleBuffer.js.
const [lines, setLines] = useState({ backend: [], frontend: [], tauri: [] });
const [loading, setLoading] = useState(false);
+ const [notifications, setNotifications] = useState([]);
+ const [hfInput, setHfInput] = useState('');
const scrollRef = useRef(null);
useEffect(() => localStorage.setItem(LS_HEIGHT, String(height)), [height]);
@@ -249,6 +252,34 @@ export default function LogsFooter() {
return () => clearInterval(iv);
}, [pullFrontend, collapsed]);
+ // ── Notifications polling ──────────────────────────────────────────────
+ const fetchNotifications = useCallback(async () => {
+ try {
+ const { API } = await import('../api/client');
+ const res = await fetch(`${API}/system/notifications`);
+ if (res.ok) {
+ const data = await res.json();
+ setNotifications(data.notifications || []);
+ }
+ } catch { /* backend not ready */ }
+ }, []);
+
+ useEffect(() => {
+ fetchNotifications();
+ const iv = setInterval(fetchNotifications, 30000);
+ return () => clearInterval(iv);
+ }, [fetchNotifications]);
+
+ // Allow header bell to open notifications tab
+ useEffect(() => {
+ const handler = () => {
+ setActive('notifications');
+ setCollapsed(false);
+ };
+ window.addEventListener('omni:open-notifications', handler);
+ return () => window.removeEventListener('omni:open-notifications', handler);
+ }, []);
+
// Auto-scroll to bottom when new lines arrive and panel is open.
useEffect(() => {
if (collapsed) return;
@@ -262,7 +293,12 @@ export default function LogsFooter() {
backend: countLevels(lines.backend),
frontend: countLevels(lines.frontend),
tauri: countLevels(lines.tauri),
- }), [lines]);
+ notifications: {
+ error: notifications.filter(n => n.level === 'error').length,
+ warn: notifications.filter(n => n.level === 'warn').length,
+ total: notifications.length,
+ },
+ }), [lines, notifications]);
const openTo = (id) => { setActive(id); setCollapsed(false); };
@@ -336,6 +372,7 @@ export default function LogsFooter() {
// ── Render ──────────────────────────────────────────────────────────
const current = lines[active] || [];
+ const notifCounts = { error: 0, warn: notifications.filter(n => n.level === 'warn').length + notifications.filter(n => n.level === 'error').length, total: notifications.length };
return (
- {!collapsed && (
+ {!collapsed && active !== 'notifications' && (
{current.length === 0 && (
@@ -431,6 +468,47 @@ export default function LogsFooter() {
})}
)}
+
+ {!collapsed && active === 'notifications' && (
+
+ {notifications.length === 0 ? (
+
+ ✅ All clear — no issues detected
+
+ ) : (
+ notifications.map(notif => (
+
{
+ if (!notif.action) return;
+ if (notif.action.type === 'navigate') {
+ useAppStore.getState().setMode?.(notif.action.target);
+ setCollapsed(true);
+ } else if (notif.action.type === 'link') {
+ import('../api/external').then(m => m.openExternal(notif.action.target));
+ }
+ }}
+ role={notif.action ? 'button' : undefined}
+ tabIndex={notif.action ? 0 : undefined}
+ >
+
+
+
+
+ {notif.title}
+ {notif.message}
+
+ {notif.action && (
+
+ {notif.action.label} →
+
+ )}
+
+ ))
+ )}
+
+ )}
);
}
diff --git a/frontend/src/components/NotificationPanel.jsx b/frontend/src/components/NotificationPanel.jsx
index f1d2a355..091fe158 100644
--- a/frontend/src/components/NotificationPanel.jsx
+++ b/frontend/src/components/NotificationPanel.jsx
@@ -1,188 +1,55 @@
/**
- * NotificationPanel — actionable notification center in the header.
- *
- * Polls GET /system/notifications on mount and every 30s.
- * Surfaces warnings (HF_TOKEN, ffmpeg, disk, GPU) with inline actions.
- * Allows setting HF_TOKEN directly from the panel.
+ * NotificationPanel — bell icon in the header that opens the
+ * Notifications tab in the footer status bar.
*/
-import React, { useState, useEffect, useRef, useCallback } from 'react';
-import { Bell, AlertTriangle, AlertCircle, Info, ExternalLink, ChevronRight, Check } from 'lucide-react';
+import React, { useState, useEffect, useCallback } from 'react';
+import { Bell } from 'lucide-react';
import { API } from '../api/client';
import './NotificationPanel.css';
-const LEVEL_ICON = {
- warn: ,
- error: ,
- info: ,
-};
+export default function NotificationPanel() {
+ const [count, setCount] = useState(0);
+ const [hasErrors, setHasErrors] = useState(false);
+ const [hasWarns, setHasWarns] = useState(false);
-export default function NotificationPanel({ onNavigate }) {
- const [open, setOpen] = useState(false);
- const [notifications, setNotifications] = useState([]);
- const [dismissed, setDismissed] = useState(() => {
- try { return JSON.parse(localStorage.getItem('omni_notif_dismissed') || '[]'); }
- catch { return []; }
- });
- const [hfInput, setHfInput] = useState('');
- const [hfSaving, setHfSaving] = useState(false);
- const panelRef = useRef(null);
-
- const fetchNotifications = useCallback(async () => {
+ const fetchCount = useCallback(async () => {
try {
const res = await fetch(`${API}/system/notifications`);
if (res.ok) {
const data = await res.json();
- setNotifications(data.notifications || []);
+ const notifs = data.notifications || [];
+ setCount(notifs.length);
+ setHasErrors(notifs.some(n => n.level === 'error'));
+ setHasWarns(notifs.some(n => n.level === 'warn'));
}
} catch {
- // Backend not ready yet
+ // Backend not ready
}
}, []);
- // Poll on mount + every 30s
useEffect(() => {
- fetchNotifications();
- const iv = setInterval(fetchNotifications, 30000);
+ fetchCount();
+ const iv = setInterval(fetchCount, 30000);
return () => clearInterval(iv);
- }, [fetchNotifications]);
+ }, [fetchCount]);
- // Click outside to close
- useEffect(() => {
- if (!open) return;
- const handler = (e) => {
- if (panelRef.current && !panelRef.current.contains(e.target)) {
- setOpen(false);
- }
- };
- document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
- }, [open]);
-
- const visibleNotifs = notifications.filter(n => !dismissed.includes(n.id));
- const hasErrors = visibleNotifs.some(n => n.level === 'error');
- const hasWarns = visibleNotifs.some(n => n.level === 'warn');
-
- const dismiss = (id) => {
- const next = [...dismissed, id];
- setDismissed(next);
- localStorage.setItem('omni_notif_dismissed', JSON.stringify(next));
- };
-
- const dismissAll = () => {
- const next = notifications.map(n => n.id);
- setDismissed(next);
- localStorage.setItem('omni_notif_dismissed', JSON.stringify(next));
- };
-
- const handleAction = (notif) => {
- if (!notif.action) return;
-
- if (notif.action.type === 'navigate' && onNavigate) {
- onNavigate(notif.action.target);
- setOpen(false);
- } else if (notif.action.type === 'link') {
- window.open(notif.action.target, '_blank');
- }
- };
-
- const saveHfToken = async () => {
- if (!hfInput.trim()) return;
- setHfSaving(true);
- try {
- const res = await fetch(`${API}/system/set-env`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key: 'HF_TOKEN', value: hfInput.trim() }),
- });
- if (res.ok) {
- dismiss('hf-token-missing');
- setHfInput('');
- // Re-fetch to reflect the change
- setTimeout(fetchNotifications, 500);
- }
- } catch (e) {
- console.warn('Failed to set HF_TOKEN:', e);
- } finally {
- setHfSaving(false);
- }
+ const openNotifications = () => {
+ window.dispatchEvent(new CustomEvent('omni:open-notifications'));
};
return (
-
- {/* Bell trigger */}
-
-
- {/* Dropdown */}
- {open && (
-
-
-
Notifications
- {visibleNotifs.length > 0 && (
-
- )}
-
-
- {visibleNotifs.length === 0 ? (
-
-
✅
- All clear — no issues detected
-
- ) : (
- visibleNotifs.map(notif => (
-
-
- {LEVEL_ICON[notif.level] || LEVEL_ICON.info}
-
-
-
{notif.title}
-
{notif.message}
-
- {/* Special: inline HF_TOKEN input */}
- {notif.id === 'hf-token-missing' && (
-
- setHfInput(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && saveHfToken()}
- aria-label="HuggingFace token"
- />
-
-
- )}
-
- {notif.action && notif.id !== 'hf-token-missing' && (
-
- )}
-
-
- ))
- )}
-
+
+
);
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 73133a07..797d71a0 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -385,6 +385,71 @@ samp,
}
.hq-flush-btn { margin-left: 2px; }
.hq-reload-btn { flex-shrink: 0; }
+
+/* Flush dropdown */
+.hq-flush-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ width: 260px;
+ background: var(--color-bg-elev-1);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+ z-index: 9999;
+ padding: 4px 0;
+ animation: flush-slide 0.12s ease-out;
+}
+@keyframes flush-slide {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.hq-flush-dropdown__header {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--color-fg-subtle);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 6px 12px 4px;
+}
+.hq-flush-dropdown__empty {
+ padding: 12px;
+ font-size: 11px;
+ color: var(--color-fg-muted);
+ text-align: center;
+}
+.hq-flush-dropdown__item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 12px;
+ gap: 8px;
+}
+.hq-flush-dropdown__item:hover { background: rgba(255,255,255,0.03); }
+.hq-flush-dropdown__info { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.hq-flush-dropdown__name { font-size: 12px; color: var(--color-fg); font-weight: 500; }
+.hq-flush-dropdown__meta { font-size: 10px; color: var(--color-fg-subtle); font-family: var(--font-mono); }
+.hq-flush-dropdown__unload {
+ font-size: 10px; font-weight: 600;
+ color: var(--color-brand);
+ background: rgba(211,134,155,0.1);
+ border: 1px solid rgba(211,134,155,0.2);
+ border-radius: var(--radius-pill);
+ padding: 2px 8px; cursor: pointer;
+ flex-shrink: 0;
+}
+.hq-flush-dropdown__unload:hover { background: rgba(211,134,155,0.2); }
+.hq-flush-dropdown__divider { height: 1px; background: var(--color-border); margin: 4px 0; }
+.hq-flush-dropdown__action {
+ display: flex; align-items: center; gap: 6px;
+ width: 100%; padding: 6px 12px;
+ font-size: 12px; color: var(--color-fg);
+ background: none; border: none; cursor: pointer;
+ text-align: left;
+}
+.hq-flush-dropdown__action:hover { background: rgba(255,255,255,0.04); }
+.hq-flush-dropdown__action--danger { color: #fb4934; }
+.hq-flush-dropdown__action--danger:hover { background: rgba(251,73,52,0.08); }
/* Decorative wavy SVG ribbon was removed — the flat chrome gets its
separation from the hairline `border-bottom` above, matching the
LogsFooter's top edge. */
diff --git a/frontend/src/pages/Settings.css b/frontend/src/pages/Settings.css
index e2e915c1..b97296fe 100644
--- a/frontend/src/pages/Settings.css
+++ b/frontend/src/pages/Settings.css
@@ -351,3 +351,56 @@
position: relative;
top: auto;
}
+
+/* ── Credentials tab ────────────────────────────────────────── */
+.settings-credential {
+ margin-bottom: var(--space-5);
+ padding-bottom: var(--space-5);
+ border-bottom: 1px solid var(--chrome-border);
+}
+.settings-credential:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
+
+.settings-credential__header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ margin-bottom: var(--space-2);
+}
+.settings-credential__label {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--chrome-fg);
+ font-family: var(--font-sans);
+}
+.settings-credential__row {
+ display: flex;
+ gap: var(--space-2);
+ align-items: center;
+}
+.settings-credential__input {
+ flex: 1;
+ max-width: 380px;
+ background: var(--chrome-bg-2, #1d1d1d);
+ border: 1px solid var(--chrome-border);
+ border-radius: var(--radius-sm, 6px);
+ color: var(--chrome-fg);
+ font-size: var(--text-sm);
+ font-family: var(--chrome-font-mono);
+ padding: 6px 10px;
+}
+.settings-credential__input:focus {
+ outline: none;
+ border-color: var(--chrome-accent, #fabd2f);
+}
+.settings-credential__help {
+ margin: var(--space-2) 0 0;
+ font-size: var(--text-xs);
+ color: var(--chrome-fg-dim);
+ font-family: var(--font-sans);
+ line-height: 1.6;
+}
+.settings-credential__help a {
+ color: var(--chrome-accent, #d3869b);
+ text-decoration: none;
+}
+.settings-credential__help a:hover { text-decoration: underline; }
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 652bb99c..6855bb61 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -9,7 +9,7 @@ import {
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
- CheckCircle, AlertCircle, Plug, Mic, MessageSquare, Download, Copy, Building2,
+ CheckCircle, AlertCircle, Plug, Mic, MessageSquare, Download, Copy, Building2, KeyRound,
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { openExternal } from '../api/external';
@@ -23,11 +23,12 @@ import { useAppStore } from '../store';
import './Settings.css';
const TABS = [
- { id: 'models', label: 'Models', icon: Cpu, accent: '#f3a5b6' },
- { id: 'engines', label: 'Engines', icon: Plug, accent: '#d3869b' },
- { id: 'logs', label: 'Logs', icon: FileText, accent: '#fabd2f' },
- { id: 'about', label: 'About', icon: Info, accent: '#8ec07c' },
- { id: 'privacy', label: 'Privacy', icon: ShieldCheck, accent: '#b8bb26' },
+ { id: 'models', label: 'Models', icon: Cpu, accent: '#f3a5b6' },
+ { id: 'engines', label: 'Engines', icon: Plug, accent: '#d3869b' },
+ { id: 'credentials', label: 'Credentials', icon: KeyRound, accent: '#fe8019' },
+ { id: 'logs', label: 'Logs', icon: FileText, accent: '#fabd2f' },
+ { id: 'about', label: 'About', icon: Info, accent: '#8ec07c' },
+ { id: 'privacy', label: 'Privacy', icon: ShieldCheck, accent: '#b8bb26' },
];
const FAMILY_META = {
@@ -976,6 +977,8 @@ export default function Settings() {
{activeTab === 'engines' && }
+ {activeTab === 'credentials' && }
+
{activeTab === 'logs' && (
@@ -1130,3 +1133,102 @@ export default function Settings() {
);
}
+
+// ── Credentials Tab ───────────────────────────────────────────────────────
+
+const CREDENTIAL_FIELDS = [
+ {
+ key: 'HF_TOKEN',
+ label: 'HuggingFace Token',
+ placeholder: 'hf_xxxxxxxxxxxx',
+ help: 'Required for speaker diarization and faster model downloads. Get yours at huggingface.co/settings/tokens.',
+ link: 'https://huggingface.co/settings/tokens',
+ },
+ {
+ key: 'TRANSLATE_API_KEY',
+ label: 'Translation API Key',
+ placeholder: 'API key',
+ help: 'Optional — for DeepL, OpenAI, or paid translation providers. Not needed for Google Translate (free tier).',
+ link: null,
+ },
+];
+
+function CredentialsTab({ info }) {
+ const [values, setValues] = useState({});
+ const [saving, setSaving] = useState(null);
+ const [saved, setSaved] = useState({});
+
+ const save = async (key) => {
+ const value = (values[key] || '').trim();
+ if (!value) return;
+ setSaving(key);
+ try {
+ const { API } = await import('../api/client');
+ const res = await fetch(`${API}/system/set-env`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key, value }),
+ });
+ if (res.ok) {
+ toast.success(`${key} saved for this session`);
+ setSaved(prev => ({ ...prev, [key]: true }));
+ setValues(prev => ({ ...prev, [key]: '' }));
+ } else {
+ const d = await res.json().catch(() => ({}));
+ toast.error(d.detail || 'Failed to save');
+ }
+ } catch (e) {
+ toast.error(`Save failed: ${e.message}`);
+ } finally {
+ setSaving(null);
+ }
+ };
+
+ return (
+
+ Credentials
+
+ API keys and tokens are set for this session only. For
+ persistence across restarts, set them as environment variables in your
+ shell profile.
+
+ {CREDENTIAL_FIELDS.map(field => (
+
+
+
+ {field.key === 'HF_TOKEN' && (
+
+ {info?.has_hf_token || saved.HF_TOKEN ? '✓ Set' : '✗ Not set'}
+
+ )}
+
+
+ setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
+ onKeyDown={e => e.key === 'Enter' && save(field.key)}
+ />
+
+
+
+ {field.help}
+ {field.link && (
+ <> { e.preventDefault(); openExternal(field.link); }}>Get token →>
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/Transcriptions.jsx b/frontend/src/pages/Transcriptions.jsx
index 1ddd718a..8d87a2aa 100644
--- a/frontend/src/pages/Transcriptions.jsx
+++ b/frontend/src/pages/Transcriptions.jsx
@@ -3,14 +3,18 @@
*
* Stores transcriptions in localStorage and displays them in a searchable,
* timestamped list. Each entry can be copied, deleted, or re-used.
+ *
+ * Reactivity: addTranscription() dispatches a custom window event so the
+ * page updates in realtime without requiring a shared store.
*/
-import React, { useState, useCallback, useMemo } from 'react';
+import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { Mic, Copy, Trash2, Search, Clock, Languages, FileText, Download } from 'lucide-react';
import { Button } from '../ui';
import { toast } from 'react-hot-toast';
import './Transcriptions.css';
const STORAGE_KEY = 'omni_transcriptions';
+const TXN_EVENT = 'omni:transcription-added';
function loadTranscriptions() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
@@ -23,17 +27,20 @@ function saveTranscriptions(list) {
export function addTranscription(entry) {
const list = loadTranscriptions();
- list.unshift({
+ const newEntry = {
id: Date.now(),
text: entry.text || '',
language: entry.language || 'unknown',
duration_s: entry.duration_s || 0,
segments: entry.segments || [],
timestamp: new Date().toISOString(),
- });
+ };
+ list.unshift(newEntry);
// Keep last 200
if (list.length > 200) list.length = 200;
saveTranscriptions(list);
+ // Fire custom event for reactive updates
+ window.dispatchEvent(new CustomEvent(TXN_EVENT, { detail: newEntry }));
}
export default function TranscriptionsPage() {
@@ -41,6 +48,15 @@ export default function TranscriptionsPage() {
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
+ // Listen for new transcriptions added from CaptureButton
+ useEffect(() => {
+ const handler = () => {
+ setTranscriptions(loadTranscriptions());
+ };
+ window.addEventListener(TXN_EVENT, handler);
+ return () => window.removeEventListener(TXN_EVENT, handler);
+ }, []);
+
const filtered = useMemo(() => {
if (!search.trim()) return transcriptions;
const q = search.toLowerCase();