feat: flush dropdown, credentials tab, whisper model selector, reactive transcriptions

Flush Dropdown:
- Flush button now opens a dropdown showing all loaded models
- Each model shows device, VRAM usage, and individual Unload button
- Backend endpoints: GET /model/loaded, POST /model/unload/{id}
- Bottom actions: Flush caches, Unload all + flush

Credentials Tab:
- New Settings > Credentials tab with HF_TOKEN and TRANSLATE_API_KEY
- Session-scoped via POST /system/set-env (no ElevenLabs — we ARE the alternative)
- Shows 'Set' / 'Not set' badge for HF token

Notification Panel:
- Moved from header dropdown to footer status bar (4th tab: Notifications)
- Bell icon in header dispatches event to open footer tab
- Click notification → navigates to relevant page (e.g., Settings for HF token)
- No inline inputs — notifications are purely informational + navigational

Whisper Model Selector:
- Capture widget now has quality preset picker: tiny → large-v3
- Persisted in localStorage; sent to backend as 'model' form field
- Backend passes chosen model to ASR backend

Reactive Transcriptions:
- Custom window event (omni:transcription-added) bridges CaptureButton → TranscriptionsPage
- Page updates in realtime when new dictation completes
This commit is contained in:
debpalash
2026-04-28 12:33:10 +05:30
parent 22de8c43fe
commit a7b7e1f897
12 changed files with 780 additions and 197 deletions
+13 -2
View File
@@ -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
+87 -2
View File
@@ -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", "")
+68
View File
@@ -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;
}
+50 -1
View File
@@ -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() {
</div>
)}
{/* Model selector */}
<div className="capture-panel__model-row">
<div className="capture-panel__model-picker" style={{ position: 'relative' }}>
<button
className="capture-panel__model-btn"
onClick={() => setShowModelPicker(p => !p)}
title="Whisper model quality"
>
{WHISPER_MODELS.find(m => m.id === whisperModel)?.icon || '🟢'}{' '}
{WHISPER_MODELS.find(m => m.id === whisperModel)?.label || 'Small'}
<ChevronDown size={10} />
</button>
{showModelPicker && (
<div className="capture-panel__model-dropdown">
{WHISPER_MODELS.map(m => (
<button
key={m.id}
className={`capture-panel__model-option ${whisperModel === m.id ? 'is-active' : ''}`}
onClick={() => {
setWhisperModel(m.id);
localStorage.setItem(LS_WHISPER_MODEL, m.id);
setShowModelPicker(false);
}}
>
<span className="capture-panel__model-icon">{m.icon}</span>
<span className="capture-panel__model-label">{m.label}</span>
<span className="capture-panel__model-desc">{m.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="capture-panel__hint">
<kbd>{navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}</kbd>+<kbd>⇧</kbd>+<kbd>Space</kbd>
</div>
+108 -16
View File
@@ -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({
</Badge>
</span>
{onFlushMemory && (
<Button
variant="subtle"
size="sm"
title="Flush RAM/VRAM caches. Alt+Click to also unload model."
loading={flushing}
leading={!flushing && <Zap size={8} />}
onClick={async (e) => {
setFlushing(true);
try { await onFlushMemory(e.altKey); } finally { setFlushing(false); }
}}
className="hq-flush-btn"
>
Flush
</Button>
<div ref={flushRef} style={{ position: 'relative' }}>
<Button
variant="subtle"
size="sm"
title="Memory management"
loading={flushing}
leading={!flushing && <Zap size={8} />}
trailing={<ChevronDown size={8} />}
onClick={() => setFlushOpen(o => !o)}
className="hq-flush-btn"
>
Flush
</Button>
{flushOpen && (
<div className="hq-flush-dropdown">
<div className="hq-flush-dropdown__header">Loaded Models</div>
{loadedModels.length === 0 ? (
<div className="hq-flush-dropdown__empty">No models loaded</div>
) : (
loadedModels.map(m => (
<div key={m.id} className="hq-flush-dropdown__item">
<div className="hq-flush-dropdown__info">
<span className="hq-flush-dropdown__name">{m.name}</span>
<span className="hq-flush-dropdown__meta">
{m.device} {m.vram_mb > 0 ? `· ${m.vram_mb.toFixed(0)} MB` : ''}
</span>
</div>
{m.unloadable && (
<button
className="hq-flush-dropdown__unload"
onClick={() => unloadModel(m.id)}
disabled={unloading === m.id}
>
{unloading === m.id ? '…' : 'Unload'}
</button>
)}
</div>
))
)}
<div className="hq-flush-dropdown__divider" />
<button
className="hq-flush-dropdown__action"
onClick={async () => {
setFlushing(true);
setFlushOpen(false);
try { await onFlushMemory(false); } finally { setFlushing(false); }
}}
>
<Zap size={10} /> Flush caches
</button>
<button
className="hq-flush-dropdown__action hq-flush-dropdown__action--danger"
onClick={async () => {
setFlushing(true);
setFlushOpen(false);
try { await onFlushMemory(true); } finally { setFlushing(false); }
}}
>
<Trash2 size={10} /> Unload all + flush
</button>
</div>
)}
</div>
)}
</div>
)}
+97
View File
@@ -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;
}
+81 -3
View File
@@ -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 (
<div className={['logs-footer', collapsed ? 'logs-footer--collapsed' : 'logs-footer--open'].join(' ')}
@@ -413,7 +450,7 @@ export default function LogsFooter() {
</div>
</div>
{!collapsed && (
{!collapsed && active !== 'notifications' && (
<div ref={scrollRef} className="logs-footer__body">
{current.length === 0 && (
<div className="logs-footer__empty">
@@ -431,6 +468,47 @@ export default function LogsFooter() {
})}
</div>
)}
{!collapsed && active === 'notifications' && (
<div className="logs-footer__body logs-footer__notif-body">
{notifications.length === 0 ? (
<div className="logs-footer__empty">
✅ All clear — no issues detected
</div>
) : (
notifications.map(notif => (
<div
key={notif.id}
className={`logs-footer__notif-item logs-footer__notif-item--${notif.level} ${notif.action ? 'logs-footer__notif-item--clickable' : ''}`}
onClick={() => {
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}
>
<span className="logs-footer__notif-icon">
<SeverityIcon level={notif.level} />
</span>
<div className="logs-footer__notif-content">
<strong>{notif.title}</strong>
<span className="logs-footer__notif-msg">{notif.message}</span>
</div>
{notif.action && (
<span className="logs-footer__notif-action">
{notif.action.label} →
</span>
)}
</div>
))
)}
</div>
)}
</div>
);
}
+31 -164
View File
@@ -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: <AlertTriangle size={11} />,
error: <AlertCircle size={11} />,
info: <Info size={11} />,
};
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 (
<div className="notif-wrap" ref={panelRef} style={{ position: 'relative' }}>
{/* Bell trigger */}
<button
className={`notif-trigger ${visibleNotifs.length > 0 ? 'notif-trigger--has-items' : ''}`}
onClick={() => setOpen(!open)}
aria-label={`Notifications (${visibleNotifs.length})`}
title="Notifications"
>
<Bell size={14} />
{visibleNotifs.length > 0 && (
<span className={`notif-badge ${hasErrors ? '' : hasWarns ? 'notif-badge--warn' : ''}`}>
{visibleNotifs.length}
</span>
)}
</button>
{/* Dropdown */}
{open && (
<div className="notif-panel" role="dialog" aria-label="Notifications">
<div className="notif-panel__header">
<h3 className="notif-panel__title">Notifications</h3>
{visibleNotifs.length > 0 && (
<button className="notif-panel__dismiss" onClick={dismissAll}>
Dismiss all
</button>
)}
</div>
{visibleNotifs.length === 0 ? (
<div className="notif-panel__empty">
<div className="notif-panel__empty-icon">✅</div>
All clear — no issues detected
</div>
) : (
visibleNotifs.map(notif => (
<div key={notif.id} className="notif-item">
<div className={`notif-item__icon notif-item__icon--${notif.level}`}>
{LEVEL_ICON[notif.level] || LEVEL_ICON.info}
</div>
<div className="notif-item__body">
<p className="notif-item__title">{notif.title}</p>
<p className="notif-item__message">{notif.message}</p>
{/* Special: inline HF_TOKEN input */}
{notif.id === 'hf-token-missing' && (
<div className="notif-hf-input">
<input
type="password"
placeholder="hf_xxxxxxxxxxxx"
value={hfInput}
onChange={e => setHfInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveHfToken()}
aria-label="HuggingFace token"
/>
<button onClick={saveHfToken} disabled={hfSaving || !hfInput.trim()}>
{hfSaving ? '…' : 'Save'}
</button>
</div>
)}
{notif.action && notif.id !== 'hf-token-missing' && (
<button
className="notif-item__action"
onClick={() => handleAction(notif)}
>
{notif.action.label}
{notif.action.type === 'link' ? <ExternalLink size={10} /> : <ChevronRight size={10} />}
</button>
)}
</div>
</div>
))
)}
</div>
<button
className={`notif-trigger ${count > 0 ? 'notif-trigger--has-items' : ''}`}
onClick={openNotifications}
aria-label={`Notifications (${count})`}
title="Notifications"
>
<Bell size={14} />
{count > 0 && (
<span className={`notif-badge ${hasErrors ? '' : hasWarns ? 'notif-badge--warn' : ''}`}>
{count}
</span>
)}
</div>
</button>
);
}
+65
View File
@@ -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. */
+53
View File
@@ -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; }
+108 -6
View File
@@ -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' && <EnginesTab />}
{activeTab === 'credentials' && <CredentialsTab info={info} />}
{activeTab === 'logs' && (
<section className="settings-section">
<h2 className="settings-section__head-row">
@@ -1130,3 +1133,102 @@ export default function Settings() {
</div>
);
}
// ── 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 (
<section className="settings-section">
<h2><KeyRound size={16} color="#fe8019" /> Credentials</h2>
<p className="settings-prose">
API keys and tokens are set <strong>for this session only</strong>. For
persistence across restarts, set them as environment variables in your
shell profile.
</p>
{CREDENTIAL_FIELDS.map(field => (
<div key={field.key} className="settings-credential">
<div className="settings-credential__header">
<label className="settings-credential__label">{field.label}</label>
{field.key === 'HF_TOKEN' && (
<Badge tone={info?.has_hf_token || saved.HF_TOKEN ? 'success' : 'warn'} size="xs">
{info?.has_hf_token || saved.HF_TOKEN ? '✓ Set' : '✗ Not set'}
</Badge>
)}
</div>
<div className="settings-credential__row">
<input
type="password"
className="settings-credential__input"
placeholder={field.placeholder}
value={values[field.key] || ''}
onChange={e => setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && save(field.key)}
/>
<Button
size="sm"
variant="subtle"
loading={saving === field.key}
onClick={() => save(field.key)}
disabled={!(values[field.key] || '').trim()}
>
Save
</Button>
</div>
<p className="settings-credential__help">
{field.help}
{field.link && (
<> <a href="#" onClick={e => { e.preventDefault(); openExternal(field.link); }}>Get token →</a></>
)}
</p>
</div>
))}
</section>
);
}
+19 -3
View File
@@ -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();