diff --git a/app/frontend/src/components/Generator.jsx b/app/frontend/src/components/Generator.jsx
deleted file mode 100644
index f9143bc..0000000
--- a/app/frontend/src/components/Generator.jsx
+++ /dev/null
@@ -1,1001 +0,0 @@
-import React, { memo, useState, useRef, useCallback } from "react";
-import { Sparkles, Download, Copy, RefreshCw, Check, Sliders, Trash2, ImagePlus } from "lucide-react";
-import {
- generateImage,
- startServer,
- stopServer,
- waitForServerReady,
- getBackendStatus,
- getGenerationProgress,
- saveGeneratedOutput,
- deleteGeneratedOutputs,
- getLlmStatus,
- listLlmModels,
- startLlm,
- chatWithLlm
-} from "../services/api";
-
-const GalleryItem = memo(({ img, idx, isSelected, onClick }) => {
- const handleClick = (e) => {
- onClick(idx, e);
- };
- return (
-
-
- {isSelected && (
-
-
-
- )}
-
- );
-});
-
-function Generator({
- prompt,
- setPrompt,
- negativePrompt,
- setNegativePrompt,
- constraints,
- setConstraints,
- activeModel,
- generatedImages,
- setGeneratedImages,
- isGenerating,
- setIsGenerating,
- generationProgress,
- setGenerationProgress,
- setActiveTab,
- showAlert = async ({ message }) => window.alert(message),
- showConfirm = async ({ message }) => window.confirm(message),
- specs,
- textSettings,
-}) {
- const [outputImage, setOutputImage] = useState(null);
- const [outputSeed, setOutputSeed] = useState(null);
- const [genDuration, setGenDuration] = useState(null);
- const [copySuccess, setCopySuccess] = useState(false);
- const [elapsedTime, setElapsedTime] = useState(0);
- const [estimatedLeftTime, setEstimatedLeftTime] = useState(0);
- const [currentStep, setCurrentStep] = useState(0);
- const [generationSpeed, setGenerationSpeed] = useState("");
- const [isCpuFallback, setIsCpuFallback] = useState(false);
- const [isCancelling, setIsCancelling] = useState(false);
- const [isRestartingBackend, setIsRestartingBackend] = useState(false);
- const [restartLoadProgress, setRestartLoadProgress] = useState(null);
- const [errorMsg, setErrorMsg] = useState(null);
- const [isDecoding, setIsDecoding] = useState(false);
- const [selectedGalleryIndexes, setSelectedGalleryIndexes] = useState([]);
- const [baseImage, setBaseImage] = useState(null);
- const [isEnhancing, setIsEnhancing] = useState(false);
- const timerRef = useRef(null);
- const abortControllerRef = useRef(null);
- const hasRealGenerationStepRef = useRef(false);
-
- const handleEnhancePrompt = async () => {
- if (!prompt.trim() || isEnhancing || isGenerating) return;
- setIsEnhancing(true);
- try {
- const status = await getLlmStatus();
- let targetModel = status.settings?.model;
-
- if (!status.ready) {
- const models = await listLlmModels();
- if (models.length === 0) {
- showAlert({
- title: "No Text Models",
- message: "You haven't downloaded any text models yet. Please go to the 'Model Manager' or 'Text Chat' tab to download a GGUF model first.",
- danger: true
- });
- setIsEnhancing(false);
- return;
- }
-
- const savedLlm = localStorage.getItem("selectedLlmModel") || models[0]?.filename;
- targetModel = models.some(m => m.filename === savedLlm) ? savedLlm : models[0]?.filename;
-
- const confirm = await showConfirm({
- title: "Load Text Model?",
- message: `Enhancing your prompt requires loading the local text model "${targetModel}". This will temporarily unload the image model from memory. Do you want to proceed?`,
- confirmLabel: "Load & Enhance",
- cancelLabel: "Cancel"
- });
-
- if (!confirm) {
- setIsEnhancing(false);
- return;
- }
-
- const cores = textSettings?.threads || specs?.cpu_cores_physical || 4;
- const context = textSettings?.contextSize || 2048;
- await startLlm(targetModel, {
- threads: cores,
- contextSize: context,
- gpuLayers: -1,
- preferredBackend: textSettings?.preferredBackend
- });
- }
-
- const systemPrompt = "You are a helpful assistant. Rewrite the user's image prompt to be more descriptive and detailed for image generation. Keep it under 80 words. Output ONLY the rewritten prompt, no explanation or introductory text.";
- const messages = [
- { role: "system", content: systemPrompt },
- { role: "user", content: `Please expand this prompt: "${prompt}"` }
- ];
-
- const temp = textSettings?.temperature || 0.7;
- const response = await chatWithLlm(messages, { temperature: temp, maxTokens: 128 });
- let cleanResponse = response.content.trim();
- if (cleanResponse.startsWith('"') && cleanResponse.endsWith('"')) {
- cleanResponse = cleanResponse.slice(1, -1);
- }
- cleanResponse = cleanResponse.replace(/^Here is the expanded prompt:?\s*/i, "");
- cleanResponse = cleanResponse.replace(/^Enhanced prompt:?\s*/i, "");
-
- setPrompt(cleanResponse);
- } catch (err) {
- showAlert({
- title: "Prompt Enhancement Failed",
- message: err.message || String(err),
- danger: true
- });
- } finally {
- setIsEnhancing(false);
- }
- };
-
- React.useEffect(() => {
- if (constraints.backendType === "apple-npu" || constraints.backendType === "openvino-npu") {
- setBaseImage(null);
- }
- }, [constraints.backendType]);
-
- const handleBaseImageSelect = (e) => {
- const file = e.target.files?.[0];
- e.target.value = "";
- if (!file) return;
- if (!file.type.startsWith("image/")) {
- setErrorMsg("Please choose an image file (PNG, JPG, or WEBP).");
- return;
- }
- const reader = new FileReader();
- reader.onload = () => setBaseImage(reader.result);
- reader.onerror = () => setErrorMsg("Could not read the selected image file.");
- reader.readAsDataURL(file);
- };
-
- const handleClearBaseImage = () => setBaseImage(null);
-
- // Trigger main image generation process
- const handleGenerate = async () => {
- if (!prompt.trim()) return;
-
- setErrorMsg(null);
- setIsRestartingBackend(true);
- setRestartLoadProgress(null);
-
- let needsRestart = false;
- try {
- const status = await getBackendStatus();
- if (status && status.settings) {
- const settings = status.settings;
- const currentModelName = settings.model ? settings.model.split(/[\\/]/).pop() : null;
- const targetModelName = activeModel ? activeModel.split(/[\\/]/).pop() : null;
- const requestedBackend = constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto");
- const currentBackend = settings.backendType || (settings.useGpu === false ? "cpu" : "auto");
- const shouldCompareModel = requestedBackend !== "openvino-npu";
- const isCoreMLBackend = requestedBackend === "apple-npu";
-
- if ((shouldCompareModel && currentModelName !== targetModelName) ||
- (!isCoreMLBackend && parseInt(settings.steps) !== parseInt(constraints.steps)) ||
- (!isCoreMLBackend && Math.abs(parseFloat(settings.cfgScale) - parseFloat(constraints.cfgScale)) > 0.05) ||
- (!isCoreMLBackend && settings.sampler !== constraints.sampler) ||
- (!isCoreMLBackend && parseInt(settings.threads) !== parseInt(constraints.threads)) ||
- Boolean(settings.useGpu) !== (constraints.useGpu !== false) ||
- parseInt(settings.width || 512) !== parseInt(constraints.width || 512) ||
- parseInt(settings.height || 512) !== parseInt(constraints.height || 512) ||
- currentBackend !== requestedBackend) {
- needsRestart = true;
- }
- } else {
- needsRestart = true;
- }
- } catch (e) {
- needsRestart = true;
- }
-
- if (needsRestart) {
- try {
- console.log("Settings out of sync, restarting backend...");
- await startServer(activeModel, constraints);
- let isReady = false;
- let crashError = null;
- const maxStartupPolls = (constraints.backendType === "openvino-npu" || constraints.backendType === "apple-npu") ? 1200 : 240;
- for (let i = 0; i < maxStartupPolls; i++) {
- const status = await getBackendStatus();
- if (status.loading) {
- setRestartLoadProgress({
- progress: status.loading.progress || 0,
- phase: status.loading.phase || "Loading model...",
- speed: status.loading.speed || "",
- current: status.loading.current || 0,
- total: status.loading.total || 0,
- backendMode: status.loading.backendMode || status.settings?.backendMode || "",
- device: status.loading.device || status.settings?.backendDevice || "",
- });
- }
- if (status.ready) {
- isReady = true;
- break;
- }
- if (status.error) {
- crashError = status.error;
- break;
- }
- if (!status.running && !status.loading?.active && i > 3) {
- crashError = "The backend process terminated immediately on startup.";
- break;
- }
- await new Promise((r) => setTimeout(r, 500));
- }
-
- if (!isReady) {
- throw new Error(crashError || "Backend server failed to start after applying configuration changes.");
- }
- } catch (err) {
- console.error("Failed to restart backend:", err);
- setErrorMsg(err.message || "Failed to sync settings with backend.");
- setIsRestartingBackend(false);
- return;
- }
- }
- setIsRestartingBackend(false);
- setRestartLoadProgress(null);
-
- if (timerRef.current) clearInterval(timerRef.current);
- setIsGenerating(true);
- setGenerationProgress(0);
- setElapsedTime(0);
- setCurrentStep(0);
- setGenerationSpeed("");
- hasRealGenerationStepRef.current = false;
- setIsCpuFallback(false);
- setIsDecoding(false);
- setGenDuration(null);
- setOutputImage(null);
-
- // Calculate dynamic step estimation times
- // CPU takes ~35s per step at 512px, ~140s at 1024px. GPU is ~0.04x of CPU time.
- const baseGpuStepTime = (constraints.width >= 1024 ? 140 : 35) * 0.04;
- const baseCpuStepTime = (constraints.width >= 1024 ? 140 : 35);
-
- const gpuSelected = (constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto")) !== "cpu";
- let activeStepTime = gpuSelected ? baseGpuStepTime : baseCpuStepTime;
- let cpuFallbackDetected = false;
- let fallbackTime = 0;
- let fallbackProgress = 0;
- let elapsedSeconds = 0;
-
- setEstimatedLeftTime(Math.round(constraints.steps * activeStepTime));
-
- timerRef.current = setInterval(async () => {
- elapsedSeconds++;
- setElapsedTime(elapsedSeconds);
-
- try {
- const progress = await getGenerationProgress();
- if (progress && progress.active) {
- const decoding = progress.decoding || false;
- setIsDecoding(decoding);
-
- const step = decoding ? (progress.steps || constraints.steps || 20) : (progress.step || 0);
- const steps = progress.steps || constraints.steps || 20;
- const speed = decoding ? "" : (progress.speed || "");
- const numericSpeed = parseFloat(speed);
- const backendMode = String(progress.backendMode || "").toLowerCase();
- const backendDevice = String(progress.backendDevice || "").toLowerCase();
- const isConfirmedGpuBackend = (
- backendMode.includes("gpu") ||
- backendMode.includes("vulkan") ||
- backendMode.includes("cuda") ||
- backendMode.includes("rocm") ||
- backendMode.includes("metal") ||
- backendDevice.includes("vulkan") ||
- backendDevice.includes("cuda") ||
- backendDevice.includes("rocm")
- );
-
- if (decoding || progress.steps > 0) {
- hasRealGenerationStepRef.current = true;
- setCurrentStep(step);
- }
- if (!decoding) {
- setGenerationSpeed(speed);
- } else {
- setGenerationSpeed("decoding");
- }
-
- // Calculate progress percentage
- let progressPercent = 0;
- if (decoding) {
- progressPercent = 99;
- } else if (steps > 0) {
- progressPercent = Math.round((step / steps) * 100);
- }
- progressPercent = Math.min(99, progressPercent);
- if (decoding || progress.steps > 0) {
- setGenerationProgress(progressPercent);
- } else {
- setGenerationProgress((prev) => Math.max(prev, progressPercent));
- }
-
- // Calculate remaining time
- let remaining = 0;
- if (decoding) {
- remaining = 3;
- } else {
- if (!isNaN(numericSpeed) && numericSpeed > 0) {
- const remainingSteps = Math.max(0, steps - step);
- if (speed.includes("it/s")) {
- remaining = remainingSteps / numericSpeed;
- } else if (speed.includes("s/it")) {
- remaining = remainingSteps * numericSpeed;
- }
- } else {
- const activeStepTime = gpuSelected ? baseGpuStepTime : baseCpuStepTime;
- remaining = Math.max(0, (steps - step) * activeStepTime);
- }
- }
-
- // Detect CPU fallback / slow generation if GPU was requested but it runs slow
- if (gpuSelected && !cpuFallbackDetected && !isConfirmedGpuBackend) {
- const isSlowItS = speed.includes("it/s") && numericSpeed < 0.2;
- const isSlowSIt = speed.includes("s/it") && numericSpeed > 5.0;
- if (isSlowItS || isSlowSIt) {
- cpuFallbackDetected = true;
- setIsCpuFallback(true);
- }
- }
-
- setEstimatedLeftTime(Math.round(remaining));
- } else {
- // Creep up slowly while loading the model/preparing
- setGenerationProgress((prev) => Math.min(15, prev + 1));
- setEstimatedLeftTime((prev) => Math.max(1, prev - 1));
- }
- } catch (e) {
- console.warn("Failed to get generation progress, running simulation fallback:", e);
-
- // Fallback simulation logic
- if (gpuSelected && !cpuFallbackDetected && elapsedSeconds > constraints.steps * baseGpuStepTime * 1.3) {
- cpuFallbackDetected = true;
- setIsCpuFallback(true);
- activeStepTime = baseCpuStepTime;
- fallbackTime = elapsedSeconds;
- fallbackProgress = Math.min(95, Math.round((elapsedSeconds / (constraints.steps * baseGpuStepTime)) * 100));
- }
-
- let step = Math.min(constraints.steps - 1, Math.floor(elapsedSeconds / activeStepTime));
- let expectedTotal = constraints.steps * activeStepTime;
- if (elapsedSeconds >= expectedTotal) {
- const computedStepTime = Math.ceil(elapsedSeconds / Math.max(0.5, step));
- activeStepTime = Math.max(activeStepTime + 15, computedStepTime + 15);
- expectedTotal = constraints.steps * activeStepTime;
- step = Math.min(constraints.steps - 1, Math.floor(elapsedSeconds / activeStepTime));
- }
- const remaining = Math.max(1, expectedTotal - elapsedSeconds);
- setEstimatedLeftTime(Math.round(remaining));
-
- let progressPercent = 0;
- if (cpuFallbackDetected) {
- const remainingProgress = 99 - fallbackProgress;
- const remainingTime = expectedTotal - fallbackTime;
- if (remainingTime > 0) {
- const ratio = Math.min(1.0, (elapsedSeconds - fallbackTime) / remainingTime);
- if (ratio < 0.85) {
- progressPercent = Math.round(fallbackProgress + ratio * remainingProgress);
- } else {
- const x = (elapsedSeconds - (fallbackTime + remainingTime * 0.85)) / (activeStepTime * 2);
- const decay = 1 - Math.exp(-x);
- progressPercent = Math.min(99, Math.round(fallbackProgress + 0.85 * remainingProgress + (0.14 * remainingProgress) * decay));
- }
- } else {
- progressPercent = 99;
- }
- } else {
- if (elapsedSeconds < expectedTotal * 0.85) {
- progressPercent = Math.round((elapsedSeconds / expectedTotal) * 100);
- } else {
- const x = (elapsedSeconds - expectedTotal * 0.85) / (activeStepTime * 2);
- const decay = 1 - Math.exp(-x);
- progressPercent = Math.min(99, Math.round(85 + 14 * decay));
- }
- }
- if (!hasRealGenerationStepRef.current) {
- setGenerationProgress((p) => Math.max(p, progressPercent));
- }
- }
- }, 1000);
-
- try {
- abortControllerRef.current = new AbortController();
- const result = await generateImage(
- prompt,
- negativePrompt,
- constraints,
- activeModel,
- baseImage,
- (prog) => setGenerationProgress((prev) => Math.max(prev, prog)),
- abortControllerRef.current.signal
- );
-
- if (timerRef.current) clearInterval(timerRef.current);
- setGenerationProgress(100);
- setCurrentStep(constraints.steps);
- setEstimatedLeftTime(0);
- setGenerationSpeed("");
-
- const metadata = {
- prompt: prompt,
- negativePrompt: negativePrompt,
- seed: result.seed,
- steps: constraints.steps,
- cfgScale: constraints.cfgScale,
- width: constraints.width,
- height: constraints.height,
- sampler: constraints.sampler,
- model: activeModel,
- mode: baseImage ? "img2img" : "txt2img",
- denoisingStrength: baseImage ? constraints.denoisingStrength : null,
- duration_sec: result.duration_sec,
- timestamp: new Date().toLocaleTimeString(),
- };
- let savedOutput = null;
- let savedUrl = null;
- try {
- savedOutput = await saveGeneratedOutput(result.image, metadata);
- savedUrl = savedOutput.url;
- if (!savedUrl) {
- throw new Error("The local server saved the image but did not return a file URL.");
- }
- } catch (saveErr) {
- console.error("Generated image could not be saved:", saveErr);
- throw new Error(`Image generated but could not be saved to USB: ${saveErr.message || saveErr}`);
- }
-
- setOutputImage(savedUrl);
- setOutputSeed(result.seed);
- setGenDuration(result.duration_sec);
-
- const historyItem = {
- ...metadata,
- ...(savedOutput || {}),
- url: savedUrl,
- };
-
- setGeneratedImages((prev) => [historyItem, ...prev]);
- } catch (e) {
- if (e.name === "AbortError") {
- console.log("Generation request was aborted by the user");
- } else {
- console.error("Generation failed:", e);
- setErrorMsg(e.message || "Generation failed. Please try again.");
- }
- if (timerRef.current) clearInterval(timerRef.current);
- } finally {
- setIsGenerating(false);
- abortControllerRef.current = null;
- }
- };
-
- // Stop/Cancel Generation Handler
- const handleCancelGeneration = async () => {
- if (await showConfirm({
- title: "Stop Generation?",
- message: "The current image generation will be cancelled and the local model server may restart.",
- confirmLabel: "Stop",
- danger: true,
- })) {
- if (timerRef.current) clearInterval(timerRef.current);
-
- // Set cancelling state first to keep the progress overlay active in cancelling mode
- setIsCancelling(true);
-
- if (abortControllerRef.current) {
- abortControllerRef.current.abort();
- }
-
- try {
- console.log("Cancelling: Stopping server to kill generation threads...");
- await stopServer();
- setCurrentStep(0);
- setGenerationSpeed("");
- setIsDecoding(false);
- hasRealGenerationStepRef.current = false;
-
- if (activeModel) {
- console.log("Cancelling: Restarting server for future generations...");
- await startServer(activeModel, constraints);
- const ready = await waitForServerReady();
- if (!ready) {
- console.warn("Server failed to restart properly.");
- }
- }
- } catch (err) {
- console.error("Failed to restart server on cancellation:", err);
- } finally {
- setIsCancelling(false);
- setIsGenerating(false);
- setGenerationProgress(0);
- setEstimatedLeftTime(0);
- }
- }
- };
-
- // Copy generated image to clipboard
- const handleCopyImage = async () => {
- if (!outputImage) return;
- try {
- if (outputImage.startsWith("data:image")) {
- // base64 conversion for clipboard
- const response = await fetch(outputImage);
- const blob = await response.blob();
- await navigator.clipboard.write([
- new ClipboardItem({ [blob.type]: blob })
- ]);
- } else {
- // Remote Unsplash images fallback (draw on canvas then copy)
- const img = new Image();
- img.crossOrigin = "anonymous";
- img.src = outputImage;
- img.onload = async () => {
- const canvas = document.createElement("canvas");
- canvas.width = img.width;
- canvas.height = img.height;
- const ctx = canvas.getContext("2d");
- ctx.drawImage(img, 0, 0);
- canvas.toBlob(async (blob) => {
- await navigator.clipboard.write([
- new ClipboardItem({ [blob.type]: blob })
- ]);
- }, "image/png");
- };
- }
- setCopySuccess(true);
- setTimeout(() => setCopySuccess(false), 2000);
- } catch (err) {
- console.error("Failed to copy image:", err);
- }
- };
-
- // Download generated image
- const handleDownload = () => {
- if (!outputImage) return;
- const link = document.createElement("a");
- link.href = outputImage;
- link.download = `diffusion-${outputSeed || Date.now()}.png`;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- };
-
- const downloadImageUrl = (url, filename = `diffusion-${Date.now()}.png`) => {
- if (!url) return;
- const link = document.createElement("a");
- link.href = url;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- };
-
- // Reuse prompt settings from history
- const loadHistoryItem = useCallback((item) => {
- setPrompt(item.prompt);
- setNegativePrompt(item.negativePrompt);
- setConstraints((prev) => ({
- ...prev,
- steps: item.steps,
- cfgScale: item.cfgScale,
- width: item.width,
- height: item.height,
- sampler: item.sampler,
- seed: item.seed,
- }));
- setOutputImage(item.url);
- setOutputSeed(item.seed);
- setGenDuration(item.duration_sec ?? item.durationSec ?? null);
- }, [setPrompt, setNegativePrompt, setConstraints]);
-
- const toggleGallerySelection = useCallback((idx, event) => {
- const multi = event.ctrlKey || event.metaKey || event.shiftKey;
- setSelectedGalleryIndexes((prev) => {
- if (!multi) return [idx];
- return prev.includes(idx) ? prev.filter((item) => item !== idx) : [...prev, idx];
- });
- if (generatedImages[idx]) {
- loadHistoryItem(generatedImages[idx]);
- }
- }, [generatedImages, loadHistoryItem]);
-
- const selectedGalleryItems = selectedGalleryIndexes
- .map((idx) => generatedImages[idx])
- .filter(Boolean);
-
- const clearGallerySelection = () => {
- setSelectedGalleryIndexes([]);
- };
-
- const useSelectedGalleryItem = () => {
- if (selectedGalleryItems[0]) {
- loadHistoryItem(selectedGalleryItems[0]);
- }
- };
-
- const downloadSelectedGalleryItems = () => {
- selectedGalleryItems.forEach((item, index) => {
- const seed = item.seed ?? Date.now();
- downloadImageUrl(item.url, `diffusion-${seed}-${index + 1}.png`);
- });
- };
-
- const deleteSelectedGalleryItems = async () => {
- if (selectedGalleryItems.length === 0) return;
- const count = selectedGalleryItems.length;
- const confirmed = await showConfirm({
- title: "Delete Selected Outputs?",
- message: `Delete ${count} selected output${count === 1 ? "" : "s"} from USB? This removes the image and metadata files.`,
- confirmLabel: "Delete",
- danger: true,
- });
- if (!confirmed) return;
-
- try {
- await deleteGeneratedOutputs(selectedGalleryItems);
- const selectedSet = new Set(selectedGalleryIndexes);
- setGeneratedImages((prev) => prev.filter((_, idx) => !selectedSet.has(idx)));
- setSelectedGalleryIndexes([]);
-
- if (selectedGalleryItems.some((item) => item.url === outputImage)) {
- setOutputImage(null);
- setOutputSeed(null);
- setGenDuration(null);
- }
- } catch (err) {
- console.error("Failed to delete selected outputs:", err);
- showAlert({
- title: "Delete Failed",
- message: err.message || String(err),
- danger: true,
- });
- }
- };
-
- return (
-
-
-
Generator Dashboard
-
- Create high-fidelity images completely locally on your external drive.
-
-
-
-
- {/* Left Side: Parameters & Input */}
-
-
-
-
- Generation Workspace
-
-
-
- {/* Prompt Textarea */}
-
- Image Prompt
-
-
- {constraints.backendType !== "apple-npu" && constraints.backendType !== "openvino-npu" && (
-
-
Base Image (Optional)
- {baseImage ? (
-
-
-
-
Base image ready
-
- The AI will redraw this image guided by your prompt.
-
-
-
-
- Remove
-
-
- ) : (
-
-
-
- Upload a base image
-
- Optional. Generate a new image based on one of your own.
-
-
- )}
-
- {baseImage && (
-
-
- Transformation strength
- {constraints.denoisingStrength.toFixed(2)}
-
-
setConstraints((prev) => ({ ...prev, denoisingStrength: parseFloat(e.target.value) }))}
- disabled={isGenerating}
- style={{ width: "100%" }}
- />
-
- Lower = closer to your image; higher = more creative freedom. 0.7 is a good start.
-
-
- )}
-
- )}
-
- {/* Configuration Status Chips (Material 3 style) */}
-
-
Active Image Constraints
-
-
-
- Resolution: {constraints.width}x{constraints.height}
-
-
-
- Steps: {constraints.steps}
-
-
-
- Sampler: {constraints.sampler}
-
-
- Seed: {constraints.seed === -1 ? "Random" : constraints.seed}
-
-
-
-
- {/* Generate Trigger Button */}
-
-
- {!activeModel ? "Select Model to Generate" : baseImage ? "Generate from Base Image" : "Generate Local Image"}
-
-
-
-
-
- {/* Right Side: Image Output Preview */}
-
-
- {isGenerating || isCancelling || isRestartingBackend ? (
-
- {isRestartingBackend ? (
-
-
-
- Syncing Settings & Initializing...
-
- {restartLoadProgress ? (
-
-
-
- {restartLoadProgress.phase}
- {restartLoadProgress.speed ? ` (${restartLoadProgress.speed})` : ""}
-
-
- {restartLoadProgress.total > 0
- ? `Loaded ${restartLoadProgress.current} / ${restartLoadProgress.total} tensors`
- : `${Math.round(restartLoadProgress.progress)}%`}
-
-
-
- {(restartLoadProgress.backendMode || restartLoadProgress.device) && (
-
- Loading on {restartLoadProgress.backendMode || "backend"}{restartLoadProgress.device ? ` • ${restartLoadProgress.device}` : ""}
-
- )}
-
- ) : (
-
- Adjusting inference settings and restarting the local model server.
-
- )}
-
- ) : isCancelling ? (
-
-
-
- Cancelling Generation...
-
-
- Stopping CPU/GPU execution threads and reloading model weights. Please wait a moment.
-
-
- ) : (
- <>
-
-
- {isDecoding ? "Decoding Latents..." : "Generating Locally..."}
-
-
- {isCpuFallback && (
-
- ⚠️ CPU Fallback Detected (Running Slower)
-
- )}
-
- {/* Real-time stats display */}
-
-
- Step: {currentStep} / {constraints.steps} ({Math.max(0, constraints.steps - currentStep)} left)
-
-
- Elapsed: {elapsedTime}s{generationSpeed && ` (${generationSpeed === "decoding" ? "decoding" : generationSpeed})`}
-
-
-
-
-
-
- Progress: {generationProgress}%
- •
- Est. remaining: {estimatedLeftTime}s
-
-
-
- Stop Generating
-
- >
- )}
-
- ) : outputImage ? (
-
- ) : errorMsg ? (
-
-
-
Generation Failed
-
- {errorMsg}
-
-
- Hint: Try lowering the resolution in the Image Constraints settings (e.g., to 512x512).
-
-
- ) : (
-
-
-
- Your generated image will appear here.
- Enter a prompt on the left and click Generate.
-
-
- )}
-
-
- {outputImage && !isGenerating && (
- <>
- {/* Telemetry info for previous generation */}
-
- Seed: {outputSeed}
- {genDuration && Inference Time: {genDuration.toFixed(1)}s }
-
- {/* Actions for generated image */}
-
-
- {copySuccess ? : }
- {copySuccess ? "Copied" : "Copy"}
-
-
-
- Save to USB
-
-
- >
- )}
-
-
-
- {/* History / Gallery Grid */}
-
-
-
Recent Outputs Gallery
- {selectedGalleryItems.length > 0 && (
-
- {selectedGalleryItems.length} selected
-
-
- Use
-
-
-
- Download
-
-
-
- Delete
-
-
- Clear
-
-
- )}
-
- {generatedImages.length === 0 ? (
-
- No images generated in this session yet. Everything will be saved directly on your external drive.
-
- ) : (
- <>
-
Click to select one image. Ctrl-click or Shift-click to select multiple.
-
- {generatedImages.map((img, idx) => (
-
- ))}
-
- >
- )}
-
-
- );
-}
-
-export default memo(Generator);
diff --git a/app/frontend/src/components/ModelManager.jsx b/app/frontend/src/components/ModelManager.jsx
index 116d6ef..d926a87 100644
--- a/app/frontend/src/components/ModelManager.jsx
+++ b/app/frontend/src/components/ModelManager.jsx
@@ -1,27 +1,9 @@
import React, { memo, useState, useEffect, useCallback } from "react";
-import { FolderOpen, DownloadCloud, RefreshCw, Database, Trash2, Square, HardDrive, Library, AlertTriangle, Search, X } from "lucide-react";
import {
- listLocalModels,
- startServer,
- stopServer,
- importModelFile,
- deleteModel,
- downloadModel,
- downloadOpenVinoModel,
- cancelModelDownload,
- getDownloadProgress,
- getBackendStatus,
- pingServer,
- formatBytes,
- normalizeModel,
- listLlmModels,
- startLlm,
- stopLlm,
- downloadLlmModel,
- deleteLlmModel,
- importLlmModel,
- getLlmStatus,
- searchHuggingFaceModels,
+ FolderOpen, DownloadCloud, RefreshCw, Database, Trash2, Square, HardDrive, Library, AlertTriangle, Search, X,
+ Play, LoaderCircle
+} from "lucide-react";
+import {
listSpeechModels,
startSpeech,
stopSpeech,
@@ -29,263 +11,16 @@ import {
deleteSpeechModel,
importSpeechModel,
getSpeechStatus,
- listTtsModels,
- startTts,
- stopTts,
- downloadTtsModel,
- deleteTtsModel,
- importTtsModel,
- getTtsStatus,
- isLocalServerMode
+ formatBytes,
} from "../services/api";
-const MODEL_FILTERS = [
- { id: "vision", label: "Vision" },
- { id: "uncensored", label: "Uncensored" },
-];
-
-const MODEL_LIBRARY = [
- {
- group: "SDXL - Best Quality",
- items: [
- {
- name: "Juggernaut XL v9 Lightning",
- filename: "Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors",
- format: "Safetensors",
- approxSize: "6.6 GB",
- resolution: "1024x1024",
- notes: "Photorealistic SDXL model tuned for 4-8 step Lightning generation.",
- url: "https://huggingface.co/RunDiffusion/Juggernaut-XL-Lightning/resolve/main/Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors",
- recommendedTiers: ["Mid", "High"],
- },
- {
- name: "DreamShaper XL Lightning",
- filename: "DreamShaperXL_Lightning.safetensors",
- format: "Safetensors",
- approxSize: "6.6 GB",
- resolution: "1024x1024",
- notes: "All-around SDXL model for fantasy art, renders, illustration, and stylized images.",
- url: "https://huggingface.co/Lykon/dreamshaper-xl-lightning/resolve/main/DreamShaperXL_Lightning.safetensors",
- recommendedTiers: ["Mid", "High"],
- },
- ],
- },
- {
- group: "SD 1.5 - Ultra Fast / Low Memory",
- items: [
- {
- name: "DreamShaper 8",
- filename: "DreamShaper_8_pruned.safetensors",
- format: "Safetensors",
- approxSize: "2.1 GB",
- resolution: "512x512",
- notes: "General-purpose SD 1.5 model for illustration, anime, and semi-realistic portraits.",
- url: "https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors",
- recommendedTiers: ["Low"],
- },
- {
- name: "CyberRealistic V8",
- filename: "CyberRealistic_V8_FP16.safetensors",
- format: "Safetensors",
- approxSize: "2.1 GB",
- resolution: "512x512",
- notes: "High-fidelity realism and photorealism in the SD 1.5 ecosystem.",
- url: "https://huggingface.co/cyberdelia/CyberRealistic/resolve/main/CyberRealistic_V8_FP16.safetensors",
- recommendedTiers: ["Low"],
- },
- {
- name: "ReV Animated v1.2.2",
- filename: "rev-animated-v1-2-2.safetensors",
- format: "Safetensors",
- approxSize: "2.1 GB",
- resolution: "512x512",
- notes: "Semi-realistic, 2.5D, anime, fantasy, and stylized digital art.",
- url: "https://huggingface.co/danbrown/RevAnimated-v1-2-2/resolve/main/rev-animated-v1-2-2.safetensors",
- recommendedTiers: ["Low"],
- },
- ],
- },
-];
-
-const TEXT_MODEL_LIBRARY = [
- {
- group: "Recommended Text Models (llama.cpp)",
- items: [
- {
- name: "Qwen2.5 Coder 0.5B Instruct Q4_K_M",
- filename: "qwen2.5-coder-0.5b-instruct-q4_k_m.gguf",
- format: "GGUF",
- approxSize: "491 MB",
- resolution: "N/A",
- notes: "Extremely fast, lightweight assistant, perfect for low RAM/VRAM machines.",
- url: "https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-0.5b-instruct-q4_k_m.gguf",
- recommendedTiers: ["Low"],
- },
- {
- name: "SmolLM2 1.7B Instruct Q4_K_M",
- filename: "smollm2-1.7b-instruct-q4_k_m.gguf",
- format: "GGUF",
- approxSize: "1.1 GB",
- resolution: "N/A",
- notes: "Excellent lightweight assistant with strong logic, reasoning, and prompt expansion capabilities.",
- url: "https://huggingface.co/HuggingFaceTB/SmolLM2-1.7B-Instruct-GGUF/resolve/main/smollm2-1.7b-instruct-q4_k_m.gguf",
- recommendedTiers: ["Low"],
- },
- {
- name: "Qwen2.5-Coder-7B-Instruct",
- filename: "qwen2.5-coder-7b-instruct-q4_k_m.gguf",
- format: "GGUF",
- size: "4.7 GB",
- approxSize: "4.7 GB",
- resolution: "N/A",
- notes: "Highly intelligent coding and text assistant. Recommended for mid/high tier systems.",
- url: "https://huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/qwen2.5-coder-7b-instruct-q4_k_m.gguf",
- recommendedTiers: ["Mid", "High"],
- },
- {
- name: "Llama-3.1-8B-Instruct",
- filename: "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
- format: "GGUF",
- size: "4.9 GB",
- approxSize: "4.9 GB",
- resolution: "N/A",
- notes: "Excellent general-purpose text model. Recommended for high-tier systems.",
- url: "https://huggingface.co/lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
- recommendedTiers: ["High"],
- },
- {
- name: "LLaVA 1.5 7B",
- filename: "ggml-model-q4_k.gguf",
- format: "GGUF",
- size: "4.5 GB",
- approxSize: "4.5 GB",
- resolution: "N/A",
- notes: "Multimodal model capable of understanding images. Downloads companion vision projector automatically.",
- url: "https://huggingface.co/mys/ggml_llava-v1.5-7b/resolve/main/ggml-model-q4_k.gguf",
- projectorUrl: "https://huggingface.co/mys/ggml_llava-v1.5-7b/resolve/main/mmproj-model-f16.gguf",
- projectorFilename: "mmproj-model-f16.gguf",
- recommendedTiers: ["Mid", "High"],
- },
- ],
- },
-];
-
-const OPENVINO_MODEL_LIBRARY = [
- {
- group: "Intel NPU - OpenVINO Test",
- items: [
- {
- id: "lcm-dreamshaper-v7-fp16",
- name: "LCM DreamShaper v7 FP16",
- filename: "lcm-dreamshaper-v7-fp16",
- format: "OpenVINO",
- backendType: "openvino-npu",
- approxSize: "2.0 GB",
- resolution: "512x512",
- notes: "NPU test model for Windows and Linux. Runs the text encoder on CPU, UNet on Intel NPU, and VAE decoder on Intel GPU or CPU fallback.",
- url: "https://huggingface.co/OpenVINO/LCM_Dreamshaper_v7-fp16-ov",
- },
- ],
- },
-];
-
-const TTS_MODEL_LIBRARY = [
- {
- group: "Kokoro Text-to-Speech Models",
- items: [
- {
- id: "kokoro-onnx-q8",
- name: "Kokoro 82M ONNX Q8",
- filename: "kokoro-onnx-q8.json",
- format: "Kokoro ONNX",
- approxSize: "Model cache on first use",
- resolution: "24 kHz WAV",
- notes: "Recommended local TTS model. Good quality and faster startup than full precision on most PCs.",
- url: "kokoro://install/kokoro-onnx-q8",
- pageUrl: "https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX",
- recommendedTiers: ["Low", "Mid", "High"],
- },
- {
- id: "kokoro-onnx-fp32",
- name: "Kokoro 82M ONNX FP32",
- filename: "kokoro-onnx-fp32.json",
- format: "Kokoro ONNX",
- approxSize: "Model cache on first use",
- resolution: "24 kHz WAV",
- notes: "Full precision Kokoro ONNX path for quality testing. Uses more memory and disk cache.",
- url: "kokoro://install/kokoro-onnx-fp32",
- pageUrl: "https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX",
- recommendedTiers: ["Mid", "High"],
- },
- ],
- },
-];
-
-const COREML_MODEL_LIBRARY = [
- {
- group: "Apple Silicon NPU - CoreML Test",
- items: [
- {
- name: "Stable Diffusion v1.5 CoreML (split_einsum, palettized 6-bit)",
- filename: "coreml-stable-diffusion-v1-5-palettized_split_einsum_v2_compiled",
- format: "CoreML",
- approxSize: "2.0 GB",
- resolution: "512x512",
- notes: "Apple Silicon ANE (Neural Engine) optimized 6-bit palettized Stable Diffusion 1.5 model. Unzips automatically on download completion and runs extremely fast on Mac NPUs.",
- url: "https://huggingface.co/apple/coreml-stable-diffusion-v1-5-palettized/resolve/main/coreml-stable-diffusion-v1-5-palettized_split_einsum_v2_compiled.zip",
- },
- {
- name: "CyberRealistic v1.5 CoreML (6-bit palettized)",
- filename: "cyberrealistic-6bit.coreml",
- format: "CoreML",
- approxSize: "815 MB",
- resolution: "512x512",
- notes: "6-bit palettized CoreML version of CyberRealistic. Extremely fast and optimized for Apple Silicon Neural Engine (ANE).",
- url: "https://huggingface.co/orailnooor/cyberrealistic-coreml/resolve/main/cyberrealistic-6bit.coreml.zip",
- },
- {
- name: "CyberRealistic v1.5 CoreML (Standard)",
- filename: "cyberrealistic.coreml",
- format: "CoreML",
- approxSize: "1.8 GB",
- resolution: "512x512",
- notes: "Standard CoreML version of CyberRealistic. Provides high-fidelity realism optimized for Apple Silicon NPUs.",
- url: "https://huggingface.co/orailnooor/cyberrealistic-coreml/resolve/main/cyberrealistic.coreml.zip",
- },
- ],
- },
-];
-
-const getHardwareTier = (specs) => {
- if (!specs) return "Low";
- const ram = Number(specs.ram_total_gb) || 0;
- const vram = Number(specs.gpu_vram_gb) || 0;
-
- if (vram >= 12 || (vram >= 8 && ram >= 32)) {
- return "High";
- } else if (vram >= 6 || ram >= 16) {
- return "Mid";
- } else {
- return "Low";
- }
-};
-
function ModelManager({
- activeModel,
- setActiveModel,
- serverRunning,
- setServerRunning,
- constraints,
- setConstraints,
- telemetry,
- backendOptions,
showAlert = async ({ message }) => window.alert(message),
showConfirm = async ({ message }) => window.confirm(message),
- activeTab,
specs,
- textSettings,
+ speechSettings,
+ setSpeechSettings,
}) {
- const [activeModelType, setActiveModelType] = useState("image"); // "image", "text", "speech", or "tts"
const [localModels, setLocalModels] = useState([]);
const [isLoadingModels, setIsLoadingModels] = useState(true);
const [downloadingModelId, setDownloadingModelId] = useState(null);
@@ -293,269 +28,43 @@ function ModelManager({
const [downloadSpeed, setDownloadSpeed] = useState("");
const [loadingModelId, setLoadingModelId] = useState(null);
const [modelLoadProgress, setModelLoadProgress] = useState(null);
- const [importProgress, setImportProgress] = useState(null); // null when not importing
+ const [importProgress, setImportProgress] = useState(null);
const [importInfo, setImportInfo] = useState({ filename: "", speed: "0.0", eta: 0, status: "" });
- const [downloadUrl, setDownloadUrl] = useState("");
const [importAbortController, setImportAbortController] = useState(null);
const [isUnloading, setIsUnloading] = useState(false);
- const [unloadProgress, setUnloadProgress] = useState({ progress: 0, phase: "" });
- const [pendingLoadModel, setPendingLoadModel] = useState(null);
- const [vramWarning, setVramWarning] = useState(null);
- const [backendInfo, setBackendInfo] = useState({ backendMode: "", backendBinary: "", backendDevice: "" });
- const [activeLlmModel, setActiveLlmModel] = useState(null);
- const [llmRunning, setLlmRunning] = useState(false);
const [activeSpeechModel, setActiveSpeechModel] = useState(null);
const [speechRunning, setSpeechRunning] = useState(false);
- const [activeTtsModel, setActiveTtsModel] = useState(null);
- const [ttsRunning, setTtsRunning] = useState(false);
- const [modelSearch, setModelSearch] = useState("");
- const [selectedFilters, setSelectedFilters] = useState([]);
- const [huggingFaceModels, setHuggingFaceModels] = useState([]);
- const [isSearchingModels, setIsSearchingModels] = useState(false);
- const [modelSearchError, setModelSearchError] = useState("");
- const [hasHuggingFaceResults, setHasHuggingFaceResults] = useState(false);
- const [modelSearchPage, setModelSearchPage] = useState(1);
- const [hasMoreModels, setHasMoreModels] = useState(false);
- const [isLoadingMoreModels, setIsLoadingMoreModels] = useState(false);
- const searchRequestRef = React.useRef(0);
- const pendingCompanionDownloadRef = React.useRef(null);
+ const [backendInfo, setBackendInfo] = useState({ speechBackendMode: "" });
- const cancelLoadRef = React.useRef(false);
-
- const handleCancelLoad = async () => {
- cancelLoadRef.current = true;
- try {
- await stopServer();
- } catch (err) {
- console.warn("Failed to stop server on cancel:", err);
- }
- };
-
- const normalizedLocalModels = activeModelType === "speech" || activeModelType === "tts"
- ? localModels.filter((model) => model.filename)
- : localModels.map(normalizeModel).filter((model) => model.filename);
- const displayedLocalModels = activeModelType === "speech" || activeModelType === "tts"
- ? normalizedLocalModels.filter((model) => model.installed)
- : normalizedLocalModels.filter((model) => !model.isProjector);
- const allModelNames = activeModelType === "speech" || activeModelType === "tts"
- ? displayedLocalModels.map((model) => model.filename)
- : normalizedLocalModels.map((model) => model.filename);
- const modelNames = displayedLocalModels.map((model) => model.filename);
+ const modelNames = localModels.filter((m) => m.installed).map((m) => m.filename);
const isBusy = loadingModelId !== null || isUnloading;
-
- const openvinoSupported = Boolean(backendOptions?.openvinoNpu?.supported);
- const appleNpuSupported = Boolean(
- backendOptions?.options?.some((opt) => opt.id === "apple-npu") ||
- backendOptions?.unavailable?.some((opt) => opt.id === "apple-npu")
- );
- const displayedHuggingFaceModels = huggingFaceModels.filter((model) => (
- selectedFilters.every((filterId) => model.tags?.includes(filterId))
- ));
-
- const getModelTypeLabel = (type) => {
- if (type === "image") return "Image";
- if (type === "text") return "Text";
- if (type === "speech") return "Speech";
- if (type === "tts") return "TTS";
- return "Model";
- };
-
- const getRuntimeForType = (type) => {
- if (type === "image" && serverRunning && activeModel) {
- return { type: "image", model: activeModel, label: getModelTypeLabel("image") };
- }
- if (type === "text" && llmRunning && activeLlmModel) {
- return { type: "text", model: activeLlmModel, label: getModelTypeLabel("text") };
- }
- if (type === "speech" && speechRunning && activeSpeechModel) {
- return { type: "speech", model: activeSpeechModel, label: getModelTypeLabel("speech") };
- }
- if (type === "tts" && ttsRunning && activeTtsModel) {
- return { type: "tts", model: activeTtsModel, label: getModelTypeLabel("tts") };
- }
- return null;
- };
-
- const getActiveHeavyRuntime = () => (
- getRuntimeForType("image") ||
- getRuntimeForType("text")
- );
-
- const blockLoadIfOtherRuntimeActive = (modelId, targetType) => {
- if (targetType !== "image" && targetType !== "text") {
- return false;
- }
-
- const runtime = getActiveHeavyRuntime();
- if (!runtime || (runtime.type === targetType && runtime.model === modelId)) {
- return false;
- }
- setPendingLoadModel({
- modelId,
- targetType,
- activeRuntime: runtime,
- });
- return true;
- };
-
- let visibleModelLibrary = [];
- if (activeModelType === "image") {
- visibleModelLibrary = [...MODEL_LIBRARY];
- if (openvinoSupported) {
- visibleModelLibrary = [...visibleModelLibrary, ...OPENVINO_MODEL_LIBRARY];
- }
- if (appleNpuSupported) {
- visibleModelLibrary = [...visibleModelLibrary, ...COREML_MODEL_LIBRARY];
- }
- } else if (activeModelType === "text") {
- visibleModelLibrary = [{
- group: "Recommended Text Models from Hugging Face",
- items: hasHuggingFaceResults ? displayedHuggingFaceModels : TEXT_MODEL_LIBRARY[0].items,
- }];
- } else if (activeModelType === "speech") {
- visibleModelLibrary = [{
- group: "Whisper Speech Models",
- items: normalizedLocalModels.filter((model) => model.url).map((model) => ({
- ...model,
- format: "Whisper.cpp",
- approxSize: model.size,
- notes: `${model.language || "Speech"} transcription model. Stored in app/speech-models/.`,
- recommendedTiers: model.recommended ? ["Low", "Mid", "High"] : [],
- })),
- }];
- } else {
- visibleModelLibrary = TTS_MODEL_LIBRARY;
- }
-
- useEffect(() => {
- if (activeModelType !== "text") return;
- const requestId = ++searchRequestRef.current;
- const timer = setTimeout(async () => {
- setIsSearchingModels(true);
- setModelSearchError("");
- try {
- const result = await searchHuggingFaceModels(modelSearch, selectedFilters, 1);
- if (requestId !== searchRequestRef.current) return;
- const matchingModels = result.models.filter((model) => (
- selectedFilters.every((filterId) => model.tags?.includes(filterId))
- ));
- setHuggingFaceModels(matchingModels);
- setHasHuggingFaceResults(true);
- setModelSearchPage(1);
- setHasMoreModels(result.hasMore);
- } catch (err) {
- if (requestId !== searchRequestRef.current) return;
- setHuggingFaceModels([]);
- setHasHuggingFaceResults(false);
- setHasMoreModels(false);
- setModelSearchError(err.message || "Could not search Hugging Face.");
- } finally {
- if (requestId === searchRequestRef.current) setIsSearchingModels(false);
- }
- }, modelSearch ? 400 : 0);
- return () => clearTimeout(timer);
- }, [activeModelType, modelSearch, selectedFilters]);
-
- const toggleModelFilter = (filterId) => {
- setSelectedFilters((current) => (
- current.includes(filterId)
- ? current.filter((id) => id !== filterId)
- : [...current, filterId]
- ));
- };
-
- const handleLoadMoreModels = async () => {
- if (isLoadingMoreModels || !hasMoreModels) return;
- const nextPage = modelSearchPage + 1;
- setIsLoadingMoreModels(true);
- setModelSearchError("");
- try {
- const [result] = await Promise.all([
- searchHuggingFaceModels(modelSearch, selectedFilters, nextPage),
- new Promise((resolve) => setTimeout(resolve, 350)),
- ]);
- const matchingModels = result.models.filter((model) => (
- selectedFilters.every((filterId) => model.tags?.includes(filterId))
- ));
- setHuggingFaceModels((current) => {
- const known = new Set(current.map((model) => model.id));
- return [...current, ...matchingModels.filter((model) => !known.has(model.id))];
- });
- setModelSearchPage(result.page);
- setHasMoreModels(result.hasMore);
- } catch (err) {
- setModelSearchError(err.message || "Could not load more Hugging Face models.");
- } finally {
- setIsLoadingMoreModels(false);
- }
- };
-
- const getLocalModelInfo = (modelId) => localModels.map(normalizeModel).find((model) => model.filename === modelId);
const fetchModels = useCallback(async () => {
setIsLoadingModels(true);
try {
- if (activeModelType === "image") {
- const list = await listLocalModels();
- setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
- } else if (activeModelType === "text") {
- const list = await listLlmModels();
- setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
- } else if (activeModelType === "speech") {
- const list = await listSpeechModels();
- setLocalModels(list.filter((model) => model.filename));
- } else {
- const list = await listTtsModels();
- setLocalModels(list.filter((model) => model.filename));
- }
+ const list = await listSpeechModels();
+ setLocalModels(list.filter((model) => model.filename));
} catch (e) {
console.error(e);
setLocalModels([]);
} finally {
setIsLoadingModels(false);
}
- }, [activeModelType]);
-
- useEffect(() => {
- if (activeTab === "models") {
- fetchModels();
- checkActiveDownload();
- }
- }, [activeTab, fetchModels]);
+ }, []);
useEffect(() => {
fetchModels();
- checkActiveDownload();
- }, [activeModelType]);
+ }, [fetchModels]);
useEffect(() => {
let cancelled = false;
- const updateBackendInfo = async () => {
+ const updateStatus = async () => {
try {
- const [sdStatus, llmStatus, speechStatus, ttsStatus] = await Promise.all([
- getBackendStatus(),
- getLlmStatus(),
- getSpeechStatus(),
- getTtsStatus()
- ]);
+ const speechStatus = await getSpeechStatus();
if (cancelled) return;
-
setBackendInfo({
- backendMode: sdStatus.settings?.backendMode || sdStatus.loading?.backendMode || "",
- backendBinary: sdStatus.settings?.backendBinary || sdStatus.loading?.backendBinary || "",
- backendDevice: sdStatus.settings?.backendDevice || sdStatus.loading?.device || "",
- llmBackendMode: llmStatus.settings?.backendMode || llmStatus.settings?.backendBinary || "",
speechBackendMode: speechStatus.settings?.backendMode || speechStatus.backendMode || "",
- ttsBackendMode: ttsStatus.settings?.backendMode || ttsStatus.backendMode || "",
});
-
- if (llmStatus.ready) {
- setActiveLlmModel(llmStatus.settings?.model || null);
- setLlmRunning(true);
- } else {
- setActiveLlmModel(null);
- setLlmRunning(false);
- }
if (speechStatus.ready) {
setActiveSpeechModel(speechStatus.settings?.model || null);
setSpeechRunning(true);
@@ -563,331 +72,64 @@ function ModelManager({
setActiveSpeechModel(null);
setSpeechRunning(false);
}
- if (ttsStatus.ready) {
- setActiveTtsModel(ttsStatus.settings?.model || null);
- setTtsRunning(true);
- } else {
- setActiveTtsModel(null);
- setTtsRunning(false);
- }
} catch (_) {}
};
-
- updateBackendInfo();
- const interval = setInterval(updateBackendInfo, 2000);
+ updateStatus();
+ const interval = setInterval(updateStatus, 2000);
return () => {
cancelled = true;
clearInterval(interval);
};
- }, [serverRunning, loadingModelId]);
+ }, []);
- const checkActiveDownload = async () => {
- try {
- const status = await getDownloadProgress();
- if (status.active && status.filename) {
- startProgressPolling(status.filename);
- }
- } catch (e) {
- console.error("Check active download failed:", e);
- }
- };
-
- const downloadByUrl = async (url, expectedFilename, companion = null, options = {}) => {
+ const downloadByUrl = async (url, expectedFilename, options = {}) => {
if (downloadingModelId) return;
-
- if (!options.skipExistingConfirm && allModelNames.includes(expectedFilename)) {
+ if (!options.skipExistingConfirm && modelNames.includes(expectedFilename)) {
if (!(await showConfirm({
title: "Overwrite Model?",
- message: `"${expectedFilename}" is already downloaded. Do you want to download and overwrite it?`,
+ message: `"${expectedFilename}" is already downloaded. Overwrite?`,
confirmLabel: "Overwrite",
- }))) {
- return;
- }
+ }))) return;
}
-
- const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
-
- if (!isTauriDesktop && !isLocalServerMode()) {
- showAlert({ title: "Server Required", message: "Model download requires the local server.", danger: true });
- return;
- }
-
try {
- pendingCompanionDownloadRef.current = companion;
- let res;
- if (activeModelType === "image") {
- res = await downloadModel(url);
- } else if (activeModelType === "text") {
- res = await downloadLlmModel(url, expectedFilename, companion);
- } else if (activeModelType === "speech") {
- res = await downloadSpeechModel(url, expectedFilename);
- } else {
- res = await downloadTtsModel(url, expectedFilename);
- }
+ const res = await downloadSpeechModel(url, expectedFilename);
if (res && res.ok) {
- if (!companion && res.projectorUrl && res.projectorFilename) {
- pendingCompanionDownloadRef.current = {
- url: res.projectorUrl,
- filename: res.projectorFilename,
- };
- }
startProgressPolling(expectedFilename);
} else {
- pendingCompanionDownloadRef.current = null;
showAlert({ title: "Download Failed", message: res.error || "Unknown error", danger: true });
}
} catch (err) {
- pendingCompanionDownloadRef.current = null;
showAlert({ title: "Download Error", message: err.message, danger: true });
}
};
const startProgressPolling = (modelId) => {
setDownloadingModelId(modelId);
-
const interval = setInterval(async () => {
try {
- const status = await getDownloadProgress();
- if (status.active) {
- setDownloadProgress(status.progress === -1 ? 0 : status.progress);
- setDownloadSpeed(`${status.speed} • ETA ${status.eta}s`);
- } else {
+ // Poll by checking model list for installation status
+ const list = await listSpeechModels();
+ const allInstalled = list.every(m => !m.downloading);
+ if (allInstalled) {
clearInterval(interval);
setDownloadingModelId(null);
- if (status.error) {
- if (!String(status.error).toLowerCase().includes("cancelled")) {
- showAlert({ title: "Download Failed", message: status.error, danger: true });
- }
- } else {
- fetchModels();
- const companion = pendingCompanionDownloadRef.current;
- pendingCompanionDownloadRef.current = null;
- if (companion?.url && companion?.filename && !allModelNames.includes(companion.filename)) {
- setTimeout(() => {
- downloadByUrl(
- companion.url,
- companion.filename,
- null,
- { skipExistingConfirm: true }
- );
- }, 500);
- }
- }
+ fetchModels();
}
} catch (e) {
console.error("Polling error:", e);
}
- }, 1000);
- };
-
- const handleUrlDownloadSubmit = async (e) => {
- e.preventDefault();
- if (!downloadUrl.trim()) return;
-
- let modelName = activeModelType === "image" ? "model.safetensors" : activeModelType === "text" ? "model.gguf" : activeModelType === "speech" ? "model.bin" : "model.json";
- try {
- const parsed = new URL(downloadUrl.trim());
- modelName = parsed.pathname.split("/").pop() || modelName;
- } catch (_) {
- showAlert({ title: "Invalid URL", message: "Please provide a valid HTTP/HTTPS link.", danger: true });
- return;
- }
-
- await downloadByUrl(downloadUrl.trim(), modelName);
- setDownloadUrl("");
+ }, 1500);
};
const handleLibraryDownload = async (model) => {
if (downloadingModelId) return;
-
- if (activeModelType === "speech") {
- const confirmed = await showConfirm({
- title: "Download Speech Model?",
- message: `Download "${model.name}" (${model.size || model.approxSize})?`,
- confirmLabel: "Download",
- });
- if (confirmed) {
- await downloadByUrl(model.url, model.filename);
- }
- return;
- }
-
- if (activeModelType === "tts") {
- const confirmed = await showConfirm({
- title: "Download TTS Model?",
- message: `Install "${model.name}"? Kokoro model files are cached on first generation.`,
- confirmLabel: "Install",
- });
- if (confirmed) {
- await downloadByUrl(model.id || model.url, model.filename);
- }
- return;
- }
-
- if (model.backendType === "openvino-npu") {
- const confirmed = await showConfirm({
- title: "Download OpenVINO Model?",
- message: `Download "${model.name}" (~${model.approxSize})? OpenVINO models are downloaded folder-by-folder via python script.`,
- confirmLabel: "Download",
- });
- if (!confirmed) return;
- try {
- await downloadOpenVinoModel(model.id);
- startProgressPolling(model.name);
- } catch (err) {
- showAlert({ title: "Download Failed", message: err.message, danger: true });
- }
- return;
- }
-
- const hasMainModel = modelNames.includes(model.filename);
- const hasProjector = !model.projectorFilename || allModelNames.includes(model.projectorFilename);
- if (hasMainModel && model.projectorUrl && model.projectorFilename && !hasProjector) {
- const confirmed = await showConfirm({
- title: "Download Vision Projector?",
- message: `"${model.name}" is downloaded, but image input needs the matching projector file (${model.projectorFilename}). Download it now?`,
- confirmLabel: "Download Projector",
- });
- if (confirmed) {
- await downloadByUrl(model.projectorUrl, model.projectorFilename, null, { skipExistingConfirm: true });
- }
- return;
- }
-
const confirmed = await showConfirm({
- title: "Download Model?",
- message: model.projectorUrl
- ? `Download "${model.name}" (~${model.approxSize}) and its required vision projector?`
- : `Download "${model.name}" (~${model.approxSize})?`,
+ title: "Download Speech Model?",
+ message: `Download "${model.name}" (${model.size || model.approxSize})?`,
confirmLabel: "Download",
});
if (confirmed) {
- await downloadByUrl(
- model.url,
- model.filename,
- model.projectorUrl && model.projectorFilename
- ? { url: model.projectorUrl, filename: model.projectorFilename }
- : null
- );
- }
- };
-
- const handleCancelDownload = async () => {
- try {
- await cancelModelDownload();
- setDownloadingModelId(null);
- setDownloadProgress(0);
- setDownloadSpeed("");
- } catch (err) {
- console.error(err);
- }
- };
-
- const checkVramAndLoad = async (modelId, options = {}) => {
- if (!options.skipActiveGuard && blockLoadIfOtherRuntimeActive(modelId, "image")) {
- return;
- }
-
- const modelInfo = getLocalModelInfo(modelId);
- if (!modelInfo) {
- await performLoadModel(modelId);
- return;
- }
-
- const isOpenVinoSelected = constraints.backendType === "openvino-npu";
- const isOpenVinoModel = modelInfo.backendType === "openvino-npu";
- const isCoreMLModel = modelInfo.format === "CoreML" || modelInfo.backendType === "apple-npu";
- const isStandardWeights = /\.(safetensors|ckpt|gguf)$/i.test(modelId) || (!isOpenVinoModel && !isCoreMLModel);
- if (isOpenVinoSelected && isStandardWeights) {
- const gpuBackend = backendOptions?.options?.find((backend) =>
- ["vulkan", "cuda", "rocm", "metal"].includes(backend.id)
- );
- const cpuBackend = backendOptions?.options?.find((backend) => backend.id === "cpu");
- const targetBackend = gpuBackend || cpuBackend;
-
- if (!targetBackend) {
- await showAlert({
- title: "Switch Backend Required",
- message: `"${modelId}" is a standard weights file. OpenVINO NPU can only load downloaded OpenVINO model folders, and no GPU or CPU backend is currently installed.`,
- danger: true,
- });
- return;
- }
-
- const confirmed = await showConfirm({
- title: "Switch Accelerator?",
- message: `"${modelId}" cannot load on OpenVINO NPU. Switch to ${targetBackend.label} and load it there?`,
- confirmLabel: `Switch to ${targetBackend.label}`,
- cancelLabel: "Cancel",
- });
- if (!confirmed) return;
-
- const nextConstraints = {
- ...constraints,
- backendType: targetBackend.id,
- useGpu: targetBackend.id !== "cpu",
- };
- setConstraints(nextConstraints);
- await performLoadModel(modelId, nextConstraints);
- return;
- }
-
- const totalSizeBytes = Number(modelInfo.sizeBytes) || 0;
- if (totalSizeBytes <= 0) {
- await performLoadModel(modelId);
- return;
- }
-
- const freeVramGb = Number(telemetry.vram_total_gb - telemetry.vram_used_gb) || 0;
- const modelSizeGb = totalSizeBytes / (1024 ** 3);
- const hasNvidiaGpu = Boolean(telemetry.gpu_name && telemetry.gpu_name.toLowerCase().includes("nvidia"));
-
- const isCpuOnlyBackend = constraints.backendType === "cpu" || constraints.useGpu === false;
-
- if (hasNvidiaGpu && !isCpuOnlyBackend && modelInfo.backendType !== "openvino-npu" && modelInfo.format !== "CoreML") {
- const safetyMarginGb = 0.8;
- if (modelSizeGb + safetyMarginGb > freeVramGb) {
- setVramWarning({
- modelId,
- modelSizeGb,
- freeVramGb,
- totalVramGb: telemetry.vram_total_gb,
- });
- return;
- }
- }
- await performLoadModel(modelId);
- };
-
- const loadTextModel = async (modelId) => {
- setLoadingModelId(modelId);
- setModelLoadProgress({
- progress: 40,
- phase: "Starting llama.cpp server...",
- speed: "",
- current: 0,
- total: 0,
- model: modelId,
- backendMode: "",
- backendBinary: "",
- device: "",
- });
- try {
- const cores = textSettings?.threads || specs?.cpu_cores_physical || 4;
- const context = textSettings?.contextSize ?? 0;
- await startLlm(modelId, {
- threads: cores,
- contextSize: context,
- gpuLayers: -1,
- preferredBackend: textSettings?.preferredBackend
- });
- setActiveLlmModel(modelId);
- setLlmRunning(true);
- } catch (err) {
- showAlert({ title: "Model Load Failed", message: err.message || String(err), danger: true });
- } finally {
- setModelLoadProgress(null);
- setLoadingModelId(null);
+ await downloadByUrl(model.url, model.filename);
}
};
@@ -896,18 +138,17 @@ function ModelManager({
setModelLoadProgress({
progress: 30,
phase: "Starting whisper.cpp speech runtime...",
- speed: "",
- current: 0,
- total: 0,
- model: modelId,
- backendMode: "",
- backendBinary: "",
- device: "",
+ speed: "", current: 0, total: 0, model: modelId,
+ backendMode: "", backendBinary: "", device: "",
});
try {
- await startSpeech(modelId, { threads: textSettings?.threads || specs?.cpu_cores_physical || 4 });
+ const threads = speechSettings?.threads || specs?.cpu_cores_physical || 4;
+ await startSpeech(modelId, { threads });
setActiveSpeechModel(modelId);
setSpeechRunning(true);
+ if (setSpeechSettings) {
+ setSpeechSettings((prev) => ({ ...prev, model: modelId }));
+ }
} catch (err) {
showAlert({ title: "Speech Model Load Failed", message: err.message || String(err), danger: true });
} finally {
@@ -916,291 +157,17 @@ function ModelManager({
}
};
- const loadTtsModel = async (modelId) => {
- setLoadingModelId(modelId);
- setModelLoadProgress({
- progress: 30,
- phase: "Starting Kokoro ONNX TTS runtime...",
- speed: "",
- current: 0,
- total: 0,
- model: modelId,
- backendMode: "",
- backendBinary: "",
- device: "",
- });
+ const handleUnloadModel = async () => {
+ if (isUnloading) return;
+ setIsUnloading(true);
try {
- await startTts(modelId);
- setActiveTtsModel(modelId);
- setTtsRunning(true);
- } catch (err) {
- showAlert({ title: "TTS Model Load Failed", message: err.message || String(err), danger: true });
- } finally {
- setModelLoadProgress(null);
- setLoadingModelId(null);
- }
- };
-
- const handleLoadModel = async (modelId) => {
- if (blockLoadIfOtherRuntimeActive(modelId, activeModelType)) {
- return;
- }
-
- if (activeModelType === "image") {
- await checkVramAndLoad(modelId);
- } else if (activeModelType === "text") {
- await loadTextModel(modelId);
- } else if (activeModelType === "speech") {
- await loadSpeechModel(modelId);
- } else {
- await loadTtsModel(modelId);
- }
- };
-
- const performLoadModel = async (modelId, forcedConstraints = null) => {
- const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
- const backendStatus = await getBackendStatus();
- const backendPort = backendStatus?.port || 8080;
-
- if (!isTauriDesktop && !isLocalServerMode()) {
- const isAlreadyRunning = await pingServer();
- const modelInfo = getLocalModelInfo(modelId);
- const isOpenVinoModel = modelInfo?.backendType === "openvino-npu";
- const cmd = isOpenVinoModel
- ? `Run the platform launcher and load "${modelId}" from Model Manager on a configured Intel NPU machine.`
- : `.\\backend\\win\\vulkan\\sd-vulkan.exe --listen-port ${backendPort} --model .\\models\\${modelId} --threads 8`;
-
- if (isAlreadyRunning) {
- if (await showConfirm({ title: "Web Browser Mode", message: `To load "${modelId}" on the active GPU server, restart your terminal backend with:\n\n${cmd}\n\nUpdate the UI status anyway?`, confirmLabel: "Update UI" })) {
- setActiveModel(modelId);
- setServerRunning(true);
- }
- } else {
- if (await showConfirm({ title: "Backend Not Running", message: `C++ backend server is not running on port ${backendPort}.\n\nTo run locally, start:\n\n${cmd}\n\nProceed in Simulation Mode instead?`, confirmLabel: "Proceed" })) {
- setActiveModel(modelId);
- setServerRunning(false);
- }
- }
- return;
- }
-
- cancelLoadRef.current = false;
- setLoadingModelId(modelId);
- setModelLoadProgress({
- progress: 0,
- phase: "Starting backend...",
- speed: "",
- current: 0,
- total: 0,
- model: modelId,
- backendMode: "",
- backendBinary: "",
- device: "",
- });
- try {
- const modelInfo = getLocalModelInfo(modelId);
- const isCoreMLModel = modelInfo?.format === "CoreML" || modelInfo?.backendType === "apple-npu";
- const activeConstraints = forcedConstraints || constraints;
- const loadConstraints = isCoreMLModel
- ? {
- ...activeConstraints,
- backendType: "apple-npu",
- useGpu: true,
- width: 512,
- height: 512,
- steps: activeConstraints.steps || 20,
- cfgScale: Number(activeConstraints.cfgScale) > 1 ? activeConstraints.cfgScale : 7,
- }
- : modelInfo?.backendType === "openvino-npu"
- ? {
- ...activeConstraints,
- backendType: "openvino-npu",
- useGpu: true,
- width: activeConstraints.width >= 1024 ? 1024 : 512,
- height: activeConstraints.height >= 1024 ? 1024 : 512,
- steps: Math.max(1, Math.min(8, activeConstraints.steps || 4)),
- cfgScale: activeConstraints.cfgScale || 1,
- }
- : activeConstraints;
-
- const response = await startServer(modelId, loadConstraints);
- console.log(response);
-
- let isReady = false;
- let crashError = null;
- let readyStatus = null;
- const isOpenVinoModel = modelInfo?.backendType === "openvino-npu";
- const maxStartupPolls = (isOpenVinoModel || isCoreMLModel) ? 1200 : 240;
- for (let i = 0; i < maxStartupPolls; i++) {
- if (cancelLoadRef.current) {
- crashError = "Model load cancelled by the user.";
- break;
- }
- const status = await getBackendStatus();
- if (status.loading) {
- setModelLoadProgress({
- progress: status.loading.progress || 0,
- phase: status.loading.phase || "Loading model...",
- speed: status.loading.speed || "",
- current: status.loading.current || 0,
- total: status.loading.total || 0,
- model: status.loading.model || modelId,
- backendMode: status.loading.backendMode || status.settings?.backendMode || "",
- backendBinary: status.loading.backendBinary || status.settings?.backendBinary || "",
- device: status.loading.device || status.settings?.backendDevice || "",
- });
- }
- if (status.ready) {
- isReady = true;
- readyStatus = status;
- break;
- }
- if (status.error) {
- crashError = status.error;
- break;
- }
- if (!status.running && !status.loading?.active && i > 3) {
- crashError = "The backend process terminated immediately on startup.";
- break;
- }
- await new Promise((r) => setTimeout(r, 500));
- }
-
- if (isReady) {
- const loadedModelId = readyStatus?.settings?.backendType === "openvino-npu"
- ? (readyStatus.settings.model || readyStatus.loading?.model || modelId)
- : modelId;
- setModelLoadProgress((prev) => ({
- ...(prev || {}),
- progress: 100,
- phase: "Model ready",
- speed: "",
- current: prev?.current || 0,
- total: prev?.total || 0,
- model: loadedModelId,
- }));
- setActiveModel(loadedModelId);
- setServerRunning(true);
- } else {
- throw new Error(crashError || `Model server failed to respond on port ${backendPort}.`);
- }
+ await stopSpeech();
+ setActiveSpeechModel(null);
+ setSpeechRunning(false);
} catch (e) {
- console.error("Failed to load model:", e);
- if (e.message !== "Model load cancelled by the user.") {
- showAlert({ title: "Model Load Failed", message: e.message || String(e), danger: true });
- }
+ showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
} finally {
- setTimeout(() => setModelLoadProgress(null), 800);
- setLoadingModelId(null);
- }
- };
-
- const handleUnloadThenLoad = async () => {
- const pendingLoad = pendingLoadModel;
- const nextModel = pendingLoad?.modelId;
- const targetType = pendingLoad?.targetType;
- setPendingLoadModel(null);
- await handleUnloadModel(pendingLoad?.activeRuntime);
- if (nextModel) {
- if (targetType === "image") {
- await checkVramAndLoad(nextModel, { skipActiveGuard: true });
- } else if (targetType === "text") {
- await loadTextModel(nextModel);
- } else if (targetType === "speech") {
- await loadSpeechModel(nextModel);
- } else if (targetType === "tts") {
- await loadTtsModel(nextModel);
- }
- }
- };
-
- const handleUnloadPendingOnly = async () => {
- const pendingLoad = pendingLoadModel;
- setPendingLoadModel(null);
- await handleUnloadModel(pendingLoad?.activeRuntime);
- };
-
- const handleUnloadModel = async (runtimeOverride = null) => {
- const runtime = runtimeOverride?.type ? runtimeOverride : getRuntimeForType(activeModelType);
- if (!runtime || isUnloading) return;
-
- if (runtime.type === "image") {
- if (isUnloading) return;
- setIsUnloading(true);
- setUnloadProgress({ progress: 10, phase: "Stopping backend process..." });
-
- const poll = setInterval(async () => {
- try {
- const status = await getBackendStatus();
- if (status.unloading?.active) {
- setUnloadProgress({
- progress: status.unloading.progress || 50,
- phase: status.unloading.phase || "Unloading model...",
- });
- } else {
- setUnloadProgress((prev) => ({
- progress: Math.min(95, prev.progress + 10),
- phase: prev.phase || "Unloading model...",
- }));
- }
- } catch (_) {}
- }, 300);
-
- try {
- await stopServer();
- setUnloadProgress({ progress: 100, phase: "Model unloaded" });
- setActiveModel(null);
- setPendingLoadModel(null);
- setServerRunning(false);
- } catch (e) {
- console.error(e);
- showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
- } finally {
- clearInterval(poll);
- setTimeout(() => {
- setIsUnloading(false);
- setUnloadProgress({ progress: 0, phase: "" });
- }, 500);
- }
- } else if (runtime.type === "text") {
- setIsUnloading(true);
- setUnloadProgress({ progress: 50, phase: "Stopping llama.cpp backend process..." });
- try {
- await stopLlm();
- setActiveLlmModel(null);
- setLlmRunning(false);
- } catch (e) {
- showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
- } finally {
- setIsUnloading(false);
- setUnloadProgress({ progress: 0, phase: "" });
- }
- } else if (runtime.type === "speech") {
- setIsUnloading(true);
- setUnloadProgress({ progress: 50, phase: "Stopping whisper.cpp speech runtime..." });
- try {
- await stopSpeech();
- setActiveSpeechModel(null);
- setSpeechRunning(false);
- } catch (e) {
- showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
- } finally {
- setIsUnloading(false);
- setUnloadProgress({ progress: 0, phase: "" });
- }
- } else {
- setIsUnloading(true);
- setUnloadProgress({ progress: 50, phase: "Stopping Kokoro TTS runtime..." });
- try {
- await stopTts();
- setActiveTtsModel(null);
- setTtsRunning(false);
- } catch (e) {
- showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
- } finally {
- setIsUnloading(false);
- setUnloadProgress({ progress: 0, phase: "" });
- }
+ setIsUnloading(false);
}
};
@@ -1208,96 +175,35 @@ function ModelManager({
if (await showConfirm({
title: "Delete Model?",
message: `Delete "${filename}" from your drive?`,
- confirmLabel: "Delete",
- danger: true,
+ confirmLabel: "Delete", danger: true,
})) {
try {
- if (activeModelType === "image") {
- await deleteModel(filename);
- if (activeModel === filename) {
- await handleUnloadModel();
- }
- } else if (activeModelType === "text") {
- await deleteLlmModel(filename);
- if (activeLlmModel === filename) {
- await handleUnloadModel();
- }
- } else if (activeModelType === "speech") {
- await deleteSpeechModel(filename);
- if (activeSpeechModel === filename) {
- await handleUnloadModel();
- }
- } else {
- await deleteTtsModel(filename);
- if (activeTtsModel === filename) {
- await handleUnloadModel();
- }
- }
+ await deleteSpeechModel(filename);
+ if (activeSpeechModel === filename) await handleUnloadModel();
await fetchModels();
} catch (err) {
- console.error("Failed to delete model:", err);
showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true });
}
}
};
- const executeImport = async (sourcePath, displayName) => {
+ const executeImport = async (file) => {
const controller = new AbortController();
setImportAbortController(controller);
setImportProgress(0);
- setImportInfo({
- filename: displayName,
- speed: "0.0",
- eta: 0,
- status: "Initiating file copy..."
- });
-
+ setImportInfo({ filename: file.name, speed: "0.0", eta: 0, status: "Copying..." });
try {
- if (activeModelType === "image") {
- await importModelFile(sourcePath, (progressData) => {
- setImportProgress(Math.round(progressData.progress));
- setImportInfo({
- filename: progressData.filename,
- speed: progressData.speed_mb_s.toFixed(1),
- eta: Math.round(progressData.eta_secs),
- status: progressData.status
- });
- }, controller.signal);
- } else if (activeModelType === "text") {
- await importLlmModel(sourcePath, (progressData) => {
- setImportProgress(Math.round(progressData.progress));
- setImportInfo({
- filename: progressData.filename,
- speed: progressData.speed_mb_s.toFixed(1),
- eta: Math.round(progressData.eta_secs),
- status: progressData.status
- });
- }, controller.signal);
- } else if (activeModelType === "speech") {
- await importSpeechModel(sourcePath, (progressData) => {
- setImportProgress(Math.round(progressData.progress));
- setImportInfo({
- filename: progressData.filename,
- speed: progressData.speed_mb_s.toFixed(1),
- eta: Math.round(progressData.eta_secs),
- status: progressData.status
- });
- }, controller.signal);
- } else {
- await importTtsModel(sourcePath, (progressData) => {
- setImportProgress(Math.round(progressData.progress));
- setImportInfo({
- filename: progressData.filename,
- speed: progressData.speed_mb_s.toFixed(1),
- eta: Math.round(progressData.eta_secs),
- status: progressData.status
- });
- }, controller.signal);
- }
-
+ await importSpeechModel(file, (progressData) => {
+ setImportProgress(Math.round(progressData.progress));
+ setImportInfo({
+ filename: progressData.filename,
+ speed: progressData.speed_mb_s.toFixed(1),
+ eta: Math.round(progressData.eta_secs),
+ status: progressData.status
+ });
+ }, controller.signal);
await fetchModels();
} catch (err) {
- console.error("Import failed:", err);
if (err.name !== "AbortError") {
showAlert({ title: "Import Failed", message: err.message || String(err), danger: true });
}
@@ -1307,126 +213,44 @@ function ModelManager({
}
};
- const handleCancelImport = () => {
- if (importAbortController) {
- importAbortController.abort();
- }
- };
-
const handleImportFile = (e) => {
const file = e.target.files[0];
- if (file) {
- executeImport(file, file.name);
- }
+ if (file) executeImport(file);
e.target.value = "";
};
- const isUrlDownloading = Boolean(downloadingModelId);
+ const handleCancelImport = () => {
+ if (importAbortController) importAbortController.abort();
+ };
+
+ const visibleModelLibrary = [{
+ group: "Whisper Speech Models",
+ items: localModels.filter((model) => model.url).map((model) => ({
+ ...model,
+ format: "Whisper.cpp",
+ approxSize: model.size,
+ notes: `${model.language || "Speech"} transcription model.`,
+ recommendedTiers: model.recommended ? ["Low", "Mid", "High"] : [],
+ })),
+ }];
return (
Model Manager
- Manage local files and download recommended weights directly to your local models folder.
+ Download and manage Whisper.cpp speech transcription models.
- {/* Tab Selector for Image vs Text */}
-
- setActiveModelType("image")}
- style={{ height: "40px", padding: "0 20px" }}
- >
- Image Models (SD)
-
- setActiveModelType("text")}
- style={{ height: "40px", padding: "0 20px" }}
- >
- Text Models (GGUF)
-
- setActiveModelType("speech")}
- style={{ height: "40px", padding: "0 20px" }}
- >
- Speech Models (Whisper)
-
- setActiveModelType("tts")}
- style={{ height: "40px", padding: "0 20px" }}
- >
- TTS Models (Kokoro)
-
-
-
-
-
- {/* Active Model Status Tonal Box */}
- {activeModelType === "image" && activeModel && (
-
-
-
-
Active Model: {activeModel}
-
- The local C++ stable-diffusion server is running. Real-time telemetry is displaying performance status in the top bar.
-
- {(backendInfo.backendMode || backendInfo.backendDevice || backendInfo.backendBinary) && (
-
- {backendInfo.backendMode && (
-
-
- Loaded on {backendInfo.backendMode}
-
- )}
- {backendInfo.backendDevice && (
-
- {backendInfo.backendDevice}
-
- )}
- {backendInfo.backendBinary && (
-
- {backendInfo.backendBinary}
-
- )}
-
- )}
-
-
-
- )}
-
- {activeModelType === "text" && activeLlmModel && (
-
-
-
-
Active Text Model: {activeLlmModel}
-
- The local C++ llama.cpp server is running. Chat requests can be sent via the Text Chat interface.
-
- {backendInfo.llmBackendMode && (
-
-
- {backendInfo.llmBackendMode}
-
-
- )}
-
-
-
- )}
-
- {activeModelType === "speech" && activeSpeechModel && (
+ {/* Active Speech Model */}
+ {activeSpeechModel && (
Active Speech Model: {activeSpeechModel}
- The local whisper.cpp runtime is ready. Speech Transcriber can process WAV recordings and uploads.
+ The local whisper.cpp runtime is ready.
{backendInfo.speechBackendMode && (
@@ -1436,89 +260,35 @@ function ModelManager({
)}
-
-
- )}
-
- {activeModelType === "tts" && activeTtsModel && (
-
-
-
-
Active TTS Model: {activeTtsModel}
-
- The local Kokoro ONNX runtime is ready. Text to Speech can generate WAV narration.
-
- {backendInfo.ttsBackendMode && (
-
-
- {backendInfo.ttsBackendMode}
-
-
- )}
-
-
-
- )}
-
- {/* Loading Progress Bar */}
- {modelLoadProgress && (
-
-
-
Loading Weights: {modelLoadProgress.model}
-
-
- Cancel Load
+
+ {isUnloading ? : }
+ {isUnloading ? "Stopping" : "Unload"}
+
+ )}
+
+ {/* Loading Progress */}
+ {modelLoadProgress && (
+
+
Loading: {modelLoadProgress.model}
-
- {modelLoadProgress.phase}
- {modelLoadProgress.speed ? ` (${modelLoadProgress.speed})` : ""}
-
-
- {modelLoadProgress.total > 0
- ? `Loaded ${modelLoadProgress.current} / ${modelLoadProgress.total} tensors`
- : `${Math.round(modelLoadProgress.progress)}%`}
-
+ {modelLoadProgress.phase}
+ {Math.round(modelLoadProgress.progress)}%
- {(modelLoadProgress.backendMode || modelLoadProgress.device) && (
-
-
- Initializing {modelLoadProgress.backendMode || "backend"}
- {modelLoadProgress.device ? ` • ${modelLoadProgress.device}` : ""}
-
- {(modelLoadProgress.backendMode === "Apple NPU" || modelLoadProgress.backendMode === "Apple Neural Engine (NPU)") && (
-
- ⚠️ Note: The first time loading this model, compilation on the Apple Neural Engine (NPU) can take 3–4 minutes. Subsequent loads will be almost instant.
-
- )}
-
- )}
-
-
- )}
-
- {/* Unloading Progress Bar */}
- {isUnloading && activeModelType === "image" && (
-
-
{unloadProgress.phase || "Unloading active model..."}
-
)}
- {/* Detected Local Models Section */}
+ {/* Local Models */}
- Local {activeModelType === "image" ? "Image" : activeModelType === "text" ? "Text" : activeModelType === "speech" ? "Speech" : "TTS"} Models ({displayedLocalModels.length})
+ Local Speech Models ({localModels.filter(m => m.installed).length})
{isLoadingModels ? (
@@ -1526,30 +296,21 @@ function ModelManager({
Scanning models folder...
- ) : displayedLocalModels.length === 0 ? (
+ ) : localModels.filter(m => m.installed).length === 0 ? (
- {activeModelType === "image"
- ? "No image models detected in app/models/. Download from the library below or import a file."
- : activeModelType === "text"
- ? "No text models detected in app/llm-models/. Download from the library below or import a file."
- : activeModelType === "speech"
- ? "No speech models detected in app/speech-models/. Download from the library below or import a .bin file."
- : "No TTS models detected in app/tts-models/. Install Kokoro from the library below or import a .json manifest."}
+ No speech models detected. Download from the library below or import a .bin file.
) : (
- {displayedLocalModels.map((model) => {
+ {localModels.filter(m => m.installed).map((model) => {
const filename = model.filename;
- const isActive = activeModelType === "image" ? activeModel === filename : activeModelType === "text" ? activeLlmModel === filename : activeModelType === "speech" ? activeSpeechModel === filename : activeTtsModel === filename;
-
+ const isActive = activeSpeechModel === filename;
return (
- {filename}
+ {model.name || filename}
- {activeModelType === "image"
- ? (model.backendType === "openvino-npu" ? "OpenVINO NPU Model" : model.format || "Local Weights File")
- : activeModelType === "text"
- ? "llama.cpp GGUF Model"
- : activeModelType === "speech"
- ? `${model.language || "Whisper"} Model`
- : "Kokoro ONNX Model"
- } • {model.size || formatBytes(model.sizeBytes)}
+ {model.language || "Whisper"} Model • {model.size || formatBytes(model.sizeBytes)}
-
{isActive ? (
-
handleUnloadModel()} disabled={isUnloading}>
+
{isUnloading ? : }
{isUnloading ? "Unloading" : "Unload"}
) : (
<>
- handleLoadModel(filename)}
- disabled={isBusy}
- >
- {loadingModelId === filename ? (
-
- ) : (
-
- )}
+ loadSpeechModel(filename)} disabled={isBusy}>
+ {loadingModelId === filename ? : }
{loadingModelId === filename ? "Loading" : "Load"}
- handleDeleteModel(filename)}
- disabled={isBusy}
- title="Delete from models folder"
- >
+ handleDeleteModel(filename)} disabled={isBusy} title="Delete model">
>
@@ -1610,118 +348,29 @@ function ModelManager({
)}
+ {/* Model Library */}
- {activeModelType === "text" ? "Hugging Face Model Library" : activeModelType === "speech" ? "Speech Model Library" : activeModelType === "tts" ? "TTS Model Library" : "Model Library"}
+ Speech Model Library
- {activeModelType === "text" && (
-
-
-
- setModelSearch(event.target.value)}
- placeholder="Search Hugging Face GGUF models..."
- aria-label="Search Hugging Face models"
- />
- {modelSearch && (
- setModelSearch("")} title="Clear search" aria-label="Clear model search">
-
-
- )}
-
-
- {MODEL_FILTERS.map((filter) => {
- const selected = selectedFilters.includes(filter.id);
- return (
- toggleModelFilter(filter.id)}
- aria-pressed={selected}
- >
- {filter.label}
- {selected && }
-
- );
- })}
- {selectedFilters.length > 0 && (
- setSelectedFilters([])}>
- Clear filters
-
- )}
-
- {isSearchingModels && (
-
-
- Loading models from Hugging Face...
-
- )}
- {modelSearchError && (
-
- {modelSearchError} Showing the built-in fallback catalog.
-
- )}
-
- )}
-
- {activeModelType === "text" && hasHuggingFaceResults && !isSearchingModels && displayedHuggingFaceModels.length === 0 && (
-
- No downloadable single-file GGUF models matched this search and filter combination.
-
- )}
{visibleModelLibrary.filter((section) => section.items.length > 0).map((section) => (
{section.group}
{section.items.map((model) => {
- const hasMainModel = modelNames.includes(model.filename);
- const hasProjector = !model.projectorFilename || allModelNames.includes(model.projectorFilename);
- const needsProjector = Boolean(hasMainModel && model.projectorFilename && !hasProjector);
- const installed = hasMainModel && hasProjector;
- const downloading = downloadingModelId === model.filename || downloadingModelId === model.projectorFilename || downloadingModelId === `${model.filename}.zip`;
- const systemTier = getHardwareTier(specs);
- const isRecommended = typeof model.recommendedFit === "boolean"
- ? model.recommendedFit
- : model.recommendedTiers && model.recommendedTiers.includes(systemTier);
- const fitLabel = model.fitLabel || "Recommended Fit";
+ const installed = modelNames.includes(model.filename);
+ const downloading = downloadingModelId === model.filename;
return (
-
+
-
- {model.name}
- {isRecommended && (
-
- [{fitLabel}]
-
- )}
-
+ {model.name}
- {model.format} {model.resolution && model.resolution !== "N/A" && `• ${model.resolution}`}
+ {model.format} • {model.approxSize}
- {model.size && model.size !== "Unknown" ? (
-
- File size: {model.size}
-
- ) : model.approxSize ? (
-
- File size: approx. {model.approxSize}
-
- ) : null}
{model.notes}
@@ -1730,19 +379,13 @@ function ModelManager({
-
- Downloading
+ Downloading
{downloadProgress > 0 ? `${downloadProgress}%` : "Preparing"}
- {downloadSpeed && (
-
- {downloadSpeed}
-
- )}
)}
{installed ? : downloading ? : }
- {installed ? "Downloaded" : downloading ? "Downloading" : needsProjector ? "Download Vision File" : "Download"}
+ {installed ? "Downloaded" : downloading ? "Downloading" : "Download"}
-
- {model.pageUrl ? "View on Hugging Face" : "Save to Downloads folder"}
-
);
})}
))}
- {activeModelType === "text" && hasHuggingFaceResults && hasMoreModels && !isSearchingModels && (
-
- {isLoadingMoreModels && }
- {isLoadingMoreModels ? "Loading more models..." : "Load More"}
-
- )}
- {/* Local Drag and Drop Importer */}
+ {/* Local Import */}
Local Model Import
- {/* Importing Progress Bar */}
+
{importProgress !== null && (
-
Copying Model to local folder: {importInfo.filename}
+ Copying: {importInfo.filename}
-
- Stop Import
+ Stop
{importInfo.status} ({importInfo.speed} MB/s)
- {importProgress}% ({importInfo.eta}s remaining)
+ {importProgress}% ({importInfo.eta}s left)
@@ -1819,154 +435,14 @@ function ModelManager({
-
+
- Choose weights file
+ Choose .bin file
- Select {activeModelType === "image" ? "`.safetensors` or `.ckpt` weights." : activeModelType === "text" ? "`.gguf` weights." : activeModelType === "speech" ? "`whisper.cpp .bin` weights." : "`Kokoro .json` manifest."}
+ Import a whisper.cpp .bin model file.
-
-
-
-
- Download Model from URL
-
-
- Download any {activeModelType === "image" ? "Safetensors" : activeModelType === "text" ? "GGUF" : activeModelType === "speech" ? "Whisper .bin" : "Kokoro manifest"} model from Hugging Face directly to your models folder.
-
- {isUrlDownloading ? (
-
-
-
- {downloadingModelId}
-
- {downloadProgress}%
-
-
-
- {downloadSpeed}
-
-
-
- Stop Download
-
-
- ) : (
-
- )}
-
-
- {pendingLoadModel && (
-
-
-
-
-
Model Already Active
-
-
- "{pendingLoadModel.activeRuntime?.model}" is already loaded as a {pendingLoadModel.activeRuntime?.label || "model"} model. Unload it before loading "{pendingLoadModel.modelId}" as a {getModelTypeLabel(pendingLoadModel.targetType)} model.
-
-
- setPendingLoadModel(null)} disabled={isUnloading}>
- Cancel
-
-
-
- Unload
-
-
- {isUnloading ? : }
- Unload and Load
-
-
-
-
- )}
-
- {vramWarning && (
-
-
-
-
-
VRAM Limit Exceeded
-
-
- The model {vramWarning.modelId} is approximately {vramWarning.modelSizeGb.toFixed(1)} GB , which exceeds your currently available GPU VRAM of {vramWarning.freeVramGb.toFixed(1)} GB free (out of {vramWarning.totalVramGb.toFixed(1)} GB total).
-
- Loading this model on your GPU may fail, crash the backend, or cause extremely slow generation due to memory spilling.
-
-
- setVramWarning(null)}
- >
- Cancel
-
- {
- const targetModel = vramWarning.modelId;
- setVramWarning(null);
- if (setConstraints) {
- setConstraints((prev) => ({
- ...prev,
- backendType: "cpu",
- useGpu: false,
- }));
- }
- const cpuConstraints = {
- ...constraints,
- backendType: "cpu",
- useGpu: false,
- };
- await performLoadModel(targetModel, cpuConstraints);
- }}
- >
- Load on CPU
-
- {
- const targetModel = vramWarning.modelId;
- setVramWarning(null);
- await performLoadModel(targetModel);
- }}
- >
- Proceed anyway (GPU)
-
-
-
-
- )}
);
}
diff --git a/app/frontend/src/components/Settings.jsx b/app/frontend/src/components/Settings.jsx
index 1f5de62..f6738d4 100644
--- a/app/frontend/src/components/Settings.jsx
+++ b/app/frontend/src/components/Settings.jsx
@@ -1,101 +1,10 @@
-import React, { memo, useEffect, useState, useCallback } from "react";
+import React, { memo, useState } from "react";
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, Volume2,
- DownloadCloud, RefreshCw
+ ChevronDown, Type, Gauge, Sparkles, Palette, Volume2, Monitor, HardDrive, MemoryStick, Thermometer,
+ Check, RotateCcw
} from "lucide-react";
-import {
- stopServer,
- formatBytes,
- getLlmBackends,
- getLlmStats,
- getLlmStatus,
- benchmarkLlm,
- startLlm,
- stopLlm,
- downloadBackend,
- getDownloadProgress,
- getBackendOptions,
-} from "../services/api";
import { THEMES } from "../themes";
-const ASPECT_RATIOS = [
- { id: "1:1", label: "1:1 Square", width: 512, height: 512, sdxl_width: 1024, sdxl_height: 1024, desc: "Social posts & avatars" },
- { id: "4:3", label: "4:3 Photo", width: 640, height: 480, sdxl_width: 1152, sdxl_height: 864, desc: "Classic photo look" },
- { id: "16:9", label: "16:9 Landscape", width: 768, height: 432, sdxl_width: 1216, sdxl_height: 684, desc: "Widescreen landscape" },
- { id: "9:16", label: "9:16 Portrait", width: 432, height: 768, sdxl_width: 684, sdxl_height: 1216, desc: "Tall phone screen" }
-];
-
-const isSD15OrCustomModel = (modelName) => {
- if (!modelName) return true;
- const name = modelName.toLowerCase();
- if (name.includes("flux") || name.includes("schnell")) return false;
- if (name.includes("sdxl") || name.includes("lightning") || name.includes("turbo")) return false;
- if (name.includes("sd3")) return false;
- return true;
-};
-
-// ─── Collapsible Card Component ───
-function CollapsibleCard({ icon: Icon, title, subtitle, children, defaultExpanded = false, id, badge, badgeColor }) {
- const [isExpanded, setIsExpanded] = useState(() => {
- const saved = localStorage.getItem(`settings_card_${id}`);
- return saved !== null ? saved === "true" : defaultExpanded;
- });
-
- const toggle = useCallback(() => {
- const newState = !isExpanded;
- setIsExpanded(newState);
- localStorage.setItem(`settings_card_${id}`, String(newState));
- }, [isExpanded, id]);
-
- return (
-
-
-
-
-
-
-
-
- {title}
- {badge && (
-
- {badge}
-
- )}
-
- {subtitle &&
{subtitle}
}
-
-
-
-
-
-
- {isExpanded && (
-
- {children}
-
- )}
-
- );
-}
-
// ─── Hardware Tier Badge Component ───
function HardwareTierBadge({ specs }) {
if (!specs?.tier) return null;
@@ -107,7 +16,6 @@ function HardwareTierBadge({ specs }) {
};
const tier = tierConfig[specs.tier] || tierConfig.low;
- const rec = specs.recommended_text_settings;
return (
@@ -122,18 +30,6 @@ function HardwareTierBadge({ specs }) {
- {rec && (
- <>
-
-
- Ctx: {rec.contextSize}
- Threads: {rec.threads}
- KV: {rec.cacheTypeK}
- Batch: {rec.batchSize}
- Profile: {rec.performanceProfile}
-
- >
- )}
);
}
@@ -216,98 +112,22 @@ const getSliderStyle = (value, min, max) => {
// ─── Main Settings Component ───
function Settings({
- constraints,
- setConstraints,
- activeModel,
specs,
- backendOptions,
- setBackendOptions,
- serverRunning,
- setServerRunning,
- setActiveModel,
- textSettings,
- setTextSettings,
speechSettings,
setSpeechSettings,
- ttsSettings,
- setTtsSettings,
showAlert = async ({ message }) => window.alert(message),
showConfirm = async ({ message }) => window.confirm(message),
- health,
- cleanupItems,
- isReadinessBusy,
- refreshReadiness,
- copyDiagnostics,
- cleanupSafeItems,
- diagnosticsCopied,
theme,
setTheme,
- fontSize,
- setFontSize,
}) {
- const [llmStatus, setLlmStatus] = useState({ ready: false, settings: {} });
- const [llmBackends, setLlmBackends] = useState({ available: [], candidates: [] });
- const [llmStats, setLlmStats] = useState({ benchmarks: [] });
- const [benchmarkBusy, setBenchmarkBusy] = useState(false);
- const [backendDownload, setBackendDownload] = useState({
- active: false,
- backendId: "",
- progress: 0,
- speed: "",
- error: null,
- });
+ const [expandedSections, setExpandedSections] = useState(() => ({
+ appearance: localStorage.getItem("settings_section_appearance") === "true",
+ speech: localStorage.getItem("settings_section_speech") === "true",
+ }));
- const [pendingTextSettings, setPendingTextSettings] = useState(textSettings || {});
-
- useEffect(() => {
- if (textSettings) {
- setPendingTextSettings(textSettings);
- }
- }, [textSettings]);
-
- const EDITABLE_TEXT_KEYS = [
- "systemPrompt", "contextSize", "temperature", "responseTokenMode",
- "maxTokens", "seed", "performanceProfile", "threads",
- "gpuLayers", "batchSize", "cacheTypeK", "cacheTypeV", "preferredBackend"
- ];
-
- const hasPendingChanges = textSettings && pendingTextSettings && EDITABLE_TEXT_KEYS.some(key => {
- return pendingTextSettings[key] !== textSettings[key];
- });
-
- const handleSaveTextSettings = async () => {
- try {
- setTextSettings(pendingTextSettings);
- if (llmStatus.ready) {
- await stopLlm();
- setLlmStatus((prev) => ({ ...prev, ready: false, running: false }));
- showAlert({
- title: "Settings Saved & Applied",
- message: "Text settings saved successfully. The running text model has been unloaded to apply these changes."
- });
- } else {
- showAlert({
- title: "Settings Saved",
- message: "Text settings saved successfully."
- });
- }
- } catch (err) {
- showAlert({
- title: "Save Failed",
- message: err.message || String(err),
- danger: true
- });
- }
- };
-
- 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",
- tts: localStorage.getItem("settings_section_tts") === "true",
- };
+ const [fontSize, setFontSize] = useState(() => {
+ const saved = localStorage.getItem("fontSize");
+ return saved ? Number(saved) : 16;
});
const toggleSection = (section) => {
@@ -318,972 +138,10 @@ function Settings({
});
};
- useEffect(() => {
- let cancelled = false;
- const fetchLlmStatus = async () => {
- try {
- const status = await getLlmStatus();
- if (!cancelled) setLlmStatus(status);
- } catch (_) {}
- try {
- const [backends, stats] = await Promise.all([getLlmBackends(), getLlmStats()]);
- if (!cancelled) {
- setLlmBackends(backends);
- setLlmStats(stats);
- }
- } catch (_) {}
- };
- fetchLlmStatus();
- const interval = setInterval(fetchLlmStatus, 3000);
- return () => {
- cancelled = true;
- clearInterval(interval);
- };
- }, []);
-
- const isSD15OrCustom = activeModel ? isSD15OrCustomModel(activeModel) : false;
- const isOpenVinoNpu = constraints.backendType === "openvino-npu";
- const supportsThinking = Boolean(llmStatus.ready && llmStatus.settings?.supportsThinking);
- const availableBackends = backendOptions?.options?.length
- ? backendOptions.options
- : [{ id: "cpu", label: "CPU", available: true }];
- const unavailableBackends = Array.isArray(backendOptions?.unavailable)
- ? backendOptions.unavailable.filter((backend) => !availableBackends.some((available) => available.id === backend.id))
- : [];
- const visibleBackends = [...availableBackends, ...unavailableBackends.map((backend) => ({ ...backend, available: false }))];
-
- const refreshBackendOptions = useCallback(async () => {
- if (typeof setBackendOptions !== "function") return;
- const nextOptions = await getBackendOptions();
- setBackendOptions(nextOptions);
- }, [setBackendOptions]);
-
- useEffect(() => {
- refreshBackendOptions().catch(() => {});
- const handleFocus = () => refreshBackendOptions().catch(() => {});
- const handleVisibility = () => {
- if (document.visibilityState === "visible") handleFocus();
- };
- window.addEventListener("focus", handleFocus);
- document.addEventListener("visibilitychange", handleVisibility);
- return () => {
- window.removeEventListener("focus", handleFocus);
- document.removeEventListener("visibilitychange", handleVisibility);
- };
- }, [refreshBackendOptions]);
-
- useEffect(() => {
- if (!backendDownload.active) return undefined;
- let cancelled = false;
-
- const poll = async () => {
- try {
- const progress = await getDownloadProgress();
- if (cancelled) return;
- setBackendDownload((prev) => ({
- ...prev,
- active: Boolean(progress.active),
- progress: Number(progress.progress ?? prev.progress ?? 0),
- speed: progress.speed || prev.speed || "",
- error: progress.error || null,
- }));
-
- if (!progress.active && !progress.error) {
- await refreshBackendOptions();
- }
- } catch (err) {
- if (!cancelled) {
- setBackendDownload((prev) => ({
- ...prev,
- active: false,
- error: err.message || "Download failed",
- }));
- }
- }
- };
-
- poll();
- const interval = setInterval(poll, 700);
- return () => {
- cancelled = true;
- clearInterval(interval);
- };
- }, [backendDownload.active, refreshBackendOptions]);
-
- const updateConstraint = (key, value) => {
- setConstraints((prev) => ({
- ...prev,
- [key]: value,
- ...(key === "steps"
- ? isOpenVinoNpu
- ? { npuSteps: value }
- : { standardSteps: value }
- : {}),
- }));
- };
-
- const updateTextSetting = (key, value) => {
- setPendingTextSettings((prev) => {
- const updated = { ...prev, [key]: value };
-
- // If the user selected a preset performance profile, apply its hardware parameters
- if (key === "performanceProfile") {
- const presets = specs?.recommended_text_settings;
- if (presets) {
- let preset = null;
- if (value === "potato") preset = presets.low;
- else if (value === "balanced") preset = presets.mid;
- else if (value === "high") preset = presets.high;
-
- if (preset) {
- Object.assign(updated, {
- contextSize: preset.contextSize,
- threads: preset.threads,
- gpuLayers: preset.gpuLayers,
- cacheTypeK: preset.cacheTypeK,
- cacheTypeV: preset.cacheTypeV,
- flashAttn: preset.flashAttn,
- mlock: preset.mlock,
- mmap: preset.mmap,
- cachePrompt: preset.cachePrompt,
- batchSize: preset.batchSize,
- ubatchSize: preset.ubatchSize,
- });
- }
- }
- } else {
- // If the user manually overrides any performance-related setting, auto-switch profile to "custom"
- const isPerformanceKey = [
- "contextSize", "threads", "gpuLayers", "cacheTypeK", "cacheTypeV",
- "flashAttn", "mlock", "mmap", "cachePrompt", "batchSize", "ubatchSize"
- ].includes(key);
-
- if (isPerformanceKey) {
- updated.performanceProfile = "custom";
- }
- }
-
- return updated;
- });
- };
-
const updateSpeechSetting = (key, value) => {
- setSpeechSettings((prev) => ({
- ...prev,
- [key]: value
- }));
+ setSpeechSettings((prev) => ({ ...prev, [key]: value }));
};
- const updateTtsSetting = (key, value) => {
- setTtsSettings((prev) => ({
- ...prev,
- [key]: value
- }));
- };
-
- const buildTextStartOptions = (settings) => ({
- threads: settings?.threads || specs?.cpu_cores_physical || 4,
- contextSize: settings?.contextSize ?? 0,
- gpuLayers: settings?.gpuLayers ?? -1,
- enableThinking: settings?.enableThinking === true,
- flashAttn: settings?.flashAttn,
- cacheTypeK: settings?.cacheTypeK,
- cacheTypeV: settings?.cacheTypeV,
- mlock: settings?.mlock,
- mmap: settings?.mmap,
- cachePrompt: settings?.cachePrompt,
- defragThold: settings?.defragThold,
- batchSize: settings?.batchSize,
- ubatchSize: settings?.ubatchSize,
- performanceProfile: settings?.performanceProfile,
- preferredBackend: settings?.preferredBackend,
- });
-
- const handleBenchmarkTextBackend = async () => {
- const model = llmStatus.settings?.model;
- if (!model) {
- showAlert({ title: "No Text Model Loaded", message: "Load a GGUF text model before benchmarking.", danger: true });
- return;
- }
- setBenchmarkBusy(true);
- try {
- const result = await benchmarkLlm(model, {
- contextSize: Math.min(2048, Number(pendingTextSettings?.contextSize) || 2048),
- gpuLayers: pendingTextSettings?.gpuLayers ?? -1,
- includeCpu: true,
- });
- const winner = result.winner;
- showAlert({
- title: winner ? "Benchmark Complete" : "Benchmark Finished",
- message: winner
- ? `Fastest backend: ${winner.backendMode} at ${Number(winner.predicted_per_second || 0).toFixed(1)} tokens/sec.`
- : "Benchmark finished, but no backend returned usable timing data.",
- });
- const [status, backends, stats] = await Promise.all([getLlmStatus(), getLlmBackends(true), getLlmStats()]);
- setLlmStatus(status);
- setLlmBackends(backends);
- setLlmStats(stats);
- } catch (err) {
- showAlert({ title: "Benchmark Failed", message: err.message || String(err), danger: true });
- } finally {
- setBenchmarkBusy(false);
- }
- };
-
- const handleThinkingToggle = async (enabled) => {
- const nextSettings = { ...pendingTextSettings, enableThinking: enabled };
-
- let status = null;
- try {
- status = await getLlmStatus();
- } catch (_) {}
- if (!status?.ready || !status?.settings?.model) {
- setPendingTextSettings(nextSettings);
- setTextSettings(nextSettings);
- return;
- }
-
- const reload = await showConfirm({
- title: enabled ? "Reload With DeepThink?" : "Reload Without DeepThink?",
- message: "Changing DeepThink requires reloading the text model before it affects new replies. Reload now, or skip and keep the currently loaded model as-is?",
- confirmLabel: "Reload",
- cancelLabel: "Skip",
- });
- if (!reload) return;
-
- try {
- setPendingTextSettings(nextSettings);
- setTextSettings(nextSettings);
- await stopLlm();
- await startLlm(status.settings.model, buildTextStartOptions(nextSettings));
- } catch (err) {
- await showAlert({
- title: "Reload Failed",
- message: err.message || String(err),
- danger: true,
- });
- }
- };
-
- const handleAspectRatioChange = (ratio, sizeType) => {
- if (isOpenVinoNpu && ratio !== "1:1") return;
- const isSDXL = sizeType === "sdxl" && !isSD15OrCustom;
- const selected = ASPECT_RATIOS.find((r) => r.id === ratio);
- if (selected) {
- let w = isSDXL ? selected.sdxl_width : selected.width;
- let h = isSDXL ? selected.sdxl_height : selected.height;
- if (isOpenVinoNpu) {
- const size = constraints.width >= 1024 ? 1024 : 512;
- w = size;
- h = size;
- } else if (isSD15OrCustom) {
- if (w > h) {
- h = Math.round((h * 512) / w);
- w = 512;
- } else {
- w = Math.round((w * 512) / h);
- h = 512;
- }
- w = Math.round(w / 64) * 64;
- h = Math.round(h / 64) * 64;
- }
- updateConstraint("width", w);
- updateConstraint("height", h);
- }
- };
-
- const handleBackendChange = async (backendType) => {
- const currentBackend = constraints.backendType || "cpu";
- if (backendType === currentBackend) return;
-
- const switchesAccelerator =
- (currentBackend === "openvino-npu" && backendType !== "openvino-npu") ||
- (currentBackend !== "openvino-npu" && backendType === "openvino-npu");
-
- if (serverRunning && switchesAccelerator) {
- const leavingNpu = currentBackend === "openvino-npu";
- const confirmed = await showConfirm({
- title: leavingNpu ? "Unload NPU Model?" : "Unload Model?",
- message: leavingNpu
- ? "The OpenVINO NPU model must be unloaded before switching to the standard backend."
- : "The active model must be unloaded before switching to the OpenVINO NPU backend.",
- confirmLabel: "Unload",
- cancelLabel: "Cancel",
- danger: true,
- });
- if (!confirmed) return;
-
- try {
- await stopServer();
- setServerRunning(false);
- setActiveModel(null);
- } catch (err) {
- await showAlert({
- title: "Unload Failed",
- message: err.message || String(err),
- danger: true,
- });
- return;
- }
- }
-
- setConstraints((prev) => ({
- ...prev,
- backendType,
- useGpu: backendType !== "cpu",
- steps: backendType === "openvino-npu"
- ? Math.max(1, Math.min(8, prev.npuSteps || 4))
- : Math.max(1, Math.min(60, prev.standardSteps || 20)),
- ...(backendType === "openvino-npu"
- ? {
- width: prev.width >= 1024 ? 1024 : 512,
- height: prev.width >= 1024 ? 1024 : 512,
- }
- : {}),
- }));
- };
-
- const handleBackendDownload = async (backend) => {
- if (!backend?.id || backendDownload.active) return;
- try {
- setBackendDownload({
- active: true,
- backendId: backend.id,
- progress: 0,
- speed: "Starting",
- error: null,
- });
- await downloadBackend(backend.id);
- } catch (err) {
- setBackendDownload({
- active: false,
- backendId: backend.id,
- progress: 0,
- speed: "",
- error: err.message || "Download failed",
- });
- await showAlert({
- title: "Backend Download Failed",
- message: err.message || "Could not start backend download.",
- danger: true,
- });
- }
- };
-
- // ─── Image Settings ───
- const renderImageSettings = () => (
- <>
-
toggleSection("image")}
- />
-
- {expandedSections.image && (
-
-
- {/* Left Column */}
-
- {/* Size & Shape */}
-
-
-
- Size & Shape
-
-
-
-
- Resolution
-
- {constraints.width >= 1024 ? "SDXL" : "SD 1.5"}
-
-
-
- {["sd15", "sdxl"].map((mode) => (
- = 1024 ? "sdxl" : "sd15") === mode ? "active" : ""}`}
- onClick={() => {
- const ratio = ASPECT_RATIOS.find(r => {
- const rw = constraints.width >= 1024 ? r.sdxl_width : r.width;
- const rh = constraints.height >= 1024 ? r.sdxl_height : r.height;
- return Math.abs(rw - constraints.width) < 10 && Math.abs(rh - constraints.height) < 10;
- })?.id || "1:1";
- handleAspectRatioChange(ratio, mode);
- }}
- disabled={isSD15OrCustom && mode === "sdxl"}
- >
- {mode === "sd15" ? "512px" : "1024px"}
-
- ))}
-
-
-
-
-
- Aspect Ratio
-
-
- {ASPECT_RATIOS.map((ratio) => {
- const isSDXL = constraints.width >= 1024 && !isSD15OrCustom;
- const rw = isSDXL ? ratio.sdxl_width : ratio.width;
- const rh = isSDXL ? ratio.sdxl_height : ratio.height;
- const isActive = Math.abs(constraints.width - rw) < 10 && Math.abs(constraints.height - rh) < 10;
- return (
-
handleAspectRatioChange(ratio.id, isSDXL ? "sdxl" : "sd15")}
- disabled={isOpenVinoNpu && ratio.id !== "1:1"}
- style={{ fontSize: "0.8rem", padding: "10px 4px", height: "auto" }}
- >
- {ratio.id}
-
- {rw}×{rh}
-
-
- );
- })}
-
-
-
-
-
-
-
- {/* Quality & Speed */}
-
-
-
- Quality & Speed
-
-
-
-
- Detail Steps
- {constraints.steps}
-
-
updateConstraint("steps", parseInt(e.target.value))}
- min="1"
- max={isOpenVinoNpu ? "8" : "60"}
- style={getSliderStyle(constraints.steps, 1, isOpenVinoNpu ? 8 : 60)}
- />
-
- {isOpenVinoNpu
- ? "LCM OpenVINO: 1-8 fast steps"
- : "More steps = sharper details, longer time"}
-
-
-
-
-
Random Seed
-
- updateConstraint("seed", parseInt(e.target.value) || -1)}
- placeholder="-1 for random"
- style={{ flex: 1 }}
- />
- updateConstraint("seed", -1)}
- >
- Random
-
-
-
-
-
-
-
- {/* Right Column */}
-
- {/* Memory Optimizations */}
-
-
-
- Memory Optimizations
-
-
-
updateConstraint("vaeTiling", v)}
- label="VAE Tiling"
- description="Process image in tiles to save VRAM"
- />
- updateConstraint("vaeOnCpu", v)}
- label="VAE on CPU"
- description="Run decoder on CPU if GPU OOM"
- />
- updateConstraint("useFlashAttn", v)}
- label="Flash Attention"
- description="Faster attention with less memory"
- />
-
-
-
- {/* Backend & Acceleration */}
-
-
-
- Backend & Acceleration
-
-
-
-
- Accelerator
-
-
- {visibleBackends.map((b) => {
- const isAvailable = b.available !== false;
- const isDownloading = backendDownload.active && backendDownload.backendId === b.id;
- return (
- isAvailable && handleBackendChange(b.id)}
- disabled={!isAvailable}
- title={!isAvailable ? b.reason : undefined}
- style={{ flex: "1 1 auto", minWidth: "80px" }}
- >
- {isDownloading ? `${Math.max(0, Math.min(100, Math.round(backendDownload.progress)))}%` : b.label}
-
- );
- })}
-
- {unavailableBackends.length > 0 && (
-
- {unavailableBackends.map((backend) => {
- const isDownloading = backendDownload.active && backendDownload.backendId === backend.id;
- const progress = Math.max(0, Math.min(100, Math.round(backendDownload.progress || 0)));
- return (
-
-
-
- {backend.label}
-
-
- {isDownloading
- ? `${progress}% ${backendDownload.speed ? `- ${backendDownload.speed}` : ""}`
- : backendDownload.error && backendDownload.backendId === backend.id
- ? backendDownload.error
- : backend.reason}
-
- {isDownloading && (
-
- )}
-
-
handleBackendDownload(backend)}
- disabled={backendDownload.active}
- style={{ flexShrink: 0 }}
- >
- {isDownloading ? : }
- {isDownloading ? "Downloading" : "Download"}
-
-
- );
- })}
-
- )}
-
-
-
-
-
-
- )}
- >
- );
-
- // ─── Text Settings ───
- const renderTextSettings = () => (
- <>
- toggleSection("text")}
- />
-
- {expandedSections.text && (
-
-
- {/* Left Column */}
-
- {/* Model & Context */}
-
-
-
- Model & Context
-
-
-
- System Prompt
-
-
-
-
- Context Size
- {pendingTextSettings.contextSize || 0}
-
-
updateTextSetting("contextSize", parseInt(e.target.value))}
- min="0"
- max="32768"
- step="512"
- style={getSliderStyle(pendingTextSettings.contextSize || 0, 0, 32768)}
- />
-
- Model memory limit. 0 uses default limit. (Recommended: 0)
-
-
-
-
-
- {/* Generation Parameters */}
-
-
-
- Generation Parameters
-
-
-
-
- Temperature
- {pendingTextSettings.temperature}
-
-
updateTextSetting("temperature", parseFloat(e.target.value))}
- min="0"
- max="2"
- step="0.1"
- style={getSliderStyle(pendingTextSettings.temperature, 0, 2)}
- />
-
- Controls creativity. Lower = focused & factual, Higher = creative & diverse. (Recommended: 0.7)
-
-
-
-
-
-
- Max Response Tokens
-
- {(pendingTextSettings.responseTokenMode || "auto") === "auto" ? "Auto" : (pendingTextSettings.maxTokens || 1024)}
-
-
-
- {[
- { id: "auto", label: "Auto" },
- { id: "manual", label: "Manual" },
- ].map((mode) => (
- updateTextSetting("responseTokenMode", mode.id)}
- >
- {mode.label}
-
- ))}
-
- {(pendingTextSettings.responseTokenMode || "auto") === "manual" ? (
- <>
-
updateTextSetting("maxTokens", parseInt(e.target.value))}
- min="64"
- max="4096"
- step="64"
- style={getSliderStyle(pendingTextSettings.maxTokens || 1024, 64, 4096)}
- />
-
- Manual uses the slider value.
-
- >
- ) : (
-
- Auto adjusts length dynamically based on context window. (Recommended)
-
- )}
-
-
-
- Seed
- updateTextSetting("seed", parseInt(e.target.value) || -1)}
- placeholder="-1"
- />
-
- Controls repeatability. Use -1 for random, positive integer for identical replies. (Recommended: -1)
-
-
-
-
-
-
- {/* Right Column */}
-
- {/* Performance Profile */}
-
-
-
- Performance Profile
-
-
-
-
- Profile
-
-
- {["potato", "balanced", "high", "custom"].map((profile) => (
- updateTextSetting("performanceProfile", profile)}
- >
- {profile.charAt(0).toUpperCase() + profile.slice(1)}
-
- ))}
-
-
-
-
-
- {llmBackends.available?.length > 1 && (
-
-
- Text Backend
-
-
- {llmBackends.available.map((backend) => (
- updateTextSetting("preferredBackend", backend.key)}
- >
- {backend.key === "cpu" ? "CPU Only" : backend.key.toUpperCase()}
-
- ))}
-
-
- )}
-
-
-
- CPU Threads
- {pendingTextSettings.threads || 4}
-
-
updateTextSetting("threads", parseInt(e.target.value))}
- min="1"
- max={specs?.cpu_cores_logical || 16}
- style={getSliderStyle(pendingTextSettings.threads || 4, 1, specs?.cpu_cores_logical || 16)}
- />
-
-
-
-
- GPU Layers
- {pendingTextSettings.gpuLayers === -1 ? "All" : pendingTextSettings.gpuLayers}
-
-
{
- const val = parseInt(e.target.value);
- updateTextSetting("gpuLayers", val >= 50 ? -1 : val);
- }}
- min="0"
- max="50"
- style={getSliderStyle(pendingTextSettings.gpuLayers === -1 ? 50 : pendingTextSettings.gpuLayers, 0, 50)}
- />
-
- 50 = All layers on GPU
-
-
-
-
-
- Batch Size
- {pendingTextSettings.batchSize || 512}
-
-
updateTextSetting("batchSize", parseInt(e.target.value))}
- min="64"
- max="2048"
- step="64"
- style={getSliderStyle(pendingTextSettings.batchSize || 512, 64, 2048)}
- />
-
-
-
-
- KV Cache
-
-
- {["q4_0", "q8_0", "f16"].map((type) => (
- {
- updateTextSetting("cacheTypeK", type);
- updateTextSetting("cacheTypeV", type);
- }}
- >
- {type.toUpperCase()}
-
- ))}
-
-
-
- {hasPendingChanges && (
-
-
-
-
-
Unsaved Text Generation Settings
-
- {llmStatus.ready
- ? "Saving will unload the active text model to apply changes."
- : "Changes will be applied on the next model load."}
-
-
-
-
- Save & Apply Changes
-
-
- )}
-
-
-
-
-
-
-
- )}
- >
- );
-
// ─── Speech Settings ───
const renderSpeechSettings = () => {
const SPEECH_LANGUAGES = [
@@ -1316,7 +174,6 @@ function Settings({
{/* Left Column */}
- {/* Speech Parameters */}
@@ -1382,7 +239,6 @@ function Settings({
{/* Right Column */}
- {/* Translation options */}
@@ -1406,94 +262,6 @@ function Settings({
};
// ─── Appearance Settings ───
- const renderTtsSettings = () => {
- const TTS_VOICES = [
- { value: "af_heart", label: "Heart - Female, US English" },
- { value: "af_bella", label: "Bella - Female, US English" },
- { value: "af_nicole", label: "Nicole - Female, US English" },
- { value: "af_sarah", label: "Sarah - Female, US English" },
- { value: "am_michael", label: "Michael - Male, US English" },
- { value: "am_fenrir", label: "Fenrir - Male, US English" },
- { value: "bf_emma", label: "Emma - Female, UK English" },
- { value: "bm_george", label: "George - Male, UK English" },
- ];
-
- return (
- <>
-
toggleSection("tts")}
- />
-
- {expandedSections.tts && (
-
-
-
-
-
-
- Voice Defaults
-
-
-
- Default Voice
- updateTtsSetting("voice", e.target.value)}
- >
- {TTS_VOICES.map((item) => (
-
- {item.label}
-
- ))}
-
-
- Default Kokoro voice for generated WAV files.
-
-
-
-
-
-
-
-
-
-
- Speech Timing
-
-
-
- Speed
- {(ttsSettings?.speed || 1).toFixed(2)}x
-
-
updateTtsSetting("speed", parseFloat(e.target.value))}
- min="0.5"
- max="2"
- step="0.05"
- style={getSliderStyle(ttsSettings?.speed || 1, 0.5, 2)}
- />
-
- 1.00x is natural speed. Lower values are slower, higher values are faster.
-
-
-
-
-
-
- )}
- >
- );
- };
-
const renderAppearanceSettings = () => (
<>
setFontSize(parseInt(e.target.value))}
+ onChange={(e) => {
+ const val = parseInt(e.target.value);
+ setFontSize(val);
+ document.documentElement.style.setProperty("--app-font-size", `${val}px`);
+ localStorage.setItem("fontSize", String(val));
+ }}
min="12"
max="24"
step="1"
style={getSliderStyle(fontSize || 16, 12, 24)}
/>
- Adjusts the global font size of the application text, including chat replies and settings labels. Default is 16px.
+ Adjusts the global font size of the application text. Default is 16px.
@@ -1616,27 +389,20 @@ function Settings({
{/* Page Header */}
-
Settings & Parameters
+
Settings
- Configure your AI models for optimal performance.
+ Configure speech transcription and appearance.
+ {/* Hardware Tier Badge */}
+ {specs &&
}
{/* Appearance & Themes Section */}
{renderAppearanceSettings()}
- {/* Image Settings Section */}
- {renderImageSettings()}
-
- {/* Text Settings Section */}
- {renderTextSettings()}
-
{/* Speech Settings Section */}
{renderSpeechSettings()}
-
- {/* Text to Speech Settings Section */}
- {renderTtsSettings()}
);
}
diff --git a/app/frontend/src/components/Sidebar.jsx b/app/frontend/src/components/Sidebar.jsx
deleted file mode 100644
index 89f898c..0000000
--- a/app/frontend/src/components/Sidebar.jsx
+++ /dev/null
@@ -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 (
-
-
- {/* Sidebar Header */}
-
-
- Local AI Studio
-
-
- {/* Sidebar Navigation Links (Material 3 style) */}
-
-
setActiveTab("generator")}
- >
-
- Image Generator
-
-
-
-
setActiveTab("chat")}
- style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
- >
-
-
- Text Chat
-
-
{
- 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 ? : }
-
-
-
- {/* Sidebar Chat History List */}
- {showHistory && (
-
- {conversations.length === 0 ? (
-
- No saved chats
-
- ) : (
- conversations.map((conv) => {
- const isActive = activeConversationId === conv.id;
- return (
-
{
- 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}
- >
-
- {conv.title}
-
- {
- 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"
- >
-
-
-
- );
- })
- )}
-
- )}
-
-
-
-
setActiveTab("speech")}
- style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
- >
-
-
- Speech Transcriber
-
-
{
- 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 ? : }
-
-
-
- {showSpeechHistory && (
-
- {speechTranscriptions.length === 0 ? (
-
- No saved transcriptions
-
- ) : (
- 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 (
-
{
- 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}
- >
-
-
- {title}
-
- {date && (
-
- {date}
-
- )}
-
-
{
- 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"
- >
-
-
-
- );
- })
- )}
-
- )}
-
-
-
-
setActiveTab("tts")}
- style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
- >
-
-
- Text to Speech
-
-
{
- 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 ? : }
-
-
-
- {showTtsHistory && (
-
- {ttsOutputs.length === 0 ? (
-
- No saved audio
-
- ) : (
- 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 (
-
{
- 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}
- >
-
-
- {title}
-
- {date && (
-
- {date}
-
- )}
-
-
{
- 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"
- >
-
-
-
- );
- })
- )}
-
- )}
-
-
-
setActiveTab("models")}
- >
-
- Model Manager
-
-
-
setActiveTab("settings")}
- >
-
- Settings
-
-
-
-
- {/* Sidebar Footer with Host Telemetry System Specs */}
-
-
-
- Host Specifications
-
-
- CPU: {specs.cpu_name}
-
-
- GPU: {specs.gpu_name}
-
-
- Memory: {specs.ram_total_gb.toFixed(0)} GB RAM ({specs.cpu_cores_physical} Cores)
-
-
- OS: {specs.os_name}
-
-
-
- );
-}
-
-export default memo(Sidebar);
diff --git a/app/frontend/src/components/SpeechTranscriber.jsx b/app/frontend/src/components/SpeechTranscriber.jsx
index facb723..41b79e9 100644
--- a/app/frontend/src/components/SpeechTranscriber.jsx
+++ b/app/frontend/src/components/SpeechTranscriber.jsx
@@ -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({
Speech Transcriber
- 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.
@@ -464,8 +510,8 @@ export default function SpeechTranscriber({
-
{audioName || "Record or upload WAV"}
-
Microphone recordings are converted to 16 kHz mono PCM16 WAV before transcription.
+
{audioName || "Record or upload audio (WAV, MP3, M4A, OGG, FLAC, WebM, etc.)"}
+
Audio files are converted to 16 kHz mono PCM16 WAV before transcription.
@@ -485,8 +531,8 @@ export default function SpeechTranscriber({
)}
- Upload WAV
-
+ Upload Audio
+
{audioBlob && (
@@ -509,7 +555,7 @@ export default function SpeechTranscriber({
disabled={!audioBlob || !selectedModel || !status.backendInstalled}
>
- Transcribe
+ Convert
)}
{isTranscribing &&
Transcribing locally...}
@@ -538,6 +584,10 @@ export default function SpeechTranscriber({
JSON
+
+
+ MD
+
{status.ready && (
stopSpeech().then(refresh)}>
diff --git a/app/frontend/src/components/TextChat.jsx b/app/frontend/src/components/TextChat.jsx
deleted file mode 100644
index ea97c0e..0000000
--- a/app/frontend/src/components/TextChat.jsx
+++ /dev/null
@@ -1,1427 +0,0 @@
-import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
-import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown, Globe2, Plus } from "lucide-react";
-import {
- getDownloadProgress,
- getLlmStatus,
- listLlmModels,
- streamChatWithLlm,
- startLlm,
- stopLlm,
-} from "../services/api";
-
-const processMessageContent = (rawText, apiReasoning = "", enableThinking = true) => {
- if (typeof rawText !== "string") {
- return { content: rawText, reasoning: apiReasoning || "" };
- }
-
- const cleanReasoningControlTags = (value) => String(value || "")
- .replace(/<\|channel\|>thought/g, "")
- .replace(/<\|channel\|>model/g, "")
- .replace(/<\|turn\|>model/g, "")
- .replace(/<\|im_start\|>model/g, "")
- .replace(/<\|think\|>|<\|thought\|>||/g, "")
- .replace(/<\|\/think\|>|<\|\/thought\|>|<\/thinking>|<\/thought>/g, "")
- .trim();
-
- const startTags = ["<|channel|>thought", "<|think|>", "<|thought|>", "", ""];
- const endTags = ["<|channel|>model", "<|turn>model", "<|im_start|>model", " ", " ", "<|/think|>", "<|/thought|>"];
-
- if (!enableThinking) {
- return { content: cleanReasoningControlTags(rawText), reasoning: "" };
- }
-
- let startIdx = -1;
- let matchedStartTag = "";
-
- for (const tag of startTags) {
- const idx = rawText.indexOf(tag);
- if (idx !== -1 && (startIdx === -1 || idx < startIdx)) {
- startIdx = idx;
- matchedStartTag = tag;
- }
- }
-
- if (startIdx === -1) {
- return { content: cleanReasoningControlTags(rawText), reasoning: "" };
- }
-
- let endIdx = -1;
- for (const tag of endTags) {
- const idx = rawText.indexOf(tag, startIdx + matchedStartTag.length);
- if (idx !== -1 && (endIdx === -1 || idx < endIdx)) {
- endIdx = idx;
- }
- }
-
- if (endIdx === -1) {
- const rawReasoning = rawText.substring(startIdx + matchedStartTag.length);
- return { content: "", reasoning: cleanReasoningControlTags(rawReasoning) };
- }
-
- const rawReasoning = rawText.substring(startIdx + matchedStartTag.length, endIdx);
- const rawContent = rawText.substring(endIdx);
-
- return {
- content: cleanReasoningControlTags(rawContent),
- reasoning: cleanReasoningControlTags(rawReasoning)
- };
-};
-
-function ChatThinkingSection({ reasoning, timeElapsed, isComplete }) {
- const [isExpanded, setIsExpanded] = useState(true);
-
- const formattedTime = timeElapsed > 0
- ? ` (${timeElapsed.toFixed(timeElapsed < 10 ? 1 : 0)}s)`
- : "";
-
- return (
-
-
setIsExpanded(!isExpanded)}
- className="chat-thinking-header"
- >
-
- {isComplete ? `Thought process${formattedTime}` : `Thinking...${formattedTime}`}
-
-
-
- {isExpanded && (
-
- {reasoning}
-
- )}
-
- );
-}
-
-const messageContainsImage = (message) => (
- Array.isArray(message?.content) &&
- message.content.some((item) => item?.type === "image_url" && item?.image_url?.url)
-);
-
-function TextChat({
- specs,
- showAlert,
- showConfirm,
- textSettings,
- setTextSettings,
- setActiveModel,
- setServerRunning,
- conversations,
- setConversations,
- activeConversationId,
- setActiveConversationId,
- showHistory,
- setShowHistory,
- saveConversationState,
- setIsLlmLoaded
-}) {
- const formatGenerationTime = (seconds) => {
- const value = Number(seconds) || 0;
- if (value < 1) return `${Math.round(value * 1000)} ms`;
- return `${value.toFixed(value < 10 ? 2 : 1)} s`;
- };
-
- const [models, setModels] = useState([]);
- const [status, setStatus] = useState({ ready: false, running: false, settings: {} });
- const [selectedModel, setSelectedModel] = useState("");
- const [messages, setMessages] = useState([]);
- const [input, setInput] = useState("");
- const [isBusy, setIsBusy] = useState(false);
- const [useWebSearch, setUseWebSearch] = useState(false);
- const [webTimeFilter, setWebTimeFilter] = useState("any");
- const [loadingModel, setLoadingModel] = useState(null);
- const [tokenUsage, setTokenUsage] = useState({
- prompt_tokens: 0,
- completion_tokens: 0,
- total_tokens: 0
- });
-
- useEffect(() => {
- if (setIsLlmLoaded) {
- setIsLlmLoaded(status.ready ? (status.settings?.model || true) : false);
- }
- }, [status.ready, status.settings?.model, setIsLlmLoaded]);
-
- const [showModelMenu, setShowModelMenu] = useState(false);
- const modelMenuRef = useRef(null);
-
- useEffect(() => {
- function handleClickOutside(event) {
- if (modelMenuRef.current && !modelMenuRef.current.contains(event.target)) {
- setShowModelMenu(false);
- }
- }
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, []);
-
- const estimateTokens = (text) => {
- const value = String(text || "").trim();
- if (!value) return 0;
- const wordCount = value.split(/\s+/).filter(Boolean).length;
- return Math.max(wordCount, Math.ceil(value.length / 4));
- };
-
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
-
- const getAutoMaxResponseTokens = (promptTokens, thinkingEnabled) => {
- const contextLimit = Number(status.settings?.contextSize || textSettings?.contextSize || 4096);
- const safetyBuffer = thinkingEnabled ? 768 : 512;
- const preferredMinimum = thinkingEnabled ? 512 : 256;
- const available = contextLimit - promptTokens - safetyBuffer;
- if (available <= 0) return 64;
- return clamp(available, Math.min(preferredMinimum, available), 4096);
- };
-
- const bottomRef = useRef(null);
- const textareaRef = useRef(null);
- const completedDownloadRef = useRef("");
- const loadingModelRef = useRef(null);
- const chatMessagesRef = useRef(null);
- const prevMessagesLengthRef = useRef(0);
- const followGenerationRef = useRef(false);
- const abortControllerRef = useRef(null);
- // rAF batching: accumulate token updates and flush once per frame
- const tokenBufferRef = useRef(null);
- const rafRef = useRef(null);
- // Debounced stats: only update stats pill every 250ms
- const statsDebounceRef = useRef(null);
-
- const [attachments, setAttachments] = useState([]);
- const fileInputRef = useRef(null);
- const supportsVision = Boolean(status.ready && status.settings?.supportsVision);
- const supportsThinking = Boolean(status.ready && status.settings?.supportsThinking);
- const deepThinkEnabled = status.ready
- ? status.settings?.enableThinking === true
- : textSettings?.enableThinking === true;
- const visionStatus = status.settings?.visionStatus || "Image input requires a matching mmproj projector file.";
-
- const resizeComposerInput = useCallback(() => {
- const textarea = textareaRef.current;
- if (!textarea) return;
- const maxHeight = 104;
- textarea.style.height = "auto";
- textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
- textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
- }, []);
-
- useLayoutEffect(() => {
- resizeComposerInput();
- }, [input, resizeComposerInput]);
-
- const isImage = (file) => {
- return /\.(jpe?g|png|webp)$/i.test(file.name) || file.type.startsWith("image/");
- };
-
- const isTextFile = (file) => {
- return /\.(txt|md|csv|js|jsx|ts|tsx|py|json|css|html|java|cpp|c|h|rs|go|sh|bat|xml|yaml|yml)$/i.test(file.name) || file.type.startsWith("text/");
- };
-
- const optimizeImageForVision = (file, maxSide = 1024, quality = 0.92) => new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onerror = () => reject(new Error(`Could not read ${file.name}`));
- reader.onload = () => {
- const originalDataUrl = reader.result;
- const img = new Image();
- img.onerror = () => reject(new Error(`Could not decode ${file.name}`));
- img.onload = () => {
- const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
- const width = Math.max(1, Math.round(img.width * scale));
- const height = Math.max(1, Math.round(img.height * scale));
- const canvas = document.createElement("canvas");
- canvas.width = width;
- canvas.height = height;
- const ctx = canvas.getContext("2d");
- ctx.drawImage(img, 0, 0, width, height);
- const sendDataUrl = canvas.toDataURL("image/jpeg", quality);
- resolve({
- previewDataUrl: originalDataUrl,
- sendDataUrl,
- originalWidth: img.width,
- originalHeight: img.height,
- width,
- height,
- });
- };
- img.src = originalDataUrl;
- };
- reader.readAsDataURL(file);
- });
-
- const handleFileChange = (e) => {
- const files = Array.from(e.target.files);
- if (!files || files.length === 0) return;
-
- files.forEach((file) => {
- if (isImage(file)) {
- optimizeImageForVision(file)
- .then((image) => {
- setAttachments((prev) => [
- ...prev,
- {
- id: Math.random().toString(36).substring(7),
- file,
- type: "image",
- name: file.name,
- dataUrl: image.previewDataUrl,
- sendDataUrl: image.sendDataUrl,
- width: image.width,
- height: image.height,
- originalWidth: image.originalWidth,
- originalHeight: image.originalHeight,
- },
- ]);
- })
- .catch((err) => {
- showAlert({ title: "Image Error", message: err.message || String(err), danger: true });
- });
- } else if (isTextFile(file)) {
- const reader = new FileReader();
- reader.onload = (event) => {
- setAttachments((prev) => [
- ...prev,
- {
- id: Math.random().toString(36).substring(7),
- file,
- type: "document",
- name: file.name,
- content: event.target.result,
- },
- ]);
- };
- reader.readAsText(file);
- } else {
- showAlert({
- title: "Unsupported File",
- message: `File "${file.name}" is not supported. Please select an image (JPEG, PNG, WEBP) or a text/code file.`,
- danger: true,
- });
- }
- });
-
- e.target.value = "";
- };
-
- useEffect(() => {
- loadingModelRef.current = loadingModel;
- }, [loadingModel]);
-
-
- const buildTextStartOptions = (settings) => ({
- threads: settings?.threads || specs?.cpu_cores_physical || 4,
- contextSize: settings?.contextSize ?? 0,
- gpuLayers: settings?.gpuLayers ?? -1,
- enableThinking: settings?.enableThinking === true,
- flashAttn: settings?.flashAttn,
- cacheTypeK: settings?.cacheTypeK,
- cacheTypeV: settings?.cacheTypeV,
- mlock: settings?.mlock,
- mmap: settings?.mmap,
- cachePrompt: settings?.cachePrompt,
- defragThold: settings?.defragThold,
- batchSize: settings?.batchSize,
- ubatchSize: settings?.ubatchSize,
- performanceProfile: settings?.performanceProfile,
- preferredBackend: settings?.preferredBackend,
- });
-
- const handleThinkingToggle = async () => {
- const enabled = !deepThinkEnabled;
- const nextSettings = { ...textSettings, enableThinking: enabled };
-
- if (!status.ready || !status.settings?.model) {
- setTextSettings(nextSettings);
- return;
- }
-
- const reload = await showConfirm({
- title: enabled ? "Reload With DeepThink?" : "Reload Without DeepThink?",
- message: "Changing DeepThink requires reloading the text model before it affects new replies. Reload now, or skip and keep the currently loaded model as-is?",
- confirmLabel: "Reload",
- cancelLabel: "Skip",
- });
- if (!reload) return;
-
- setIsBusy(true);
- try {
- setTextSettings(nextSettings);
- await stopLlm();
- const result = await startLlm(status.settings.model, buildTextStartOptions(nextSettings));
- setStatus({ ...status, ...result, ready: true, running: true, settings: result.settings });
- } catch (err) {
- showAlert({ title: "Text Model Reload Failed", message: err.message || String(err), danger: true });
- } finally {
- setIsBusy(false);
- }
- };
-
- useEffect(() => {
- if (!supportsVision) {
- setAttachments((current) => current.filter((attachment) => attachment.type !== "image"));
- }
- }, [supportsVision]);
-
- // Load conversation messages when activeConversationId changes
- useEffect(() => {
- if (isBusy) return;
- if (activeConversationId) {
- const conv = conversations.find(c => c.id === activeConversationId);
- if (conv) {
- setMessages(conv.messages);
- if (conv.model && models.some(m => m.filename === conv.model)) {
- setSelectedModel(conv.model);
- }
- const total = conv.messages.reduce((sum, m) => {
- const text = Array.isArray(m.content)
- ? m.content.map(c => c.text || "").join(" ")
- : (m.content || "");
- return sum + estimateTokens(text) + estimateTokens(m.reasoning || "");
- }, 0);
- setTokenUsage({
- prompt_tokens: Math.round(total * 0.7),
- completion_tokens: Math.round(total * 0.3),
- total_tokens: total
- });
- }
- } else {
- setMessages([]);
- setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
- }
- }, [activeConversationId, conversations, models, isBusy]);
-
- const refresh = useCallback(async () => {
- const [nextModels, nextStatus] = await Promise.all([listLlmModels(), getLlmStatus()]);
- const selectableModels = nextModels.filter((model) => !model.isProjector);
- setModels(selectableModels);
- setStatus(nextStatus);
- const active = nextStatus.settings?.model;
- setSelectedModel((current) => {
- const saved = localStorage.getItem("selectedLlmModel");
- const savedExists = selectableModels.some((m) => m.filename === saved);
- return active || current || (savedExists ? saved : "") || selectableModels[0]?.filename || "";
- });
- }, []);
-
- useEffect(() => {
- refresh().catch(() => {});
- const timer = setInterval(() => {
- getLlmStatus().then((nextStatus) => {
- setStatus(nextStatus);
- // If it suddenly loaded or became ready externally, update selection and reset loading states
- if (nextStatus.ready && nextStatus.settings?.model) {
- setSelectedModel(nextStatus.settings.model);
- setLoadingModel(null);
- }
- }).catch(() => {});
- getDownloadProgress().then((state) => {
- if (state.kind === "text" && (state.active || state.error || state.progress === 100)) {
- const completionKey = `${state.filename || ""}:${state.downloadedBytes || 0}`;
- if (!state.active && !state.error && completedDownloadRef.current !== completionKey) {
- completedDownloadRef.current = completionKey;
- refresh().catch(() => {});
- }
- }
- }).catch(() => {});
- }, 1500);
- return () => clearInterval(timer);
- }, [refresh]);
-
- const handleStopGeneration = () => {
- if (abortControllerRef.current) {
- abortControllerRef.current.abort();
- abortControllerRef.current = null;
- }
- };
-
- useEffect(() => {
- const container = chatMessagesRef.current;
- if (!container) return;
-
- const len = messages.length;
- const prevLen = prevMessagesLengthRef.current;
- prevMessagesLengthRef.current = len;
-
- const lastMessage = messages[len - 1];
- const isNewUserMessage = len > prevLen && lastMessage?.role === "user";
-
- if (isNewUserMessage || followGenerationRef.current) {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
- } else {
- const threshold = 150;
- const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
- if (isNearBottom) {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
- }
- }
- }, [messages, isBusy, loadingModel]);
-
- const handleModelChange = async (filename) => {
- if (!filename) {
- if (status.ready) {
- setIsBusy(true);
- try {
- await stopLlm();
- setStatus((prev) => ({ ...prev, ready: false, running: false }));
- setSelectedModel("");
- setMessages([]);
- setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
- } catch (err) {
- showAlert({ title: "Unload Failed", message: err.message || String(err), danger: true });
- } finally {
- setIsBusy(false);
- }
- }
- return;
- }
-
- setSelectedModel(filename);
- localStorage.setItem("selectedLlmModel", filename);
- setIsBusy(true);
- setLoadingModel(filename);
- try {
- // Unload active image engine if running
- if (setActiveModel) setActiveModel(null);
- if (setServerRunning) setServerRunning(false);
-
- const result = await startLlm(filename, {
- threads: textSettings?.threads || specs?.cpu_cores_physical || 4,
- contextSize: textSettings?.contextSize ?? 0,
- gpuLayers: textSettings?.gpuLayers ?? -1,
- enableThinking: textSettings?.enableThinking === true,
- flashAttn: textSettings?.flashAttn,
- cacheTypeK: textSettings?.cacheTypeK,
- cacheTypeV: textSettings?.cacheTypeV,
- mlock: textSettings?.mlock,
- mmap: textSettings?.mmap,
- cachePrompt: textSettings?.cachePrompt,
- defragThold: textSettings?.defragThold,
- batchSize: textSettings?.batchSize,
- ubatchSize: textSettings?.ubatchSize,
- performanceProfile: textSettings?.performanceProfile,
- preferredBackend: textSettings?.preferredBackend,
- });
- setStatus({ ...status, ...result, ready: true, running: true, settings: result.settings });
- setMessages([]);
- setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
- } catch (err) {
- if (loadingModelRef.current === filename) {
- showAlert({ title: "Text Model Load Failed", message: err.message, danger: true });
- }
- } finally {
- setLoadingModel(null);
- setIsBusy(false);
- }
- };
-
- const handleCancelLlmLoad = async () => {
- try {
- await stopLlm();
- } catch (_) {}
- setLoadingModel(null);
- setIsBusy(false);
- setSelectedModel("");
- };
-
- const handleNewChat = () => {
- setActiveConversationId(null);
- };
-
- const sendMessage = async () => {
- const text = input.trim();
- const hasAttachments = attachments.length > 0;
- if ((!text && !hasAttachments) || isBusy || !status.ready) return;
-
- let convId = activeConversationId;
- let isNew = false;
- if (!convId) {
- convId = "chat_" + Date.now();
- setActiveConversationId(convId);
- isNew = true;
- }
-
- // Delimited context blocks for document attachments
- let documentContext = "";
- attachments.filter(att => att.type === "document").forEach((att) => {
- documentContext += `\n[Attached File: ${att.name}]\n${att.content}\n`;
- });
-
- const imageAttachments = attachments.filter(att => att.type === "image");
- const conversationHasImage = messages.some(messageContainsImage);
- const requestHasImage = imageAttachments.length > 0 || conversationHasImage;
- const visionInstruction = requestHasImage
- ? "For images in this conversation: answer in natural language, describe the actual image content, and read visible text carefully when relevant. If the user asks a follow-up like \"what is this\" or \"explain\", treat it as referring to the most recent image unless they say otherwise. Do not output raw JSON, arrays, bounding boxes, OCR layout objects, UI class names, or detection labels unless the user explicitly asks for that format."
- : "";
- const requestText = imageAttachments.length > 0
- ? (text && text !== "?" ? text : "Describe what is visible in the image.")
- : text;
- const displayText = text || (imageAttachments.length > 0 ? "Describe what is visible in the image." : "");
- const requestCombinedText = [
- requestText,
- documentContext,
- ].filter(Boolean).join("\n\n").trim();
- const displayCombinedText = documentContext
- ? [displayText, documentContext].filter(Boolean).join("\n\n").trim()
- : displayText;
-
- let userMessageContent;
- let requestUserMessageContent;
- if (imageAttachments.length > 0) {
- userMessageContent = [
- {
- type: "text",
- text: displayCombinedText
- },
- ...imageAttachments.map((img) => ({
- type: "image_url",
- image_url: {
- url: img.dataUrl
- }
- }))
- ];
- requestUserMessageContent = [
- {
- type: "text",
- text: requestCombinedText
- },
- ...imageAttachments.map((img) => ({
- type: "image_url",
- image_url: {
- url: img.sendDataUrl || img.dataUrl
- }
- }))
- ];
- } else {
- userMessageContent = displayCombinedText;
- requestUserMessageContent = requestCombinedText;
- }
-
- const nextMessages = [...messages, { role: "user", content: userMessageContent }];
- const requestConversationMessages = [...messages, { role: "user", content: requestUserMessageContent }];
- followGenerationRef.current = true;
- setMessages(nextMessages);
- setInput("");
- setIsBusy(true);
-
- const displayTitleText = text || (imageAttachments.length > 0 ? "Sent Image" : "Sent File");
- const firstTitle = isNew ? (displayTitleText.slice(0, 26) + (displayTitleText.length > 26 ? "..." : "")) : null;
- saveConversationState(convId, nextMessages, selectedModel, firstTitle);
-
- const requestStartedAt = performance.now();
- setMessages([...nextMessages, {
- role: "assistant",
- content: "",
- generationStats: { status: "starting", tokens: 0, tokensPerSecond: 0, seconds: 0, vision: requestHasImage, web: useWebSearch },
- }]);
-
- const controller = new AbortController();
- abortControllerRef.current = controller;
-
- try {
- const systemPrompt = textSettings?.systemPrompt || "You are a helpful local AI assistant.";
- const combinedSystemPrompt = [
- systemPrompt.trim(),
- visionInstruction,
- ].filter(Boolean).join("\n\n");
- const requestMessages = [
- ...(combinedSystemPrompt ? [{ role: "system", content: combinedSystemPrompt }] : []),
- ...requestConversationMessages,
- ];
- const promptTokenEstimate = requestMessages.reduce((sum, message) => {
- const messageText = Array.isArray(message.content)
- ? message.content.map((item) => item.text || "").join(" ")
- : (message.content || "");
- return sum + estimateTokens(messageText);
- }, 0);
- setTokenUsage({
- prompt_tokens: promptTokenEstimate,
- completion_tokens: 0,
- total_tokens: promptTokenEstimate,
- });
-
- let assistantText = "";
- let rawAssistantText = "";
- let assistantReasoning = "";
- let streamedTokens = 0;
- let firstTokenAt = null;
- let thinkingStartedAt = null;
- let thinkingEndedAt = null;
- let thinkingDuration = 0;
-
- const thinkingEnabled = deepThinkEnabled;
- const manualMaxTokens = textSettings?.maxTokens || 1024;
- const effectiveMaxTokens = textSettings?.responseTokenMode === "manual"
- ? (thinkingEnabled ? Math.max(manualMaxTokens, 1024) : manualMaxTokens)
- : getAutoMaxResponseTokens(promptTokenEstimate, thinkingEnabled);
-
- const response = await streamChatWithLlm(requestMessages, {
- temperature: textSettings?.temperature || 0.7,
- maxTokens: effectiveMaxTokens,
- topP: textSettings?.topP,
- topK: textSettings?.topK,
- minP: textSettings?.minP,
- repeatPenalty: textSettings?.repeatPenalty,
- seed: textSettings?.seed,
- enableThinking: thinkingEnabled,
- useWeb: useWebSearch,
- timeFilter: webTimeFilter,
- signal: controller.signal,
- }, (_token, fullText, _reasoningToken, fullReasoning) => {
- const now = performance.now();
- if (streamedTokens === 0) {
- firstTokenAt = now;
- }
- streamedTokens += 1;
- const generationSeconds = firstTokenAt
- ? Math.max(0.05, (now - firstTokenAt) / 1000)
- : Math.max(0.05, (now - requestStartedAt) / 1000);
-
- rawAssistantText = fullText;
- const processed = processMessageContent(fullText, fullReasoning, deepThinkEnabled);
- assistantText = processed.content;
- assistantReasoning = processed.reasoning;
-
- // Calculate thinking duration
- if (processed.reasoning && !thinkingStartedAt) {
- thinkingStartedAt = now;
- }
- if (processed.content && thinkingStartedAt && !thinkingEndedAt) {
- thinkingEndedAt = now;
- thinkingDuration = (thinkingEndedAt - thinkingStartedAt) / 1000;
- }
- const currentThinkingDuration = thinkingEndedAt
- ? thinkingDuration
- : (thinkingStartedAt ? (now - thinkingStartedAt) / 1000 : 0);
-
- // Debounced stats update: only update stats every 250ms for smoother UI
- // while text still updates per-frame via rAF batching
- const currentStats = {
- status: "streaming",
- tokens: streamedTokens,
- tokensPerSecond: streamedTokens / generationSeconds,
- seconds: (now - requestStartedAt) / 1000,
- };
- // rAF batching: accumulate updates and flush once per frame (~16ms)
- // This reduces React re-renders from N per token to ~60/sec
- tokenBufferRef.current = {
- content: processed.content,
- reasoning: processed.reasoning,
- thinkingDuration: currentThinkingDuration,
- stats: currentStats,
- };
-
- if (!rafRef.current) {
- rafRef.current = requestAnimationFrame(() => {
- const buffer = tokenBufferRef.current;
- if (buffer) {
- setMessages((prev) => {
- const updated = [...prev];
- if (updated.length > 0 && updated[updated.length - 1].role === "assistant") {
- updated[updated.length - 1] = {
- ...updated[updated.length - 1],
- content: buffer.content,
- reasoning: buffer.reasoning,
- thinkingDuration: buffer.thinkingDuration,
- generationStats: buffer.stats,
- };
- }
- return updated;
- });
- tokenBufferRef.current = null;
- }
- rafRef.current = null;
- });
- }
- });
-
- const completedAt = performance.now();
- let finalThinkingDuration = thinkingDuration;
- if (thinkingStartedAt && !thinkingEndedAt) {
- thinkingEndedAt = completedAt;
- finalThinkingDuration = (thinkingEndedAt - thinkingStartedAt) / 1000;
- }
-
- const exactTokens = Number(response.timings?.predicted_n) || streamedTokens;
- const backendTotalMs = Number(response.timings?.prompt_ms || 0) + Number(response.timings?.predicted_ms || 0);
- const exactSeconds = backendTotalMs > 0
- ? backendTotalMs / 1000
- : (completedAt - requestStartedAt) / 1000;
- const exactTokensPerSecond = Number(response.timings?.predicted_per_second)
- || (exactTokens / Math.max(0.001, exactSeconds));
- const generationStats = {
- status: "complete",
- tokens: exactTokens,
- tokensPerSecond: exactTokensPerSecond,
- seconds: exactSeconds,
- finishReason: response.finishReason || null,
- truncated: response.finishReason === "length",
- };
-
- const processed = processMessageContent(response.content || rawAssistantText || assistantText, response.reasoningContent || assistantReasoning, deepThinkEnabled);
- const finalMessages = [...nextMessages, {
- role: "assistant",
- content: processed.content,
- reasoning: processed.reasoning,
- thinkingDuration: finalThinkingDuration,
- generationStats,
- webSources: response.webSources || [],
- }];
- setMessages(finalMessages);
- saveConversationState(convId, finalMessages, selectedModel);
- const finalCompletionEstimate = Math.max(
- exactTokens,
- estimateTokens(processed.content) + estimateTokens(processed.reasoning)
- );
- const finalUsageEstimate = {
- prompt_tokens: promptTokenEstimate,
- completion_tokens: finalCompletionEstimate,
- total_tokens: promptTokenEstimate + finalCompletionEstimate,
- };
- setTokenUsage(response.usage
- ? {
- prompt_tokens: Math.max(Number(response.usage.prompt_tokens) || 0, finalUsageEstimate.prompt_tokens),
- completion_tokens: Math.max(Number(response.usage.completion_tokens) || 0, finalUsageEstimate.completion_tokens),
- total_tokens: Math.max(Number(response.usage.total_tokens) || 0, finalUsageEstimate.total_tokens),
- }
- : finalUsageEstimate
- );
-
- // Clean up attached files on success
- setAttachments([]);
- } catch (err) {
- if (err.name === "AbortError") {
- setMessages((prev) => {
- const updated = [...prev];
- if (updated.length > 0 && updated[updated.length - 1].role === "assistant") {
- const lastMsg = updated[updated.length - 1];
- updated[updated.length - 1] = {
- ...lastMsg,
- generationStats: lastMsg.generationStats ? {
- ...lastMsg.generationStats,
- status: "complete",
- } : null
- };
- saveConversationState(convId, updated, selectedModel);
- }
- return updated;
- });
- } else {
- const finalMessages = [...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }];
- setMessages(finalMessages);
- saveConversationState(convId, finalMessages, selectedModel);
- }
- } finally {
- setIsBusy(false);
- abortControllerRef.current = null;
- setTimeout(() => {
- followGenerationRef.current = false;
- }, 250);
- }
- };
-
- const handleClearChat = () => {
- setMessages([]);
- setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
- if (activeConversationId) {
- saveConversationState(activeConversationId, [], selectedModel);
- }
- };
-
- return (
-
-
- {/* ─── Header ─────────────────────────────────────────── */}
-
-
-
setShowHistory(!showHistory)}
- className="m3-btn m3-btn-tonal"
- style={{
- height: "34px", width: "34px", padding: 0,
- display: "flex", alignItems: "center", justifyContent: "center",
- borderRadius: "var(--md-shape-corner-medium)", cursor: "pointer",
- background: showHistory ? "var(--md-sys-color-primary-container)" : "var(--md-sys-color-surface-variant)",
- color: showHistory ? "var(--md-sys-color-on-primary-container)" : "var(--md-sys-color-on-surface)",
- border: "1px solid var(--border-color)", flexShrink: 0
- }}
- title="Toggle Chat History"
- >
-
-
-
-
-
-
-
-
-
!isBusy && setShowModelMenu(!showModelMenu)}
- className={`chat-model-select-trigger ${showModelMenu ? "active" : ""}`}
- disabled={isBusy}
- title="Select GGUF Model"
- >
-
- {selectedModel ? selectedModel : "No model loaded (Select GGUF)"}
-
-
-
-
- {showModelMenu && (
-
- {
- handleModelChange("");
- setShowModelMenu(false);
- }}
- >
- No model loaded (Select GGUF)
-
- {models.map((m) => {
- const isActive = m.filename === status.settings?.model && status.ready;
- const isSelected = m.filename === selectedModel;
- return (
- {
- handleModelChange(m.filename);
- setShowModelMenu(false);
- }}
- title={m.filename}
- >
- {m.filename}
- {isActive && Active }
-
- );
- })}
-
- )}
-
- {selectedModel && (!status.ready || status.settings?.model !== selectedModel) && !loadingModel && (
-
handleModelChange(selectedModel)}
- disabled={isBusy}
- style={{
- height: "38px", padding: "0 16px", fontSize: "0.85rem",
- borderRadius: "var(--md-shape-corner-medium)",
- background: "var(--md-sys-color-primary)",
- color: "var(--md-sys-color-on-primary)",
- cursor: "pointer", border: "none", fontWeight: "600",
- display: "flex", alignItems: "center", gap: "6px"
- }}
- >
- Load Model
-
- )}
-
-
-
- {/* Context gauge */}
- {(() => {
- const maxTokens = status.settings?.contextSize || 4096;
- const used = tokenUsage.total_tokens || 0;
- const percent = Math.min(100, Math.round((used / maxTokens) * 100));
- return (
-
-
-
-
-
-
-
- {percent}%
-
-
-
- Context
- {used} / {maxTokens}
-
-
- );
- })()}
-
-
-
- Clear
-
-
-
-
- {/* ─── Messages area ──────────────────────────────────── */}
- {
- if (isBusy) followGenerationRef.current = false;
- }}
- onTouchMove={() => {
- if (isBusy) followGenerationRef.current = false;
- }}
- >
- {loadingModel ? (
-
-
-
Loading Text Model
-
- {loadingModel}
-
-
- Initializing llama.cpp server and loading the model weights into memory. This can take up to 30 seconds depending on model size and hardware speed.
-
-
-
- Cancel Load
-
-
- ) : (
- <>
- {messages.length === 0 && (
-
-
-
-
-
Local AI Chat
-
Your private, offline AI assistant. Choose a GGUF model above and start a conversation — everything stays on your machine.
- {status.ready && (
-
- {[
- { icon: "✍️", text: "Write a professional email to reschedule a meeting" },
- { icon: "💡", text: "Explain how transformers work in simple terms" },
- { icon: "🐛", text: "Help me debug this Python code" },
- { icon: "📋", text: "Summarize the key points of a topic" },
- ].map((s, i) => (
- { setInput(s.text); }}
- >
- {s.icon}
- {s.text}
-
- ))}
-
- )}
-
- )}
-
- {messages.map((message, index) => {
- const processed = processMessageContent(
- Array.isArray(message.content) ? "" : (message.content || ""),
- message.reasoning || "",
- deepThinkEnabled
- );
- const displayContent = Array.isArray(message.content) ? message.content : processed.content;
- const displayReasoning = processed.reasoning;
- const hasDisplayContent = Array.isArray(displayContent)
- ? displayContent.length > 0
- : Boolean(String(displayContent || "").trim());
-
- return (
-
- {/* Avatar */}
-
- {message.role === "user" ? "You" : "AI"}
-
-
- {/* Bubble + stats */}
-
-
- {message.role === "user" ? "You" : "Local AI"}
-
- {message.role === "assistant" && displayReasoning && deepThinkEnabled && (
-
- )}
- {(hasDisplayContent || message.error) && (
-
- {Array.isArray(displayContent) ? (
-
- {displayContent.map((item, idx) => {
- if (item.type === "text") return
;
- if (item.type === "image_url") return (
-
- );
- return null;
- })}
-
- ) : (
-
- )}
-
- )}
- {message.role === "assistant" && Array.isArray(message.webSources) && message.webSources.length > 0 && (
-
- )}
-
- {/* Generation stats pill */}
- {message.role === "assistant" && message.generationStats && !message.error && (
- <>
- {message.generationStats.truncated && (
-
- Response reached the token limit. Ask "continue" or switch Max Response Tokens to Manual for a larger cap.
-
- )}
-
- {message.generationStats.status === "starting" ? (
- <> {message.generationStats.web ? "Searching web..." : message.generationStats.vision ? "Processing image..." : "Waiting for first token..."}>
- ) : message.generationStats.status === "streaming" ? (
- <>⚡ {message.generationStats.tokensPerSecond.toFixed(1)} tok/s>
- ) : (
- <>{message.generationStats.tokens} tokens • {formatGenerationTime(message.generationStats.seconds)}>
- )}
-
- >
- )}
-
-
- );
- })}
- >
- )}
-
-
-
- {/* ─── Composer ───────────────────────────────────────── */}
-
- {/* Attachment previews */}
- {attachments.length > 0 && (
-
- {attachments.map((att) => (
-
- {att.type === "image" ? (
-
- ) : (
-
📄
- )}
-
- {att.name}
-
-
setAttachments(prev => prev.filter(item => item.id !== att.id))}
- style={{
- position: "absolute", right: "4px", top: "50%", transform: "translateY(-50%)",
- background: "none", border: "none", color: "var(--md-sys-color-error)",
- cursor: "pointer", padding: "2px", display: "flex", alignItems: "center", justifyContent: "center"
- }}
- >
-
-
-
- ))}
-
- )}
-
- {/* Input container with vertical layout */}
-
-
-
-
-
-
-
-
fileInputRef.current?.click()}
- disabled={!supportsVision || isBusy}
- title={supportsVision ? "Attach files or images" : visionStatus}
- >
-
-
-
setUseWebSearch((value) => !value)}
- disabled={!status.ready || isBusy}
- title={useWebSearch ? "Disable web search" : "Enable web search"}
- >
-
- Web
-
-
- {status.ready && supportsThinking && (
-
-
-
-
-
-
- DeepThink
-
- )}
-
-
-
- {isBusy && status.ready ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
-
-
Enter to send · Shift+Enter for new line
-
-
-
- );
-}
-function parseInlineMarkdown(text) {
- const regex = /(\*\*.*?\*\*|`.*?`|\*.*?\*|\[.*?\]\(.*?\))/g;
- const parts = text.split(regex);
-
- return parts.map((part, idx) => {
- if (part.startsWith("**") && part.endsWith("**")) {
- return {part.slice(2, -2)} ;
- }
- if (part.startsWith("`") && part.endsWith("`")) {
- return {part.slice(1, -1)};
- }
- if (part.startsWith("*") && part.endsWith("*")) {
- return {part.slice(1, -1)} ;
- }
- if (part.startsWith("[") && part.includes("](") && part.endsWith(")")) {
- const match = part.match(/\[(.*?)\]\((.*?)\)/);
- if (match) {
- return (
-
- {match[1]}
-
- );
- }
- }
- return part;
- });
-}
-
-export function MarkdownRenderer({ content }) {
- if (typeof content !== 'string') return null;
-
- const parts = content.split(/(```[\s\S]*?```)/g);
-
- return (
-
- {parts.map((part, index) => {
- if (part.startsWith("```") && part.endsWith("```")) {
- const match = part.match(/```(\w*)\n([\s\S]*?)```/);
- const lang = match ? match[1] : "";
- const code = match ? match[2] : part.slice(3, -3);
- return (
-
- {lang && {lang}
}
- {code.trim()}
-
- );
- } else {
- const rawLines = part.split(/\r?\n/);
- const blocks = [];
- let currentBlock = null;
-
- for (let i = 0; i < rawLines.length; i++) {
- const line = rawLines[i];
- const trimmed = line.trim();
-
- if (trimmed.startsWith("### ")) {
- if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
- blocks.push({ type: "h4", content: trimmed.slice(4) });
- } else if (trimmed.startsWith("## ")) {
- if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
- blocks.push({ type: "h3", content: trimmed.slice(3) });
- } else if (trimmed.startsWith("# ")) {
- if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
- blocks.push({ type: "h2", content: trimmed.slice(2) });
- } else if (trimmed.startsWith("* ") || trimmed.startsWith("- ")) {
- const itemContent = trimmed.slice(2);
- if (currentBlock && currentBlock.type === "ul") {
- currentBlock.items.push(itemContent);
- } else {
- if (currentBlock) { blocks.push(currentBlock); }
- currentBlock = { type: "ul", items: [itemContent] };
- }
- } else {
- const numMatch = trimmed.match(/^(\d+)\.\s+(.*)/);
- if (numMatch) {
- const itemContent = numMatch[2];
- if (currentBlock && currentBlock.type === "ol") {
- currentBlock.items.push(itemContent);
- } else {
- if (currentBlock) { blocks.push(currentBlock); }
- currentBlock = { type: "ol", items: [itemContent] };
- }
- } else if (trimmed === "") {
- if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
- blocks.push({ type: "spacer" });
- } else {
- if (currentBlock && currentBlock.type === "p") {
- currentBlock.lines.push(line);
- } else {
- if (currentBlock) { blocks.push(currentBlock); }
- currentBlock = { type: "p", lines: [line] };
- }
- }
- }
- }
- if (currentBlock) {
- blocks.push(currentBlock);
- }
-
- return blocks.map((block, blockIdx) => {
- switch (block.type) {
- case "h4":
- return
{parseInlineMarkdown(block.content)} ;
- case "h3":
- return
{parseInlineMarkdown(block.content)} ;
- case "h2":
- return
{parseInlineMarkdown(block.content)} ;
- case "ul":
- return (
-
- {block.items.map((item, itemIdx) => (
- {parseInlineMarkdown(item)}
- ))}
-
- );
- case "ol":
- return (
-
- {block.items.map((item, itemIdx) => (
- {parseInlineMarkdown(item)}
- ))}
-
- );
- case "spacer":
- return
;
- case "p":
- return
{parseInlineMarkdown(block.lines.join(" "))}
;
- default:
- return null;
- }
- });
- }
- })}
-
- );
-}
-
-export default TextChat;
diff --git a/app/frontend/src/components/TextToSpeech.jsx b/app/frontend/src/components/TextToSpeech.jsx
deleted file mode 100644
index a621eda..0000000
--- a/app/frontend/src/components/TextToSpeech.jsx
+++ /dev/null
@@ -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 (
-
-
-
Text to Speech
-
- Generate local WAV narration with Kokoro ONNX.
-
-
-
-
-
-
-
Runtime
-
- {status.runtimeInstalled ? (status.ready ? "Ready" : "Installed") : "Runtime missing"}
-
-
-
- {status.error && {status.error}
}
-
-
- Kokoro model
- setSelectedModel(event.target.value)}>
- {installedModels.length === 0 && No downloaded TTS models }
- {installedModels.map((model) => (
-
- {model.name || model.filename}
-
- ))}
-
-
-
-
-
- Voice
- updateTtsSetting("voice", event.target.value)}>
- {voices.map((voice) => (
-
- {voice.name} ({voice.gender}, {voice.language})
-
- ))}
-
-
-
- Speed
- updateTtsSetting("speed", Math.max(0.5, Math.min(2, Number(event.target.value) || 1)))}
- />
-
-
-
-
-
- {isLoadingModel ? : }
- {isLoaded ? "Loaded" : isLoadingModel ? "Loading" : "Load"}
-
-
-
- Refresh
-
- {status.ready && (
-
-
- Stop Runtime
-
- )}
-
-
- {installedModels.length === 0 && (
-
- Download a Kokoro model from Model Manager, TTS Models first.
-
- )}
-
-
-
-
-
Input
- {text.trim().length} chars
-
-
-
-
-
-
-
Output
-
- output?.url && downloadUrl(output.url, output.audioFile || "tts.wav")} disabled={!output?.url}>
-
- WAV
-
-
-
- {output?.url ? (
-
-
-
- {output.voiceName || output.voice} • {output.modelName || output.model} • {new Date(output.createdAt).toLocaleString()}
-
-
- ) : (
-
-
-
Generated speech will appear here.
-
- )}
-
-
- );
-}
diff --git a/app/frontend/src/components/TopStatusBar.jsx b/app/frontend/src/components/TopStatusBar.jsx
deleted file mode 100644
index 3e424af..0000000
--- a/app/frontend/src/components/TopStatusBar.jsx
+++ /dev/null
@@ -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 (
-
-
-
- {sidebarVisible ? : }
-
-
-
-
-
- {getStatusText()}
-
- {(activeModel || (typeof isLlmLoaded === "string" && isLlmLoaded)) && (
- <>
-
|
-
- {truncateModelName(activeModel || isLlmLoaded)}
-
- >
- )}
-
-
-
-
-
setTheme(isDark ? "light" : "dark")}
- title={`Switch to ${isDark ? "light" : "dark"} theme`}
- >
- {isDark ? : }
-
-
-
-
setShowThemeMenu(!showThemeMenu)}
- title="Choose a custom color theme"
- style={{ marginRight: "12px" }}
- >
-
-
-
- {showThemeMenu && (
-
-
Select Theme
-
- {THEMES.map((t) => {
- const isActive = theme === t.id;
- return (
-
{
- setTheme(t.id);
- setShowThemeMenu(false);
- }}
- >
-
-
-
{t.name}
- {isActive &&
}
-
-
- );
- })}
-
- )}
-
-
- {serverRunning && (
-
- {isStoppingServer ? : }
- {isStoppingServer ? "Stopping" : "Stop Server"}
-
- )}
-
- {/* CPU Telemetry Chip */}
-
- CPU: {Number.isFinite(Number(telemetry.cpu_usage)) ? telemetry.cpu_usage : "--"}%
-
-
- {/* RAM Telemetry Chip */}
-
- RAM: {formatGb(telemetry.ram_used_gb)} / {formatGb(telemetry.ram_total_gb)} GB
-
-
- {/* GPU VRAM Telemetry Chip */}
- {telemetry.vram_total_gb > 0 && (
-
- VRAM: {formatGb(telemetry.vram_used_gb, { allowZero: true })} / {formatGb(telemetry.vram_total_gb)} GB
-
- )}
-
-
- );
-}
-
-export default memo(TopStatusBar);
diff --git a/app/frontend/src/services/api.js b/app/frontend/src/services/api.js
index 8598305..c0b35a9 100644
--- a/app/frontend/src/services/api.js
+++ b/app/frontend/src/services/api.js
@@ -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", {
diff --git a/scripts/server/serve.cjs b/scripts/server/serve.cjs
index 20200a3..ecfc4ae 100644
--- a/scripts/server/serve.cjs
+++ b/scripts/server/serve.cjs
@@ -2425,7 +2425,7 @@ async function stopSpeech() {
speechTranscriptionState = { active: false, phase: "", progress: 0, model: "", filename: "" };
}
-function readBinaryBody(req, limitBytes = 250 * 1024 * 1024) {
+function readBinaryBody(req, limitBytes = 1024 * 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let bytes = 0;
@@ -2449,6 +2449,24 @@ function isWaveBuffer(buffer) {
buffer.toString("ascii", 8, 12) === "WAVE";
}
+function isAudioFile(buffer) {
+ try {
+ if (!buffer || buffer.length < 12) return false;
+ if (isWaveBuffer(buffer)) return true;
+ const magic = buffer.toString("ascii", 0, 3);
+ if (magic === "ID3") return true; // MP3 with ID3 tag
+ const ftyp = buffer.toString("ascii", 4, 8);
+ if (ftyp === "ftyp") return true; // MP4/M4A container
+ const ogg = buffer.toString("ascii", 0, 4);
+ if (ogg === "OggS") return true; // OGG/OPUS
+ const flac = buffer.toString("ascii", 0, 4);
+ if (flac === "fLaC") return true; // FLAC
+ return false;
+ } catch (_) {
+ return false;
+ }
+}
+
function saveTranscript(result) {
const createdAt = new Date().toISOString();
const stamp = createdAt.replace(/[:.]/g, "-");
@@ -2600,6 +2618,62 @@ function transcribeWavBuffer(buffer, options = {}) {
});
}
+function transcribeAudioBuffer(buffer, options = {}) {
+ if (isWaveBuffer(buffer)) {
+ return transcribeWavBuffer(buffer, options);
+ }
+ return new Promise((resolve, reject) => {
+ const backendPreference = normalizeSpeechBackendPreference(options.backendPreference || speechSettings.backendPreference);
+ const backend = getSpeechBackend(backendPreference);
+ if (!backend.cli) {
+ reject(new Error(`${backend.label || "Selected"} whisper.cpp CLI backend is not installed.`));
+ return;
+ }
+ const model = resolveSpeechModel(options.model || speechSettings.model);
+ const language = String(options.language || speechSettings.language || "auto");
+ const sourceFilename = safeOutputName(options.filename || "recording") || "recording";
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const tempBase = path.join(TRANSCRIPTIONS, `.speech-${stamp}-${sourceFilename.replace(/\.[^.]+$/, "")}`);
+ const inputPath = `${tempBase}-input`;
+ const wavPath = `${tempBase}.wav`;
+ const outBase = `${tempBase}-out`;
+ fs.writeFileSync(inputPath, buffer);
+
+ speechTranscriptionState = {
+ active: true,
+ phase: "Converting audio...",
+ progress: -1,
+ model: model.filename,
+ filename: sourceFilename,
+ };
+
+ const ffmpegArgs = ["-y", "-i", inputPath, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath];
+ console.log(" [speech] Converting with ffmpeg:", ffmpegArgs.join(" "));
+ const ffmpegProc = spawn("ffmpeg", ffmpegArgs, {
+ stdio: ["ignore", "pipe", "pipe"],
+ windowsHide: true,
+ });
+ let ffmpegStderr = "";
+ ffmpegProc.stderr.on("data", (data) => { ffmpegStderr += data.toString(); });
+ ffmpegProc.on("exit", (code) => {
+ try { fs.unlinkSync(inputPath); } catch (_) {}
+ if (code !== 0) {
+ const message = (ffmpegStderr || `ffmpeg exited with code ${code}`).trim().slice(-1200);
+ speechError = message;
+ try { fs.unlinkSync(wavPath); } catch (_) {}
+ reject(new Error("Audio conversion failed: " + message));
+ return;
+ }
+ const wavBuffer = fs.readFileSync(wavPath);
+ try { fs.unlinkSync(wavPath); } catch (_) {}
+ transcribeWavBuffer(wavBuffer, options).then(resolve).catch((err) => {
+ try { fs.unlinkSync(wavPath); } catch (_) {}
+ reject(err);
+ });
+ });
+ });
+}
+
function pingLlmReady(expectedModel = "") {
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${PORT_LLM}/v1/models`, (res) => {
@@ -5956,10 +6030,10 @@ const server = http.createServer(async (req, res) => {
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
try {
const audio = await readBinaryBody(req);
- const result = await runExclusiveSpeechOperation(() => transcribeWavBuffer(audio, {
+ const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, {
model: parsed.searchParams.get("model") || speechSettings.model,
language: parsed.searchParams.get("language") || "auto",
- filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording.wav",
+ filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording",
threads: parsed.searchParams.get("threads") || speechSettings.threads,
backendPreference: parsed.searchParams.get("backendPreference") || speechSettings.backendPreference,
translate: parsed.searchParams.get("translate") === "true",
@@ -5971,6 +6045,63 @@ const server = http.createServer(async (req, res) => {
}
}
+
+ // API endpoint that returns transcription as downloadable file (txt, json, md)
+ if (req.url.startsWith("/api/speech/transcribe-file") && req.method === "POST") {
+ const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
+ try {
+ const audio = await readBinaryBody(req);
+ const outputFormat = parsed.searchParams.get("format") || "txt";
+ const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, {
+ model: parsed.searchParams.get("model") || speechSettings.model,
+ language: parsed.searchParams.get("language") || "auto",
+ filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording",
+ threads: parsed.searchParams.get("threads") || speechSettings.threads,
+ backendPreference: parsed.searchParams.get("backendPreference") || speechSettings.backendPreference,
+ translate: parsed.searchParams.get("translate") === "true",
+ }));
+
+ const filename = parsed.searchParams.get("output_filename") || `transcription-${Date.now()}`;
+ let fileContent = "";
+ let contentType = "text/plain;charset=utf-8";
+ let extension = "txt";
+
+ if (outputFormat === "json") {
+ fileContent = JSON.stringify({
+ text: result.text,
+ model: result.model,
+ modelName: result.modelName,
+ language: result.language,
+ backend: result.backend,
+ backendMode: result.backendMode,
+ sourceFilename: result.sourceFilename,
+ durationMs: result.durationMs,
+ raw: result.raw,
+ createdAt: result.createdAt
+ }, null, 2);
+ contentType = "application/json;charset=utf-8";
+ extension = "json";
+ } else if (outputFormat === "md") {
+ fileContent = `# Trascrizione\n\n${result.text}\n\n---\n*Modello: ${result.modelName || result.model || "unknown"}\n*Lingua: ${result.language || "auto"}\n*Backend: ${result.backend || "unknown"} (${result.backendMode || ""})\n*File sorgente: ${result.sourceFilename || "unknown"}\n*Durata elaborazione: ${result.durationMs ? (result.durationMs / 1000).toFixed(1) : "?"}s\n*Generato il: ${new Date().toISOString()}`;
+ contentType = "text/markdown;charset=utf-8";
+ extension = "md";
+ } else {
+ fileContent = result.text;
+ contentType = "text/plain;charset=utf-8";
+ extension = "txt";
+ }
+
+ res.writeHead(200, {
+ "Content-Type": contentType,
+ "Content-Disposition": `attachment; filename="${filename}.${extension}"`,
+ "Content-Length": Buffer.byteLength(fileContent, "utf8")
+ });
+ res.end(fileContent);
+ } catch (err) {
+ speechError = err.message || String(err);
+ return json(res, err.message?.includes("too large") ? 413 : 500, { ok: false, error: speechError });
+ }
+ }
if (req.url === "/api/speech/download-model" && req.method === "POST") {
const body = await readJsonBody(req, res);
if (!body) return;
diff --git a/windows.bat b/windows.bat
index 09d919e..309da41 100644
--- a/windows.bat
+++ b/windows.bat
@@ -1,5 +1,5 @@
@echo off
-title Uncensored AI Studio
+title Speech Transcriber
cd /d "%~dp0"
set APP=%~dp0app
@@ -7,18 +7,11 @@ set NODE=%APP%\tools\node-win\node.exe
set NPM=%APP%\tools\node-win\npm.cmd
set DIST=%APP%\dist\index.html
set SETUP=%~dp0scripts\setup\setup.ps1
-set CUDA_BACKEND=%APP%\backend\win\cuda\sd-cuda.exe
-set VULKAN_BACKEND=%APP%\backend\win\vulkan\sd-vulkan.exe
-set LLM_CUDA_BACKEND=%APP%\llm-backend\win\cuda\llama-server.exe
-set LLM_HIP_BACKEND=%APP%\llm-backend\win\hip\llama-server.exe
-set LLM_VULKAN_BACKEND=%APP%\llm-backend\win\vulkan\llama-server.exe
-set LLM_SYCL_BACKEND=%APP%\llm-backend\win\sycl\llama-server.exe
-set LLM_CPU_BACKEND=%APP%\llm-backend\win\cpu\llama-server.exe
set SPEECH_BACKEND=%APP%\speech-backend\win\cpu\whisper-cli.exe
-set TTS_RUNTIME=%APP%\tts-runtime\node_modules\kokoro-js
+set SPEECH_BACKEND_VULKAN=%APP%\speech-backend\win\vulkan\whisper-cli.exe
+set SPEECH_BACKEND_CUDA=%APP%\speech-backend\win\cuda\whisper-cli.exe
set SERVE=%~dp0scripts\server\serve.cjs
if "%FRONTEND_PORT%"=="" set FRONTEND_PORT=1420
-if "%LLM_PORT%"=="" set LLM_PORT=10086
set SETUP_REASON=
set SETUP_MODE=Repair
@@ -36,44 +29,35 @@ if not exist "%DIST%" (
set SETUP_REASON=Frontend build is missing.
goto :run_setup
)
-if not exist "%LLM_CUDA_BACKEND%" if not exist "%LLM_HIP_BACKEND%" if not exist "%LLM_VULKAN_BACKEND%" if not exist "%LLM_SYCL_BACKEND%" if not exist "%LLM_CPU_BACKEND%" (
- set SETUP_REASON=llama.cpp text backend is missing.
- goto :run_setup
-)
if not exist "%SPEECH_BACKEND%" (
- set SETUP_REASON=whisper.cpp speech backend is missing.
- goto :run_setup
+ if not exist "%SPEECH_BACKEND_VULKAN%" (
+ if not exist "%SPEECH_BACKEND_CUDA%" (
+ set SETUP_REASON=whisper.cpp speech backend is missing.
+ goto :run_setup
+ )
+ )
)
-if not exist "%TTS_RUNTIME%" (
- set SETUP_REASON=Kokoro text-to-speech runtime is missing.
- goto :run_setup
-)
-if exist "%CUDA_BACKEND%" goto :launch
-if exist "%VULKAN_BACKEND%" goto :launch
-set SETUP_REASON=No backend binary is installed.
-goto :run_setup
+goto :launch
:run_setup
echo.
echo ============================================================
-echo UNCENSORED AI STUDIO ^| %SETUP_MODE%
+echo SPEECH TRANSCRIBER ^| %SETUP_MODE%
echo ============================================================
echo.
if "%SETUP_MODE%"=="First-Time Setup" (
echo This looks like your first run. Setting up automatically...
) else (
- echo Uncensored AI Studio needs a quick repair before launch.
+ echo Speech Transcriber needs a quick repair before launch.
)
if not "%SETUP_REASON%"=="" echo Reason: %SETUP_REASON%
-echo Models are not downloaded during setup. Download or import them in the app.
+echo Whisper models are not downloaded during setup. Download or import them in the app.
echo.
echo Press any key to continue, or Ctrl+C to cancel.
pause >nul
:: Clear old frontend and backend server processes before setup so app/tools/node-win can be replaced
for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":%FRONTEND_PORT% "') do taskkill /f /pid %%a >nul 2>nul
-for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":8080 "') do taskkill /f /pid %%a >nul 2>nul
-for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":%LLM_PORT% "') do taskkill /f /pid %%a >nul 2>nul
powershell -ExecutionPolicy Bypass -File "%SETUP%"
if errorlevel 1 (
@@ -90,18 +74,16 @@ goto :launch
:launch
echo.
echo ============================================================
-echo UNCENSORED AI STUDIO ^| Launching...
+echo SPEECH TRANSCRIBER ^| Launching...
echo ============================================================
echo.
-:: Clear frontend and backend ports to prevent address conflicts.
-echo Clearing frontend port %FRONTEND_PORT%, backend port 8080, and text port %LLM_PORT%...
+:: Clear frontend port to prevent address conflicts.
+echo Clearing frontend port %FRONTEND_PORT%...
for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":%FRONTEND_PORT% "') do taskkill /f /pid %%a >nul 2>nul
-for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":8080 "') do taskkill /f /pid %%a >nul 2>nul
-for /f "tokens=5" %%a in ('netstat -aon 2^>nul ^| findstr ":%LLM_PORT% "') do taskkill /f /pid %%a >nul 2>nul
-:: Start frontend server + backend manager (serve.cjs manages sd-vulkan.exe)
-echo Starting Uncensored AI Studio...
+:: Start frontend server + backend manager
+echo Starting Speech Transcriber...
echo Opening browser at http://localhost:%FRONTEND_PORT%...
start /b cmd /c "timeout /t 2 >nul && start http://localhost:%FRONTEND_PORT%"
@@ -109,10 +91,7 @@ echo.
echo ============================================================
echo Running!
echo Web UI: http://localhost:%FRONTEND_PORT%
-echo GPU API: Auto-selected by the app (starts at 8080)
-echo Text API: Starts when a GGUF model is loaded (port %LLM_PORT%)
-echo Speech: Managed locally by the app
-echo TTS: Managed locally by the app
+echo Speech: Managed locally by the app (whisper.cpp)
echo.
echo Press Ctrl+C in this window to stop all services.
echo ============================================================