feat: add local backend server script and frontend application infrastructure
This commit is contained in:
1 parent
b71b4ecafa
commit
d5d0c0ac19
7 files changed
+516
-224
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user