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:
@@ -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
|
||||
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user