From 45540aee296f287787e9869a926fc8edeb26c0d6 Mon Sep 17 00:00:00 2001 From: techjarves Date: Thu, 25 Jun 2026 22:04:52 +0530 Subject: [PATCH 1/4] implement model management UI and backend integration for image, text, and speech models with platform-specific setup scripts --- README.md | 46 ++++ app/frontend/src/components/ModelManager.jsx | 9 +- app/frontend/src/services/api.js | 14 +- scripts/server/serve.cjs | 106 +++++++++- scripts/setup/setup-llama.ps1 | 107 ++++++---- scripts/setup/setup.ps1 | 211 +++++++++++-------- scripts/setup/setup.sh | 77 +++++-- 7 files changed, 413 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 3638f91..6c4c94c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ * [What is Uncensored AI Studio?](#what-is-uncensored-ai-studio) * [Key Features](#key-features) * [Workspace & Engine Architecture](#workspace-architecture) +* [Supported Models](#supported-models) * [Folder Architecture](#folder-architecture) * [Getting Started](#getting-started) * [Windows Setup](#windows-setup) @@ -73,6 +74,46 @@ To avoid exhausting system RAM or VRAM, text and image engines are mutually excl --- +## Supported Models + +The app is designed around single-file local models that can be loaded directly by the bundled backend engines. + +### Image generation + +| Model type | Supported | Put files in | Notes | +| :--- | :--- | :--- | :--- | +| Stable Diffusion 1.5 checkpoints | Yes | `app/models/` | Best compatibility. Use `.safetensors` or `.ckpt` files. | +| SDXL checkpoints | Yes | `app/models/` | Supported as single-file checkpoints. Requires more RAM/VRAM than SD 1.5. | +| Single-file SD/SDXL GGUF checkpoints | Limited | `app/models/` | Only complete single-file checkpoints are supported. | +| OpenVINO image model folders | Intel NPU only | `app/openvino-models/` | Download from the Model Manager after running the OpenVINO setup. | +| CoreML image models | Apple Silicon only | `app/models/` | Requires macOS on Apple Silicon and the CoreML setup path. | +| Flux, HiDream, Hunyuan, Wan, Qwen Image, Z-Image workflows | No | N/A | These usually require separate diffusion, VAE, and text encoder files and are not one-click checkpoint loads in this app. | +| LoRA, ControlNet, VAE-only, text-encoder-only, or diffusion-only files | No | N/A | Companion files are not loaded as standalone image models. | + +Known-good image models available from the Model Manager: + +| Name | Filename | Type | Approx. size | Recommended use | +| :--- | :--- | :--- | :--- | :--- | +| Juggernaut XL v9 Lightning | `Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors` | SDXL | 6.6 GB | High-quality photorealism on mid/high tier machines. | +| DreamShaper XL Lightning | `DreamShaperXL_Lightning.safetensors` | SDXL | 6.6 GB | General SDXL images, fantasy, renders, and illustration. | +| DreamShaper 8 | `DreamShaper_8_pruned.safetensors` | SD 1.5 | 2.1 GB | Faster, lower-memory image generation. | +| CyberRealistic V8 | `CyberRealistic_V8_FP16.safetensors` | SD 1.5 | 2.0 GB | Realistic SD 1.5 images and lower-memory systems. | +| Rev Animated | `rev-animated-v1-2-2.safetensors` | SD 1.5 | 2.0 GB | Stylized/anime SD 1.5 images. | +| LCM DreamShaper OpenVINO | `OpenVINO/LCM_Dreamshaper_v7-fp16-ov` | OpenVINO | 2.7 GB | Intel Core Ultra NPU test model. | + +### Text, speech, and TTS + +| Workspace | Supported model files | Put files in | Notes | +| :--- | :--- | :--- | :--- | +| Text Chat | `.gguf` llama.cpp models | `app/llm-models/` | Use single-file GGUF chat/instruct models. Vision models may also require a matching `mmproj` file. | +| Speech-to-Text | whisper.cpp `.bin` models | `app/speech-models/` | Use Whisper GGML/whisper.cpp model files. | +| Text-to-Speech | Kokoro `.json` manifests and model assets | `app/tts-models/` / `app/tts-runtime/` | Use the built-in Kokoro setup and Model Manager entries. | + +> [!NOTE] +> Linux release binaries are built for Ubuntu 24.04-era systems and require `glibc 2.38+` plus `GLIBCXX_3.4.32+`. On older Ubuntu/Debian VMs, a model such as CyberRealistic may be valid but the backend can still fail before loading it. Upgrade the VM OS or build the backend from source. + +--- + ## 📁 Folder Architecture ``` @@ -195,6 +236,11 @@ Ensure you have a modern web browser installed. Follow the quick guide below for

Ensure your AMD GPU hardware and host kernel are fully compatible with ROCm 7.13. If ROCm fails to initialize correctly, the application will automatically fall back to Vulkan acceleration.

+
+ Linux uses the integrated GPU instead of the discrete GPU +

On dual-GPU Linux systems, Vulkan device order can put the integrated Intel GPU at vulkan0 and the discrete AMD/NVIDIA GPU at vulkan1. The launcher now tries to prefer a discrete Vulkan device when vulkaninfo --summary is available. To force a device manually, start the app with SD_VULKAN_DEVICE=vulkan1 ./linux.sh or use another index such as vulkan0/vulkan2.

+
+
Windows exits with code 3221225781 (0xC0000135)

This code means Windows could not locate a required backend DLL:

diff --git a/app/frontend/src/components/ModelManager.jsx b/app/frontend/src/components/ModelManager.jsx index 72a97c7..116d6ef 100644 --- a/app/frontend/src/components/ModelManager.jsx +++ b/app/frontend/src/components/ModelManager.jsx @@ -35,7 +35,8 @@ import { downloadTtsModel, deleteTtsModel, importTtsModel, - getTtsStatus + getTtsStatus, + isLocalServerMode } from "../services/api"; const MODEL_FILTERS = [ @@ -605,9 +606,8 @@ function ModelManager({ } const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined; - const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); - if (!isTauriDesktop && !isLocalServerMode) { + if (!isTauriDesktop && !isLocalServerMode()) { showAlert({ title: "Server Required", message: "Model download requires the local server.", danger: true }); return; } @@ -959,11 +959,10 @@ function ModelManager({ const performLoadModel = async (modelId, forcedConstraints = null) => { const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined; - const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); const backendStatus = await getBackendStatus(); const backendPort = backendStatus?.port || 8080; - if (!isTauriDesktop && !isLocalServerMode) { + if (!isTauriDesktop && !isLocalServerMode()) { const isAlreadyRunning = await pingServer(); const modelInfo = getLocalModelInfo(modelId); const isOpenVinoModel = modelInfo?.backendType === "openvino-npu"; diff --git a/app/frontend/src/services/api.js b/app/frontend/src/services/api.js index 3623892..a719f05 100644 --- a/app/frontend/src/services/api.js +++ b/app/frontend/src/services/api.js @@ -11,8 +11,9 @@ let cachedSpecs = null; let cachedBackendPort = null; export const EXPECTED_SERVER_BUILD = "text-image-v1"; -const isLocalServerMode = () => { - return typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); +export const isLocalServerMode = () => { + if (typeof window === "undefined") return false; + return window.location.protocol === "http:" || window.location.protocol === "https:"; }; async function getBackendPort() { @@ -31,6 +32,9 @@ async function getBackendPort() { } async function getBackendBaseUrl() { + if (!isTauri() && isLocalServerMode()) { + return ""; + } return `http://127.0.0.1:${await getBackendPort()}`; } @@ -224,8 +228,7 @@ export async function listLocalModels() { } } - const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); - if (isLocalServerMode) { + if (isLocalServerMode()) { return await listModelsFromDisk(); } @@ -1162,8 +1165,7 @@ export async function deleteModel(filename) { return await invoke("delete_model_file", { filename }); } - const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); - if (isLocalServerMode) { + if (isLocalServerMode()) { try { const res = await fetch("/api/delete-model", { method: "POST", diff --git a/scripts/server/serve.cjs b/scripts/server/serve.cjs index 3eded97..8d79411 100644 --- a/scripts/server/serve.cjs +++ b/scripts/server/serve.cjs @@ -587,6 +587,50 @@ function isVirtualOrSoftwareGpu(name) { lowercase.includes("vmware"); } +let cachedPreferredVulkanBackend = null; +let cachedPreferredVulkanBackendChecked = false; +function getPreferredVulkanBackendName() { + const explicit = String(process.env.SD_VULKAN_DEVICE || process.env.UAIS_VULKAN_DEVICE || "").trim(); + if (/^vulkan\d+$/i.test(explicit)) return explicit.toLowerCase(); + if (/^\d+$/.test(explicit)) return `vulkan${explicit}`; + + if (cachedPreferredVulkanBackendChecked) return cachedPreferredVulkanBackend || "vulkan0"; + cachedPreferredVulkanBackendChecked = true; + cachedPreferredVulkanBackend = "vulkan0"; + + if (osPlatform !== "linux") return cachedPreferredVulkanBackend; + try { + const result = spawnSync("vulkaninfo", ["--summary"], { + encoding: "utf8", + timeout: 5000, + maxBuffer: 1024 * 1024, + }); + const output = `${result.stdout || ""}\n${result.stderr || ""}`; + const devices = []; + let current = null; + for (const line of output.split(/\r?\n/)) { + const gpuMatch = line.match(/^\s*GPU(\d+)\s*:/i); + if (gpuMatch) { + current = { index: Number(gpuMatch[1]), name: "", type: "" }; + devices.push(current); + continue; + } + if (!current) continue; + const nameMatch = line.match(/deviceName\s*=\s*(.+)$/i); + if (nameMatch) current.name = nameMatch[1].trim(); + const typeMatch = line.match(/deviceType\s*=\s*(.+)$/i); + if (typeMatch) current.type = typeMatch[1].trim(); + } + const usable = devices.filter((device) => Number.isInteger(device.index) && !isVirtualOrSoftwareGpu(device.name)); + const discrete = usable.find((device) => /discrete/i.test(device.type)); + const amd = usable.find((device) => /amd|radeon/i.test(device.name)); + const selected = discrete || amd || usable[0]; + if (selected) cachedPreferredVulkanBackend = `vulkan${selected.index}`; + } catch (_) {} + + return cachedPreferredVulkanBackend; +} + let cachedLlamaCudaGpu = null; let cachedLlamaCudaGpuChecked = false; function detectLlamaCudaGpu() { @@ -3285,6 +3329,36 @@ function requestJson(url, payload = null, timeoutMs = 120000) { }); } +function proxyImageBackendRequest(req, res) { + const headers = { ...req.headers }; + delete headers.host; + headers.host = `127.0.0.1:${PORT_BACKEND}`; + + const proxyReq = http.request({ + hostname: "127.0.0.1", + port: PORT_BACKEND, + path: req.url, + method: req.method, + headers, + timeout: 300000, + }, (proxyRes) => { + res.writeHead(proxyRes.statusCode || 502, { + ...proxyRes.headers, + "Access-Control-Allow-Origin": "*", + }); + proxyRes.pipe(res); + }); + + proxyReq.on("error", (err) => { + json(res, 502, { ok: false, error: `Image backend is not reachable: ${err.message}` }); + }); + proxyReq.on("timeout", () => { + proxyReq.destroy(new Error("Image backend request timed out")); + }); + + req.pipe(proxyReq); +} + function requestHttpsJson(url, timeoutMs = 30000) { return new Promise((resolve, reject) => { const req = https.get(url, { @@ -4303,6 +4377,7 @@ async function startBackend(settings = {}) { let args = []; const requestedBackend = resolveBackendType(currentSettings.useGpu, currentSettings.backendType, currentSettings.model); + const selectedVulkanBackend = requestedBackend === "vulkan" ? getPreferredVulkanBackendName() : ""; const supportsFlags = backendSupportsFlags[backendPath] !== false; @@ -4331,7 +4406,7 @@ async function startBackend(settings = {}) { args.push("--rng", "cpu", "--sampler-rng", "cpu"); } else if (requestedBackend === "vulkan") { if (supportsFlags) { - args.push("--backend", "vulkan0", "--params-backend", "vulkan0"); + args.push("--backend", selectedVulkanBackend, "--params-backend", selectedVulkanBackend); } args.push("--rng", "cpu", "--sampler-rng", "cpu"); } else if (requestedBackend === "cuda") { @@ -4350,6 +4425,11 @@ async function startBackend(settings = {}) { } + if (requestedBackend === "vulkan" && selectedVulkanBackend) { + currentSettings.backendDevice = selectedVulkanBackend; + backendLoadState.device = selectedVulkanBackend; + } + if (requestedBackend !== "apple-npu") { if (currentSettings.vaeTiling) { args.push("--vae-tiling"); @@ -4485,6 +4565,11 @@ async function startBackend(settings = {}) { process.stderr.write(" [sd-err] " + output); updateCoreMLLoadProgress(output); const cleanOutput = stripAnsi(output); + const runtimeLinkerError = describeLinuxRuntimeLinkerError(cleanOutput); + if (runtimeLinkerError) { + backendError = runtimeLinkerError; + backendLoadState.phase = "Linux runtime is too old for this backend"; + } const deviceMatch = cleanOutput.match(/Device\s+\d+:\s*([^,\r\n]+)/); if (deviceMatch) { backendLoadState.device = deviceMatch[1].trim(); @@ -5217,6 +5302,22 @@ function describeBackendError(rawError, modelPath) { return `${raw}\n\nThe backend could not create the model context. Common causes are a corrupt/incomplete model file, unsupported checkpoint type, or not enough free RAM/VRAM. Delete and re-download the model, then try CPU or Vulkan mode at 512x512.`; } +function describeLinuxRuntimeLinkerError(rawError) { + const raw = String(rawError || "").trim(); + const lower = raw.toLowerCase(); + if (osPlatform !== "linux") return null; + if (!lower.includes("glibc") && !lower.includes("libstdc++") && !lower.includes("libc.so.6")) return null; + + const needsGlibc = raw.match(/GLIBC_([0-9.]+)/)?.[1]; + const needsGlibcxx = raw.match(/GLIBCXX_([0-9.]+)/)?.[1]; + const requirements = []; + if (needsGlibc) requirements.push(`glibc ${needsGlibc}+`); + if (needsGlibcxx) requirements.push(`GLIBCXX_${needsGlibcxx}+`); + const requirementText = requirements.length ? requirements.join(" and ") : "newer glibc/libstdc++ runtime libraries"; + + return `${raw}\n\nThe selected model is not the problem. The Linux backend binary cannot start because this OS is missing ${requirementText}. The bundled Linux backends are built for Ubuntu 24.04-era systems. Use Ubuntu 24.04+, Fedora 40+, another glibc 2.38+ distro, or build stable-diffusion.cpp from source on this machine.`; +} + function describeBackendExitCode(code, backendPath) { const numericCode = Number(code); if (osPlatform === "win32" && numericCode === 3221225781) { @@ -5483,6 +5584,9 @@ const server = http.createServer(async (req, res) => { res.end(); return; } + if ((req.url.startsWith("/v1/") || req.url.startsWith("/sdapi/")) && ["GET", "POST"].includes(req.method)) { + return proxyImageBackendRequest(req, res); + } // ── Management API ──────────────────────────────────────────────────────── // GET /api/health if (req.url === "/api/health" && req.method === "GET") { diff --git a/scripts/setup/setup-llama.ps1 b/scripts/setup/setup-llama.ps1 index 4cf8112..c46c3b5 100644 --- a/scripts/setup/setup-llama.ps1 +++ b/scripts/setup/setup-llama.ps1 @@ -34,57 +34,82 @@ function Download-File { param([string]$Url, [string]$DestPath, [string]$Label) $barWidth = 48 - $lastBytes = [long]0 - $lastTime = [DateTime]::Now Write-Host " >> Downloading $Label..." - try { - Enable-Tls12 - $req = [System.Net.HttpWebRequest]::Create($Url) - $req.UserAgent = "Mozilla/5.0" - $resp = $req.GetResponse() - $total = [long]$resp.ContentLength - $stream = $resp.GetResponseStream() - $out = [System.IO.File]::Create($DestPath) - $buf = New-Object byte[] 65536 - $done = [long]0 + for ($attempt = 1; $attempt -le 3; $attempt++) { + $lastBytes = [long]0 + $lastTime = [DateTime]::Now + $resp = $null + $stream = $null + $out = $null - while ($true) { - $read = $stream.Read($buf, 0, $buf.Length) - if ($read -le 0) { break } - $out.Write($buf, 0, $read) - $done += $read + try { + if (Test-Path $DestPath) { Remove-Item $DestPath -Force } + Enable-Tls12 + $req = [System.Net.HttpWebRequest]::Create($Url) + $req.UserAgent = "Mozilla/5.0" + $req.Timeout = 300000 + $req.ReadWriteTimeout = 300000 + $resp = $req.GetResponse() + $total = [long]$resp.ContentLength + $stream = $resp.GetResponseStream() + $out = [System.IO.File]::Create($DestPath) + $buf = New-Object byte[] 65536 + $done = [long]0 - $now = [DateTime]::Now - $elapsed = ($now - $lastTime).TotalSeconds - if ($elapsed -ge 0.35) { - $speed = ($done - $lastBytes) / $elapsed - $lastBytes = $done - $lastTime = $now - $pct = if ($total -gt 0) { [int](($done / $total) * 100) } else { 0 } - $fill = [int](($pct / 100) * $barWidth) - $bar = ("#" * $fill) + ("-" * ($barWidth - $fill)) + while ($true) { + $read = $stream.Read($buf, 0, $buf.Length) + if ($read -le 0) { break } + $out.Write($buf, 0, $read) + $done += $read - $eta = "" - if ($speed -gt 0 -and $total -gt 0) { - $rem = [int](($total - $done) / $speed) - $eta = " ETA $([int]($rem/60))m$($rem%60)s" + $now = [DateTime]::Now + $elapsed = ($now - $lastTime).TotalSeconds + if ($elapsed -ge 0.35) { + $speed = ($done - $lastBytes) / $elapsed + $lastBytes = $done + $lastTime = $now + $pct = if ($total -gt 0) { [int](($done / $total) * 100) } else { 0 } + $fill = [int](($pct / 100) * $barWidth) + $bar = ("#" * $fill) + ("-" * ($barWidth - $fill)) + + $eta = "" + if ($speed -gt 0 -and $total -gt 0) { + $rem = [int](($total - $done) / $speed) + $eta = " ETA $([int]($rem/60))m$($rem%60)s" + } + + $dl = Format-Bytes $done + $tot = if ($total -gt 0) { " / " + (Format-Bytes $total) } else { "" } + $spd = Format-Speed $speed + Write-Host -NoNewline "`r [$bar] $pct% $dl$tot $spd$eta " } - - $dl = Format-Bytes $done - $tot = if ($total -gt 0) { " / " + (Format-Bytes $total) } else { "" } - $spd = Format-Speed $speed - Write-Host -NoNewline "`r [$bar] $pct% $dl$tot $spd$eta " } + + if ($total -gt 0 -and $done -ne $total) { + throw "Download ended early: $(Format-Bytes $done) of $(Format-Bytes $total) received." + } + + Write-Host "`r [$("#" * $barWidth)] 100% $(Format-Bytes $done) Done! " -ForegroundColor Green + Write-Host "" + return + } catch { + Write-Host "" + if ($attempt -lt 3) { + Write-Host " !! Download attempt $attempt failed: $_" -ForegroundColor Yellow + Write-Host " >> Retrying download..." -ForegroundColor Cyan + Start-Sleep -Seconds (2 * $attempt) + } else { + throw "Download failed after $attempt attempts: $_" + } + } finally { + if ($out) { $out.Close() } + if ($stream) { $stream.Close() } + if ($resp) { $resp.Close() } } - $out.Close(); $stream.Close() - Write-Host "`r [$("#" * $barWidth)] 100% $(Format-Bytes $done) Done! " -ForegroundColor Green - Write-Host "" - } catch { - Write-Host "" - throw "Download failed: $_" + if (Test-Path $DestPath) { Remove-Item $DestPath -Force -ErrorAction SilentlyContinue } } } diff --git a/scripts/setup/setup.ps1 b/scripts/setup/setup.ps1 index 25a6afb..6a0fe68 100644 --- a/scripts/setup/setup.ps1 +++ b/scripts/setup/setup.ps1 @@ -64,58 +64,83 @@ function Invoke-RichDownload { Write-Host "" $barWidth = 48 - $lastBytes = [long]0 - $lastTime = [DateTime]::Now - try { - Enable-Tls12 - $req = [System.Net.HttpWebRequest]::Create($Url) - $req.UserAgent = "Mozilla/5.0" - $resp = $req.GetResponse() - $total = [long]$resp.ContentLength - $stream = $resp.GetResponseStream() - $out = [System.IO.File]::Create($Dest) - $buf = New-Object byte[] 65536 - $done = [long]0 + for ($attempt = 1; $attempt -le 3; $attempt++) { + $lastBytes = [long]0 + $lastTime = [DateTime]::Now + $resp = $null + $stream = $null + $out = $null - while ($true) { - $read = $stream.Read($buf, 0, $buf.Length) - if ($read -le 0) { break } - $out.Write($buf, 0, $read) - $done += $read + try { + if (Test-Path $Dest) { Remove-Item $Dest -Force } + Enable-Tls12 + $req = [System.Net.HttpWebRequest]::Create($Url) + $req.UserAgent = "Mozilla/5.0" + $req.Timeout = 300000 + $req.ReadWriteTimeout = 300000 + $resp = $req.GetResponse() + $total = [long]$resp.ContentLength + $stream = $resp.GetResponseStream() + $out = [System.IO.File]::Create($Dest) + $buf = New-Object byte[] 65536 + $done = [long]0 - $now = [DateTime]::Now - $elapsed = ($now - $lastTime).TotalSeconds - if ($elapsed -ge 0.35) { - $speed = ($done - $lastBytes) / $elapsed - $lastBytes = $done - $lastTime = $now - $pct = if ($total -gt 0) { [int](($done / $total) * 100) } else { 0 } - $fill = [int](($pct / 100) * $barWidth) - $bar = ("#" * $fill) + ("-" * ($barWidth - $fill)) + while ($true) { + $read = $stream.Read($buf, 0, $buf.Length) + if ($read -le 0) { break } + $out.Write($buf, 0, $read) + $done += $read - $eta = "" - if ($speed -gt 0 -and $total -gt 0) { - $rem = [int](($total - $done) / $speed) - $eta = " ETA $([int]($rem/60))m$($rem%60)s" + $now = [DateTime]::Now + $elapsed = ($now - $lastTime).TotalSeconds + if ($elapsed -ge 0.35) { + $speed = ($done - $lastBytes) / $elapsed + $lastBytes = $done + $lastTime = $now + $pct = if ($total -gt 0) { [int](($done / $total) * 100) } else { 0 } + $fill = [int](($pct / 100) * $barWidth) + $bar = ("#" * $fill) + ("-" * ($barWidth - $fill)) + + $eta = "" + if ($speed -gt 0 -and $total -gt 0) { + $rem = [int](($total - $done) / $speed) + $eta = " ETA $([int]($rem/60))m$($rem%60)s" + } + + $dl = Format-Bytes $done + $tot = if ($total -gt 0) { " / " + (Format-Bytes $total) } else { "" } + $spd = Format-Speed $speed + Write-Host -NoNewline "`r [$bar] $pct% $dl$tot $spd$eta " } - - $dl = Format-Bytes $done - $tot = if ($total -gt 0) { " / " + (Format-Bytes $total) } else { "" } - $spd = Format-Speed $speed - Write-Host -NoNewline "`r [$bar] $pct% $dl$tot $spd$eta " } + + if ($total -gt 0 -and $done -ne $total) { + throw "Download ended early: $(Format-Bytes $done) of $(Format-Bytes $total) received." + } + + Write-Host "`r [$("#" * $barWidth)] 100% $(Format-Bytes $done) Done! " -ForegroundColor Green + Write-Host "" + return $true + } catch { + Write-Host "" + if ($attempt -lt 3) { + Print-Warn "Download attempt $attempt failed: $_" + Print-Info "Retrying download..." + Start-Sleep -Seconds (2 * $attempt) + } else { + Print-Fail "Download failed after $attempt attempts: $_" + } + } finally { + if ($out) { $out.Close() } + if ($stream) { $stream.Close() } + if ($resp) { $resp.Close() } } - $out.Close(); $stream.Close() - Write-Host "`r [$("#" * $barWidth)] 100% $(Format-Bytes $done) Done! " -ForegroundColor Green - Write-Host "" - return $true - } catch { - Write-Host "" - Print-Fail "Download failed: $_" - return $false + if (Test-Path $Dest) { Remove-Item $Dest -Force -ErrorAction SilentlyContinue } } + + return $false } function Expand-WithProgress { @@ -297,9 +322,11 @@ if ($hasNvidia) { $backendDest = Join-Path $appDir "backend\win\cuda" $backendExe = Join-Path $backendDest "sd-cuda.exe" $backendDll = Join-Path $backendDest "stable-diffusion.dll" + $cudaBackendReady = $false if ((Test-Path $backendExe) -and (Test-Path $backendDll)) { Print-OK "CUDA GPU backend binaries already ready." + $cudaBackendReady = $true } else { $backendZip = Join-Path $toolsDir "sd-cuda.zip" New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null @@ -310,59 +337,63 @@ if ($hasNvidia) { -Dest $backendZip ` -Label "stable-diffusion.cpp CUDA Backend (Windows x64)" - if (-not $ok) { Print-Fail "Cannot download CUDA backend binaries."; Read-Host; exit 1 } - - $tempExt = Join-Path $toolsDir "sd-cuda-temp" - Expand-WithProgress -ZipPath $backendZip -Destination $tempExt -Label "CUDA Backend" - Remove-Item $backendZip -Force - - # Move files and rename sd.exe/sd-server.exe to sd-cuda.exe - if (Test-Path $tempExt) { - $extractedExe = Join-Path $tempExt "bin\sd-server.exe" - if (-not (Test-Path $extractedExe)) { $extractedExe = Join-Path $tempExt "sd-server.exe" } - if (-not (Test-Path $extractedExe)) { $extractedExe = Join-Path $tempExt "bin\sd.exe" } - if (-not (Test-Path $extractedExe)) { $extractedExe = Join-Path $tempExt "sd.exe" } - - $extractedDll = Join-Path $tempExt "bin\stable-diffusion.dll" - if (-not (Test-Path $extractedDll)) { $extractedDll = Join-Path $tempExt "stable-diffusion.dll" } - - if (Test-Path $extractedExe) { Copy-Item $extractedExe $backendExe -Force } - if (Test-Path $extractedDll) { Copy-Item $extractedDll $backendDll -Force } - - # Copy any other DLLs or EXEs - Get-ChildItem $tempExt -Filter "*.dll" -Recurse | ForEach-Object { Copy-Item $_.FullName $backendDest -Force } - Get-ChildItem $tempExt -Filter "*.exe" -Recurse | ForEach-Object { - if ($_.FullName -ne $extractedExe) { Copy-Item $_.FullName $backendDest -Force } - } - Remove-Item $tempExt -Recurse -Force - } - - if ((Test-Path $backendExe) -and (Test-Path $backendDll)) { - Print-OK "CUDA GPU backend binaries installed successfully!" + if (-not $ok) { + Print-Warn "Cannot download CUDA backend binaries. Continuing with the Vulkan backend fallback." } else { - Print-Fail "Failed to copy backend binaries to app/backend/win/cuda/." - Read-Host; exit 1 + $tempExt = Join-Path $toolsDir "sd-cuda-temp" + Expand-WithProgress -ZipPath $backendZip -Destination $tempExt -Label "CUDA Backend" + Remove-Item $backendZip -Force + + # Move files and rename sd.exe/sd-server.exe to sd-cuda.exe + if (Test-Path $tempExt) { + $extractedExe = Join-Path $tempExt "bin\sd-server.exe" + if (-not (Test-Path $extractedExe)) { $extractedExe = Join-Path $tempExt "sd-server.exe" } + if (-not (Test-Path $extractedExe)) { $extractedExe = Join-Path $tempExt "bin\sd.exe" } + if (-not (Test-Path $extractedExe)) { $extractedExe = Join-Path $tempExt "sd.exe" } + + $extractedDll = Join-Path $tempExt "bin\stable-diffusion.dll" + if (-not (Test-Path $extractedDll)) { $extractedDll = Join-Path $tempExt "stable-diffusion.dll" } + + if (Test-Path $extractedExe) { Copy-Item $extractedExe $backendExe -Force } + if (Test-Path $extractedDll) { Copy-Item $extractedDll $backendDll -Force } + + # Copy any other DLLs or EXEs + Get-ChildItem $tempExt -Filter "*.dll" -Recurse | ForEach-Object { Copy-Item $_.FullName $backendDest -Force } + Get-ChildItem $tempExt -Filter "*.exe" -Recurse | ForEach-Object { + if ($_.FullName -ne $extractedExe) { Copy-Item $_.FullName $backendDest -Force } + } + Remove-Item $tempExt -Recurse -Force + } + + if ((Test-Path $backendExe) -and (Test-Path $backendDll)) { + Print-OK "CUDA GPU backend binaries installed successfully!" + $cudaBackendReady = $true + } else { + Print-Warn "Failed to copy CUDA backend binaries. Continuing with the Vulkan backend fallback." + } } } - $cudaDllsExist = (Test-Path (Join-Path $backendDest "cublas64_12.dll")) -and ` - (Test-Path (Join-Path $backendDest "cublasLt64_12.dll")) -and ` - (Test-Path (Join-Path $backendDest "cudart64_12.dll")) + if ($cudaBackendReady) { + $cudaDllsExist = (Test-Path (Join-Path $backendDest "cublas64_12.dll")) -and ` + (Test-Path (Join-Path $backendDest "cublasLt64_12.dll")) -and ` + (Test-Path (Join-Path $backendDest "cudart64_12.dll")) - if (-not $cudaDllsExist) { - Print-Info "CUDA runtime DLLs are missing from backend folder. Downloading portable CUDA v12 runtime..." - $dllZip = Join-Path $toolsDir "cuda-dlls.zip" - $ok = Invoke-RichDownload ` - -Url "https://github.com/ggml-org/llama.cpp/releases/download/b9509/cudart-llama-bin-win-cuda-12.4-x64.zip" ` - -Dest $dllZip ` - -Label "CUDA v12 Runtime DLLs (llama.cpp)" + if (-not $cudaDllsExist) { + Print-Info "CUDA runtime DLLs are missing from backend folder. Downloading portable CUDA v12 runtime..." + $dllZip = Join-Path $toolsDir "cuda-dlls.zip" + $ok = Invoke-RichDownload ` + -Url "https://github.com/ggml-org/llama.cpp/releases/download/b9509/cudart-llama-bin-win-cuda-12.4-x64.zip" ` + -Dest $dllZip ` + -Label "CUDA v12 Runtime DLLs (llama.cpp)" - if ($ok) { - Expand-WithProgress -ZipPath $dllZip -Destination $backendDest -Label "CUDA Runtime DLLs" - Remove-Item $dllZip -Force - Print-OK "CUDA runtime DLLs set up successfully!" - } else { - Print-Warn "Could not download portable CUDA runtime DLLs automatically. If the app fails to start in CUDA mode, you may need to install the CUDA Toolkit manually." + if ($ok) { + Expand-WithProgress -ZipPath $dllZip -Destination $backendDest -Label "CUDA Runtime DLLs" + Remove-Item $dllZip -Force + Print-OK "CUDA runtime DLLs set up successfully!" + } else { + Print-Warn "Could not download portable CUDA runtime DLLs automatically. If the app fails to start in CUDA mode, you may need to install the CUDA Toolkit manually." + } } } diff --git a/scripts/setup/setup.sh b/scripts/setup/setup.sh index 079aa51..884d3e7 100644 --- a/scripts/setup/setup.sh +++ b/scripts/setup/setup.sh @@ -194,23 +194,69 @@ detect_gpu_vendor() { echo "$vendor" } -# Official Linux binaries are built on Ubuntu 24.04 and link against glibc 2.38+. -# This routine prints a warning on older distributions so users know upfront. -check_glibc() { - local required="2.38" - local current="" +# Official Linux binaries are built on Ubuntu 24.04 and link against glibc 2.38+ +# plus libstdc++ with GLIBCXX_3.4.32+. Stop early on older distributions so +# setup does not appear successful when the backend cannot start. +version_at_least() { + local current="$1" required="$2" + [[ "$(printf '%s\n' "$required" "$current" | sort -V | head -n1)" == "$required" ]] +} + +detect_glibcxx_version() { + local libstdcpp="" + if command -v ldconfig >/dev/null 2>&1; then + libstdcpp="$(ldconfig -p 2>/dev/null | awk '/libstdc\+\+\.so\.6/{print $NF; exit}')" + fi + if [[ -z "$libstdcpp" ]]; then + for candidate in /usr/lib/x86_64-linux-gnu/libstdc++.so.6 /usr/lib64/libstdc++.so.6 /lib/x86_64-linux-gnu/libstdc++.so.6; do + if [[ -f "$candidate" ]]; then + libstdcpp="$candidate" + break + fi + done + fi + if [[ -n "$libstdcpp" && -f "$libstdcpp" ]] && command -v strings >/dev/null 2>&1; then + strings "$libstdcpp" 2>/dev/null | grep -oE 'GLIBCXX_[0-9]+\.[0-9]+\.[0-9]+' | sed 's/GLIBCXX_//' | sort -V | tail -n1 + fi +} + +check_linux_runtime_abi() { + local required_glibc="2.38" + local required_glibcxx="3.4.32" + local current_glibc="" + local current_glibcxx="" + local unsupported=0 + if command -v ldd >/dev/null 2>&1; then - current="$(ldd --version 2>/dev/null | head -n1 | grep -oP '[0-9]+\.[0-9]+' | head -n1 || true)" + current_glibc="$(ldd --version 2>/dev/null | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -n1 || true)" fi - if [[ -z "$current" ]]; then - print_warn "Could not detect glibc version. Prebuilt Linux backends require glibc $required+ (Ubuntu 24.04)." - return + current_glibcxx="$(detect_glibcxx_version || true)" + + if [[ -z "$current_glibc" ]]; then + print_warn "Could not detect glibc version. Prebuilt Linux backends require glibc $required_glibc+ (Ubuntu 24.04)." + elif ! version_at_least "$current_glibc" "$required_glibc"; then + print_fail "Detected glibc $current_glibc. Prebuilt Linux backends require glibc $required_glibc+ (Ubuntu 24.04 or newer)." + unsupported=1 fi - if [[ "$(printf '%s\n' "$required" "$current" | sort -V | head -n1)" != "$required" ]]; then - print_warn "Detected glibc $current. Prebuilt Linux backends require glibc $required+ (Ubuntu 24.04)." - print_info "You can still use the app, but the downloaded backends will not start on this system." - print_info "To fix: upgrade to Ubuntu 24.04+ or build stable-diffusion.cpp from source (see README)." + + if [[ -z "$current_glibcxx" ]]; then + print_warn "Could not detect GLIBCXX version. Prebuilt Linux backends require GLIBCXX_$required_glibcxx+." + elif ! version_at_least "$current_glibcxx" "$required_glibcxx"; then + print_fail "Detected GLIBCXX_$current_glibcxx. Prebuilt Linux backends require GLIBCXX_$required_glibcxx+." + unsupported=1 fi + + if [[ $unsupported -ne 0 ]]; then + print_info "CyberRealistic and other valid models will fail before loading on this OS because the backend binary cannot start." + print_info "Fix: use Ubuntu 24.04+, Fedora 40+, another glibc 2.38+ distro, or build stable-diffusion.cpp from source on this machine." + if [[ "${UAIS_ALLOW_UNSUPPORTED_LINUX:-0}" == "1" ]]; then + print_warn "Continuing anyway because UAIS_ALLOW_UNSUPPORTED_LINUX=1 is set." + return 0 + fi + return 1 + fi + + print_ok "Linux runtime ABI ready: glibc $current_glibc, GLIBCXX_$current_glibcxx" } copy_binaries_from_extracted() { @@ -255,7 +301,10 @@ copy_macos_backend_from_extracted() { print_header if [[ "$PLATFORM" == "Linux" ]]; then - check_glibc + if ! check_linux_runtime_abi; then + print_fail "Linux setup stopped before downloading incompatible backend binaries." + exit 1 + fi fi TOTAL_STEPS=7 From 1d28391243d4085d150c5e956cf2bf5670bdf33d Mon Sep 17 00:00:00 2001 From: techjarves Date: Thu, 25 Jun 2026 22:28:06 +0530 Subject: [PATCH 2/4] add image generation component with prompt enhancement and dynamic backend synchronization --- app/frontend/src/components/Generator.jsx | 16 ++++++++++++++-- scripts/server/serve.cjs | 10 +++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/frontend/src/components/Generator.jsx b/app/frontend/src/components/Generator.jsx index b0967ab..f9143bc 100644 --- a/app/frontend/src/components/Generator.jsx +++ b/app/frontend/src/components/Generator.jsx @@ -294,6 +294,19 @@ function Generator({ 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; @@ -324,7 +337,6 @@ function Generator({ if (decoding) { remaining = 3; } else { - const numericSpeed = parseFloat(speed); if (!isNaN(numericSpeed) && numericSpeed > 0) { const remainingSteps = Math.max(0, steps - step); if (speed.includes("it/s")) { @@ -339,7 +351,7 @@ function Generator({ } // Detect CPU fallback / slow generation if GPU was requested but it runs slow - if (gpuSelected && !cpuFallbackDetected) { + 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) { diff --git a/scripts/server/serve.cjs b/scripts/server/serve.cjs index 8d79411..b0cbb0d 100644 --- a/scripts/server/serve.cjs +++ b/scripts/server/serve.cjs @@ -4914,6 +4914,8 @@ let generationState = { steps: 0, speed: "", decoding: false, + backendMode: "", + backendDevice: "", }; function resetGenerationState() { @@ -4923,6 +4925,8 @@ function resetGenerationState() { steps: 0, speed: "", decoding: false, + backendMode: "", + backendDevice: "", }; } @@ -6693,7 +6697,11 @@ async function getLlmfitRecommendations(useCase = "chat", limit = 10) { // GET /api/generation-progress if (req.url === "/api/generation-progress" && req.method === "GET") { - return json(res, 200, generationState); + return json(res, 200, { + ...generationState, + backendMode: generationState.backendMode || currentSettings.backendMode || "", + backendDevice: generationState.backendDevice || currentSettings.backendDevice || "", + }); } if (req.url === "/api/openvino-generate" && req.method === "POST") { From 349b9e167f73af36192366892de464ac602202c8 Mon Sep 17 00:00:00 2001 From: techjarves Date: Fri, 26 Jun 2026 07:00:17 +0530 Subject: [PATCH 3/4] add cross-platform reset scripts, portable server manager, and frontend scaffolding --- .gitignore | 1 + app/frontend/src/App.jsx | 77 ++++++++++++---- app/frontend/src/services/api.js | 25 +++++ scripts/reset/reset.ps1 | 14 ++- scripts/reset/reset.sh | 12 ++- scripts/server/serve.cjs | 151 +++++++++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 7cb43f3..bbe008b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ app/config/ app/cache/ app/dist/ app/outputs/ +app/chat-history/ app/transcriptions/ app/tts-outputs/ app/tts-cache/ diff --git a/app/frontend/src/App.jsx b/app/frontend/src/App.jsx index dc92a01..0872619 100644 --- a/app/frontend/src/App.jsx +++ b/app/frontend/src/App.jsx @@ -7,7 +7,7 @@ import Settings from "./components/Settings"; import TextChat from "./components/TextChat"; import SpeechTranscriber from "./components/SpeechTranscriber"; import TextToSpeech from "./components/TextToSpeech"; -import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, deleteSpeechTranscription, listTtsOutputs, deleteTtsOutput, stopServer } from "./services/api"; +import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listLlmConversations, saveLlmConversation, deleteLlmConversation, listSpeechTranscriptions, deleteSpeechTranscription, listTtsOutputs, deleteTtsOutput, stopServer } from "./services/api"; import "./App.css"; function App() { @@ -240,17 +240,6 @@ function App() { const [selectedTtsOutput, setSelectedTtsOutput] = useState(null); const [showTtsHistory, setShowTtsHistory] = useState(false); - useEffect(() => { - const saved = localStorage.getItem("chat_conversations"); - if (saved) { - try { - setConversations(JSON.parse(saved)); - } catch (_) { - localStorage.removeItem("chat_conversations"); - } - } - }, []); - const sanitizeMessageForStorage = (message) => { if (!Array.isArray(message?.content)) return message; return { @@ -272,37 +261,83 @@ function App() { : [], }); - const persistConversations = (list) => { - const compactList = list.map(sanitizeConversationForStorage); + const persistConversation = (conversation) => { + const compactConversation = sanitizeConversationForStorage(conversation); try { - localStorage.setItem("chat_conversations", JSON.stringify(compactList)); + saveLlmConversation(compactConversation).catch((err) => { + console.warn("Could not save chat history to app folder:", err); + }); } catch (err) { console.warn("Could not save chat history:", err); } }; + useEffect(() => { + let cancelled = false; + async function loadConversations() { + let legacyConversations = []; + const legacySaved = localStorage.getItem("chat_conversations"); + if (legacySaved) { + try { + legacyConversations = JSON.parse(legacySaved); + } catch (_) { + localStorage.removeItem("chat_conversations"); + } + } + + try { + const diskConversations = await listLlmConversations(); + const diskIds = new Set(diskConversations.map((conversation) => conversation.id)); + const missingLegacy = legacyConversations + .map(sanitizeConversationForStorage) + .filter((conversation) => conversation.id && !diskIds.has(conversation.id)); + + if (missingLegacy.length > 0) { + await Promise.all(missingLegacy.map((conversation) => saveLlmConversation(conversation))); + } + + const merged = [...diskConversations, ...missingLegacy] + .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)); + if (!cancelled) setConversations(merged); + if (legacySaved) localStorage.removeItem("chat_conversations"); + } catch (err) { + console.warn("Could not load chat history from app folder:", err); + if (!cancelled && legacyConversations.length > 0) { + setConversations(legacyConversations.map(sanitizeConversationForStorage)); + } + } + } + loadConversations(); + return () => { + cancelled = true; + }; + }, []); + const saveConversationState = useCallback((id, msgs, modelName, newTitle = null) => { setConversations((prev) => { const list = [...prev]; const idx = list.findIndex(c => c.id === id); + let conversation; if (idx !== -1) { - list[idx] = { + conversation = { ...list[idx], messages: msgs, timestamp: Date.now(), model: modelName, ...(newTitle ? { title: newTitle } : {}) }; + list[idx] = conversation; } else { - list.unshift({ + conversation = { id, title: newTitle || "Chat Session", model: modelName, messages: msgs, timestamp: Date.now() - }); + }; + list.unshift(conversation); } - persistConversations(list); + persistConversation(conversation); return list; }); }, []); @@ -311,9 +346,11 @@ function App() { if (e) e.stopPropagation(); setConversations((prev) => { const filtered = prev.filter((c) => c.id !== id); - persistConversations(filtered); return filtered; }); + deleteLlmConversation(id).catch((err) => { + console.warn("Could not delete chat history from app folder:", err); + }); if (activeConversationId === id) { setActiveConversationId(null); } diff --git a/app/frontend/src/services/api.js b/app/frontend/src/services/api.js index a719f05..8598305 100644 --- a/app/frontend/src/services/api.js +++ b/app/frontend/src/services/api.js @@ -365,6 +365,31 @@ export async function listLlmModels() { return (data.models || []).map(normalizeModel); } +export async function listLlmConversations() { + const res = await fetch("/api/llm/conversations"); + const data = await readJsonResponse(res, "The local server returned invalid chat history."); + return data.conversations || []; +} + +export async function saveLlmConversation(conversation) { + const res = await fetch("/api/llm/save-conversation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversation }), + }); + const data = await readJsonResponse(res, "The local server returned an invalid chat save response."); + return data.conversation; +} + +export async function deleteLlmConversation(id) { + const res = await fetch("/api/llm/delete-conversation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + return await readJsonResponse(res, "The local server returned an invalid chat delete response."); +} + export async function searchHuggingFaceModels(query = "", filters = [], page = 1) { const params = new URLSearchParams(); if (query.trim()) params.set("query", query.trim()); diff --git a/scripts/reset/reset.ps1 b/scripts/reset/reset.ps1 index 6c9d24f..b9f005c 100644 --- a/scripts/reset/reset.ps1 +++ b/scripts/reset/reset.ps1 @@ -78,6 +78,18 @@ if (Test-Path $ttsModelsDir) { Write-Host " >> Preserving TTS models in app/tts-models." -ForegroundColor Cyan } +# Preserve generated image outputs +$outputsDir = Join-Path $appDir "outputs" +if (Test-Path $outputsDir) { + Write-Host " >> Preserving generated image outputs in app/outputs." -ForegroundColor Cyan +} + +# Preserve chat history +$chatHistoryDir = Join-Path $appDir "chat-history" +if (Test-Path $chatHistoryDir) { + Write-Host " >> Preserving chat history in app/chat-history." -ForegroundColor Cyan +} + # Preserve transcriptions $transcriptionsDir = Join-Path $appDir "transcriptions" if (Test-Path $transcriptionsDir) { @@ -125,7 +137,7 @@ if (Test-Path $lockFile) { Write-Host "" Write-Host " ============================================================" -ForegroundColor Green -Write-Host " Reset complete. Image models, text models, speech models, TTS models, transcripts, TTS outputs, OpenVINO models, and generated outputs were preserved." -ForegroundColor Green +Write-Host " Reset complete. Models, generated images, chat history, transcripts, TTS outputs, TTS cache, and OpenVINO models were preserved." -ForegroundColor Green Write-Host " ============================================================" -ForegroundColor Green Write-Host "" Read-Host " Press Enter to close..." diff --git a/scripts/reset/reset.sh b/scripts/reset/reset.sh index 7dfef68..a16ffea 100644 --- a/scripts/reset/reset.sh +++ b/scripts/reset/reset.sh @@ -72,6 +72,16 @@ if [[ -d "$APP_DIR/tts-models" ]]; then echo " >> Preserving TTS models in app/tts-models." fi +# Preserve generated image outputs +if [[ -d "$APP_DIR/outputs" ]]; then + echo " >> Preserving generated image outputs in app/outputs." +fi + +# Preserve chat history +if [[ -d "$APP_DIR/chat-history" ]]; then + echo " >> Preserving chat history in app/chat-history." +fi + # Preserve transcriptions if [[ -d "$APP_DIR/transcriptions" ]]; then echo " >> Preserving speech transcripts in app/transcriptions." @@ -111,7 +121,7 @@ fi echo "" echo " ============================================================" -echo " Reset complete. Image models, text models, speech models, TTS models, transcripts, TTS outputs, OpenVINO models, and generated outputs were preserved." +echo " Reset complete. Models, generated images, chat history, transcripts, TTS outputs, TTS cache, and OpenVINO models were preserved." echo " ============================================================" echo "" read -rp " Press Enter to close..." diff --git a/scripts/server/serve.cjs b/scripts/server/serve.cjs index b0cbb0d..20200a3 100644 --- a/scripts/server/serve.cjs +++ b/scripts/server/serve.cjs @@ -94,6 +94,10 @@ const LLM_MODELS = path.join(ROOT, "app", "llm-models"); if (!fs.existsSync(LLM_MODELS)) { fs.mkdirSync(LLM_MODELS, { recursive: true }); } +const CHAT_HISTORY = path.join(ROOT, "app", "chat-history"); +if (!fs.existsSync(CHAT_HISTORY)) { + fs.mkdirSync(CHAT_HISTORY, { recursive: true }); +} const LLM_BACKEND_PATHS = { winCuda: path.join(ROOT, "app", "llm-backend", "win", "cuda", "llama-server.exe"), winVulkan: path.join(ROOT, "app", "llm-backend", "win", "vulkan", "llama-server.exe"), @@ -2706,6 +2710,9 @@ function getSetupPaths() { cpuBackend: BACKEND_PATHS.cpu, models: MODELS, outputs: OUTPUTS, + chatHistory: CHAT_HISTORY, + transcriptions: TRANSCRIPTIONS, + ttsOutputs: TTS_OUTPUTS, }; } else if (osPlatform === "darwin") { return { @@ -2715,6 +2722,9 @@ function getSetupPaths() { macBackend: BACKEND_PATHS.mac, models: MODELS, outputs: OUTPUTS, + chatHistory: CHAT_HISTORY, + transcriptions: TRANSCRIPTIONS, + ttsOutputs: TTS_OUTPUTS, }; } else { // Linux / WSL @@ -2727,6 +2737,9 @@ function getSetupPaths() { linuxRocmBackend: BACKEND_PATHS.linuxRocm, models: MODELS, outputs: OUTPUTS, + chatHistory: CHAT_HISTORY, + transcriptions: TRANSCRIPTIONS, + ttsOutputs: TTS_OUTPUTS, }; } } @@ -2739,6 +2752,9 @@ async function getHealth() { getPathInfo("Frontend build", paths.distIndex), getDirInfo("Models folder", paths.models), getDirInfo("Outputs folder", paths.outputs), + getDirInfo("Chat history folder", paths.chatHistory), + getDirInfo("Transcriptions folder", paths.transcriptions), + getDirInfo("TTS outputs folder", paths.ttsOutputs), ]; if (osPlatform === "win32") { @@ -2811,6 +2827,16 @@ async function getHealth() { totalBytes: getPathSize(OUTPUTS), totalSize: formatBytes(getPathSize(OUTPUTS)), }, + chat: { + count: fs.existsSync(CHAT_HISTORY) ? fs.readdirSync(CHAT_HISTORY).filter((file) => file.toLowerCase().endsWith(".json")).length : 0, + totalBytes: getPathSize(CHAT_HISTORY), + totalSize: formatBytes(getPathSize(CHAT_HISTORY)), + }, + speech: { + transcriptions: fs.existsSync(TRANSCRIPTIONS) ? fs.readdirSync(TRANSCRIPTIONS).filter((file) => file.toLowerCase().endsWith(".json")).length : 0, + totalBytes: getPathSize(TRANSCRIPTIONS), + totalSize: formatBytes(getPathSize(TRANSCRIPTIONS)), + }, tts: { models: fs.existsSync(TTS_MODELS) ? fs.readdirSync(TTS_MODELS).filter((file) => file.toLowerCase().endsWith(".json")).length : 0, outputs: fs.existsSync(TTS_OUTPUTS) ? fs.readdirSync(TTS_OUTPUTS).filter((file) => file.toLowerCase().endsWith(".json")).length : 0, @@ -2827,6 +2853,8 @@ function addCleanupCandidate(candidates, id, targetPath, reason, options = {}) { if (!options.allowUserData && ( pathInside(targetPath, MODELS) || pathInside(targetPath, OUTPUTS) || + pathInside(targetPath, CHAT_HISTORY) || + pathInside(targetPath, TRANSCRIPTIONS) || pathInside(targetPath, TTS_MODELS) || pathInside(targetPath, TTS_OUTPUTS) )) return; @@ -5402,6 +5430,101 @@ function safeOutputName(value) { .slice(0, 120); } +function sanitizeChatMessageForStorage(message) { + if (!message || typeof message !== "object") return null; + const role = String(message.role || "").trim(); + if (!["system", "user", "assistant"].includes(role)) return null; + let content = message.content; + if (Array.isArray(content)) { + content = content.map((item) => { + if (!item || typeof item !== "object") return item; + if (item.type === "image_url") { + return { type: "text", text: "[Attached image omitted from saved chat history]" }; + } + return item; + }); + } else if (typeof content !== "string") { + content = content === undefined || content === null ? "" : String(content); + } + return { + ...message, + role, + content, + }; +} + +function sanitizeChatConversationForStorage(conversation = {}) { + const now = Date.now(); + const id = safeOutputName(conversation.id || `chat_${now}`) || `chat_${now}`; + const messages = Array.isArray(conversation.messages) + ? conversation.messages.map(sanitizeChatMessageForStorage).filter(Boolean) + : []; + const timestamp = Number(conversation.timestamp) || now; + return { + id, + title: String(conversation.title || "Chat Session").slice(0, 160), + model: String(conversation.model || ""), + messages, + timestamp, + createdAt: conversation.createdAt || new Date(timestamp).toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +function getChatConversationPath(id) { + const safeId = safeOutputName(id); + if (!safeId) throw new Error("A chat id is required."); + const filePath = path.join(CHAT_HISTORY, `${safeId}.json`); + if (!pathInside(filePath, CHAT_HISTORY)) { + throw new Error("Invalid chat id."); + } + return filePath; +} + +function saveChatConversation(conversation = {}) { + const saved = sanitizeChatConversationForStorage(conversation); + const filePath = getChatConversationPath(saved.id); + fs.writeFileSync(filePath, JSON.stringify(saved, null, 2), "utf8"); + return saved; +} + +function listChatConversations() { + try { + return fs.readdirSync(CHAT_HISTORY) + .filter((file) => file.toLowerCase().endsWith(".json")) + .map((file) => { + try { + const filePath = path.join(CHAT_HISTORY, file); + if (!pathInside(filePath, CHAT_HISTORY)) return null; + const conversation = JSON.parse(fs.readFileSync(filePath, "utf8")); + const stat = fs.statSync(filePath); + return { + ...conversation, + filename: file, + modifiedAt: stat.mtime.toISOString(), + sizeBytes: stat.size, + size: formatBytes(stat.size), + }; + } catch (_) { + return null; + } + }) + .filter(Boolean) + .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)); + } catch (_) { + return []; + } +} + +function deleteChatConversation(id) { + const filePath = getChatConversationPath(id); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; + } + return false; +} + function saveGeneratedOutput(imageDataUrl, metadata = {}) { const match = String(imageDataUrl || "").match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/); if (!match) { @@ -5699,6 +5822,34 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { ok: true, model: filename, settings: updated }); } + if (req.url === "/api/llm/conversations" && req.method === "GET") { + return json(res, 200, { ok: true, conversations: listChatConversations() }); + } + + if (req.url === "/api/llm/save-conversation" && req.method === "POST") { + const body = await readJsonBody(req, res); + if (!body) return; + try { + const conversation = saveChatConversation(body.conversation || body); + return json(res, 200, { ok: true, conversation }); + } catch (err) { + console.error(" [api] Failed to save chat conversation:", err); + return json(res, 500, { ok: false, error: err.message || String(err) }); + } + } + + if (req.url === "/api/llm/delete-conversation" && req.method === "POST") { + const body = await readJsonBody(req, res); + if (!body) return; + try { + const deleted = deleteChatConversation(body.id); + return json(res, 200, { ok: true, deleted }); + } catch (err) { + console.error(" [api] Failed to delete chat conversation:", err); + return json(res, 500, { ok: false, error: err.message || String(err) }); + } + } + if (req.url === "/api/llm/models" && req.method === "GET") { return json(res, 200, { models: getLlmModels() }); } From 690cce5cc317a694fab2f7aa86a3cb06f2d424a2 Mon Sep 17 00:00:00 2001 From: jarves Date: Fri, 26 Jun 2026 10:09:38 +0530 Subject: [PATCH 4/4] implement cross-platform tar extraction utility with symlink resolution fallback for setup scripts --- scripts/setup/extract_tar.py | 124 +++++++++++++++++++++++++++++++++ scripts/setup/setup-llama.sh | 6 +- scripts/setup/setup-whisper.sh | 6 +- 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 scripts/setup/extract_tar.py diff --git a/scripts/setup/extract_tar.py b/scripts/setup/extract_tar.py new file mode 100644 index 0000000..d832be7 --- /dev/null +++ b/scripts/setup/extract_tar.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +import os +import sys +import shutil +import tarfile +import argparse + +def main(): + parser = argparse.ArgumentParser(description="Extract tar archives, resolving symlinks as copies if symlinks are not supported.") + parser.add_argument("--archive", required=True, help="Path to the tar archive") + parser.add_argument("--dest", required=True, help="Destination directory") + parser.add_argument("--strip-components", type=int, default=0, help="Number of leading components to strip") + args = parser.parse_args() + + archive_path = args.archive + dest_dir = args.dest + strip_components = args.strip_components + + if not os.path.exists(archive_path): + print(f"Error: Archive not found: {archive_path}", file=sys.stderr) + sys.exit(1) + + os.makedirs(dest_dir, exist_ok=True) + + # Check if filesystem supports symlinks in dest_dir + test_link = os.path.join(dest_dir, f".test_symlink_{os.getpid()}") + use_symlinks = True + try: + if os.path.lexists(test_link): + os.unlink(test_link) + os.symlink("test_target", test_link) + os.unlink(test_link) + except OSError: + use_symlinks = False + + print(f"Extracting {archive_path} to {dest_dir} (strip-components={strip_components}, use_symlinks={use_symlinks})...") + + try: + with tarfile.open(archive_path, "r") as tar: + members = tar.getmembers() + + # Map of stripped_path -> target (for symlinks) + symlinks_dict = {} + regular_members = [] + + for member in members: + parts = member.name.split('/') + # Strip leading components + if len(parts) <= strip_components: + continue + stripped_name = '/'.join(parts[strip_components:]) + if not stripped_name: + continue + + # Update the member name in-place + member.name = stripped_name + + if member.issym() or member.islnk(): + symlinks_dict[member.name] = member.linkname + else: + regular_members.append(member) + + # Extract regular files and directories first + tar.extractall(path=dest_dir, members=regular_members) + + # Now handle symlinks + for link_path, link_target in symlinks_dict.items(): + full_link_path = os.path.join(dest_dir, link_path) + + # Resolve final target recursively in case of nested symlinks + link_dir = os.path.dirname(full_link_path) + + # Recursive resolver helper + def resolve_final_target(current_path, current_target): + # target_abs is the absolute path to the target file/dir + target_abs = os.path.normpath(os.path.join(os.path.dirname(current_path), current_target)) + # Check if target_abs is inside dest_dir + rel_to_dest = os.path.relpath(target_abs, dest_dir) + if rel_to_dest in symlinks_dict: + next_target = symlinks_dict[rel_to_dest] + return resolve_final_target(target_abs, next_target) + return target_abs + + target_abs = resolve_final_target(full_link_path, link_target) + + # Ensure parent directory exists + os.makedirs(link_dir, exist_ok=True) + + if os.path.lexists(full_link_path): + try: + os.unlink(full_link_path) + except OSError: + shutil.rmtree(full_link_path, ignore_errors=True) + + if use_symlinks: + try: + os.symlink(link_target, full_link_path) + except OSError: + # Fallback to copy if symlink creation fails unexpectedly + copy_fallback(target_abs, full_link_path) + else: + copy_fallback(target_abs, full_link_path) + + print("Extraction completed successfully.") + except Exception as e: + print(f"Error extracting archive: {e}", file=sys.stderr) + sys.exit(1) + +def copy_fallback(src, dst): + if not os.path.exists(src): + # Target doesn't exist. Maybe it's an external link or wasn't extracted. + print(f"Warning: Symlink target does not exist, skipping copy: {src}", file=sys.stderr) + return + try: + if os.path.isdir(src): + shutil.copytree(src, dst) + else: + shutil.copy2(src, dst) + print(f"Created fallback copy: {dst} -> {src}") + except Exception as e: + print(f"Error copying {src} to {dst}: {e}", file=sys.stderr) + +if __name__ == "__main__": + main() diff --git a/scripts/setup/setup-llama.sh b/scripts/setup/setup-llama.sh index b849226..3b3a4d7 100644 --- a/scripts/setup/setup-llama.sh +++ b/scripts/setup/setup-llama.sh @@ -25,7 +25,11 @@ download_and_extract() { echo " >> Downloading $asset" curl -fSL --progress-bar "$url" -o "$archive.part" mv "$archive.part" "$archive" - tar -xzf "$archive" -C "$dest" --strip-components=1 + if command -v python3 >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/extract_tar.py" --archive "$archive" --dest "$dest" --strip-components=1 + else + tar -xzf "$archive" -C "$dest" --strip-components=1 + fi rm -f "$archive" chmod +x "$dest"/llama-* 2>/dev/null || true diff --git a/scripts/setup/setup-whisper.sh b/scripts/setup/setup-whisper.sh index 80d5b78..83f1bda 100644 --- a/scripts/setup/setup-whisper.sh +++ b/scripts/setup/setup-whisper.sh @@ -25,7 +25,11 @@ download_and_extract() { echo " >> Downloading $asset" curl -fSL --progress-bar "$url" -o "$archive.part" mv "$archive.part" "$archive" - tar -xzf "$archive" -C "$dest" --strip-components=1 + if command -v python3 >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/extract_tar.py" --archive "$archive" --dest "$dest" --strip-components=1 + else + tar -xzf "$archive" -C "$dest" --strip-components=1 + fi rm -f "$archive" chmod +x "$dest"/whisper-* "$dest"/main "$dest"/server 2>/dev/null || true