diff --git a/app/frontend/src/App.css b/app/frontend/src/App.css index b5c1fec..364617e 100644 --- a/app/frontend/src/App.css +++ b/app/frontend/src/App.css @@ -3349,6 +3349,14 @@ html[data-theme^="dark"] .settings-section-header:hover { transition: all 0.3s ease; } +.settings-option-desc { + font-size: 0.75rem; + color: var(--md-sys-color-outline); + font-weight: 300; + opacity: 0.85; + line-height: 1.4; +} + .settings-subsection:hover { box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); border-color: color-mix(in srgb, var(--md-sys-color-primary) 15%, var(--md-sys-color-card-border)); @@ -3516,3 +3524,45 @@ html[data-theme^="dark"] .settings-subsection:hover { opacity: 0.85; } +.sidebar-history-delete { + transition: color 0.2s, background-color 0.2s, opacity 0.2s; + border-radius: 4px; + opacity: 0.6; +} + +.sidebar-history-delete:hover { + color: var(--md-sys-color-error) !important; + background-color: rgba(239, 68, 68, 0.1) !important; + opacity: 1 !important; +} + +.sidebar-history-item:hover .sidebar-history-delete { + opacity: 1; +} + +/* Global modern scrollbars */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--md-sys-color-outline) 25%, transparent); + border-radius: 99px; +} +::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--md-sys-color-outline) 45%, transparent); +} + +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--md-sys-color-outline) 25%, transparent) transparent; +} + +.settings-expanded-content { + animation: fadeInSlide 0.3s var(--md-transition-easing); +} + diff --git a/app/frontend/src/App.jsx b/app/frontend/src/App.jsx index 11412fe..39b6607 100644 --- a/app/frontend/src/App.jsx +++ b/app/frontend/src/App.jsx @@ -6,7 +6,7 @@ import ModelManager from "./components/ModelManager"; import Settings from "./components/Settings"; import TextChat from "./components/TextChat"; import SpeechTranscriber from "./components/SpeechTranscriber"; -import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, stopServer } from "./services/api"; +import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, deleteSpeechTranscription, stopServer } from "./services/api"; import "./App.css"; function App() { @@ -160,6 +160,29 @@ function App() { localStorage.setItem("textSettings", JSON.stringify(textSettings)); }, [textSettings]); + const [speechSettings, setSpeechSettings] = useState(() => { + const saved = localStorage.getItem("speechSettings"); + if (saved) { + try { + const parsed = JSON.parse(saved); + return { + language: parsed.language || "auto", + threads: Math.max(1, Math.min(32, Number(parsed.threads) || Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4)))), + translate: parsed.translate === true, + }; + } catch (_) {} + } + return { + language: "auto", + threads: Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4)), + translate: false, + }; + }); + + useEffect(() => { + localStorage.setItem("speechSettings", JSON.stringify(speechSettings)); + }, [speechSettings]); + // Lifted Chat History States const [conversations, setConversations] = useState([]); const [activeConversationId, setActiveConversationId] = useState(null); @@ -257,6 +280,37 @@ function App() { } }, []); + const handleDeleteSpeechTranscription = useCallback(async (item, e) => { + if (e) e.stopPropagation(); + const itemId = item.filename || item.metadata || item.textFile; + if (!itemId) return; + + const ok = await showConfirm({ + title: "Delete Transcription", + message: `Are you sure you want to delete "${item.displayName || item.sourceFilename || "this transcription"}"? This action cannot be undone.`, + confirmLabel: "Delete", + danger: true + }); + if (!ok) return; + + try { + await deleteSpeechTranscription(itemId); + + setSelectedSpeechTranscript((prev) => { + if (prev) { + const prevId = prev.filename || prev.metadata || prev.textFile; + if (prevId === itemId) return null; + } + return prev; + }); + + refreshSpeechTranscriptions(); + } catch (err) { + console.error("Failed to delete speech transcription:", err); + showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true }); + } + }, [showConfirm, showAlert, setSelectedSpeechTranscript, refreshSpeechTranscriptions]); + useEffect(() => { refreshSpeechTranscriptions(); }, [refreshSpeechTranscriptions]); @@ -510,8 +564,9 @@ function App() { setSelectedSpeechTranscript={setSelectedSpeechTranscript} showSpeechHistory={showSpeechHistory} setShowSpeechHistory={setShowSpeechHistory} + onDeleteSpeechTranscription={handleDeleteSpeechTranscription} /> - ), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory]); + ), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory, handleDeleteSpeechTranscription]); const handleStopServer = useCallback(async () => { if (!serverRunning || isStoppingServer) return; @@ -613,6 +668,8 @@ function App() { showConfirm={showConfirm} selectedTranscript={selectedSpeechTranscript} onTranscriptionsChanged={refreshSpeechTranscriptions} + speechSettings={speechSettings} + setSpeechSettings={setSpeechSettings} /> @@ -630,6 +687,8 @@ function App() { showConfirm={showConfirm} textSettings={textSettings} setTextSettings={setTextSettings} + speechSettings={speechSettings} + setSpeechSettings={setSpeechSettings} health={health} cleanupItems={cleanupItems} isReadinessBusy={isReadinessBusy} diff --git a/app/frontend/src/components/Settings.jsx b/app/frontend/src/components/Settings.jsx index 02e4c72..0597fac 100644 --- a/app/frontend/src/components/Settings.jsx +++ b/app/frontend/src/components/Settings.jsx @@ -3,7 +3,7 @@ import { Crop, Sliders, Cpu, Info, MessageSquare, SlidersHorizontal, Zap, ChevronDown, Image, Type, Settings2, Gauge, Brain, Sparkles, Monitor, HardDrive, MemoryStick, Thermometer, Hash, Layers, - ChevronRight, Box, Wand2, Lightbulb, RotateCcw, Check, Palette + ChevronRight, Box, Wand2, Lightbulb, RotateCcw, Check, Palette, Volume2 } from "lucide-react"; import { stopServer, formatBytes, getLlmBackends, getLlmStats, getLlmStatus, benchmarkLlm, startLlm, stopLlm } from "../services/api"; import { THEMES } from "../themes"; @@ -146,18 +146,49 @@ function PremiumToggle({ checked, onChange, label, description }) { } // ─── Section Header Component ─── -function SectionHeader({ icon: Icon, title, count, color }) { +function SectionHeader({ icon: Icon, title, count, color, isExpanded, onToggle }) { return ( -
-
- -
-
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggle(); + } + }} + aria-expanded={isExpanded} + > +
+
+ +
{title}
- {count && ( - {count} settings - )} +
+ {count && ( + {count} settings + )} + +
); } @@ -174,6 +205,8 @@ function Settings({ setActiveModel, textSettings, setTextSettings, + speechSettings, + setSpeechSettings, showAlert = async ({ message }) => window.alert(message), showConfirm = async ({ message }) => window.confirm(message), health, @@ -191,6 +224,23 @@ function Settings({ const [llmStats, setLlmStats] = useState({ benchmarks: [] }); const [benchmarkBusy, setBenchmarkBusy] = useState(false); + const [expandedSections, setExpandedSections] = useState(() => { + return { + appearance: localStorage.getItem("settings_section_appearance") === "true", + image: localStorage.getItem("settings_section_image") === "true", + text: localStorage.getItem("settings_section_text") === "true", + speech: localStorage.getItem("settings_section_speech") === "true", + }; + }); + + const toggleSection = (section) => { + setExpandedSections((prev) => { + const nextState = !prev[section]; + localStorage.setItem(`settings_section_${section}`, String(nextState)); + return { ...prev, [section]: nextState }; + }); + }; + useEffect(() => { let cancelled = false; const fetchLlmStatus = async () => { @@ -240,6 +290,13 @@ function Settings({ })); }; + const updateSpeechSetting = (key, value) => { + setSpeechSettings((prev) => ({ + ...prev, + [key]: value + })); + }; + const buildTextStartOptions = (settings) => ({ threads: settings?.threads || specs?.cpu_cores_physical || 4, contextSize: settings?.contextSize ?? 0, @@ -400,16 +457,20 @@ function Settings({ }; // ─── Image Settings ─── - const ImageSettings = () => ( + const renderImageSettings = () => ( <> toggleSection("image")} /> -
+ {expandedSections.image && ( +
+
{/* Left Column */}
{/* Size & Shape */} @@ -524,7 +585,7 @@ function Settings({ min="1" max={isOpenVinoNpu ? "8" : "60"} /> - + {isOpenVinoNpu ? "LCM OpenVINO: 1-8 fast steps" : "More steps = sharper details, longer time"} @@ -611,21 +672,27 @@ function Settings({
-
+
+
+ )} ); // ─── Text Settings ─── - const TextSettings = () => ( + const renderTextSettings = () => ( <> toggleSection("text")} /> -
+ {expandedSections.text && ( +
+
{/* Left Column */}
{/* Model & Context */} @@ -638,13 +705,16 @@ function Settings({