feat: add local backend server script and frontend application infrastructure

This commit is contained in:
techjarves committed 2026-06-21 20:33:59 +05:30
1 parent b71b4ecafa
commit d5d0c0ac19
7 files changed
+516 -224

No files matched your search

+50
View File
@@ -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);
}
+61 -2
View File
@@ -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}
/>
</div>
@@ -630,6 +687,8 @@ function App() {
showConfirm={showConfirm}
textSettings={textSettings}
setTextSettings={setTextSettings}
speechSettings={speechSettings}
setSpeechSettings={setSpeechSettings}
health={health}
cleanupItems={cleanupItems}
isReadinessBusy={isReadinessBusy}
+246 -201
View File
@@ -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 (
<div className="settings-section-header" style={{ borderLeftColor: color }}>
<div className="settings-section-icon" style={{ background: color + "15", color }}>
<Icon size={22} />
</div>
<div>
<div
className="settings-section-header"
style={{
borderLeftColor: color,
cursor: "pointer",
userSelect: "none",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
onClick={onToggle}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggle();
}
}}
aria-expanded={isExpanded}
>
<div style={{ display: "flex", alignItems: "center", gap: "14px", flex: 1 }}>
<div className="settings-section-icon" style={{ background: color + "15", color }}>
<Icon size={22} />
</div>
<div className="settings-section-title">{title}</div>
</div>
{count && (
<span className="settings-section-count">{count} settings</span>
)}
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
{count && (
<span className="settings-section-count">{count} settings</span>
)}
<ChevronDown
size={20}
style={{
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.3s var(--md-transition-easing)",
color: isExpanded ? "var(--md-sys-color-primary)" : "var(--md-sys-color-outline)",
flexShrink: 0
}}
/>
</div>
</div>
);
}
@@ -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 = () => (
<>
<SectionHeader
icon={Image}
title="Image Generation"
count={4}
color="#3b82f6"
isExpanded={expandedSections.image}
onToggle={() => toggleSection("image")}
/>
<div className="settings-two-column">
{expandedSections.image && (
<div className="settings-expanded-content">
<div className="settings-two-column">
{/* Left Column */}
<div className="settings-column">
{/* Size & Shape */}
@@ -524,7 +585,7 @@ function Settings({
min="1"
max={isOpenVinoNpu ? "8" : "60"}
/>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
<span className="settings-option-desc">
{isOpenVinoNpu
? "LCM OpenVINO: 1-8 fast steps"
: "More steps = sharper details, longer time"}
@@ -611,21 +672,27 @@ function Settings({
</div>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
// ─── Text Settings ───
const TextSettings = () => (
const renderTextSettings = () => (
<>
<SectionHeader
icon={Type}
title="Text Generation"
count={5}
color="#8b5cf6"
isExpanded={expandedSections.text}
onToggle={() => toggleSection("text")}
/>
<div className="settings-two-column">
{expandedSections.text && (
<div className="settings-expanded-content">
<div className="settings-two-column">
{/* Left Column */}
<div className="settings-column">
{/* Model & Context */}
@@ -638,13 +705,16 @@ function Settings({
<div className="m3-text-field">
<label className="m3-text-field-label">System Prompt</label>
<textarea
className="m3-input"
value={textSettings.systemPrompt || ""}
onChange={(e) => updateTextSetting("systemPrompt", e.target.value)}
placeholder="Enter system prompt..."
rows={3}
style={{ resize: "vertical", minHeight: "60px" }}
className="m3-input"
value={textSettings.systemPrompt || ""}
onChange={(e) => updateTextSetting("systemPrompt", e.target.value)}
placeholder="Enter system prompt..."
rows={3}
style={{ resize: "vertical", minHeight: "60px" }}
/>
<span className="settings-option-desc" style={{ marginTop: "4px", display: "block" }}>
Defines assistant personality/instructions. (Recommended: Default)
</span>
</div>
<div className="m3-slider-group">
@@ -661,8 +731,8 @@ function Settings({
max="32768"
step="512"
/>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
0 = Auto (model default)
<span className="settings-option-desc">
Model memory limit. 0 uses default limit. (Recommended: 0)
</span>
</div>
</div>
@@ -689,39 +759,11 @@ function Settings({
max="2"
step="0.1"
/>
<span className="settings-option-desc">
Controls creativity. Lower = focused & factual, Higher = creative & diverse. (Recommended: 0.7)
</span>
</div>
<div className="m3-slider-group">
<div className="m3-slider-header">
<span className="m3-slider-label">Top P</span>
<span className="settings-value-badge">{textSettings.topP}</span>
</div>
<input
type="range"
className="m3-slider"
value={textSettings.topP}
onChange={(e) => updateTextSetting("topP", parseFloat(e.target.value))}
min="0"
max="1"
step="0.05"
/>
</div>
<div className="m3-slider-group">
<div className="m3-slider-header">
<span className="m3-slider-label">Top K</span>
<span className="settings-value-badge">{textSettings.topK}</span>
</div>
<input
type="range"
className="m3-slider"
value={textSettings.topK}
onChange={(e) => updateTextSetting("topK", parseInt(e.target.value))}
min="1"
max="100"
step="1"
/>
</div>
<div className="m3-slider-group">
<div className="m3-slider-header">
@@ -744,55 +786,30 @@ function Settings({
</button>
))}
</div>
<input
type="range"
className="m3-slider"
value={textSettings.maxTokens || 1024}
onChange={(e) => updateTextSetting("maxTokens", parseInt(e.target.value))}
min="64"
max="4096"
step="64"
disabled={(textSettings.responseTokenMode || "auto") === "auto"}
/>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
Auto uses remaining context with a safety buffer. Manual uses the slider value.
</span>
</div>
<div className="m3-slider-group">
<div className="m3-slider-header">
<span className="m3-slider-label">Min P</span>
<span className="settings-value-badge">{textSettings.minP}</span>
</div>
<input
type="range"
className="m3-slider"
value={textSettings.minP}
onChange={(e) => updateTextSetting("minP", parseFloat(e.target.value))}
min="0"
max="1"
step="0.01"
/>
</div>
<div className="m3-slider-group">
<div className="m3-slider-header">
<span className="m3-slider-label">Repeat Penalty</span>
<span className="settings-value-badge">{textSettings.repeatPenalty}</span>
</div>
<input
type="range"
className="m3-slider"
value={textSettings.repeatPenalty}
onChange={(e) => updateTextSetting("repeatPenalty", parseFloat(e.target.value))}
min="1"
max="2"
step="0.05"
/>
{(textSettings.responseTokenMode || "auto") === "manual" ? (
<>
<input
type="range"
className="m3-slider"
value={textSettings.maxTokens || 1024}
onChange={(e) => updateTextSetting("maxTokens", parseInt(e.target.value))}
min="64"
max="4096"
step="64"
/>
<span className="settings-option-desc">
Manual uses the slider value.
</span>
</>
) : (
<span className="settings-option-desc">
Auto adjusts length dynamically based on context window. (Recommended)
</span>
)}
</div>
<div className="m3-text-field">
<label className="m3-text-field-label">Seed (-1 = Random)</label>
<label className="m3-text-field-label">Seed</label>
<input
type="number"
className="m3-input"
@@ -800,6 +817,9 @@ function Settings({
onChange={(e) => updateTextSetting("seed", parseInt(e.target.value) || -1)}
placeholder="-1"
/>
<span className="settings-option-desc" style={{ marginTop: "4px", display: "block" }}>
Controls repeatability. Use -1 for random, positive integer for identical replies. (Recommended: -1)
</span>
</div>
</div>
</div>
@@ -862,7 +882,7 @@ function Settings({
min="0"
max="50"
/>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
<span className="settings-option-desc">
50 = All layers on GPU
</span>
</div>
@@ -905,105 +925,132 @@ function Settings({
</div>
</div>
{/* Thinking & Reasoning */}
<div className="settings-subsection">
<div className="settings-subsection-title">
<Brain size={16} />
Thinking & Reasoning
</div>
<div className="m3-field-group">
<PremiumToggle
checked={textSettings.enableThinking !== false}
onChange={handleThinkingToggle}
label="DeepThink"
description={supportsThinking
? "Show model's reasoning process"
: "Model does not support thinking"
}
/>
{!supportsThinking && (
<div style={{
padding: "10px 14px",
borderRadius: "10px",
background: "rgba(239, 68, 68, 0.08)",
border: "1px solid rgba(239, 68, 68, 0.2)",
fontSize: "0.8rem",
color: "var(--md-sys-color-error)"
}}>
<Info size={14} style={{ verticalAlign: "middle", marginRight: "6px" }} />
Current model does not support thinking. Load a reasoning model to enable.
</div>
)}
</div>
</div>
{/* Text Backend */}
<div className="settings-subsection">
<div className="settings-subsection-title">
<Cpu size={16} />
Text Backend
</div>
<div className="m3-field-group">
<div style={{
padding: "12px 16px",
borderRadius: "12px",
background: "var(--md-sys-color-surface-variant)",
fontSize: "0.85rem",
color: "var(--md-sys-color-on-surface-variant)"
}}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "6px" }}>
<Monitor size={16} />
<strong style={{ color: "var(--md-sys-color-on-surface)" }}>
{llmStatus.ready ? "Running" : "Stopped"}
</strong>
</div>
{llmStatus.ready && (
<div style={{ fontSize: "0.8rem", lineHeight: "1.5" }}>
Model: {llmStatus.settings?.model || "Unknown"}<br />
Backend: {llmStatus.settings?.backendMode || "Unknown"}<br />
Threads: {llmStatus.settings?.threads || "-"}<br />
GPU Layers: {llmStatus.settings?.gpuLayers === -1 ? "All" : llmStatus.settings?.gpuLayers}
</div>
)}
<div style={{ marginTop: "10px", fontSize: "0.78rem", lineHeight: "1.5" }}>
Available: {(llmBackends.available || []).map((backend) => backend.key.toUpperCase()).join(", ") || "None"}<br />
Fastest saved: {(() => {
const model = llmStatus.settings?.model;
const winner = (llmStats.benchmarks || []).find((item) => item.ok && (!model || item.model === model));
return winner
? `${winner.backendMode} (${Number(winner.predicted_per_second || 0).toFixed(1)} tok/s)`
: "Not benchmarked";
})()}
</div>
<button
className="m3-button secondary"
type="button"
onClick={handleBenchmarkTextBackend}
disabled={benchmarkBusy || !llmStatus.ready}
style={{ marginTop: "12px", width: "100%", justifyContent: "center" }}
>
<Gauge size={15} />
{benchmarkBusy ? "Benchmarking..." : "Benchmark Text Backends"}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
// ─── Speech Settings ───
const renderSpeechSettings = () => {
const SPEECH_LANGUAGES = [
{ value: "auto", label: "Auto detect" },
{ value: "en", label: "English" },
{ value: "es", label: "Spanish" },
{ value: "fr", label: "French" },
{ value: "de", label: "German" },
{ value: "it", label: "Italian" },
{ value: "pt", label: "Portuguese" },
{ value: "hi", label: "Hindi" },
{ value: "ja", label: "Japanese" },
{ value: "ko", label: "Korean" },
{ value: "zh", label: "Chinese" },
];
return (
<>
<SectionHeader
icon={Volume2}
title="Speech Transcription"
count={3}
color="#10b981"
isExpanded={expandedSections.speech}
onToggle={() => toggleSection("speech")}
/>
{expandedSections.speech && (
<div className="settings-expanded-content">
<div className="settings-two-column">
{/* Left Column */}
<div className="settings-column">
{/* Speech Parameters */}
<div className="settings-subsection">
<div className="settings-subsection-title">
<Volume2 size={16} />
Transcription Settings
</div>
<div className="m3-field-group">
<div className="m3-text-field">
<label className="m3-text-field-label">Default Language</label>
<select
className="m3-input"
value={speechSettings.language || "auto"}
onChange={(e) => updateSpeechSetting("language", e.target.value)}
>
{SPEECH_LANGUAGES.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<span className="settings-option-desc" style={{ marginTop: "4px", display: "block" }}>
Default language for transcribing audio. (Recommended: Auto detect)
</span>
</div>
<div className="m3-slider-group">
<div className="m3-slider-header">
<span className="m3-slider-label">CPU Threads</span>
<span className="settings-value-badge">{speechSettings.threads || 4}</span>
</div>
<input
type="range"
className="m3-slider"
value={speechSettings.threads || 4}
onChange={(e) => updateSpeechSetting("threads", parseInt(e.target.value))}
min="1"
max={specs?.cpu_cores_logical || 16}
/>
<span className="settings-option-desc">
Number of threads to allocate for transcription. (Recommended: 4)
</span>
</div>
</div>
</div>
</div>
{/* Right Column */}
<div className="settings-column">
{/* Translation options */}
<div className="settings-subsection">
<div className="settings-subsection-title">
<Sparkles size={16} />
Translation Settings
</div>
<div className="m3-field-group">
<PremiumToggle
checked={speechSettings.translate === true}
onChange={(val) => updateSpeechSetting("translate", val)}
label="Translate to English"
description="Auto-translate foreign languages to English during transcription"
/>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
};
// ─── Appearance Settings ───
const AppearanceSettings = () => (
const renderAppearanceSettings = () => (
<>
<SectionHeader
icon={Palette}
title="Appearance & Themes"
count={THEMES.length}
color="var(--md-sys-color-primary)"
isExpanded={expandedSections.appearance}
onToggle={() => toggleSection("appearance")}
/>
<div className="settings-subsection" style={{ marginBottom: "28px" }}>
{expandedSections.appearance && (
<div className="settings-expanded-content">
<div className="settings-subsection" style={{ marginBottom: "28px" }}>
<div className="settings-subsection-title">
<Palette size={16} />
Color Themes
@@ -1070,35 +1117,33 @@ function Settings({
})}
</div>
</div>
</div>
)}
</>
);
return (
<div className="workspace-area">
{/* Page Header */}
<div className="settings-page-header">
<div>
<div className="settings-page-title">
<Settings2 size={24} style={{ color: "var(--md-sys-color-primary)" }} />
Settings & Parameters
</div>
<div className="settings-page-subtitle">
Configure your AI models for optimal performance
</div>
</div>
<div className="workspace-title-section">
<h2 className="workspace-title">Settings & Parameters</h2>
<p className="workspace-subtitle">
Configure your AI models for optimal performance.
</p>
</div>
{/* Hardware Tier Badge */}
<HardwareTierBadge specs={specs} />
{/* Appearance & Themes Section */}
<AppearanceSettings />
{renderAppearanceSettings()}
{/* Image Settings Section */}
<ImageSettings />
{renderImageSettings()}
{/* Text Settings Section */}
<TextSettings />
{renderTextSettings()}
{/* Speech Settings Section */}
{renderSpeechSettings()}
</div>
);
}
+37 -11
View File
@@ -4,7 +4,9 @@ import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, Chev
function formatSidebarDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString();
const dateStr = date.toLocaleDateString();
const timeStr = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: true });
return `${dateStr} ${timeStr}`;
}
function Sidebar({
@@ -21,7 +23,8 @@ function Sidebar({
selectedSpeechTranscript,
setSelectedSpeechTranscript,
showSpeechHistory,
setShowSpeechHistory
setShowSpeechHistory,
onDeleteSpeechTranscription
}) {
return (
<div className="sidebar">
@@ -227,8 +230,9 @@ function Sidebar({
}}
style={{
display: "flex",
flexDirection: "column",
gap: "2px",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: "7px 8px 7px 10px",
borderRadius: "var(--md-shape-corner-small)",
fontSize: "0.78rem",
@@ -241,14 +245,36 @@ function Sidebar({
className="sidebar-history-item"
title={title}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
{title}
</span>
{date && (
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{date}
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1, minWidth: 0 }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
{title}
</span>
)}
{date && (
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{date}
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteSpeechTranscription?.(item, e);
}}
style={{
background: "none",
border: "none",
color: "var(--md-sys-color-outline)",
cursor: "pointer",
padding: "2px",
marginLeft: "6px",
display: "flex",
alignItems: "center"
}}
className="sidebar-history-delete"
title="Delete Transcription"
>
<Trash2 size={12} />
</button>
</div>
);
})
@@ -115,27 +115,41 @@ function normalizeTimestamp(value) {
return "";
}
function formatTranscriptWithTimes(transcription) {
function formatTranscriptText(transcription, showTimestamps) {
const segments = transcription?.raw?.transcription;
if (!Array.isArray(segments) || segments.length === 0) {
return transcription?.text || "";
}
return segments
.map((segment) => {
const time = normalizeTimestamp(segment.timestamps);
const text = String(segment.text || "").trim();
if (!showTimestamps) return text;
const time = normalizeTimestamp(segment.timestamps);
return time ? `[${time}] ${text}` : text;
})
.filter(Boolean)
.join("\n");
}
export default function SpeechTranscriber({ showAlert, showConfirm, selectedTranscript, onTranscriptionsChanged }) {
export default function SpeechTranscriber({
showAlert,
showConfirm,
selectedTranscript,
onTranscriptionsChanged,
speechSettings,
setSpeechSettings,
}) {
const [status, setStatus] = useState({ ready: false, running: false, backendInstalled: false, settings: {} });
const [models, setModels] = useState([]);
const [selectedModel, setSelectedModel] = useState("");
const [language, setLanguage] = useState("auto");
const [threads, setThreads] = useState(Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4)));
const language = speechSettings?.language || "auto";
const threads = speechSettings?.threads || 4;
const translate = speechSettings?.translate === true;
const setLanguage = (val) => setSpeechSettings((prev) => ({ ...prev, language: val }));
const setThreads = (val) => setSpeechSettings((prev) => ({ ...prev, threads: val }));
const [audioBlob, setAudioBlob] = useState(null);
const [audioName, setAudioName] = useState("");
const [audioUrl, setAudioUrl] = useState("");
@@ -143,12 +157,19 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
const [isTranscribing, setIsTranscribing] = useState(false);
const [result, setResult] = useState(null);
const [resultText, setResultText] = useState("");
const [showTimestamps, setShowTimestamps] = useState(false);
const [isLoadingModel, setIsLoadingModel] = useState(false);
const recorderRef = useRef(null);
const abortRef = useRef(null);
const installedModels = useMemo(() => models.filter((model) => model.installed), [models]);
const isEnglishOnly = useMemo(() => {
if (!selectedModel) return false;
const model = models.find((m) => m.filename === selectedModel);
return model?.language === "English" || selectedModel.toLowerCase().includes(".en");
}, [selectedModel, models]);
const refresh = useCallback(async () => {
const [nextStatus, nextModels] = await Promise.all([
getSpeechStatus(),
@@ -169,14 +190,22 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
useEffect(() => {
refresh().catch((err) => showAlert?.({ title: "Speech Status Failed", message: err.message || String(err), danger: true }));
const interval = setInterval(() => {
refresh().catch(() => {});
}, 2000);
return () => clearInterval(interval);
}, [refresh, showAlert]);
useEffect(() => {
if (!selectedTranscript) return;
setResult(selectedTranscript);
setResultText(formatTranscriptWithTimes(selectedTranscript));
}, [selectedTranscript]);
useEffect(() => {
if (!result) return;
setResultText(formatTranscriptText(result, showTimestamps));
}, [result, showTimestamps]);
useEffect(() => {
return () => {
if (audioUrl) URL.revokeObjectURL(audioUrl);
@@ -272,19 +301,33 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
abortRef.current = controller;
try {
if (!status.ready || status.settings?.model !== selectedModel) {
await startSpeech(selectedModel, { language, threads });
setIsLoadingModel(true);
try {
await startSpeech(selectedModel, { language, threads });
await refresh();
} finally {
setIsLoadingModel(false);
}
}
const transcription = await transcribeSpeech(audioBlob, {
model: selectedModel,
language,
threads,
translate,
filename: audioName || "audio.wav",
signal: controller.signal,
});
setResult(transcription);
setResultText(formatTranscriptWithTimes(transcription));
await refresh();
await onTranscriptionsChanged?.();
if (transcription?.text?.toLowerCase().includes("foreign language")) {
showAlert?.({
title: "Foreign Language Detected",
message: "The loaded model is English-only and cannot transcribe non-English speech. Please download and load a Multilingual model (e.g. Whisper Base Multilingual) from the Model Manager to transcribe this audio.",
danger: false,
});
}
} catch (err) {
if (err.name !== "AbortError") {
showAlert?.({ title: "Transcription Failed", message: err.message || String(err), danger: true });
@@ -361,12 +404,23 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
/>
</label>
</div>
<div style={{ marginTop: "10px", paddingLeft: "2px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "0.82rem", cursor: "pointer", color: "var(--md-sys-color-on-surface)" }}>
<input
type="checkbox"
checked={translate}
onChange={(event) => setSpeechSettings((prev) => ({ ...prev, translate: event.target.checked }))}
style={{ cursor: "pointer", width: "15px", height: "15px" }}
/>
<span>Translate to English</span>
</label>
</div>
<div className="speech-button-row">
<button
className="m3-btn m3-btn-filled"
onClick={handleLoadSpeechModel}
disabled={!selectedModel || isLoadingModel || (status.ready && status.settings?.model === selectedModel)}
disabled={!selectedModel || isLoadingModel || isTranscribing || (status.ready && status.settings?.model === selectedModel)}
>
{isLoadingModel ? <LoaderCircle className="progress-spinner" size={14} /> : <Play size={14} />}
<span>{status.ready && status.settings?.model === selectedModel ? "Loaded" : isLoadingModel ? "Loading" : "Load"}</span>
@@ -450,6 +504,15 @@ export default function SpeechTranscriber({ showAlert, showConfirm, selectedTran
<div className="speech-panel-header">
<h3>Transcript</h3>
<div className="speech-button-row">
<label style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "0.78rem", cursor: "pointer", userSelect: "none", color: "var(--md-sys-color-on-surface-variant)", marginRight: "12px" }}>
<input
type="checkbox"
checked={showTimestamps}
onChange={(e) => setShowTimestamps(e.target.checked)}
style={{ width: "14px", height: "14px", cursor: "pointer" }}
/>
<span>Show Timestamps</span>
</label>
<button className="m3-btn m3-btn-outlined" onClick={saveText} disabled={!resultText.trim()}>
<Save size={14} />
<span>TXT</span>
+10
View File
@@ -609,6 +609,7 @@ export async function transcribeSpeech(fileOrBlob, options = {}) {
if (options.language) params.set("language", options.language);
if (options.filename) params.set("filename", options.filename);
if (options.threads) params.set("threads", String(options.threads));
if (options.translate) params.set("translate", "true");
const res = await fetch(`/api/speech/transcribe?${params.toString()}`, {
method: "POST",
@@ -653,6 +654,15 @@ export async function listSpeechTranscriptions() {
return data.transcriptions || [];
}
export async function deleteSpeechTranscription(filename) {
const res = await fetch("/api/speech/delete-transcription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
return await readJsonResponse(res, "The local server returned an invalid speech transcription delete response.");
}
export async function getLlmRecommendations(useCase = "chat", limit = 10) {
try {
const res = await fetch(`/api/llm/recommend?useCase=${encodeURIComponent(useCase)}&limit=${limit}`);
+40 -1
View File
@@ -1821,6 +1821,9 @@ function transcribeWavBuffer(buffer, options = {}) {
} else {
args.push("-l", "auto");
}
if (options.translate) {
args.push("-tr");
}
const startedAt = Date.now();
console.log(" [speech] Starting:", backend.cli, args.join(" "));
@@ -4664,7 +4667,7 @@ const server = http.createServer(async (req, res) => {
}
if (req.url === "/api/speech/stop" && req.method === "POST") {
await runExclusiveSpeechOperation(() => stopSpeech());
await stopSpeech();
return json(res, 200, { ok: true });
}
@@ -4677,6 +4680,7 @@ const server = http.createServer(async (req, res) => {
language: parsed.searchParams.get("language") || "auto",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording.wav",
threads: parsed.searchParams.get("threads") || speechSettings.threads,
translate: parsed.searchParams.get("translate") === "true",
}));
return json(res, 200, { ok: true, transcription: result });
} catch (err) {
@@ -4732,6 +4736,41 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { ok: true, transcriptions: listTranscriptions() });
}
if (req.url === "/api/speech/delete-transcription" && req.method === "POST") {
const body = await readJsonBody(req, res);
if (!body) return;
const filename = path.basename(String(body.filename || ""));
if (!filename || !filename.endsWith(".json")) {
return json(res, 400, { ok: false, error: "Invalid filename" });
}
const jsonPath = path.join(TRANSCRIPTIONS, filename);
if (!pathInside(jsonPath, TRANSCRIPTIONS)) {
return json(res, 400, { ok: false, error: "Invalid path" });
}
try {
if (!fs.existsSync(jsonPath)) {
return json(res, 404, { ok: false, error: "Transcription metadata not found" });
}
const metadata = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
const textFile = metadata.textFile ? path.basename(String(metadata.textFile)) : "";
fs.unlinkSync(jsonPath);
if (textFile) {
const textPath = path.join(TRANSCRIPTIONS, textFile);
if (pathInside(textPath, TRANSCRIPTIONS) && fs.existsSync(textPath)) {
fs.unlinkSync(textPath);
}
}
return json(res, 200, { ok: true });
} catch (err) {
console.error(" [api] Failed to delete transcription:", err);
return json(res, 500, { ok: false, error: err.message });
}
}
function isOomError(msg) {
if (!msg) return false;
const lower = String(msg).toLowerCase();