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 && ( - +
+ + {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();