Refactor app to speech-only transcription tool
Remove image generation, text-to-speech, and chat features. Keep speech transcription with Whisper.cpp, model manager, and settings. Support multiple audio formats (WAV, MP3, M4A, OGG, FLAC, WebM, AAC) via ffmpeg transcoding. Single-call API: upload → transcribe → download as txt/json/md.
This commit is contained in:
1 parent
313b994c9b
commit
76946f8594
14 files changed
+671
-10753
No files matched your search
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>Uncensored AI Studio</title>
|
||||
<title>Speech Transcriber</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
+211
-3732
File diff suppressed because it is too large.
Load diff
+85
-676
@@ -1,13 +1,8 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import TopStatusBar from "./components/TopStatusBar";
|
||||
import Generator from "./components/Generator";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import SpeechTranscriber from "./components/SpeechTranscriber";
|
||||
import ModelManager from "./components/ModelManager";
|
||||
import Settings from "./components/Settings";
|
||||
import TextChat from "./components/TextChat";
|
||||
import SpeechTranscriber from "./components/SpeechTranscriber";
|
||||
import TextToSpeech from "./components/TextToSpeech";
|
||||
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listLlmConversations, saveLlmConversation, deleteLlmConversation, listSpeechTranscriptions, deleteSpeechTranscription, listTtsOutputs, deleteTtsOutput, stopServer } from "./services/api";
|
||||
import { getHardwareSpecs, getTelemetry, listSpeechTranscriptions, deleteSpeechTranscription } from "./services/api";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
@@ -47,122 +42,10 @@ function App() {
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
// Navigation
|
||||
const [activeTab, setActiveTab] = useState("generator");
|
||||
|
||||
// Prompts
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [negativePrompt, setNegativePrompt] = useState("");
|
||||
|
||||
// Model & Server Status
|
||||
const [activeModel, setActiveModel] = useState(null);
|
||||
const [serverRunning, setServerRunning] = useState(false);
|
||||
const [isStoppingServer, setIsStoppingServer] = useState(false);
|
||||
const [isLlmLoaded, setIsLlmLoaded] = useState(false);
|
||||
const [backendOptions, setBackendOptions] = useState({
|
||||
options: [{ id: "cpu", label: "CPU", available: true }],
|
||||
cudaAvailable: false,
|
||||
vulkanAvailable: false,
|
||||
defaultBackendType: "cpu",
|
||||
});
|
||||
const [health, setHealth] = useState(null);
|
||||
const [cleanupItems, setCleanupItems] = useState([]);
|
||||
const [isReadinessBusy, setIsReadinessBusy] = useState(false);
|
||||
const [diagnosticsCopied, setDiagnosticsCopied] = useState(false);
|
||||
|
||||
// Gallery History
|
||||
const [generatedImages, setGeneratedImages] = useState([]);
|
||||
|
||||
// Generation status
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generationProgress, setGenerationProgress] = useState(0);
|
||||
|
||||
// System Specifications & Telemetry
|
||||
const [specs, setSpecs] = useState({
|
||||
os_name: "Loading Specs...",
|
||||
cpu_name: "Loading Specs...",
|
||||
cpu_cores_physical: 4,
|
||||
cpu_cores_logical: 8,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Loading Specs...",
|
||||
});
|
||||
|
||||
const [telemetry, setTelemetry] = useState({
|
||||
cpu_usage: 0,
|
||||
ram_used_gb: 0,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Detecting...",
|
||||
vram_used_gb: 0,
|
||||
vram_total_gb: 0,
|
||||
});
|
||||
|
||||
// Default Image Constraints
|
||||
const [constraints, setConstraints] = useState({
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
steps: 4, // Recommended default steps for Flux.1 Schnell/Lightning
|
||||
npuSteps: 4,
|
||||
standardSteps: 20,
|
||||
cfgScale: 1.0, // Recommended default CFG for Flux.1 Schnell
|
||||
sampler: "euler_a",
|
||||
seed: -1,
|
||||
denoisingStrength: 0.7,
|
||||
useGpu: true,
|
||||
useTaesd: true,
|
||||
useFlashAttn: true,
|
||||
useTiling: false,
|
||||
vaeTiling: true,
|
||||
vaeOnCpu: false,
|
||||
threads: 4,
|
||||
backendType: "auto",
|
||||
});
|
||||
|
||||
const [textSettings, setTextSettings] = useState(() => {
|
||||
const saved = localStorage.getItem("textSettings");
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return {
|
||||
...parsed,
|
||||
enableThinking: parsed.enableThinking === true,
|
||||
maxTokens: !parsed.maxTokens || parsed.maxTokens === 384 ? 1024 : parsed.maxTokens,
|
||||
responseTokenMode: parsed.responseTokenMode || "auto",
|
||||
};
|
||||
} catch (_) {}
|
||||
}
|
||||
return {
|
||||
contextSize: 0,
|
||||
temperature: 0.7,
|
||||
systemPrompt: "You are a helpful local AI assistant.",
|
||||
threads: Math.max(4, Math.min(16, (navigator.hardwareConcurrency || 4) - 2)),
|
||||
enableThinking: false,
|
||||
// New performance settings
|
||||
gpuLayers: -1,
|
||||
maxTokens: 1024,
|
||||
responseTokenMode: "auto",
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
minP: 0.05,
|
||||
repeatPenalty: 1.1,
|
||||
seed: null,
|
||||
performanceProfile: "balanced",
|
||||
flashAttn: true,
|
||||
cacheTypeK: "q8_0",
|
||||
cacheTypeV: "q8_0",
|
||||
mlock: false,
|
||||
mmap: true,
|
||||
cachePrompt: true,
|
||||
defragThold: 0.1,
|
||||
batchSize: 512,
|
||||
ubatchSize: 512,
|
||||
preferredBackend: "auto",
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("textSettings", JSON.stringify(textSettings));
|
||||
}, [textSettings]);
|
||||
// Navigation - only speech-related tabs
|
||||
const [activeTab, setActiveTab] = useState("speech");
|
||||
|
||||
// Speech settings
|
||||
const [speechSettings, setSpeechSettings] = useState(() => {
|
||||
const saved = localStorage.getItem("speechSettings");
|
||||
if (saved) {
|
||||
@@ -188,173 +71,28 @@ function App() {
|
||||
localStorage.setItem("speechSettings", JSON.stringify(speechSettings));
|
||||
}, [speechSettings]);
|
||||
|
||||
const [ttsSettings, setTtsSettings] = useState(() => {
|
||||
const saved = localStorage.getItem("ttsSettings");
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return {
|
||||
model: parsed.model || "",
|
||||
voice: parsed.voice || "af_heart",
|
||||
speed: Math.max(0.5, Math.min(2, Number(parsed.speed) || 1)),
|
||||
};
|
||||
} catch (_) {}
|
||||
}
|
||||
return {
|
||||
model: "",
|
||||
voice: "af_heart",
|
||||
speed: 1,
|
||||
};
|
||||
// System Specifications & Telemetry
|
||||
const [specs, setSpecs] = useState({
|
||||
os_name: "Loading Specs...",
|
||||
cpu_name: "Loading Specs...",
|
||||
cpu_cores_physical: 4,
|
||||
cpu_cores_logical: 8,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Loading Specs...",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("ttsSettings", JSON.stringify(ttsSettings));
|
||||
}, [ttsSettings]);
|
||||
|
||||
// Lifted Chat History States
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [activeConversationId, setActiveConversationId] = useState(null);
|
||||
const [showHistory, setShowHistory] = useState(false); // Default hide
|
||||
const [sidebarVisible, setSidebarVisible] = useState(() => {
|
||||
const saved = localStorage.getItem("sidebarVisible");
|
||||
return saved !== "false";
|
||||
const [telemetry, setTelemetry] = useState({
|
||||
cpu_usage: 0,
|
||||
ram_used_gb: 0,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Detecting...",
|
||||
vram_used_gb: 0,
|
||||
vram_total_gb: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("sidebarVisible", String(sidebarVisible));
|
||||
}, [sidebarVisible]);
|
||||
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
const saved = localStorage.getItem("fontSize");
|
||||
return saved ? Number(saved) : 16;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty("--app-font-size", `${fontSize}px`);
|
||||
localStorage.setItem("fontSize", String(fontSize));
|
||||
}, [fontSize]);
|
||||
// Speech transcriptions history
|
||||
const [speechTranscriptions, setSpeechTranscriptions] = useState([]);
|
||||
const [selectedSpeechTranscript, setSelectedSpeechTranscript] = useState(null);
|
||||
const [showSpeechHistory, setShowSpeechHistory] = useState(false);
|
||||
const [ttsOutputs, setTtsOutputs] = useState([]);
|
||||
const [selectedTtsOutput, setSelectedTtsOutput] = useState(null);
|
||||
const [showTtsHistory, setShowTtsHistory] = useState(false);
|
||||
|
||||
const sanitizeMessageForStorage = (message) => {
|
||||
if (!Array.isArray(message?.content)) return message;
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((item) => {
|
||||
if (item?.type !== "image_url") return item;
|
||||
return {
|
||||
type: "text",
|
||||
text: "[Attached image omitted from saved chat history]",
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeConversationForStorage = (conversation) => ({
|
||||
...conversation,
|
||||
messages: Array.isArray(conversation.messages)
|
||||
? conversation.messages.map(sanitizeMessageForStorage)
|
||||
: [],
|
||||
});
|
||||
|
||||
const persistConversation = (conversation) => {
|
||||
const compactConversation = sanitizeConversationForStorage(conversation);
|
||||
try {
|
||||
saveLlmConversation(compactConversation).catch((err) => {
|
||||
console.warn("Could not save chat history to app folder:", err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Could not save chat history:", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadConversations() {
|
||||
let legacyConversations = [];
|
||||
const legacySaved = localStorage.getItem("chat_conversations");
|
||||
if (legacySaved) {
|
||||
try {
|
||||
legacyConversations = JSON.parse(legacySaved);
|
||||
} catch (_) {
|
||||
localStorage.removeItem("chat_conversations");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const diskConversations = await listLlmConversations();
|
||||
const diskIds = new Set(diskConversations.map((conversation) => conversation.id));
|
||||
const missingLegacy = legacyConversations
|
||||
.map(sanitizeConversationForStorage)
|
||||
.filter((conversation) => conversation.id && !diskIds.has(conversation.id));
|
||||
|
||||
if (missingLegacy.length > 0) {
|
||||
await Promise.all(missingLegacy.map((conversation) => saveLlmConversation(conversation)));
|
||||
}
|
||||
|
||||
const merged = [...diskConversations, ...missingLegacy]
|
||||
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0));
|
||||
if (!cancelled) setConversations(merged);
|
||||
if (legacySaved) localStorage.removeItem("chat_conversations");
|
||||
} catch (err) {
|
||||
console.warn("Could not load chat history from app folder:", err);
|
||||
if (!cancelled && legacyConversations.length > 0) {
|
||||
setConversations(legacyConversations.map(sanitizeConversationForStorage));
|
||||
}
|
||||
}
|
||||
}
|
||||
loadConversations();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveConversationState = useCallback((id, msgs, modelName, newTitle = null) => {
|
||||
setConversations((prev) => {
|
||||
const list = [...prev];
|
||||
const idx = list.findIndex(c => c.id === id);
|
||||
let conversation;
|
||||
if (idx !== -1) {
|
||||
conversation = {
|
||||
...list[idx],
|
||||
messages: msgs,
|
||||
timestamp: Date.now(),
|
||||
model: modelName,
|
||||
...(newTitle ? { title: newTitle } : {})
|
||||
};
|
||||
list[idx] = conversation;
|
||||
} else {
|
||||
conversation = {
|
||||
id,
|
||||
title: newTitle || "Chat Session",
|
||||
model: modelName,
|
||||
messages: msgs,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
list.unshift(conversation);
|
||||
}
|
||||
persistConversation(conversation);
|
||||
return list;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteConversation = useCallback((id, e) => {
|
||||
if (e) e.stopPropagation();
|
||||
setConversations((prev) => {
|
||||
const filtered = prev.filter((c) => c.id !== id);
|
||||
return filtered;
|
||||
});
|
||||
deleteLlmConversation(id).catch((err) => {
|
||||
console.warn("Could not delete chat history from app folder:", err);
|
||||
});
|
||||
if (activeConversationId === id) {
|
||||
setActiveConversationId(null);
|
||||
}
|
||||
}, [activeConversationId]);
|
||||
|
||||
const refreshSpeechTranscriptions = useCallback(async () => {
|
||||
try {
|
||||
@@ -401,65 +139,13 @@ function App() {
|
||||
refreshSpeechTranscriptions();
|
||||
}, [refreshSpeechTranscriptions]);
|
||||
|
||||
const refreshTtsOutputs = useCallback(async () => {
|
||||
try {
|
||||
const list = await listTtsOutputs();
|
||||
setTtsOutputs(list);
|
||||
} catch (err) {
|
||||
console.warn("Could not load TTS outputs:", err);
|
||||
setTtsOutputs([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteTtsOutput = useCallback(async (item, e) => {
|
||||
if (e) e.stopPropagation();
|
||||
const itemId = item.filename || item.metadata;
|
||||
if (!itemId) return;
|
||||
|
||||
const ok = await showConfirm({
|
||||
title: "Delete TTS Output",
|
||||
message: `Are you sure you want to delete "${item.displayName || item.audioFile || "this audio"}"? This action cannot be undone.`,
|
||||
confirmLabel: "Delete",
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await deleteTtsOutput(itemId);
|
||||
setSelectedTtsOutput((prev) => {
|
||||
if (prev) {
|
||||
const prevId = prev.filename || prev.metadata;
|
||||
if (prevId === itemId) return null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
refreshTtsOutputs();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete TTS output:", err);
|
||||
showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
}, [showConfirm, showAlert, refreshTtsOutputs]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshTtsOutputs();
|
||||
}, [refreshTtsOutputs]);
|
||||
|
||||
// Load hardware specifications on mount
|
||||
useEffect(() => {
|
||||
async function loadSpecs() {
|
||||
try {
|
||||
const hardware = await getHardwareSpecs();
|
||||
const backendInfo = await getBackendOptions();
|
||||
setBackendOptions(backendInfo);
|
||||
setSpecs(hardware);
|
||||
// Default CPU threads count to match physical cores
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
threads: hardware.cpu_cores_physical || 4,
|
||||
backendType: prev.backendType === "auto" ? backendInfo.defaultBackendType || "cpu" : prev.backendType,
|
||||
useGpu: (prev.backendType === "auto" ? backendInfo.defaultBackendType : prev.backendType) !== "cpu",
|
||||
}));
|
||||
setTextSettings((prev) => ({
|
||||
setSpeechSettings((prev) => ({
|
||||
...prev,
|
||||
threads: prev.threads || hardware.cpu_cores_physical || 4
|
||||
}));
|
||||
@@ -470,120 +156,6 @@ function App() {
|
||||
loadSpecs();
|
||||
}, []);
|
||||
|
||||
const refreshReadiness = useCallback(async () => {
|
||||
setIsReadinessBusy(true);
|
||||
try {
|
||||
const [healthInfo, candidates] = await Promise.all([
|
||||
getHealth(),
|
||||
getCleanupCandidates().catch(() => []),
|
||||
]);
|
||||
setHealth(healthInfo);
|
||||
setCleanupItems(candidates);
|
||||
} finally {
|
||||
setIsReadinessBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshReadiness();
|
||||
}, [refreshReadiness]);
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
try {
|
||||
const diagnostics = await getDiagnostics();
|
||||
await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2));
|
||||
setDiagnosticsCopied(true);
|
||||
setTimeout(() => setDiagnosticsCopied(false), 1800);
|
||||
} catch (err) {
|
||||
showAlert({ title: "Diagnostics Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
}, [showAlert]);
|
||||
|
||||
const cleanupSafeItems = useCallback(async () => {
|
||||
if (cleanupItems.length === 0) return;
|
||||
const totalBytes = cleanupItems.reduce((sum, item) => sum + Number(item.sizeBytes || 0), 0);
|
||||
const ok = await showConfirm({
|
||||
title: "Clean Safe Files?",
|
||||
message: `Delete ${cleanupItems.length} temporary/cache item${cleanupItems.length === 1 ? "" : "s"} and free about ${formatBytes(totalBytes)}?\n\nModels and generated outputs are not included.`,
|
||||
confirmLabel: "Clean",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setIsReadinessBusy(true);
|
||||
try {
|
||||
await cleanupCandidates(cleanupItems.map((item) => item.id));
|
||||
await refreshReadiness();
|
||||
} catch (err) {
|
||||
showAlert({ title: "Cleanup Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setIsReadinessBusy(false);
|
||||
}
|
||||
}, [cleanupItems, refreshReadiness, showAlert, showConfirm]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSavedOutputs() {
|
||||
const outputs = await listGeneratedOutputs();
|
||||
setGeneratedImages(outputs);
|
||||
}
|
||||
loadSavedOutputs();
|
||||
}, []);
|
||||
|
||||
// Save active model configuration to localStorage for persistence across reloads
|
||||
useEffect(() => {
|
||||
if (activeModel) {
|
||||
localStorage.setItem("active-model", activeModel);
|
||||
} else {
|
||||
localStorage.removeItem("active-model");
|
||||
}
|
||||
}, [activeModel]);
|
||||
|
||||
// Check if server is already running on mount (handles page refresh and startup lag)
|
||||
useEffect(() => {
|
||||
let checkInterval = null;
|
||||
|
||||
async function checkServerRunning() {
|
||||
const status = await getBackendStatus();
|
||||
const isRunning = Boolean(status.ready);
|
||||
if (isRunning) {
|
||||
if (checkInterval) clearInterval(checkInterval);
|
||||
|
||||
const savedModel = localStorage.getItem("active-model");
|
||||
if (savedModel) {
|
||||
setActiveModel(savedModel);
|
||||
setServerRunning(true);
|
||||
} else {
|
||||
// Query the active model from status if possible, otherwise use fallback
|
||||
try {
|
||||
if (status && status.settings && status.settings.model) {
|
||||
const modelName = status.settings.model.split(/[\\/]/).pop();
|
||||
setActiveModel(modelName);
|
||||
}
|
||||
} catch (_) {}
|
||||
setServerRunning(true);
|
||||
}
|
||||
} else {
|
||||
// If server is not running, check if they had a simulated model active in browser mode
|
||||
const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||
if (!isTauriDesktop) {
|
||||
const savedModel = localStorage.getItem("active-model");
|
||||
if (savedModel) {
|
||||
setActiveModel(savedModel);
|
||||
setServerRunning(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkServerRunning();
|
||||
// Poll every 2 seconds to auto-detect when the backend becomes ready
|
||||
checkInterval = setInterval(checkServerRunning, 2000);
|
||||
|
||||
return () => {
|
||||
if (checkInterval) clearInterval(checkInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Poll system telemetry usage statistics on interval
|
||||
useEffect(() => {
|
||||
async function updateTelemetry() {
|
||||
@@ -605,204 +177,62 @@ function App() {
|
||||
}
|
||||
|
||||
updateTelemetry();
|
||||
const interval = setInterval(updateTelemetry, 1500); // Poll every 1.5 seconds
|
||||
const interval = setInterval(updateTelemetry, 1500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Sync active model settings default parameters
|
||||
useEffect(() => {
|
||||
if (activeModel) {
|
||||
const name = activeModel.toLowerCase();
|
||||
if (name.includes("lcm-dreamshaper-v7-fp16")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 4,
|
||||
npuSteps: 4,
|
||||
cfgScale: 1.0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
backendType: "openvino-npu",
|
||||
useGpu: true,
|
||||
}));
|
||||
} else if (name.includes("flux") || name.includes("schnell")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 4,
|
||||
standardSteps: 4,
|
||||
cfgScale: 1.0,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}));
|
||||
} else if (name.includes("lightning") || name.includes("turbo")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 4,
|
||||
standardSteps: 4,
|
||||
cfgScale: 1.5,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}));
|
||||
} else if (name.includes("sd15")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 25,
|
||||
standardSteps: 25,
|
||||
cfgScale: 7.0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
}));
|
||||
} else if (name.includes("sd35")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 20,
|
||||
standardSteps: 20,
|
||||
cfgScale: 4.5,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}));
|
||||
} else {
|
||||
// Fallback for custom or SD 1.x models (like CyberRealistic or arbitrary safetensors)
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 20,
|
||||
standardSteps: 20,
|
||||
cfgScale: 7.0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [activeModel]);
|
||||
|
||||
// Tab contents are kept mounted to preserve loading/generation state when switching tabs
|
||||
|
||||
const sidebarContent = useMemo(() => (
|
||||
<Sidebar
|
||||
collapsed={!sidebarVisible}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
specs={specs}
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
setActiveConversationId={setActiveConversationId}
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
speechTranscriptions={speechTranscriptions}
|
||||
selectedSpeechTranscript={selectedSpeechTranscript}
|
||||
setSelectedSpeechTranscript={setSelectedSpeechTranscript}
|
||||
showSpeechHistory={showSpeechHistory}
|
||||
setShowSpeechHistory={setShowSpeechHistory}
|
||||
onDeleteSpeechTranscription={handleDeleteSpeechTranscription}
|
||||
ttsOutputs={ttsOutputs}
|
||||
selectedTtsOutput={selectedTtsOutput}
|
||||
setSelectedTtsOutput={setSelectedTtsOutput}
|
||||
showTtsHistory={showTtsHistory}
|
||||
setShowTtsHistory={setShowTtsHistory}
|
||||
onDeleteTtsOutput={handleDeleteTtsOutput}
|
||||
/>
|
||||
), [sidebarVisible, activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory, handleDeleteSpeechTranscription, ttsOutputs, selectedTtsOutput, showTtsHistory, handleDeleteTtsOutput]);
|
||||
|
||||
const handleStopServer = useCallback(async () => {
|
||||
if (!serverRunning || isStoppingServer) return;
|
||||
setIsStoppingServer(true);
|
||||
try {
|
||||
await stopServer();
|
||||
setServerRunning(false);
|
||||
setActiveModel(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to stop server:", err);
|
||||
showAlert({ title: "Stop Server Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setIsStoppingServer(false);
|
||||
}
|
||||
}, [serverRunning, isStoppingServer, showAlert]);
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Sidebar with Navigation Rail & Specs */}
|
||||
{sidebarContent}
|
||||
|
||||
{/* Main Panel */}
|
||||
<div className="main-content">
|
||||
{/* Top telemetry specs chips bar */}
|
||||
<TopStatusBar
|
||||
telemetry={telemetry}
|
||||
serverRunning={serverRunning}
|
||||
activeModel={activeModel}
|
||||
isLlmLoaded={isLlmLoaded}
|
||||
onStopServer={handleStopServer}
|
||||
isStoppingServer={isStoppingServer}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
sidebarVisible={sidebarVisible}
|
||||
onToggleSidebar={() => setSidebarVisible(!sidebarVisible)}
|
||||
/>
|
||||
|
||||
{/* Dynamic Workspace Container */}
|
||||
<div style={{ display: activeTab === "generator" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<Generator
|
||||
prompt={prompt}
|
||||
setPrompt={setPrompt}
|
||||
negativePrompt={negativePrompt}
|
||||
setNegativePrompt={setNegativePrompt}
|
||||
constraints={constraints}
|
||||
setConstraints={setConstraints}
|
||||
activeModel={activeModel}
|
||||
generatedImages={generatedImages}
|
||||
setGeneratedImages={setGeneratedImages}
|
||||
isGenerating={isGenerating}
|
||||
setIsGenerating={setIsGenerating}
|
||||
generationProgress={generationProgress}
|
||||
setGenerationProgress={setGenerationProgress}
|
||||
setActiveTab={setActiveTab}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
specs={specs}
|
||||
textSettings={textSettings}
|
||||
/>
|
||||
{/* Simple Header */}
|
||||
<header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<h1 className="app-title">🎙️ Speech Transcriber</h1>
|
||||
<p className="app-subtitle">Local AI audio transcription with Whisper</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: activeTab === "models" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<ModelManager
|
||||
activeModel={activeModel}
|
||||
setActiveModel={setActiveModel}
|
||||
serverRunning={serverRunning}
|
||||
setServerRunning={setServerRunning}
|
||||
constraints={constraints}
|
||||
setConstraints={setConstraints}
|
||||
telemetry={telemetry}
|
||||
backendOptions={backendOptions}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
activeTab={activeTab}
|
||||
specs={specs}
|
||||
textSettings={textSettings}
|
||||
/>
|
||||
<div className="app-header-right">
|
||||
<div className="telemetry-chips">
|
||||
<span className="chip">CPU: {telemetry.cpu_usage}%</span>
|
||||
<span className="chip">RAM: {telemetry.ram_used_gb.toFixed(1)}/{telemetry.ram_total_gb.toFixed(1)} GB</span>
|
||||
{telemetry.gpu_name !== "Detecting..." && telemetry.gpu_name !== "Unavailable" && (
|
||||
<span className="chip">GPU: {telemetry.gpu_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ display: activeTab === "chat" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<TextChat
|
||||
specs={specs}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
textSettings={textSettings}
|
||||
setTextSettings={setTextSettings}
|
||||
setActiveModel={setActiveModel}
|
||||
setServerRunning={setServerRunning}
|
||||
conversations={conversations}
|
||||
setConversations={setConversations}
|
||||
activeConversationId={activeConversationId}
|
||||
setActiveConversationId={setActiveConversationId}
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
saveConversationState={saveConversationState}
|
||||
setIsLlmLoaded={setIsLlmLoaded}
|
||||
/>
|
||||
</div>
|
||||
{/* Navigation Tabs */}
|
||||
<nav className="app-nav">
|
||||
<button
|
||||
className={`nav-tab ${activeTab === "speech" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("speech")}
|
||||
>
|
||||
🎙️ Transcriber
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${activeTab === "models" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("models")}
|
||||
>
|
||||
📁 Models
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${activeTab === "settings" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("settings")}
|
||||
>
|
||||
⚙️ Settings
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div style={{ display: activeTab === "speech" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Main Content */}
|
||||
<main className="app-main">
|
||||
{activeTab === "speech" && (
|
||||
<SpeechTranscriber
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
@@ -811,53 +241,32 @@ function App() {
|
||||
speechSettings={speechSettings}
|
||||
setSpeechSettings={setSpeechSettings}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: activeTab === "tts" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<TextToSpeech
|
||||
{activeTab === "models" && (
|
||||
<ModelManager
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
specs={specs}
|
||||
speechSettings={speechSettings}
|
||||
setSpeechSettings={setSpeechSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<Settings
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
selectedOutput={selectedTtsOutput}
|
||||
onOutputsChanged={refreshTtsOutputs}
|
||||
ttsSettings={ttsSettings}
|
||||
setTtsSettings={setTtsSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: activeTab === "settings" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<Settings
|
||||
constraints={constraints}
|
||||
setConstraints={setConstraints}
|
||||
activeModel={activeModel}
|
||||
specs={specs}
|
||||
backendOptions={backendOptions}
|
||||
setBackendOptions={setBackendOptions}
|
||||
serverRunning={serverRunning}
|
||||
setServerRunning={setServerRunning}
|
||||
setActiveModel={setActiveModel}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
textSettings={textSettings}
|
||||
setTextSettings={setTextSettings}
|
||||
speechSettings={speechSettings}
|
||||
setSpeechSettings={setSpeechSettings}
|
||||
ttsSettings={ttsSettings}
|
||||
setTtsSettings={setTtsSettings}
|
||||
health={health}
|
||||
cleanupItems={cleanupItems}
|
||||
isReadinessBusy={isReadinessBusy}
|
||||
refreshReadiness={refreshReadiness}
|
||||
copyDiagnostics={copyDiagnostics}
|
||||
cleanupSafeItems={cleanupSafeItems}
|
||||
diagnosticsCopied={diagnosticsCopied}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
fontSize={fontSize}
|
||||
setFontSize={setFontSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Dialog */}
|
||||
{dialog && (
|
||||
<div className="app-dialog-backdrop" role="presentation">
|
||||
<div className="app-dialog" role="dialog" aria-modal="true" aria-labelledby="app-dialog-title">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,455 +0,0 @@
|
||||
import React, { memo } from "react";
|
||||
import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2, Volume2 } from "lucide-react";
|
||||
|
||||
function formatSidebarDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const dateStr = date.toLocaleDateString();
|
||||
const timeStr = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
collapsed = false,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
specs,
|
||||
conversations = [],
|
||||
activeConversationId,
|
||||
setActiveConversationId,
|
||||
showHistory,
|
||||
setShowHistory,
|
||||
onDeleteConversation,
|
||||
speechTranscriptions = [],
|
||||
selectedSpeechTranscript,
|
||||
setSelectedSpeechTranscript,
|
||||
showSpeechHistory,
|
||||
setShowSpeechHistory,
|
||||
onDeleteSpeechTranscription,
|
||||
ttsOutputs = [],
|
||||
selectedTtsOutput,
|
||||
setSelectedTtsOutput,
|
||||
showTtsHistory,
|
||||
setShowTtsHistory,
|
||||
onDeleteTtsOutput
|
||||
}) {
|
||||
return (
|
||||
<div className={`sidebar ${collapsed ? "collapsed" : ""}`}>
|
||||
<div>
|
||||
{/* Sidebar Header */}
|
||||
<div className="sidebar-logo">
|
||||
<Shield className="sidebar-logo-icon" />
|
||||
<span className="sidebar-logo-text">Local AI Studio</span>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Navigation Links (Material 3 style) */}
|
||||
<div className="nav-list">
|
||||
<div
|
||||
className={`nav-item ${activeTab === "generator" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("generator")}
|
||||
>
|
||||
<Image size={20} />
|
||||
<span>Image Generator</span>
|
||||
</div>
|
||||
|
||||
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
className={`nav-item ${activeTab === "chat" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("chat")}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<MessageSquare size={20} />
|
||||
<span>Text Chat</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowHistory(!showHistory);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
transition: "background-color 0.2s"
|
||||
}}
|
||||
className="history-toggle-arrow"
|
||||
title={showHistory ? "Hide Chat History" : "Show Chat History"}
|
||||
>
|
||||
{showHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Chat History List */}
|
||||
{showHistory && (
|
||||
<div
|
||||
className="sidebar-history-list"
|
||||
style={{
|
||||
paddingLeft: "14px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
maxHeight: "220px",
|
||||
overflowY: "auto",
|
||||
borderLeft: "2px solid var(--border-color)"
|
||||
}}
|
||||
>
|
||||
{conversations.length === 0 ? (
|
||||
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
|
||||
No saved chats
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => {
|
||||
const isActive = activeConversationId === conv.id;
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
setActiveConversationId(conv.id);
|
||||
setActiveTab("chat");
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "6px 8px 6px 10px",
|
||||
borderRadius: "var(--md-shape-corner-small)",
|
||||
fontSize: "0.78rem",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
|
||||
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
|
||||
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
|
||||
transition: "background 0.2s"
|
||||
}}
|
||||
className="sidebar-history-item"
|
||||
title={conv.title}
|
||||
>
|
||||
<span style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
fontWeight: isActive ? 600 : 400
|
||||
}}>
|
||||
{conv.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteConversation(conv.id, 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 Conversation"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
className={`nav-item ${activeTab === "speech" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("speech")}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<Mic size={20} />
|
||||
<span>Speech Transcriber</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSpeechHistory(!showSpeechHistory);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
transition: "background-color 0.2s"
|
||||
}}
|
||||
className="history-toggle-arrow"
|
||||
title={showSpeechHistory ? "Hide Transcriptions" : "Show Transcriptions"}
|
||||
>
|
||||
{showSpeechHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSpeechHistory && (
|
||||
<div
|
||||
className="sidebar-history-list"
|
||||
style={{
|
||||
paddingLeft: "14px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
maxHeight: "220px",
|
||||
overflowY: "auto",
|
||||
borderLeft: "2px solid var(--border-color)"
|
||||
}}
|
||||
>
|
||||
{speechTranscriptions.length === 0 ? (
|
||||
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
|
||||
No saved transcriptions
|
||||
</div>
|
||||
) : (
|
||||
speechTranscriptions.map((item) => {
|
||||
const itemId = item.filename || item.metadata || item.textFile;
|
||||
const isActive = selectedSpeechTranscript && (selectedSpeechTranscript.filename || selectedSpeechTranscript.metadata || selectedSpeechTranscript.textFile) === itemId;
|
||||
const title = item.displayName || item.sourceFilename || item.textFile || item.filename || "Transcript";
|
||||
const date = formatSidebarDate(item.modifiedAt || item.createdAt);
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
onClick={() => {
|
||||
setSelectedSpeechTranscript(item);
|
||||
setActiveTab("speech");
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "7px 8px 7px 10px",
|
||||
borderRadius: "var(--md-shape-corner-small)",
|
||||
fontSize: "0.78rem",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
|
||||
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
|
||||
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
|
||||
transition: "background 0.2s"
|
||||
}}
|
||||
className="sidebar-history-item"
|
||||
title={title}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
className={`nav-item ${activeTab === "tts" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("tts")}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<Volume2 size={20} />
|
||||
<span>Text to Speech</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowTtsHistory(!showTtsHistory);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
transition: "background-color 0.2s"
|
||||
}}
|
||||
className="history-toggle-arrow"
|
||||
title={showTtsHistory ? "Hide TTS Outputs" : "Show TTS Outputs"}
|
||||
>
|
||||
{showTtsHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showTtsHistory && (
|
||||
<div
|
||||
className="sidebar-history-list"
|
||||
style={{
|
||||
paddingLeft: "14px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
maxHeight: "220px",
|
||||
overflowY: "auto",
|
||||
borderLeft: "2px solid var(--border-color)"
|
||||
}}
|
||||
>
|
||||
{ttsOutputs.length === 0 ? (
|
||||
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
|
||||
No saved audio
|
||||
</div>
|
||||
) : (
|
||||
ttsOutputs.map((item) => {
|
||||
const itemId = item.filename || item.metadata || item.audioFile;
|
||||
const isActive = selectedTtsOutput && (selectedTtsOutput.filename || selectedTtsOutput.metadata || selectedTtsOutput.audioFile) === itemId;
|
||||
const title = item.displayName || item.text || item.audioFile || item.filename || "TTS Output";
|
||||
const date = formatSidebarDate(item.modifiedAt || item.createdAt);
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
onClick={() => {
|
||||
setSelectedTtsOutput(item);
|
||||
setActiveTab("tts");
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "7px 8px 7px 10px",
|
||||
borderRadius: "var(--md-shape-corner-small)",
|
||||
fontSize: "0.78rem",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
|
||||
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
|
||||
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
|
||||
transition: "background 0.2s"
|
||||
}}
|
||||
className="sidebar-history-item"
|
||||
title={title}
|
||||
>
|
||||
<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();
|
||||
onDeleteTtsOutput?.(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 TTS Output"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`nav-item ${activeTab === "models" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("models")}
|
||||
>
|
||||
<FolderDown size={20} />
|
||||
<span>Model Manager</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`nav-item ${activeTab === "settings" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("settings")}
|
||||
>
|
||||
<Settings size={20} />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer with Host Telemetry System Specs */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-specs-header">
|
||||
<Terminal size={12} />
|
||||
<span>Host Specifications</span>
|
||||
</div>
|
||||
<div className="sidebar-specs-item" title={specs.cpu_name}>
|
||||
CPU: {specs.cpu_name}
|
||||
</div>
|
||||
<div className="sidebar-specs-item" title={specs.gpu_name}>
|
||||
GPU: {specs.gpu_name}
|
||||
</div>
|
||||
<div className="sidebar-specs-item">
|
||||
Memory: {specs.ram_total_gb.toFixed(0)} GB RAM ({specs.cpu_cores_physical} Cores)
|
||||
</div>
|
||||
<div className="sidebar-specs-os">
|
||||
OS: {specs.os_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Sidebar);
|
||||
@@ -290,8 +290,11 @@ export default function SpeechTranscriber({
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".wav")) {
|
||||
showAlert?.({ title: "Unsupported Audio", message: "V1 accepts WAV files only. MP3/WebM/M4A will need the later FFmpeg path.", danger: true });
|
||||
const name = file.name.toLowerCase();
|
||||
const allowedExt = [".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aac", ".m4b", ".opus", ".wma"];
|
||||
const isAllowed = allowedExt.some(ext => name.endsWith(ext));
|
||||
if (!isAllowed) {
|
||||
showAlert?.({ title: "Unsupported Audio", message: "Supported formats: WAV, MP3, M4A, OGG, FLAC, WebM, AAC, M4B, OPUS, WMA", danger: true });
|
||||
return;
|
||||
}
|
||||
setCurrentAudio(file, file.name);
|
||||
@@ -349,14 +352,57 @@ export default function SpeechTranscriber({
|
||||
setIsTranscribing(false);
|
||||
};
|
||||
|
||||
const saveText = () => {
|
||||
if (!resultText.trim()) return;
|
||||
downloadBlob("transcription.txt", resultText, "text/plain;charset=utf-8");
|
||||
const saveText = async () => {
|
||||
if (!audioBlob) return;
|
||||
await downloadTranscription("txt");
|
||||
};
|
||||
|
||||
const saveJson = () => {
|
||||
const payload = { ...(result || {}), text: resultText };
|
||||
downloadBlob("transcription.json", JSON.stringify(payload, null, 2), "application/json;charset=utf-8");
|
||||
const saveJson = async () => {
|
||||
if (!audioBlob) return;
|
||||
await downloadTranscription("json");
|
||||
};
|
||||
|
||||
const saveMarkdown = async () => {
|
||||
if (!audioBlob) return;
|
||||
await downloadTranscription("md");
|
||||
};
|
||||
|
||||
const downloadTranscription = async (format) => {
|
||||
if (!audioBlob) return;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedModel) params.set("model", selectedModel);
|
||||
params.set("language", speechSettings?.language || "auto");
|
||||
params.set("format", format);
|
||||
params.set("threads", String(speechSettings?.threads || 4));
|
||||
params.set("backendPreference", speechSettings?.backendPreference || "auto");
|
||||
if (speechSettings?.translate) params.set("translate", "true");
|
||||
params.set("filename", audioName || "audio.wav");
|
||||
params.set("output_filename", `transcription-${Date.now()}`);
|
||||
|
||||
const res = await fetch(`/api/speech/transcribe-file?${params.toString()}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "audio/wav" },
|
||||
body: audioBlob,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || `Download failed (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `transcription-${Date.now()}.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
showAlert?.({ title: "Download Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -364,7 +410,7 @@ export default function SpeechTranscriber({
|
||||
<div className="workspace-title-section">
|
||||
<h2 className="workspace-title">Speech Transcriber</h2>
|
||||
<p className="workspace-subtitle">
|
||||
Record microphone audio or upload a WAV file, then transcribe locally with whisper.cpp.
|
||||
Record microphone audio or upload audio (WAV, MP3, M4A, OGG, FLAC, WebM, AAC, etc.), then transcribe locally with whisper.cpp.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -464,8 +510,8 @@ export default function SpeechTranscriber({
|
||||
<div className="speech-capture-zone">
|
||||
<FileAudio size={42} />
|
||||
<div>
|
||||
<strong>{audioName || "Record or upload WAV"}</strong>
|
||||
<p>Microphone recordings are converted to 16 kHz mono PCM16 WAV before transcription.</p>
|
||||
<strong>{audioName || "Record or upload audio (WAV, MP3, M4A, OGG, FLAC, WebM, etc.)"}</strong>
|
||||
<p>Audio files are converted to 16 kHz mono PCM16 WAV before transcription.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -485,8 +531,8 @@ export default function SpeechTranscriber({
|
||||
)}
|
||||
<label className="m3-btn m3-btn-outlined">
|
||||
<Upload size={14} />
|
||||
<span>Upload WAV</span>
|
||||
<input type="file" accept=".wav,audio/wav" onChange={handleUploadAudio} hidden />
|
||||
<span>Upload Audio</span>
|
||||
<input type="file" accept=".wav,.mp3,.m4a,.ogg,.flac,.webm,.aac,.m4b,.opus,.wma,audio/*" onChange={handleUploadAudio} hidden />
|
||||
</label>
|
||||
{audioBlob && (
|
||||
<button className="m3-btn m3-btn-error" onClick={clearCurrentAudio} disabled={isTranscribing || isRecording}>
|
||||
@@ -509,7 +555,7 @@ export default function SpeechTranscriber({
|
||||
disabled={!audioBlob || !selectedModel || !status.backendInstalled}
|
||||
>
|
||||
<Mic size={14} />
|
||||
<span>Transcribe</span>
|
||||
<span>Convert</span>
|
||||
</button>
|
||||
)}
|
||||
{isTranscribing && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Transcribing locally...</span>}
|
||||
@@ -538,6 +584,10 @@ export default function SpeechTranscriber({
|
||||
<Save size={14} />
|
||||
<span>JSON</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={saveMarkdown} disabled={!resultText.trim()}>
|
||||
<Save size={14} />
|
||||
<span>MD</span>
|
||||
</button>
|
||||
{status.ready && (
|
||||
<button className="m3-btn m3-btn-error" onClick={() => stopSpeech().then(refresh)}>
|
||||
<Square size={14} />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,317 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
LoaderCircle,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Square,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getTtsStatus,
|
||||
listTtsModels,
|
||||
speakTts,
|
||||
startTts,
|
||||
stopTts,
|
||||
} from "../services/api";
|
||||
|
||||
const FALLBACK_VOICES = [
|
||||
{ id: "af_heart", name: "Heart", language: "en-us", gender: "Female" },
|
||||
{ id: "af_bella", name: "Bella", language: "en-us", gender: "Female" },
|
||||
{ id: "am_michael", name: "Michael", language: "en-us", gender: "Male" },
|
||||
{ id: "bf_emma", name: "Emma", language: "en-gb", gender: "Female" },
|
||||
{ id: "bm_george", name: "George", language: "en-gb", gender: "Male" },
|
||||
];
|
||||
|
||||
function downloadUrl(url, filename) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
export default function TextToSpeech({
|
||||
showAlert,
|
||||
selectedOutput,
|
||||
onOutputsChanged,
|
||||
ttsSettings,
|
||||
setTtsSettings,
|
||||
}) {
|
||||
const [status, setStatus] = useState({ ready: false, running: false, runtimeInstalled: false, settings: {}, voices: [] });
|
||||
const [models, setModels] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [text, setText] = useState("Hello from Local AI Studio text to speech.");
|
||||
const [isLoadingModel, setIsLoadingModel] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [output, setOutput] = useState(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const installedModels = useMemo(() => models.filter((model) => model.installed), [models]);
|
||||
const voices = status.voices?.length ? status.voices : FALLBACK_VOICES;
|
||||
const selectedVoice = ttsSettings?.voice || "af_heart";
|
||||
const speed = ttsSettings?.speed || 1;
|
||||
|
||||
const updateTtsSetting = (key, value) => {
|
||||
setTtsSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextStatus, nextModels] = await Promise.all([
|
||||
getTtsStatus(),
|
||||
listTtsModels(),
|
||||
]);
|
||||
setStatus(nextStatus);
|
||||
setModels(nextModels);
|
||||
setSelectedModel((current) => {
|
||||
const installed = nextModels.filter((model) => model.installed);
|
||||
if (current && installed.some((model) => model.filename === current || model.id === current)) return current;
|
||||
const active = nextStatus.settings?.model;
|
||||
if (active && installed.some((model) => model.filename === active)) return active;
|
||||
const preferred = ttsSettings?.model;
|
||||
if (preferred && installed.some((model) => model.filename === preferred)) return preferred;
|
||||
return installed.find((model) => model.recommended)?.filename || installed[0]?.filename || "";
|
||||
});
|
||||
}, [ttsSettings?.model]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err) => showAlert?.({ title: "TTS Status Failed", message: err.message || String(err), danger: true }));
|
||||
const interval = setInterval(() => {
|
||||
refresh().catch(() => {});
|
||||
}, 2500);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh, showAlert]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOutput) return;
|
||||
setOutput(selectedOutput);
|
||||
setText(selectedOutput.text || "");
|
||||
if (selectedOutput.voice) updateTtsSetting("voice", selectedOutput.voice);
|
||||
if (selectedOutput.speed) updateTtsSetting("speed", selectedOutput.speed);
|
||||
if (selectedOutput.model) setSelectedModel(selectedOutput.model);
|
||||
}, [selectedOutput]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const handleLoadModel = async () => {
|
||||
if (!selectedModel) return;
|
||||
setIsLoadingModel(true);
|
||||
try {
|
||||
await startTts(selectedModel, { voice: selectedVoice, speed });
|
||||
updateTtsSetting("model", selectedModel);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
showAlert?.({ title: "TTS Model Load Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setIsLoadingModel(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await stopTts();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
showAlert?.({ title: "Stop TTS Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!text.trim() || !selectedModel) return;
|
||||
setIsGenerating(true);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
if (!status.ready || status.settings?.model !== selectedModel) {
|
||||
setIsLoadingModel(true);
|
||||
try {
|
||||
await startTts(selectedModel, { voice: selectedVoice, speed });
|
||||
updateTtsSetting("model", selectedModel);
|
||||
await refresh();
|
||||
} finally {
|
||||
setIsLoadingModel(false);
|
||||
}
|
||||
}
|
||||
const generated = await speakTts(text, {
|
||||
model: selectedModel,
|
||||
voice: selectedVoice,
|
||||
speed,
|
||||
signal: controller.signal,
|
||||
});
|
||||
setOutput(generated);
|
||||
await refresh();
|
||||
await onOutputsChanged?.();
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
showAlert?.({ title: "TTS Generation Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
setIsGenerating(false);
|
||||
};
|
||||
|
||||
const isLoaded = status.ready && status.settings?.model === selectedModel;
|
||||
|
||||
return (
|
||||
<div className="workspace-area speech-workspace">
|
||||
<div className="workspace-title-section">
|
||||
<h2 className="workspace-title">Text to Speech</h2>
|
||||
<p className="workspace-subtitle">
|
||||
Generate local WAV narration with Kokoro ONNX.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="speech-grid">
|
||||
<section className="m3-card speech-panel">
|
||||
<div className="speech-panel-header">
|
||||
<h3>Runtime</h3>
|
||||
<span className={`status-chip ${status.runtimeInstalled ? "" : "offline"}`}>
|
||||
{status.runtimeInstalled ? (status.ready ? "Ready" : "Installed") : "Runtime missing"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status.error && <div className="text-progress error">{status.error}</div>}
|
||||
|
||||
<label className="speech-label">
|
||||
Kokoro model
|
||||
<select className="m3-input" value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)}>
|
||||
{installedModels.length === 0 && <option value="">No downloaded TTS models</option>}
|
||||
{installedModels.map((model) => (
|
||||
<option key={model.id || model.filename} value={model.filename}>
|
||||
{model.name || model.filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="speech-controls-row">
|
||||
<label className="speech-label">
|
||||
Voice
|
||||
<select className="m3-input" value={selectedVoice} onChange={(event) => updateTtsSetting("voice", event.target.value)}>
|
||||
{voices.map((voice) => (
|
||||
<option key={voice.id} value={voice.id}>
|
||||
{voice.name} ({voice.gender}, {voice.language})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="speech-label">
|
||||
Speed
|
||||
<input
|
||||
className="m3-input"
|
||||
type="number"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.05"
|
||||
value={speed}
|
||||
onChange={(event) => updateTtsSetting("speed", Math.max(0.5, Math.min(2, Number(event.target.value) || 1)))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="speech-button-row">
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
onClick={handleLoadModel}
|
||||
disabled={!selectedModel || isLoadingModel || isGenerating || isLoaded}
|
||||
>
|
||||
{isLoadingModel ? <LoaderCircle className="progress-spinner" size={14} /> : <Play size={14} />}
|
||||
<span>{isLoaded ? "Loaded" : isLoadingModel ? "Loading" : "Load"}</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={refresh}>
|
||||
<RefreshCw size={14} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
{status.ready && (
|
||||
<button className="m3-btn m3-btn-error" onClick={handleStop}>
|
||||
<Square size={14} />
|
||||
<span>Stop Runtime</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{installedModels.length === 0 && (
|
||||
<div className="text-progress">
|
||||
Download a Kokoro model from Model Manager, TTS Models first.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="m3-card speech-panel">
|
||||
<div className="speech-panel-header">
|
||||
<h3>Input</h3>
|
||||
<span className="status-chip">{text.trim().length} chars</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="m3-input"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={10}
|
||||
maxLength={5000}
|
||||
style={{ width: "100%", resize: "vertical", minHeight: "220px", lineHeight: 1.45 }}
|
||||
placeholder="Type text to turn into speech..."
|
||||
/>
|
||||
<div className="speech-button-row">
|
||||
{isGenerating ? (
|
||||
<button className="m3-btn m3-btn-error" onClick={handleCancel}>
|
||||
<Square size={14} />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
onClick={handleGenerate}
|
||||
disabled={!text.trim() || !selectedModel || !status.runtimeInstalled}
|
||||
>
|
||||
<Volume2 size={14} />
|
||||
<span>Generate WAV</span>
|
||||
</button>
|
||||
)}
|
||||
{isGenerating && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Generating locally...</span>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="m3-card speech-result-panel">
|
||||
<div className="speech-panel-header">
|
||||
<h3>Output</h3>
|
||||
<div className="speech-button-row">
|
||||
<button className="m3-btn m3-btn-outlined" onClick={() => output?.url && downloadUrl(output.url, output.audioFile || "tts.wav")} disabled={!output?.url}>
|
||||
<Save size={14} />
|
||||
<span>WAV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{output?.url ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||
<audio controls src={output.url} style={{ width: "100%" }} />
|
||||
<div className="text-progress">
|
||||
{output.voiceName || output.voice} • {output.modelName || output.model} • {new Date(output.createdAt).toLocaleString()}
|
||||
</div>
|
||||
<textarea
|
||||
className="m3-input"
|
||||
value={output.text || text}
|
||||
onChange={(event) => setOutput((prev) => ({ ...(prev || {}), text: event.target.value }))}
|
||||
rows={5}
|
||||
style={{ width: "100%", resize: "vertical", minHeight: "120px", lineHeight: 1.45 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state" style={{ padding: "32px 0" }}>
|
||||
<Volume2 size={42} />
|
||||
<p>Generated speech will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import React, { memo, useState, useRef, useEffect } from "react";
|
||||
import { Square, RefreshCw, Sun, Moon, Palette, Check, PanelLeftClose, PanelLeft } from "lucide-react";
|
||||
import { THEMES } from "../themes";
|
||||
|
||||
const truncateModelName = (name) => {
|
||||
if (!name || typeof name !== "string") return "";
|
||||
let cleanName = name;
|
||||
if (name.includes("--")) {
|
||||
const parts = name.split("--");
|
||||
cleanName = parts[parts.length - 1];
|
||||
}
|
||||
if (cleanName.length > 25) {
|
||||
return cleanName.substring(0, 22) + "...";
|
||||
}
|
||||
return cleanName;
|
||||
};
|
||||
|
||||
function TopStatusBar({
|
||||
telemetry,
|
||||
serverRunning,
|
||||
activeModel,
|
||||
isLlmLoaded = false,
|
||||
onStopServer,
|
||||
isStoppingServer = false,
|
||||
theme,
|
||||
setTheme,
|
||||
sidebarVisible,
|
||||
onToggleSidebar
|
||||
}) {
|
||||
const [showThemeMenu, setShowThemeMenu] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
setShowThemeMenu(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const formatGb = (value, { allowZero = false } = {}) => {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < 0) return "--";
|
||||
if (number === 0 && !allowZero) return "--";
|
||||
return number.toFixed(number >= 10 ? 0 : 1);
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isLlmLoaded) return "Model Loaded (Text)";
|
||||
if (activeModel) return "Model Loaded (Image)";
|
||||
if (serverRunning) return "Server Active";
|
||||
return "Local Mode";
|
||||
};
|
||||
|
||||
const getStatusClass = () => {
|
||||
if (activeModel || isLlmLoaded) return "status-indicator";
|
||||
if (serverRunning) return "status-indicator busy";
|
||||
return "status-indicator offline";
|
||||
};
|
||||
|
||||
const isDark = (theme || "dark").startsWith("dark");
|
||||
|
||||
return (
|
||||
<div className="top-status-bar">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="sidebar-toggle-btn"
|
||||
title={sidebarVisible ? "Collapse Sidebar" : "Expand Sidebar"}
|
||||
>
|
||||
{sidebarVisible ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}
|
||||
</button>
|
||||
|
||||
<div className="current-model-info">
|
||||
<div className={getStatusClass()}></div>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
{(activeModel || (typeof isLlmLoaded === "string" && isLlmLoaded)) && (
|
||||
<>
|
||||
<span style={{ color: "var(--md-sys-color-outline-variant)" }}>|</span>
|
||||
<span
|
||||
style={{ color: "var(--md-sys-color-primary)", fontWeight: 700 }}
|
||||
title={activeModel || isLlmLoaded}
|
||||
>
|
||||
{truncateModelName(activeModel || isLlmLoaded)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="telemetry-group" style={{ position: "relative" }}>
|
||||
<button
|
||||
className="theme-toggle-btn"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={`Switch to ${isDark ? "light" : "dark"} theme`}
|
||||
>
|
||||
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
|
||||
<div ref={menuRef} style={{ position: "relative", display: "inline-block" }}>
|
||||
<button
|
||||
className={`theme-toggle-btn ${showThemeMenu ? "active" : ""}`}
|
||||
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||
title="Choose a custom color theme"
|
||||
style={{ marginRight: "12px" }}
|
||||
>
|
||||
<Palette size={18} />
|
||||
</button>
|
||||
|
||||
{showThemeMenu && (
|
||||
<div className="theme-dropdown-menu">
|
||||
<div className="theme-dropdown-header">Select Theme</div>
|
||||
<div className="theme-dropdown-divider"></div>
|
||||
{THEMES.map((t) => {
|
||||
const isActive = theme === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`theme-dropdown-item ${isActive ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setTheme(t.id);
|
||||
setShowThemeMenu(false);
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", width: "100%" }}>
|
||||
<div style={{ display: "flex", gap: "2px" }}>
|
||||
<div style={{ width: "8px", height: "8px", borderRadius: "50%", background: t.primary }} />
|
||||
<div style={{ width: "8px", height: "8px", borderRadius: "50%", background: t.secondary }} />
|
||||
</div>
|
||||
<span className="theme-name-text">{t.name}</span>
|
||||
{isActive && <Check size={12} style={{ marginLeft: "auto", color: "var(--md-sys-color-primary)" }} />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serverRunning && (
|
||||
<button
|
||||
className="m3-btn m3-btn-error"
|
||||
style={{ height: "34px", padding: "0 14px" }}
|
||||
onClick={onStopServer}
|
||||
disabled={isStoppingServer}
|
||||
title="Stop local model server"
|
||||
>
|
||||
{isStoppingServer ? <RefreshCw className="progress-spinner" size={14} /> : <Square size={14} />}
|
||||
<span>{isStoppingServer ? "Stopping" : "Stop Server"}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* CPU Telemetry Chip */}
|
||||
<div className="telemetry-chip" title="CPU Utilization">
|
||||
<span>CPU: {Number.isFinite(Number(telemetry.cpu_usage)) ? telemetry.cpu_usage : "--"}%</span>
|
||||
</div>
|
||||
|
||||
{/* RAM Telemetry Chip */}
|
||||
<div className="telemetry-chip" title="System Memory Usage">
|
||||
<span>RAM: {formatGb(telemetry.ram_used_gb)} / {formatGb(telemetry.ram_total_gb)} GB</span>
|
||||
</div>
|
||||
|
||||
{/* GPU VRAM Telemetry Chip */}
|
||||
{telemetry.vram_total_gb > 0 && (
|
||||
<div className="telemetry-chip" title={`${telemetry.gpu_name} VRAM`}>
|
||||
<span>VRAM: {formatGb(telemetry.vram_used_gb, { allowZero: true })} / {formatGb(telemetry.vram_total_gb)} GB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TopStatusBar);
|
||||
@@ -683,16 +683,22 @@ export async function transcribeSpeech(fileOrBlob, options = {}) {
|
||||
if (options.backendPreference) params.set("backendPreference", options.backendPreference);
|
||||
if (options.translate) params.set("translate", "true");
|
||||
|
||||
let contentType = "audio/wav";
|
||||
if (fileOrBlob instanceof File && fileOrBlob.name) {
|
||||
const ext = fileOrBlob.name.toLowerCase().split(".").pop();
|
||||
const mimeTypes = {wav: "audio/wav", mp3: "audio/mpeg", m4a: "audio/mp4", ogg: "audio/ogg", flac: "audio/flac", webm: "audio/webm", aac: "audio/aac", m4b: "audio/mp4", opus: "audio/opus", wma: "audio/x-ms-wma"};
|
||||
contentType = mimeTypes[ext] || "audio/wav";
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/speech/transcribe?${params.toString()}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "audio/wav" },
|
||||
headers: { "Content-Type": contentType },
|
||||
body: fileOrBlob,
|
||||
signal: options.signal,
|
||||
});
|
||||
const data = await readJsonResponse(res, "The local server returned an invalid transcription response.");
|
||||
return data.transcription;
|
||||
}
|
||||
|
||||
export async function downloadSpeechModel(modelIdOrUrl, filename = null) {
|
||||
const isUrl = /^https?:\/\//i.test(String(modelIdOrUrl || ""));
|
||||
const res = await fetch("/api/speech/download-model", {
|
||||
|
||||
Reference in new issue
Block a user