Compare commits
10
Commits
ba987db776
...
76946f8594
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76946f8594 | ||
|
|
313b994c9b | ||
|
|
46a0eec121 | ||
|
|
1ee1f56f6c | ||
|
|
1cfd5efe16 | ||
|
|
690cce5cc3 | ||
|
|
349b9e167f | ||
|
|
bb2935d3af | ||
|
|
1d28391243 | ||
|
|
45540aee29 |
No files matched your search
@@ -17,6 +17,7 @@ app/config/
|
||||
app/cache/
|
||||
app/dist/
|
||||
app/outputs/
|
||||
app/chat-history/
|
||||
app/transcriptions/
|
||||
app/tts-outputs/
|
||||
app/tts-cache/
|
||||
|
||||
@@ -1,239 +1,275 @@
|
||||
# 🖼️ Uncensored AI Studio
|
||||
# Uncensored AI Studio
|
||||
|
||||
<p align="center">
|
||||
<strong>A premium, zero-configuration local AI studio and offline GUI for Stable Diffusion (Image Generation), LLMs (Chat), Whisper (Speech-to-Text), and Kokoro (Text-to-Speech). Powered by hardware-accelerated GPU and NPU execution on Windows, Linux, and macOS.</strong>
|
||||
<strong>Uno studio AI locale premium, senza configurazione, e una GUI offline per Stable Diffusion (generazione immagini), LLM (chat), Whisper (speech-to-text) e Kokoro (text-to-speech). Alimentato da esecuzione accelerata via GPU e NPU su Windows, Linux e macOS.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Offline-100%25-green?style=for-the-badge&logo=offline" alt="100% Offline" />
|
||||
<img src="https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=for-the-badge" alt="Platforms" />
|
||||
<img src="https://img.shields.io/badge/License-MIT-orange?style=for-the-badge" alt="License" />
|
||||
<img src="https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=for-the-badge" alt="Piattaforme" />
|
||||
<img src="https://img.shields.io/badge/License-MIT-orange?style=for-the-badge" alt="Licenza" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🎥 <strong>Watch the Setup & Demo Video:</strong> <a href="https://youtu.be/ESELhY-G_9w">https://youtu.be/ESELhY-G_9w</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://youtu.be/ESELhY-G_9w">
|
||||
<img src="https://img.youtube.com/vi/ESELhY-G_9w/maxresdefault.jpg" alt="Uncensored AI Studio Video Tutorial" width="800" style="border-radius: 8px;" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
## 📖 Indice
|
||||
* [Che cos’è Uncensored AI Studio?](#what-is-uncensored-ai-studio)
|
||||
* [Funzionalità principali](#key-features)
|
||||
* [Architettura workspace ed engine](#workspace-architecture)
|
||||
* [Modelli supportati](#supported-models)
|
||||
* [Architettura delle cartelle](#folder-architecture)
|
||||
* [Guida introduttiva](#getting-started)
|
||||
* [Configurazione Windows](#windows-setup)
|
||||
* [Configurazione Linux](#linux-setup)
|
||||
* [Configurazione macOS](#macos-setup)
|
||||
* [Compatibilità hardware e accelerazione](#hardware-compatibility-acceleration)
|
||||
* [Risoluzione problemi e FAQ](#troubleshooting-faq)
|
||||
* [Compilazione da sorgente](#building-from-source)
|
||||
* [Licenza](#licensing)
|
||||
|
||||
---
|
||||
|
||||
## <a id="what-is-uncensored-ai-studio"></a>📖 Che cos’è Uncensored AI Studio?
|
||||
|
||||
## 📖 Table of Contents
|
||||
* [What is Uncensored AI Studio?](#what-is-uncensored-ai-studio)
|
||||
* [Key Features](#key-features)
|
||||
* [Workspace & Engine Architecture](#workspace-architecture)
|
||||
* [Folder Architecture](#folder-architecture)
|
||||
* [Getting Started](#getting-started)
|
||||
* [Windows Setup](#windows-setup)
|
||||
* [Linux Setup](#linux-setup)
|
||||
* [macOS Setup](#macos-setup)
|
||||
* [Hardware Compatibility & Acceleration](#hardware-compatibility-acceleration)
|
||||
* [Troubleshooting & FAQ](#troubleshooting-faq)
|
||||
* [Building From Source](#building-from-source)
|
||||
* [Licensing](#licensing)
|
||||
**Uncensored AI Studio** è uno studio AI completamente offline, senza configurazione e autonomo per Windows, Linux e macOS. A differenza dei sistemi AI basati su cloud, funziona interamente sul tuo hardware, senza censura, tracciamento, abbonamenti o requisiti di login.
|
||||
|
||||
Unifica quattro grandi funzionalità AI locali in un’unica interfaccia desktop ad alte prestazioni:
|
||||
1. **🎨 Generazione immagini (Stable Diffusion):** genera e modifica immagini di alta qualità offline usando pesi modello `.safetensors`, `.gguf` o `.ckpt`.
|
||||
2. **💬 Chat testuale (LLM):** conversa in privato con modelli linguistici open-source (formato GGUF) alimentati dai backend ufficiali e ad alte prestazioni `llama.cpp`.
|
||||
3. **🎙️ Speech-to-Text (Whisper):** trascrive registrazioni vocali e parlato in testo in tempo reale con un motore `whisper.cpp` integrato.
|
||||
4. **🗣️ Text-to-Speech (Kokoro TTS):** converte output testuali in audio vocale altamente naturale e realistico offline usando il modello ONNX `Kokoro-82M`.
|
||||
|
||||
---
|
||||
|
||||
## <a id="what-is-uncensored-ai-studio"></a>📖 What is Uncensored AI Studio?
|
||||
## <a id="key-features"></a>🌟 Funzionalità principali
|
||||
|
||||
**Uncensored AI Studio** is a completely offline, zero-setup, self-contained AI studio for Windows, Linux, and macOS. Unlike cloud-based AI systems, it runs entirely on your own hardware with no censorship, tracking, subscriptions, or login requirements.
|
||||
|
||||
It unifies four major local AI capabilities into one high-performance desktop interface:
|
||||
1. **🎨 Image Generation (Stable Diffusion):** Generate and edit high-quality images offline using `.safetensors`, `.gguf`, or `.ckpt` model weights.
|
||||
2. **💬 Text Chat (LLMs):** Converse privately with open-source language models (GGUF format) powered by official, high-performance `llama.cpp` backends.
|
||||
3. **🎙️ Speech-to-Text (Whisper):** Transcribe voice recordings and speech to text in real-time with an integrated `whisper.cpp` engine.
|
||||
4. **🗣️ Text-to-Speech (Kokoro TTS):** Convert text outputs into highly natural, lifelike vocal audio offline using the `Kokoro-82M` ONNX model.
|
||||
* **100% offline e privato:** esegue le inferenze localmente. Non richiede internet, telemetria, logging cloud o chiavi API.
|
||||
* **Portabilità senza installazione:** l’intero runtime (Node.js, modelli, backend GPU) è autonomo. Nessuna modifica globale all’ambiente di sistema.
|
||||
* **Accelerazione autoconfigurata:** rileva automaticamente le specifiche hardware per caricare backend CUDA (Nvidia), ROCm (AMD), Vulkan (Intel/AMD/NVIDIA), Metal (macOS) o OpenVINO (Intel NPU).
|
||||
* **Gestore modelli integrato:** incolla URL Hugging Face per scaricare direttamente i pesi, oppure trascina e rilascia pesi locali per importarli.
|
||||
* **Monitor prestazioni live:** monitora l’utilizzo di CPU, RAM, GPU e VRAM in tempo reale direttamente nella web UI.
|
||||
* **Galleria output locale:** salva le immagini generate affiancate ai parametri del prompt e ai file JSON dei metadati.
|
||||
|
||||
---
|
||||
|
||||
## <a id="key-features"></a>🌟 Key Features
|
||||
## <a id="workspace-architecture"></a>⚙️ Architettura workspace ed engine
|
||||
|
||||
* **100% Offline & Private:** Run inferences locally. No internet, telemetry, cloud logging, or API keys required.
|
||||
* **Zero-Install Portability:** Entire runtime (Node.js, models, GPU backends) is self-contained. Zero global system environment changes.
|
||||
* **Auto-Configured Acceleration:** Auto-detects hardware specs to load CUDA (Nvidia), ROCm (AMD), Vulkan (Intel/AMD/NVIDIA), Metal (macOS), or OpenVINO (Intel NPU) backends.
|
||||
* **Integrated Model Manager:** Paste Hugging Face URLs to download weights directly, or drag-and-drop local weights to import them.
|
||||
* **Live Performance Monitor:** Track CPU, RAM, GPU, and VRAM utilization in real-time directly inside the web UI.
|
||||
* **Local Output Gallery:** Saves generated images side-by-side with prompt parameters and metadata JSON files.
|
||||
Per evitare di esaurire la RAM o la VRAM di sistema, i motori testuali e quelli per immagini sono mutuamente esclusivi per impostazione predefinita. Puoi passare da un workspace all’altro dentro la UI:
|
||||
|
||||
* **Workspace generazione immagini:** usa un nodo backend dedicato `stable-diffusion.cpp`. I pesi dei modelli sono archiviati in `app/models/`.
|
||||
* **Workspace chat testuale:** usa un backend server portatile `llama.cpp`. I pesi dei modelli (.gguf) sono archiviati in `app/llm-models/`. Un piccolo modello iniziale Qwen2.5 Coder può essere scaricato direttamente dal pannello Text Chat.
|
||||
* **Worker vocale (Whisper):** esegue un processo localizzato `whisper-cli` per convertire il tuo input vocale in testo.
|
||||
* **Output audio (Kokoro TTS):** utilizza `kokoro-js` localmente lato server per leggere le risposte con voci naturali.
|
||||
|
||||
---
|
||||
|
||||
## <a id="workspace-architecture"></a>⚙️ Workspace & Engine Architecture
|
||||
## <a id="supported-models"></a>Modelli supportati
|
||||
|
||||
To avoid exhausting system RAM or VRAM, text and image engines are mutually exclusive by default. You can switch between workspaces inside the UI:
|
||||
L’app è progettata intorno a modelli locali a file singolo che possono essere caricati direttamente dai motori backend inclusi.
|
||||
|
||||
* **Image Generation Workspace:** Uses a dedicated `stable-diffusion.cpp` backend node. Model weights are stored in `app/models/`.
|
||||
* **Text Chat Workspace:** Uses a portable `llama.cpp` server backend. Model weights (.gguf) are stored in `app/llm-models/`. A small Qwen2.5 Coder starter model can be downloaded directly from the Text Chat panel.
|
||||
* **Speech Worker (Whisper):** Runs a localized `whisper-cli` process to convert your vocal input to text.
|
||||
* **Audio Output (Kokoro TTS):** Utilizes `kokoro-js` locally on the server side to read responses in natural voices.
|
||||
### Generazione immagini
|
||||
|
||||
| Tipo di modello | Supportato | Inserire i file in | Note |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| Checkpoint Stable Diffusion 1.5 | Sì | `app/models/` | Migliore compatibilità. Usa file `.safetensors` o `.ckpt`. |
|
||||
| Checkpoint SDXL | Sì | `app/models/` | Supportati come checkpoint a file singolo. Richiedono più RAM/VRAM rispetto a SD 1.5. |
|
||||
| Checkpoint SD/SDXL GGUF a file singolo | Limitato | `app/models/` | Sono supportati solo checkpoint completi a file singolo. |
|
||||
| Cartelle modelli immagine OpenVINO | Solo Intel NPU | `app/openvino-models/` | Scarica dal Model Manager dopo aver eseguito la configurazione OpenVINO. |
|
||||
| Modelli immagine CoreML | Solo Apple Silicon | `app/models/` | Richiede macOS su Apple Silicon e il percorso di configurazione CoreML. |
|
||||
| Workflow Flux, HiDream, Hunyuan, Wan, Qwen Image, Z-Image | No | N/A | Di solito richiedono file separati per diffusion, VAE e text encoder e non sono caricamenti one-click di checkpoint in questa app. |
|
||||
| File LoRA, ControlNet, solo VAE, solo text encoder o solo diffusion | No | N/A | I file companion non vengono caricati come modelli immagine autonomi. |
|
||||
|
||||
Modelli immagine noti e funzionanti disponibili dal Model Manager:
|
||||
|
||||
| Nome | Nome file | Tipo | Dimensione approx. | Uso consigliato |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| Juggernaut XL v9 Lightning | `Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors` | SDXL | 6,6 GB | Fotorealismo di alta qualità su macchine di fascia media/alta. |
|
||||
| DreamShaper XL Lightning | `DreamShaperXL_Lightning.safetensors` | SDXL | 6,6 GB | Immagini SDXL generiche, fantasy, render e illustrazione. |
|
||||
| DreamShaper 8 | `DreamShaper_8_pruned.safetensors` | SD 1.5 | 2,1 GB | Generazione immagini più veloce e con meno memoria. |
|
||||
| CyberRealistic V8 | `CyberRealistic_V8_FP16.safetensors` | SD 1.5 | 2,0 GB | Immagini realistiche SD 1.5 e sistemi con meno memoria. |
|
||||
| Rev Animated | `rev-animated-v1-2-2.safetensors` | SD 1.5 | 2,0 GB | Immagini stilizzate/anime SD 1.5. |
|
||||
| LCM DreamShaper OpenVINO | `OpenVINO/LCM_Dreamshaper_v7-fp16-ov` | OpenVINO | 2,7 GB | Modello di test Intel Core Ultra NPU. |
|
||||
|
||||
### Testo, voce e TTS
|
||||
|
||||
| Workspace | File modello supportati | Inserire i file in | Note |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| Chat testuale | Modelli `.gguf` llama.cpp | `app/llm-models/` | Usa modelli chat/instruct GGUF a file singolo. I modelli vision possono richiedere anche un file `mmproj` corrispondente. |
|
||||
| Speech-to-Text | Modelli whisper.cpp `.bin` | `app/speech-models/` | Usa file modello Whisper GGML/whisper.cpp. |
|
||||
| Text-to-Speech | Manifest `.json` Kokoro e asset modello | `app/tts-models/` / `app/tts-runtime/` | Usa la configurazione Kokoro integrata e le voci del Model Manager. |
|
||||
|
||||
> [!NOTE]
|
||||
> I binari della release Linux sono compilati per sistemi dell’era Ubuntu 24.04 e richiedono `glibc 2.38+` più `GLIBCXX_3.4.32+`. Su VM Ubuntu/Debian più vecchie, un modello come CyberRealistic può essere valido, ma il backend può comunque fallire prima di caricarlo. Aggiorna il sistema operativo della VM oppure compila il backend da sorgente.
|
||||
|
||||
---
|
||||
|
||||
## <a id="folder-architecture"></a>📁 Folder Architecture
|
||||
## <a id="folder-architecture"></a>📁 Architettura delle cartelle
|
||||
|
||||
```
|
||||
Uncensored-AI-Studio/
|
||||
├── windows.bat # Windows Launcher (Double-click entrypoint)
|
||||
├── linux.sh # Linux Launcher (Terminal entrypoint)
|
||||
├── mac.sh # macOS Launcher (Terminal entrypoint)
|
||||
├── LICENSE # MIT Open Source License
|
||||
├── .gitignore # Excludes models and output images from version control
|
||||
├── README.md # Detailed system documentation
|
||||
├── windows.bat # Launcher Windows (punto di ingresso con doppio clic)
|
||||
├── linux.sh # Launcher Linux (punto di ingresso da terminale)
|
||||
├── mac.sh # Launcher macOS (punto di ingresso da terminale)
|
||||
├── LICENSE # Licenza open source MIT
|
||||
├── .gitignore # Esclude modelli e immagini di output dal controllo versione
|
||||
├── README.md # Documentazione dettagliata del sistema
|
||||
├── scripts/
|
||||
│ ├── setup/ # Platform setup and backend installers
|
||||
│ ├── reset/ # Clean install & environment repair
|
||||
│ ├── server/ # UI web server and backend lifecycle manager
|
||||
│ ├── workers/ # Local worker processes
|
||||
│ ├── build/ # Optional source build helpers
|
||||
│ └── config/ # Runtime configuration catalogs
|
||||
│ ├── setup/ # Configurazione piattaforma e installer backend
|
||||
│ ├── reset/ # Installazione pulita e riparazione ambiente
|
||||
│ ├── server/ # Web server UI e gestore ciclo di vita backend
|
||||
│ ├── workers/ # Processi worker locali
|
||||
│ ├── build/ # Helper opzionali per build da sorgente
|
||||
│ └── config/ # Cataloghi configurazione runtime
|
||||
└── app/
|
||||
├── frontend/ # UI source code (Vite + React)
|
||||
├── models/ # Place image weights here (.safetensors, .gguf, .ckpt)
|
||||
├── llm-models/ # Place text GGUF weights here
|
||||
└── outputs/ # Saved images and parameters metadata
|
||||
├── frontend/ # Codice sorgente UI (Vite + React)
|
||||
├── models/ # Inserisci qui i pesi immagine (.safetensors, .gguf, .ckpt)
|
||||
├── llm-models/ # Inserisci qui i pesi testuali GGUF
|
||||
└── outputs/ # Immagini salvate e metadati parametri
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## <a id="getting-started"></a>🚀 Getting Started
|
||||
## <a id="getting-started"></a>🚀 Guida introduttiva
|
||||
|
||||
Ensure you have a modern web browser installed. Follow the quick guide below for your platform:
|
||||
Assicurati di avere installato un browser web moderno. Segui la guida rapida qui sotto per la tua piattaforma:
|
||||
|
||||
### Windows Setup
|
||||
### Configurazione Windows
|
||||
|
||||
1. **Launch:** Double-click **`windows.bat`**.
|
||||
1. **Avvio:** fai doppio clic su **`windows.bat`**.
|
||||
> [!NOTE]
|
||||
> On the first run, the script will automatically download a portable Node.js runtime and configure pre-compiled GPU/CPU backend binaries.
|
||||
2. **Add Models:** Drop `.safetensors`, `.gguf`, or `.ckpt` weights into `app/models/` (or download them via the **Model Manager** tab in the UI).
|
||||
3. **Generate:** Open `http://localhost:1420` in your browser, select your model, and write a prompt.
|
||||
> Al primo avvio, lo script scaricherà automaticamente un runtime Node.js portatile e configurerà binari backend GPU/CPU precompilati.
|
||||
2. **Aggiungi modelli:** trascina file `.safetensors`, `.gguf` o `.ckpt` dentro `app/models/` oppure scaricali tramite la scheda **Model Manager** nella UI.
|
||||
3. **Genera:** apri `http://localhost:1420` nel browser, seleziona il modello e scrivi un prompt.
|
||||
|
||||
### Linux Setup
|
||||
### Configurazione Linux
|
||||
|
||||
1. **Make executable:** Open a terminal in the project folder and make the script executable:
|
||||
1. **Rendi eseguibile:** apri un terminale nella cartella del progetto e rendi eseguibile lo script:
|
||||
```bash
|
||||
chmod +x linux.sh
|
||||
```
|
||||
2. **Launch:** Run **`./linux.sh`**.
|
||||
- **NVIDIA GPU Users:** You will be prompted to set up the high-performance **CUDA** backend (downloads prebuilt or automatically compiles from source as a fallback).
|
||||
- **AMD Radeon Performance:** Run with **`./linux.sh --max-perf`** to add the ROCm backend (~1.3 GB download).
|
||||
- **Intel Core Ultra NPU:** Run with **`./linux.sh --setup-openvino`** to configure Intel NPU support (requires Intel Linux NPU driver).
|
||||
3. **Add Models:** Drop your weights into `app/models/` or download them via the **Model Manager** tab.
|
||||
4. **Generate:** Open `http://localhost:1420` in your browser.
|
||||
2. **Avvio:** esegui **`./linux.sh`**.
|
||||
- **Utenti GPU NVIDIA:** ti verrà chiesto di configurare il backend **CUDA** ad alte prestazioni (scarica precompilato o compila automaticamente da sorgente come fallback).
|
||||
- **Prestazioni AMD Radeon:** esegui con **`./linux.sh --max-perf`** per aggiungere il backend ROCm (~1,3 GB di download).
|
||||
- **Intel Core Ultra NPU:** esegui con **`./linux.sh --setup-openvino`** per configurare il supporto Intel NPU (richiede driver Intel Linux NPU).
|
||||
3. **Aggiungi modelli:** trascina i pesi dentro `app/models/` oppure scaricali tramite la scheda **Model Manager**.
|
||||
4. **Genera:** apri `http://localhost:1420` nel browser.
|
||||
|
||||
### macOS Setup
|
||||
### Configurazione macOS
|
||||
|
||||
1. **Make executable:** Open a terminal in the project folder and make the script executable:
|
||||
1. **Rendi eseguibile:** apri un terminale nella cartella del progetto e rendi eseguibile lo script:
|
||||
```bash
|
||||
chmod +x mac.sh
|
||||
```
|
||||
2. **Launch:** Run **`./mac.sh`**.
|
||||
2. **Avvio:** esegui **`./mac.sh`**.
|
||||
> [!IMPORTANT]
|
||||
> The prebuilt macOS backend is optimized for **Apple Silicon (M1 or newer)** and uses **Metal** GPU acceleration. *(macOS Intel hardware is completely unsupported)*.
|
||||
3. **Add Models:** Drop your weights into `app/models/` or download them via the **Model Manager** tab.
|
||||
4. **Generate:** Open `http://localhost:1420` in your browser.
|
||||
> Il backend macOS precompilato è ottimizzato per **Apple Silicon (M1 o più recente)** e usa accelerazione GPU **Metal**. *(L’hardware macOS Intel non è supportato in alcun modo)*.
|
||||
3. **Aggiungi modelli:** trascina i pesi dentro `app/models/` oppure scaricali tramite la scheda **Model Manager**.
|
||||
4. **Genera:** apri `http://localhost:1420` nel browser.
|
||||
|
||||
---
|
||||
|
||||
## <a id="hardware-compatibility-acceleration"></a>🖥️ Hardware Compatibility & Acceleration
|
||||
## <a id="hardware-compatibility-acceleration"></a>🖥️ Compatibilità hardware e accelerazione
|
||||
|
||||
### Windows
|
||||
|
||||
| GPU Vendor | Tech | Status | Notes |
|
||||
| Produttore GPU | Tecnologia | Stato | Note |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Nvidia** | CUDA | ✅ Native | Maps `sd-cuda.exe` with Nvidia SDK 12 optimizations. |
|
||||
| **AMD Radeon** | Vulkan | ✅ Native | Maps `sd-vulkan.exe` with Vulkan API acceleration. |
|
||||
| **Intel Arc** | Vulkan | ✅ Native | Maps `sd-vulkan.exe` for Intel hardware. |
|
||||
| **Integrated / None** | CPU | ⚠️ Fallback | Runs on logical CPU threads (slow). |
|
||||
| **Nvidia** | CUDA | ✅ Nativo | Mappa `sd-cuda.exe` con ottimizzazioni Nvidia SDK 12. |
|
||||
| **AMD Radeon** | Vulkan | ✅ Nativo | Mappa `sd-vulkan.exe` con accelerazione API Vulkan. |
|
||||
| **Intel Arc** | Vulkan | ✅ Nativo | Mappa `sd-vulkan.exe` per hardware Intel. |
|
||||
| **Integrata / Nessuna** | CPU | ⚠️ Fallback | Esegue sui thread logici della CPU (lento). |
|
||||
|
||||
### Linux
|
||||
|
||||
| GPU Vendor | Primary | Fallback | Notes |
|
||||
| Produttore GPU | Primario | Fallback | Note |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **NVIDIA** | CUDA / Vulkan | Vulkan / CPU | Auto-detects NVIDIA. Prompt-driven CUDA setup downloads prebuilt or compiles from source. Falls back to Vulkan for GTX. |
|
||||
| **AMD Radeon** | ROCm | Vulkan | ROCm provides best AMD performance when host ROCm drivers are available. |
|
||||
| **Intel Arc / integrated** | Vulkan | CPU | Cross-vendor Vulkan support. |
|
||||
| **Intel Core Ultra NPU** | OpenVINO NPU | CPU | Requires the Intel Linux NPU driver, kernel 6.6+, Python 3, and `./linux.sh --setup-openvino`. |
|
||||
| **Integrated / None** | CPU | — | Runs on logical CPU threads (slow). |
|
||||
| **NVIDIA** | CUDA / Vulkan | Vulkan / CPU | Rileva automaticamente NVIDIA. La configurazione CUDA guidata scarica il precompilato o compila da sorgente. Ripiega su Vulkan per GTX. |
|
||||
| **AMD Radeon** | ROCm | Vulkan | ROCm fornisce le migliori prestazioni AMD quando sono disponibili i driver ROCm dell’host. |
|
||||
| **Intel Arc / integrata** | Vulkan | CPU | Supporto Vulkan cross-vendor. |
|
||||
| **Intel Core Ultra NPU** | OpenVINO NPU | CPU | Richiede driver Intel Linux NPU, kernel 6.6+, Python 3 e `./linux.sh --setup-openvino`. |
|
||||
| **Integrata / Nessuna** | CPU | — | Esegue sui thread logici della CPU (lento). |
|
||||
|
||||
### macOS
|
||||
|
||||
| Hardware | Primary | Fallback | Notes |
|
||||
| Hardware | Primario | Fallback | Note |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Apple Silicon (M1 or newer)** | Metal | CPU | Uses the official Darwin arm64 stable-diffusion.cpp backend. |
|
||||
| **Apple Silicon (M1 o più recente)** | Metal | CPU | Usa il backend ufficiale Darwin arm64 stable-diffusion.cpp. |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **System Requirements & Notes:**
|
||||
> - **64-bit Windows 10 or Windows 11** is required for the portable Node.js 22 runtime used by the Windows launcher.
|
||||
> - **glibc 2.38 or newer** is required for the prebuilt Linux backends (Ubuntu 24.04, Fedora 40+, etc.). The setup script will warn you if your glibc is older.
|
||||
> - **Linux OpenVINO NPU:** Intel Core Ultra, x86_64 Linux, kernel 6.6+, a working `/dev/accel/accel0` device, Python 3 with `venv`, and the Intel Linux NPU driver are required.
|
||||
> **Requisiti di sistema e note:**
|
||||
> - È richiesto **Windows 10 o Windows 11 a 64 bit** per il runtime Node.js 22 portatile usato dal launcher Windows.
|
||||
> - È richiesto **glibc 2.38 o più recente** per i backend Linux precompilati (Ubuntu 24.04, Fedora 40+, ecc.). Lo script di configurazione ti avviserà se la tua glibc è più vecchia.
|
||||
> - **Linux OpenVINO NPU:** sono richiesti Intel Core Ultra, Linux x86_64, kernel 6.6+, un dispositivo `/dev/accel/accel0` funzionante, Python 3 con `venv` e il driver Intel Linux NPU.
|
||||
|
||||
---
|
||||
|
||||
## <a id="troubleshooting-faq"></a>🛠️ Troubleshooting & FAQ
|
||||
## <a id="troubleshooting-faq"></a>🛠️ Risoluzione problemi e FAQ
|
||||
|
||||
<details>
|
||||
<summary><strong> Reset Environment: If a build fails or you want to clear dependencies</strong></summary>
|
||||
<p>Run <code>scripts/reset/reset.ps1</code> (Windows) or <code>scripts/reset/reset.sh</code> (Linux/macOS). This will clear temporary compilation and package caches to repair your environment. <em>(Note: This preserves your model weights and generated output images).</em></p>
|
||||
<summary><strong> Reset ambiente: se una build fallisce o vuoi cancellare le dipendenze</strong></summary>
|
||||
<p>Esegui <code>scripts/reset/reset.ps1</code> (Windows) oppure <code>scripts/reset/reset.sh</code> (Linux/macOS). Questo cancellerà cache temporanee di compilazione e pacchetti per riparare il tuo ambiente. <em>(Nota: conserva i pesi dei modelli e le immagini generate).</em></p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> Linux backends fail to start with <code>GLIBC_2.38 not found</code></strong></summary>
|
||||
<p>The prebuilt binaries require glibc 2.38+ (e.g. Ubuntu 24.04). If your distribution uses an older glibc version, you can upgrade your operating system or compile the backend from source (see the <a href="#building-from-source">Building From Source</a> guide below).</p>
|
||||
<summary><strong> I backend Linux non si avviano con <code>GLIBC_2.38 not found</code></strong></summary>
|
||||
<p>I binari precompilati richiedono glibc 2.38+ (es. Ubuntu 24.04). Se la tua distribuzione usa una versione più vecchia di glibc, puoi aggiornare il sistema operativo oppure compilare il backend da sorgente (vedi la guida <a href="#building-from-source">Compilazione da sorgente</a> qui sotto).</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> Port Conflicts: Default port address already busy</strong></summary>
|
||||
<p>The web user interface runs on port <code>1420</code> by default. The GPU backend manager attempts to bind to port <code>8080</code> first, then automatically detects and falls back to a free system port if <code>8080</code> is already occupied.</p>
|
||||
<summary><strong> Conflitti porta: indirizzo porta predefinito già occupato</strong></summary>
|
||||
<p>L’interfaccia web utente funziona sulla porta <code>1420</code> per impostazione predefinita. Il gestore backend GPU prova prima ad agganciarsi alla porta <code>8080</code>, poi rileva automaticamente e passa a una porta libera se <code>8080</code> è già occupata.</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> Linux ROCm not loading for AMD Radeon GPUs</strong></summary>
|
||||
<p>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.</p>
|
||||
<summary><strong> Linux ROCm non si carica per GPU AMD Radeon</strong></summary>
|
||||
<p>Assicurati che l’hardware della tua GPU AMD e il kernel host siano pienamente compatibili con ROCm 7.13. Se ROCm non riesce a inizializzarsi correttamente, l’applicazione passerà automaticamente all’accelerazione Vulkan.</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> Windows exits with code <code>3221225781</code> (0xC0000135)</strong></summary>
|
||||
<p>This code means Windows could not locate a required backend DLL:</p>
|
||||
<summary><strong> Linux usa la GPU integrata invece della GPU dedicata</strong></summary>
|
||||
<p>Su sistemi Linux dual-GPU, l’ordine dei dispositivi Vulkan può mettere la GPU Intel integrata su <code>vulkan0</code> e la GPU AMD/NVIDIA dedicata su <code>vulkan1</code>. Il launcher ora prova a preferire un dispositivo Vulkan dedicato quando <code>vulkaninfo --summary</code> è disponibile. Per forzare manualmente un dispositivo, avvia l’app con <code>SD_VULKAN_DEVICE=vulkan1 ./linux.sh</code> oppure usa un altro indice come <code>vulkan0</code>/<code>vulkan2</code>.</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> Windows esce con codice <code>3221225781</code> (0xC0000135)</strong></summary>
|
||||
<p>Questo codice significa che Windows non è riuscito a trovare una DLL backend richiesta:</p>
|
||||
<ul>
|
||||
<li><strong>For AMD/Intel Vulkan:</strong> Update your GPU driver to one with full Vulkan runtime support, then rerun the setup script to restore <code>app/backend/win/vulkan/</code>.</li>
|
||||
<li><strong>For NVIDIA CUDA:</strong> Install or update your NVIDIA graphics driver, then rerun the setup script to restore the CUDA runtime DLLs.</li>
|
||||
<li><strong>Per AMD/Intel Vulkan:</strong> aggiorna il driver GPU a una versione con supporto completo al runtime Vulkan, poi riesegui lo script di configurazione per ripristinare <code>app/backend/win/vulkan/</code>.</li>
|
||||
<li><strong>Per NVIDIA CUDA:</strong> installa o aggiorna il driver grafico NVIDIA, poi riesegui lo script di configurazione per ripristinare le DLL runtime CUDA.</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> Generation shows "server is not responding or crashed"</strong></summary>
|
||||
<p>This indicates that the local backend engine process terminated. Check your launch terminal (where you executed <code>windows.bat</code>, <code>./linux.sh</code>, or <code>./mac.sh</code>) for the exact console error. Common causes include glibc version mismatches, missing Vulkan drivers, or system out-of-memory (OOM) issues.</p>
|
||||
<summary><strong> La generazione mostra "server is not responding or crashed"</strong></summary>
|
||||
<p>Questo indica che il processo del motore backend locale è terminato. Controlla il terminale di avvio (quello in cui hai eseguito <code>windows.bat</code>, <code>./linux.sh</code> o <code>./mac.sh</code>) per l’errore console esatto. Le cause comuni includono incompatibilità della versione glibc, driver Vulkan mancanti o problemi di memoria insufficiente del sistema (OOM).</p>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## <a id="building-from-source"></a>🔨 Building From Source
|
||||
## <a id="building-from-source"></a>🔨 Compilazione da sorgente
|
||||
|
||||
The setup script (`scripts/setup/setup.sh`) now automates building and setting up the CUDA backend from source when selected. If you want to manually build all backends (CPU, Vulkan, and CUDA) at once, you can run the included `scripts/build/build_from_source.sh` script.
|
||||
Lo script di configurazione (`scripts/setup/setup.sh`) ora automatizza la compilazione e la configurazione del backend CUDA da sorgente quando selezionato. Se vuoi compilare manualmente tutti i backend (CPU, Vulkan e CUDA) in una volta sola, puoi eseguire lo script incluso `scripts/build/build_from_source.sh`.
|
||||
|
||||
For macOS, the included `scripts/build/build_from_source.sh` builds the Metal backend and copies it to `app/backend/mac/sd`.
|
||||
Per macOS, lo script incluso `scripts/build/build_from_source.sh` compila il backend Metal e lo copia in `app/backend/mac/sd`.
|
||||
|
||||
### Requirements
|
||||
- `git`, `cmake`, `make` (or `ninja`), and a C++17 compiler (`g++` / `clang++`).
|
||||
- For **CUDA**: the NVIDIA CUDA toolkit (`nvcc`) must be on your `PATH`.
|
||||
- For **Vulkan**: the Vulkan SDK / loader and a compatible driver.
|
||||
- For **ROCm**: AMD ROCm development libraries.
|
||||
- For **macOS Metal**: Apple Command Line Tools or Xcode.
|
||||
### Requisiti
|
||||
- `git`, `cmake`, `make` (o `ninja`) e un compilatore C++17 (`g++` / `clang++`).
|
||||
- Per **CUDA**: il toolkit NVIDIA CUDA (`nvcc`) deve essere nel tuo `PATH`.
|
||||
- Per **Vulkan**: Vulkan SDK / loader e un driver compatibile.
|
||||
- Per **ROCm**: librerie di sviluppo AMD ROCm.
|
||||
- Per **macOS Metal**: Apple Command Line Tools o Xcode.
|
||||
|
||||
### Build commands
|
||||
### Comandi di build
|
||||
|
||||
```bash
|
||||
# 1. Clone upstream
|
||||
# 1. Clona upstream
|
||||
git clone https://github.com/leejet/stable-diffusion.cpp.git
|
||||
cd stable-diffusion.cpp
|
||||
mkdir build && cd build
|
||||
|
||||
# 2. Configure for your backend (pick ONE)
|
||||
# CPU only
|
||||
# 2. Configura per il tuo backend (scegline UNO)
|
||||
# Solo CPU
|
||||
cmake .. -DSD_BUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
# CUDA
|
||||
@@ -248,21 +284,21 @@ cmake .. -DSD_HIPBLAS=ON -DSD_BUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release
|
||||
# macOS Metal
|
||||
cmake .. -DSD_METAL=ON -DSD_BUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
# 3. Build
|
||||
# 3. Compila
|
||||
cmake --build . --config Release -j$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu)
|
||||
|
||||
# 4. Copy the binaries into this project
|
||||
# 4. Copia i binari in questo progetto
|
||||
cp bin/sd* /path/to/Uncensored-AI-Studio/app/backend/linux/<backend>/
|
||||
```
|
||||
|
||||
After copying, rename the server binary to match what `scripts/server/serve.cjs` expects:
|
||||
Dopo la copia, rinomina il binario server per farlo corrispondere a ciò che `scripts/server/serve.cjs` si aspetta:
|
||||
- Vulkan: `sd` → `sd-vulkan`
|
||||
- ROCm: `sd` → `sd-rocm`
|
||||
|
||||
Then restart the app with `./linux.sh` (Linux) or `./mac.sh` (macOS).
|
||||
Poi riavvia l’app con `./linux.sh` (Linux) o `./mac.sh` (macOS).
|
||||
|
||||
---
|
||||
|
||||
## <a id="licensing"></a>📝 License
|
||||
## <a id="licensing"></a>📝 Licenza
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file. Bundles [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp) (MIT License). Model weights are subject to their respective creators' licenses.
|
||||
Questo progetto è distribuito con licenza MIT - consulta il file [LICENSE](LICENSE). Include [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp) (licenza MIT). I pesi dei modelli sono soggetti alle licenze dei rispettivi creatori.
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>Uncensored AI Studio</title>
|
||||
<title>Speech Transcriber</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
+211
-3732
File diff suppressed because it is too large.
Load diff
+85
-639
@@ -1,13 +1,8 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import TopStatusBar from "./components/TopStatusBar";
|
||||
import Generator from "./components/Generator";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import SpeechTranscriber from "./components/SpeechTranscriber";
|
||||
import ModelManager from "./components/ModelManager";
|
||||
import Settings from "./components/Settings";
|
||||
import TextChat from "./components/TextChat";
|
||||
import SpeechTranscriber from "./components/SpeechTranscriber";
|
||||
import TextToSpeech from "./components/TextToSpeech";
|
||||
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, listSpeechTranscriptions, deleteSpeechTranscription, listTtsOutputs, deleteTtsOutput, stopServer } from "./services/api";
|
||||
import { getHardwareSpecs, getTelemetry, listSpeechTranscriptions, deleteSpeechTranscription } from "./services/api";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
@@ -47,122 +42,10 @@ function App() {
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
// Navigation
|
||||
const [activeTab, setActiveTab] = useState("generator");
|
||||
|
||||
// Prompts
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [negativePrompt, setNegativePrompt] = useState("");
|
||||
|
||||
// Model & Server Status
|
||||
const [activeModel, setActiveModel] = useState(null);
|
||||
const [serverRunning, setServerRunning] = useState(false);
|
||||
const [isStoppingServer, setIsStoppingServer] = useState(false);
|
||||
const [isLlmLoaded, setIsLlmLoaded] = useState(false);
|
||||
const [backendOptions, setBackendOptions] = useState({
|
||||
options: [{ id: "cpu", label: "CPU", available: true }],
|
||||
cudaAvailable: false,
|
||||
vulkanAvailable: false,
|
||||
defaultBackendType: "cpu",
|
||||
});
|
||||
const [health, setHealth] = useState(null);
|
||||
const [cleanupItems, setCleanupItems] = useState([]);
|
||||
const [isReadinessBusy, setIsReadinessBusy] = useState(false);
|
||||
const [diagnosticsCopied, setDiagnosticsCopied] = useState(false);
|
||||
|
||||
// Gallery History
|
||||
const [generatedImages, setGeneratedImages] = useState([]);
|
||||
|
||||
// Generation status
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generationProgress, setGenerationProgress] = useState(0);
|
||||
|
||||
// System Specifications & Telemetry
|
||||
const [specs, setSpecs] = useState({
|
||||
os_name: "Loading Specs...",
|
||||
cpu_name: "Loading Specs...",
|
||||
cpu_cores_physical: 4,
|
||||
cpu_cores_logical: 8,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Loading Specs...",
|
||||
});
|
||||
|
||||
const [telemetry, setTelemetry] = useState({
|
||||
cpu_usage: 0,
|
||||
ram_used_gb: 0,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Detecting...",
|
||||
vram_used_gb: 0,
|
||||
vram_total_gb: 0,
|
||||
});
|
||||
|
||||
// Default Image Constraints
|
||||
const [constraints, setConstraints] = useState({
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
steps: 4, // Recommended default steps for Flux.1 Schnell/Lightning
|
||||
npuSteps: 4,
|
||||
standardSteps: 20,
|
||||
cfgScale: 1.0, // Recommended default CFG for Flux.1 Schnell
|
||||
sampler: "euler_a",
|
||||
seed: -1,
|
||||
denoisingStrength: 0.7,
|
||||
useGpu: true,
|
||||
useTaesd: true,
|
||||
useFlashAttn: true,
|
||||
useTiling: false,
|
||||
vaeTiling: true,
|
||||
vaeOnCpu: false,
|
||||
threads: 4,
|
||||
backendType: "auto",
|
||||
});
|
||||
|
||||
const [textSettings, setTextSettings] = useState(() => {
|
||||
const saved = localStorage.getItem("textSettings");
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return {
|
||||
...parsed,
|
||||
enableThinking: parsed.enableThinking === true,
|
||||
maxTokens: !parsed.maxTokens || parsed.maxTokens === 384 ? 1024 : parsed.maxTokens,
|
||||
responseTokenMode: parsed.responseTokenMode || "auto",
|
||||
};
|
||||
} catch (_) {}
|
||||
}
|
||||
return {
|
||||
contextSize: 0,
|
||||
temperature: 0.7,
|
||||
systemPrompt: "You are a helpful local AI assistant.",
|
||||
threads: Math.max(4, Math.min(16, (navigator.hardwareConcurrency || 4) - 2)),
|
||||
enableThinking: false,
|
||||
// New performance settings
|
||||
gpuLayers: -1,
|
||||
maxTokens: 1024,
|
||||
responseTokenMode: "auto",
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
minP: 0.05,
|
||||
repeatPenalty: 1.1,
|
||||
seed: null,
|
||||
performanceProfile: "balanced",
|
||||
flashAttn: true,
|
||||
cacheTypeK: "q8_0",
|
||||
cacheTypeV: "q8_0",
|
||||
mlock: false,
|
||||
mmap: true,
|
||||
cachePrompt: true,
|
||||
defragThold: 0.1,
|
||||
batchSize: 512,
|
||||
ubatchSize: 512,
|
||||
preferredBackend: "auto",
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("textSettings", JSON.stringify(textSettings));
|
||||
}, [textSettings]);
|
||||
// Navigation - only speech-related tabs
|
||||
const [activeTab, setActiveTab] = useState("speech");
|
||||
|
||||
// Speech settings
|
||||
const [speechSettings, setSpeechSettings] = useState(() => {
|
||||
const saved = localStorage.getItem("speechSettings");
|
||||
if (saved) {
|
||||
@@ -188,136 +71,28 @@ function App() {
|
||||
localStorage.setItem("speechSettings", JSON.stringify(speechSettings));
|
||||
}, [speechSettings]);
|
||||
|
||||
const [ttsSettings, setTtsSettings] = useState(() => {
|
||||
const saved = localStorage.getItem("ttsSettings");
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return {
|
||||
model: parsed.model || "",
|
||||
voice: parsed.voice || "af_heart",
|
||||
speed: Math.max(0.5, Math.min(2, Number(parsed.speed) || 1)),
|
||||
};
|
||||
} catch (_) {}
|
||||
}
|
||||
return {
|
||||
model: "",
|
||||
voice: "af_heart",
|
||||
speed: 1,
|
||||
};
|
||||
// System Specifications & Telemetry
|
||||
const [specs, setSpecs] = useState({
|
||||
os_name: "Loading Specs...",
|
||||
cpu_name: "Loading Specs...",
|
||||
cpu_cores_physical: 4,
|
||||
cpu_cores_logical: 8,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Loading Specs...",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("ttsSettings", JSON.stringify(ttsSettings));
|
||||
}, [ttsSettings]);
|
||||
|
||||
// Lifted Chat History States
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [activeConversationId, setActiveConversationId] = useState(null);
|
||||
const [showHistory, setShowHistory] = useState(false); // Default hide
|
||||
const [sidebarVisible, setSidebarVisible] = useState(() => {
|
||||
const saved = localStorage.getItem("sidebarVisible");
|
||||
return saved !== "false";
|
||||
const [telemetry, setTelemetry] = useState({
|
||||
cpu_usage: 0,
|
||||
ram_used_gb: 0,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Detecting...",
|
||||
vram_used_gb: 0,
|
||||
vram_total_gb: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("sidebarVisible", String(sidebarVisible));
|
||||
}, [sidebarVisible]);
|
||||
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
const saved = localStorage.getItem("fontSize");
|
||||
return saved ? Number(saved) : 16;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty("--app-font-size", `${fontSize}px`);
|
||||
localStorage.setItem("fontSize", String(fontSize));
|
||||
}, [fontSize]);
|
||||
// Speech transcriptions history
|
||||
const [speechTranscriptions, setSpeechTranscriptions] = useState([]);
|
||||
const [selectedSpeechTranscript, setSelectedSpeechTranscript] = useState(null);
|
||||
const [showSpeechHistory, setShowSpeechHistory] = useState(false);
|
||||
const [ttsOutputs, setTtsOutputs] = useState([]);
|
||||
const [selectedTtsOutput, setSelectedTtsOutput] = useState(null);
|
||||
const [showTtsHistory, setShowTtsHistory] = useState(false);
|
||||
|
||||
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 {
|
||||
...message,
|
||||
content: message.content.map((item) => {
|
||||
if (item?.type !== "image_url") return item;
|
||||
return {
|
||||
type: "text",
|
||||
text: "[Attached image omitted from saved chat history]",
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeConversationForStorage = (conversation) => ({
|
||||
...conversation,
|
||||
messages: Array.isArray(conversation.messages)
|
||||
? conversation.messages.map(sanitizeMessageForStorage)
|
||||
: [],
|
||||
});
|
||||
|
||||
const persistConversations = (list) => {
|
||||
const compactList = list.map(sanitizeConversationForStorage);
|
||||
try {
|
||||
localStorage.setItem("chat_conversations", JSON.stringify(compactList));
|
||||
} catch (err) {
|
||||
console.warn("Could not save chat history:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConversationState = useCallback((id, msgs, modelName, newTitle = null) => {
|
||||
setConversations((prev) => {
|
||||
const list = [...prev];
|
||||
const idx = list.findIndex(c => c.id === id);
|
||||
if (idx !== -1) {
|
||||
list[idx] = {
|
||||
...list[idx],
|
||||
messages: msgs,
|
||||
timestamp: Date.now(),
|
||||
model: modelName,
|
||||
...(newTitle ? { title: newTitle } : {})
|
||||
};
|
||||
} else {
|
||||
list.unshift({
|
||||
id,
|
||||
title: newTitle || "Chat Session",
|
||||
model: modelName,
|
||||
messages: msgs,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
persistConversations(list);
|
||||
return list;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteConversation = useCallback((id, e) => {
|
||||
if (e) e.stopPropagation();
|
||||
setConversations((prev) => {
|
||||
const filtered = prev.filter((c) => c.id !== id);
|
||||
persistConversations(filtered);
|
||||
return filtered;
|
||||
});
|
||||
if (activeConversationId === id) {
|
||||
setActiveConversationId(null);
|
||||
}
|
||||
}, [activeConversationId]);
|
||||
|
||||
const refreshSpeechTranscriptions = useCallback(async () => {
|
||||
try {
|
||||
@@ -364,65 +139,13 @@ function App() {
|
||||
refreshSpeechTranscriptions();
|
||||
}, [refreshSpeechTranscriptions]);
|
||||
|
||||
const refreshTtsOutputs = useCallback(async () => {
|
||||
try {
|
||||
const list = await listTtsOutputs();
|
||||
setTtsOutputs(list);
|
||||
} catch (err) {
|
||||
console.warn("Could not load TTS outputs:", err);
|
||||
setTtsOutputs([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteTtsOutput = useCallback(async (item, e) => {
|
||||
if (e) e.stopPropagation();
|
||||
const itemId = item.filename || item.metadata;
|
||||
if (!itemId) return;
|
||||
|
||||
const ok = await showConfirm({
|
||||
title: "Delete TTS Output",
|
||||
message: `Are you sure you want to delete "${item.displayName || item.audioFile || "this audio"}"? This action cannot be undone.`,
|
||||
confirmLabel: "Delete",
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await deleteTtsOutput(itemId);
|
||||
setSelectedTtsOutput((prev) => {
|
||||
if (prev) {
|
||||
const prevId = prev.filename || prev.metadata;
|
||||
if (prevId === itemId) return null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
refreshTtsOutputs();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete TTS output:", err);
|
||||
showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
}, [showConfirm, showAlert, refreshTtsOutputs]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshTtsOutputs();
|
||||
}, [refreshTtsOutputs]);
|
||||
|
||||
// Load hardware specifications on mount
|
||||
useEffect(() => {
|
||||
async function loadSpecs() {
|
||||
try {
|
||||
const hardware = await getHardwareSpecs();
|
||||
const backendInfo = await getBackendOptions();
|
||||
setBackendOptions(backendInfo);
|
||||
setSpecs(hardware);
|
||||
// Default CPU threads count to match physical cores
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
threads: hardware.cpu_cores_physical || 4,
|
||||
backendType: prev.backendType === "auto" ? backendInfo.defaultBackendType || "cpu" : prev.backendType,
|
||||
useGpu: (prev.backendType === "auto" ? backendInfo.defaultBackendType : prev.backendType) !== "cpu",
|
||||
}));
|
||||
setTextSettings((prev) => ({
|
||||
setSpeechSettings((prev) => ({
|
||||
...prev,
|
||||
threads: prev.threads || hardware.cpu_cores_physical || 4
|
||||
}));
|
||||
@@ -433,120 +156,6 @@ function App() {
|
||||
loadSpecs();
|
||||
}, []);
|
||||
|
||||
const refreshReadiness = useCallback(async () => {
|
||||
setIsReadinessBusy(true);
|
||||
try {
|
||||
const [healthInfo, candidates] = await Promise.all([
|
||||
getHealth(),
|
||||
getCleanupCandidates().catch(() => []),
|
||||
]);
|
||||
setHealth(healthInfo);
|
||||
setCleanupItems(candidates);
|
||||
} finally {
|
||||
setIsReadinessBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshReadiness();
|
||||
}, [refreshReadiness]);
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
try {
|
||||
const diagnostics = await getDiagnostics();
|
||||
await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2));
|
||||
setDiagnosticsCopied(true);
|
||||
setTimeout(() => setDiagnosticsCopied(false), 1800);
|
||||
} catch (err) {
|
||||
showAlert({ title: "Diagnostics Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
}, [showAlert]);
|
||||
|
||||
const cleanupSafeItems = useCallback(async () => {
|
||||
if (cleanupItems.length === 0) return;
|
||||
const totalBytes = cleanupItems.reduce((sum, item) => sum + Number(item.sizeBytes || 0), 0);
|
||||
const ok = await showConfirm({
|
||||
title: "Clean Safe Files?",
|
||||
message: `Delete ${cleanupItems.length} temporary/cache item${cleanupItems.length === 1 ? "" : "s"} and free about ${formatBytes(totalBytes)}?\n\nModels and generated outputs are not included.`,
|
||||
confirmLabel: "Clean",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setIsReadinessBusy(true);
|
||||
try {
|
||||
await cleanupCandidates(cleanupItems.map((item) => item.id));
|
||||
await refreshReadiness();
|
||||
} catch (err) {
|
||||
showAlert({ title: "Cleanup Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setIsReadinessBusy(false);
|
||||
}
|
||||
}, [cleanupItems, refreshReadiness, showAlert, showConfirm]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSavedOutputs() {
|
||||
const outputs = await listGeneratedOutputs();
|
||||
setGeneratedImages(outputs);
|
||||
}
|
||||
loadSavedOutputs();
|
||||
}, []);
|
||||
|
||||
// Save active model configuration to localStorage for persistence across reloads
|
||||
useEffect(() => {
|
||||
if (activeModel) {
|
||||
localStorage.setItem("active-model", activeModel);
|
||||
} else {
|
||||
localStorage.removeItem("active-model");
|
||||
}
|
||||
}, [activeModel]);
|
||||
|
||||
// Check if server is already running on mount (handles page refresh and startup lag)
|
||||
useEffect(() => {
|
||||
let checkInterval = null;
|
||||
|
||||
async function checkServerRunning() {
|
||||
const status = await getBackendStatus();
|
||||
const isRunning = Boolean(status.ready);
|
||||
if (isRunning) {
|
||||
if (checkInterval) clearInterval(checkInterval);
|
||||
|
||||
const savedModel = localStorage.getItem("active-model");
|
||||
if (savedModel) {
|
||||
setActiveModel(savedModel);
|
||||
setServerRunning(true);
|
||||
} else {
|
||||
// Query the active model from status if possible, otherwise use fallback
|
||||
try {
|
||||
if (status && status.settings && status.settings.model) {
|
||||
const modelName = status.settings.model.split(/[\\/]/).pop();
|
||||
setActiveModel(modelName);
|
||||
}
|
||||
} catch (_) {}
|
||||
setServerRunning(true);
|
||||
}
|
||||
} else {
|
||||
// If server is not running, check if they had a simulated model active in browser mode
|
||||
const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||
if (!isTauriDesktop) {
|
||||
const savedModel = localStorage.getItem("active-model");
|
||||
if (savedModel) {
|
||||
setActiveModel(savedModel);
|
||||
setServerRunning(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkServerRunning();
|
||||
// Poll every 2 seconds to auto-detect when the backend becomes ready
|
||||
checkInterval = setInterval(checkServerRunning, 2000);
|
||||
|
||||
return () => {
|
||||
if (checkInterval) clearInterval(checkInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Poll system telemetry usage statistics on interval
|
||||
useEffect(() => {
|
||||
async function updateTelemetry() {
|
||||
@@ -568,204 +177,62 @@ function App() {
|
||||
}
|
||||
|
||||
updateTelemetry();
|
||||
const interval = setInterval(updateTelemetry, 1500); // Poll every 1.5 seconds
|
||||
const interval = setInterval(updateTelemetry, 1500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Sync active model settings default parameters
|
||||
useEffect(() => {
|
||||
if (activeModel) {
|
||||
const name = activeModel.toLowerCase();
|
||||
if (name.includes("lcm-dreamshaper-v7-fp16")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 4,
|
||||
npuSteps: 4,
|
||||
cfgScale: 1.0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
backendType: "openvino-npu",
|
||||
useGpu: true,
|
||||
}));
|
||||
} else if (name.includes("flux") || name.includes("schnell")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 4,
|
||||
standardSteps: 4,
|
||||
cfgScale: 1.0,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}));
|
||||
} else if (name.includes("lightning") || name.includes("turbo")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 4,
|
||||
standardSteps: 4,
|
||||
cfgScale: 1.5,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}));
|
||||
} else if (name.includes("sd15")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 25,
|
||||
standardSteps: 25,
|
||||
cfgScale: 7.0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
}));
|
||||
} else if (name.includes("sd35")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 20,
|
||||
standardSteps: 20,
|
||||
cfgScale: 4.5,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}));
|
||||
} else {
|
||||
// Fallback for custom or SD 1.x models (like CyberRealistic or arbitrary safetensors)
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 20,
|
||||
standardSteps: 20,
|
||||
cfgScale: 7.0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [activeModel]);
|
||||
|
||||
// Tab contents are kept mounted to preserve loading/generation state when switching tabs
|
||||
|
||||
const sidebarContent = useMemo(() => (
|
||||
<Sidebar
|
||||
collapsed={!sidebarVisible}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
specs={specs}
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
setActiveConversationId={setActiveConversationId}
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
speechTranscriptions={speechTranscriptions}
|
||||
selectedSpeechTranscript={selectedSpeechTranscript}
|
||||
setSelectedSpeechTranscript={setSelectedSpeechTranscript}
|
||||
showSpeechHistory={showSpeechHistory}
|
||||
setShowSpeechHistory={setShowSpeechHistory}
|
||||
onDeleteSpeechTranscription={handleDeleteSpeechTranscription}
|
||||
ttsOutputs={ttsOutputs}
|
||||
selectedTtsOutput={selectedTtsOutput}
|
||||
setSelectedTtsOutput={setSelectedTtsOutput}
|
||||
showTtsHistory={showTtsHistory}
|
||||
setShowTtsHistory={setShowTtsHistory}
|
||||
onDeleteTtsOutput={handleDeleteTtsOutput}
|
||||
/>
|
||||
), [sidebarVisible, activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation, speechTranscriptions, selectedSpeechTranscript, showSpeechHistory, handleDeleteSpeechTranscription, ttsOutputs, selectedTtsOutput, showTtsHistory, handleDeleteTtsOutput]);
|
||||
|
||||
const handleStopServer = useCallback(async () => {
|
||||
if (!serverRunning || isStoppingServer) return;
|
||||
setIsStoppingServer(true);
|
||||
try {
|
||||
await stopServer();
|
||||
setServerRunning(false);
|
||||
setActiveModel(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to stop server:", err);
|
||||
showAlert({ title: "Stop Server Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setIsStoppingServer(false);
|
||||
}
|
||||
}, [serverRunning, isStoppingServer, showAlert]);
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Sidebar with Navigation Rail & Specs */}
|
||||
{sidebarContent}
|
||||
|
||||
{/* Main Panel */}
|
||||
<div className="main-content">
|
||||
{/* Top telemetry specs chips bar */}
|
||||
<TopStatusBar
|
||||
telemetry={telemetry}
|
||||
serverRunning={serverRunning}
|
||||
activeModel={activeModel}
|
||||
isLlmLoaded={isLlmLoaded}
|
||||
onStopServer={handleStopServer}
|
||||
isStoppingServer={isStoppingServer}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
sidebarVisible={sidebarVisible}
|
||||
onToggleSidebar={() => setSidebarVisible(!sidebarVisible)}
|
||||
/>
|
||||
|
||||
{/* Dynamic Workspace Container */}
|
||||
<div style={{ display: activeTab === "generator" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<Generator
|
||||
prompt={prompt}
|
||||
setPrompt={setPrompt}
|
||||
negativePrompt={negativePrompt}
|
||||
setNegativePrompt={setNegativePrompt}
|
||||
constraints={constraints}
|
||||
setConstraints={setConstraints}
|
||||
activeModel={activeModel}
|
||||
generatedImages={generatedImages}
|
||||
setGeneratedImages={setGeneratedImages}
|
||||
isGenerating={isGenerating}
|
||||
setIsGenerating={setIsGenerating}
|
||||
generationProgress={generationProgress}
|
||||
setGenerationProgress={setGenerationProgress}
|
||||
setActiveTab={setActiveTab}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
specs={specs}
|
||||
textSettings={textSettings}
|
||||
/>
|
||||
{/* Simple Header */}
|
||||
<header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<h1 className="app-title">🎙️ Speech Transcriber</h1>
|
||||
<p className="app-subtitle">Local AI audio transcription with Whisper</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: activeTab === "models" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<ModelManager
|
||||
activeModel={activeModel}
|
||||
setActiveModel={setActiveModel}
|
||||
serverRunning={serverRunning}
|
||||
setServerRunning={setServerRunning}
|
||||
constraints={constraints}
|
||||
setConstraints={setConstraints}
|
||||
telemetry={telemetry}
|
||||
backendOptions={backendOptions}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
activeTab={activeTab}
|
||||
specs={specs}
|
||||
textSettings={textSettings}
|
||||
/>
|
||||
<div className="app-header-right">
|
||||
<div className="telemetry-chips">
|
||||
<span className="chip">CPU: {telemetry.cpu_usage}%</span>
|
||||
<span className="chip">RAM: {telemetry.ram_used_gb.toFixed(1)}/{telemetry.ram_total_gb.toFixed(1)} GB</span>
|
||||
{telemetry.gpu_name !== "Detecting..." && telemetry.gpu_name !== "Unavailable" && (
|
||||
<span className="chip">GPU: {telemetry.gpu_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ display: activeTab === "chat" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<TextChat
|
||||
specs={specs}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
textSettings={textSettings}
|
||||
setTextSettings={setTextSettings}
|
||||
setActiveModel={setActiveModel}
|
||||
setServerRunning={setServerRunning}
|
||||
conversations={conversations}
|
||||
setConversations={setConversations}
|
||||
activeConversationId={activeConversationId}
|
||||
setActiveConversationId={setActiveConversationId}
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
saveConversationState={saveConversationState}
|
||||
setIsLlmLoaded={setIsLlmLoaded}
|
||||
/>
|
||||
</div>
|
||||
{/* Navigation Tabs */}
|
||||
<nav className="app-nav">
|
||||
<button
|
||||
className={`nav-tab ${activeTab === "speech" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("speech")}
|
||||
>
|
||||
🎙️ Transcriber
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${activeTab === "models" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("models")}
|
||||
>
|
||||
📁 Models
|
||||
</button>
|
||||
<button
|
||||
className={`nav-tab ${activeTab === "settings" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("settings")}
|
||||
>
|
||||
⚙️ Settings
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div style={{ display: activeTab === "speech" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Main Content */}
|
||||
<main className="app-main">
|
||||
{activeTab === "speech" && (
|
||||
<SpeechTranscriber
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
@@ -774,53 +241,32 @@ function App() {
|
||||
speechSettings={speechSettings}
|
||||
setSpeechSettings={setSpeechSettings}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: activeTab === "tts" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<TextToSpeech
|
||||
{activeTab === "models" && (
|
||||
<ModelManager
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
specs={specs}
|
||||
speechSettings={speechSettings}
|
||||
setSpeechSettings={setSpeechSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<Settings
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
selectedOutput={selectedTtsOutput}
|
||||
onOutputsChanged={refreshTtsOutputs}
|
||||
ttsSettings={ttsSettings}
|
||||
setTtsSettings={setTtsSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: activeTab === "settings" ? "flex" : "none", flex: 1, flexDirection: "column", overflow: "hidden" }}>
|
||||
<Settings
|
||||
constraints={constraints}
|
||||
setConstraints={setConstraints}
|
||||
activeModel={activeModel}
|
||||
specs={specs}
|
||||
backendOptions={backendOptions}
|
||||
setBackendOptions={setBackendOptions}
|
||||
serverRunning={serverRunning}
|
||||
setServerRunning={setServerRunning}
|
||||
setActiveModel={setActiveModel}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
textSettings={textSettings}
|
||||
setTextSettings={setTextSettings}
|
||||
speechSettings={speechSettings}
|
||||
setSpeechSettings={setSpeechSettings}
|
||||
ttsSettings={ttsSettings}
|
||||
setTtsSettings={setTtsSettings}
|
||||
health={health}
|
||||
cleanupItems={cleanupItems}
|
||||
isReadinessBusy={isReadinessBusy}
|
||||
refreshReadiness={refreshReadiness}
|
||||
copyDiagnostics={copyDiagnostics}
|
||||
cleanupSafeItems={cleanupSafeItems}
|
||||
diagnosticsCopied={diagnosticsCopied}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
fontSize={fontSize}
|
||||
setFontSize={setFontSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Dialog */}
|
||||
{dialog && (
|
||||
<div className="app-dialog-backdrop" role="presentation">
|
||||
<div className="app-dialog" role="dialog" aria-modal="true" aria-labelledby="app-dialog-title">
|
||||
|
||||
@@ -1,989 +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 (
|
||||
<div
|
||||
className={`gallery-item ${isSelected ? "selected" : ""}`}
|
||||
onClick={handleClick}
|
||||
title={`Prompt: ${img.prompt}\nSeed: ${img.seed}${img.duration_sec ? `\nTime: ${img.duration_sec.toFixed(1)}s` : ""}`}
|
||||
>
|
||||
<img src={img.url} className="gallery-thumb" alt="Thumbnail" loading="lazy" decoding="async" />
|
||||
{isSelected && (
|
||||
<div className="gallery-select-badge">
|
||||
<Check size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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 || "");
|
||||
|
||||
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 {
|
||||
const numericSpeed = parseFloat(speed);
|
||||
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) {
|
||||
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 (
|
||||
<div className="workspace-area">
|
||||
<div className="workspace-title-section">
|
||||
<h2 className="workspace-title">Generator Dashboard</h2>
|
||||
<p className="workspace-subtitle">
|
||||
Create high-fidelity images completely locally on your external drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="generator-layout">
|
||||
{/* Left Side: Parameters & Input */}
|
||||
<div>
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">
|
||||
<Sparkles size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
Generation Workspace
|
||||
</h3>
|
||||
|
||||
<div className="m3-field-group">
|
||||
{/* Prompt Textarea */}
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Image Prompt</label>
|
||||
<textarea
|
||||
className="m3-textarea"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Enter a descriptive prompt (e.g. 'A futuristic city in cyberpunk aesthetic, high details, cinematic lighting')..."
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{constraints.backendType !== "apple-npu" && constraints.backendType !== "openvino-npu" && (
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Base Image (Optional)</label>
|
||||
{baseImage ? (
|
||||
<div style={{ display: "flex", gap: "12px", alignItems: "center", padding: "12px", background: "var(--md-sys-color-surface-variant)", borderRadius: "var(--md-shape-corner-medium)", border: "1px solid var(--md-sys-color-outline-variant)" }}>
|
||||
<img src={baseImage} alt="Base" style={{ width: "64px", height: "64px", objectFit: "cover", borderRadius: "8px", flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.85rem" }}>Base image ready</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
|
||||
The AI will redraw this image guided by your prompt.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="m3-btn m3-btn-error" style={{ height: "34px", flexShrink: 0 }} onClick={handleClearBaseImage} disabled={isGenerating}>
|
||||
<Trash2 size={14} />
|
||||
<span>Remove</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="import-box" style={{ margin: 0, padding: "16px", cursor: isGenerating ? "not-allowed" : "pointer" }}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleBaseImageSelect}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<ImagePlus className="import-icon" />
|
||||
<span style={{ fontWeight: 600 }}>Upload a base image</span>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", textAlign: "center" }}>
|
||||
Optional. Generate a new image based on one of your own.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{baseImage && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "0.8rem", marginBottom: "4px" }}>
|
||||
<span>Transformation strength</span>
|
||||
<span>{constraints.denoisingStrength.toFixed(2)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={constraints.denoisingStrength}
|
||||
onChange={(e) => setConstraints((prev) => ({ ...prev, denoisingStrength: parseFloat(e.target.value) }))}
|
||||
disabled={isGenerating}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<div style={{ fontSize: "0.72rem", color: "var(--md-sys-color-outline)", marginTop: "2px" }}>
|
||||
Lower = closer to your image; higher = more creative freedom. 0.7 is a good start.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration Status Chips (Material 3 style) */}
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Active Image Constraints</label>
|
||||
<div className="chips-container">
|
||||
<div className="status-chip">
|
||||
<Sliders size={14} />
|
||||
<span>Resolution: {constraints.width}x{constraints.height}</span>
|
||||
</div>
|
||||
<div className="status-chip">
|
||||
<RefreshCw size={14} />
|
||||
<span>Steps: {constraints.steps}</span>
|
||||
</div>
|
||||
<div className="status-chip">
|
||||
<Sparkles size={14} />
|
||||
<span>Sampler: {constraints.sampler}</span>
|
||||
</div>
|
||||
<div className="status-chip">
|
||||
<span>Seed: {constraints.seed === -1 ? "Random" : constraints.seed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Trigger Button */}
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
style={{ height: "48px", marginTop: "8px" }}
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim() || !activeModel}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
<span>{!activeModel ? "Select Model to Generate" : baseImage ? "Generate from Base Image" : "Generate Local Image"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Image Output Preview */}
|
||||
<div className="m3-card output-panel">
|
||||
<div className="output-image-container">
|
||||
{isGenerating || isCancelling || isRestartingBackend ? (
|
||||
<div className="progress-overlay">
|
||||
{isRestartingBackend ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "16px", padding: "20px" }}>
|
||||
<div className="progress-spinner"></div>
|
||||
<div className="progress-text" style={{ fontSize: "1.1rem", fontWeight: 700, textAlign: "center" }}>
|
||||
Syncing Settings & Initializing...
|
||||
</div>
|
||||
{restartLoadProgress ? (
|
||||
<div style={{ width: "min(360px, 90%)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "12px", fontSize: "0.82rem", fontWeight: 700, marginBottom: "6px" }}>
|
||||
<span>
|
||||
{restartLoadProgress.phase}
|
||||
{restartLoadProgress.speed ? ` (${restartLoadProgress.speed})` : ""}
|
||||
</span>
|
||||
<span>
|
||||
{restartLoadProgress.total > 0
|
||||
? `Loaded ${restartLoadProgress.current} / ${restartLoadProgress.total} tensors`
|
||||
: `${Math.round(restartLoadProgress.progress)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-bar-container" style={{ margin: 0 }}>
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{ width: `${Math.min(100, Math.max(0, restartLoadProgress.progress))}%`, transition: "width 0.25s ease" }}
|
||||
></div>
|
||||
</div>
|
||||
{(restartLoadProgress.backendMode || restartLoadProgress.device) && (
|
||||
<div style={{ fontSize: "0.78rem", opacity: 0.8, textAlign: "center", marginTop: "8px" }}>
|
||||
Loading on {restartLoadProgress.backendMode || "backend"}{restartLoadProgress.device ? ` • ${restartLoadProgress.device}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: "0.85rem", opacity: 0.8, textAlign: "center", maxWidth: "280px", margin: 0 }}>
|
||||
Adjusting inference settings and restarting the local model server.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : isCancelling ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "16px", padding: "20px" }}>
|
||||
<div className="progress-spinner"></div>
|
||||
<div className="progress-text" style={{ fontSize: "1.1rem", fontWeight: 700, textAlign: "center" }}>
|
||||
Cancelling Generation...
|
||||
</div>
|
||||
<p style={{ fontSize: "0.85rem", opacity: 0.8, textAlign: "center", maxWidth: "280px", margin: 0 }}>
|
||||
Stopping CPU/GPU execution threads and reloading model weights. Please wait a moment.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="progress-spinner"></div>
|
||||
<div className="progress-text" style={{ fontSize: "1.1rem", fontWeight: 700 }}>
|
||||
{isDecoding ? "Decoding Latents..." : "Generating Locally..."}
|
||||
</div>
|
||||
|
||||
{isCpuFallback && (
|
||||
<div style={{ fontSize: "0.8rem", color: "var(--md-sys-color-primary)", fontWeight: 600, background: "var(--md-sys-color-primary-container)", padding: "4px 12px", borderRadius: "12px", margin: "4px 0 8px 0", animation: "pulse 2s infinite" }}>
|
||||
⚠️ CPU Fallback Detected (Running Slower)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Real-time stats display */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px", width: "80%", margin: "8px 0 0 0", fontSize: "0.85rem", opacity: 0.9 }}>
|
||||
<div style={{ textAlign: "left" }}>
|
||||
<strong>Step:</strong> {currentStep} / {constraints.steps} ({Math.max(0, constraints.steps - currentStep)} left)
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<strong>Elapsed:</strong> {elapsedTime}s{generationSpeed && ` (${generationSpeed === "decoding" ? "decoding" : generationSpeed})`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-container" style={{ margin: "4px 0 10px 0" }}>
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{ width: `${generationProgress}%`, transition: "width 1s linear" }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "0.85rem", opacity: 0.8, display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<span>Progress: {generationProgress}%</span>
|
||||
<span>•</span>
|
||||
<span>Est. remaining: {estimatedLeftTime}s</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="m3-btn m3-btn-error"
|
||||
style={{ marginTop: "16px", height: "36px", padding: "0 24px" }}
|
||||
onClick={handleCancelGeneration}
|
||||
>
|
||||
Stop Generating
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : outputImage ? (
|
||||
<img src={outputImage} className="output-image" alt="Generated Output" />
|
||||
) : errorMsg ? (
|
||||
<div style={{ textAlign: "center", padding: "24px", color: "var(--md-sys-color-error)", display: "flex", flexDirection: "column", alignItems: "center", gap: "16px" }}>
|
||||
<div style={{ background: "rgba(239, 68, 68, 0.1)", padding: "16px", borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: "1.2rem", fontWeight: "bold" }}>Generation Failed</h3>
|
||||
<p style={{ margin: 0, fontSize: "0.95rem", opacity: 0.9, maxWidth: "320px", lineHeight: "1.4" }}>
|
||||
{errorMsg}
|
||||
</p>
|
||||
<div style={{ marginTop: "8px", fontSize: "0.85rem", opacity: 0.8, color: "var(--md-sys-color-on-surface-variant)" }}>
|
||||
Hint: Try lowering the resolution in the <strong>Image Constraints</strong> settings (e.g., to 512x512).
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Sparkles className="output-placeholder-icon" />
|
||||
<p className="output-placeholder-text">
|
||||
Your generated image will appear here.<br />
|
||||
Enter a prompt on the left and click Generate.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{outputImage && !isGenerating && (
|
||||
<>
|
||||
{/* Telemetry info for previous generation */}
|
||||
<div style={{ width: "100%", display: "flex", justifyContent: "space-between", marginTop: "12px", fontSize: "0.8rem", color: "var(--md-sys-color-outline)" }}>
|
||||
<span>Seed: {outputSeed}</span>
|
||||
{genDuration && <span>Inference Time: {genDuration.toFixed(1)}s</span>}
|
||||
</div>
|
||||
{/* Actions for generated image */}
|
||||
<div className="image-actions">
|
||||
<button className="m3-btn m3-btn-tonal" onClick={handleCopyImage}>
|
||||
{copySuccess ? <Check size={16} /> : <Copy size={16} />}
|
||||
<span>{copySuccess ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={handleDownload}>
|
||||
<Download size={16} />
|
||||
<span>Save to USB</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History / Gallery Grid */}
|
||||
<div className="gallery-section">
|
||||
<div className="gallery-header">
|
||||
<h3 className="m3-card-title" style={{ marginBottom: 0 }}>Recent Outputs Gallery</h3>
|
||||
{selectedGalleryItems.length > 0 && (
|
||||
<div className="gallery-selection-actions">
|
||||
<span className="gallery-selection-count">{selectedGalleryItems.length} selected</span>
|
||||
<button className="m3-btn m3-btn-tonal" onClick={useSelectedGalleryItem}>
|
||||
<Check size={14} />
|
||||
<span>Use</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={downloadSelectedGalleryItems}>
|
||||
<Download size={14} />
|
||||
<span>Download</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-error" onClick={deleteSelectedGalleryItems}>
|
||||
<Trash2 size={14} />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={clearGallerySelection}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{generatedImages.length === 0 ? (
|
||||
<div className="m3-card gallery-empty">
|
||||
No images generated in this session yet. Everything will be saved directly on your external drive.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="gallery-hint">Click to select one image. Ctrl-click or Shift-click to select multiple.</div>
|
||||
<div className="gallery-grid">
|
||||
{generatedImages.map((img, idx) => (
|
||||
<GalleryItem
|
||||
key={img.image || idx}
|
||||
img={img}
|
||||
idx={idx}
|
||||
isSelected={selectedGalleryIndexes.includes(idx)}
|
||||
onClick={toggleGallerySelection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Generator);
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,455 +0,0 @@
|
||||
import React, { memo } from "react";
|
||||
import { Image, FolderDown, MessageSquare, Mic, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2, Volume2 } from "lucide-react";
|
||||
|
||||
function formatSidebarDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const dateStr = date.toLocaleDateString();
|
||||
const timeStr = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
collapsed = false,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
specs,
|
||||
conversations = [],
|
||||
activeConversationId,
|
||||
setActiveConversationId,
|
||||
showHistory,
|
||||
setShowHistory,
|
||||
onDeleteConversation,
|
||||
speechTranscriptions = [],
|
||||
selectedSpeechTranscript,
|
||||
setSelectedSpeechTranscript,
|
||||
showSpeechHistory,
|
||||
setShowSpeechHistory,
|
||||
onDeleteSpeechTranscription,
|
||||
ttsOutputs = [],
|
||||
selectedTtsOutput,
|
||||
setSelectedTtsOutput,
|
||||
showTtsHistory,
|
||||
setShowTtsHistory,
|
||||
onDeleteTtsOutput
|
||||
}) {
|
||||
return (
|
||||
<div className={`sidebar ${collapsed ? "collapsed" : ""}`}>
|
||||
<div>
|
||||
{/* Sidebar Header */}
|
||||
<div className="sidebar-logo">
|
||||
<Shield className="sidebar-logo-icon" />
|
||||
<span className="sidebar-logo-text">Local AI Studio</span>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Navigation Links (Material 3 style) */}
|
||||
<div className="nav-list">
|
||||
<div
|
||||
className={`nav-item ${activeTab === "generator" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("generator")}
|
||||
>
|
||||
<Image size={20} />
|
||||
<span>Image Generator</span>
|
||||
</div>
|
||||
|
||||
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
className={`nav-item ${activeTab === "chat" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("chat")}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<MessageSquare size={20} />
|
||||
<span>Text Chat</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowHistory(!showHistory);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
transition: "background-color 0.2s"
|
||||
}}
|
||||
className="history-toggle-arrow"
|
||||
title={showHistory ? "Hide Chat History" : "Show Chat History"}
|
||||
>
|
||||
{showHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Chat History List */}
|
||||
{showHistory && (
|
||||
<div
|
||||
className="sidebar-history-list"
|
||||
style={{
|
||||
paddingLeft: "14px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
maxHeight: "220px",
|
||||
overflowY: "auto",
|
||||
borderLeft: "2px solid var(--border-color)"
|
||||
}}
|
||||
>
|
||||
{conversations.length === 0 ? (
|
||||
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
|
||||
No saved chats
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => {
|
||||
const isActive = activeConversationId === conv.id;
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
setActiveConversationId(conv.id);
|
||||
setActiveTab("chat");
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "6px 8px 6px 10px",
|
||||
borderRadius: "var(--md-shape-corner-small)",
|
||||
fontSize: "0.78rem",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
|
||||
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
|
||||
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
|
||||
transition: "background 0.2s"
|
||||
}}
|
||||
className="sidebar-history-item"
|
||||
title={conv.title}
|
||||
>
|
||||
<span style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
fontWeight: isActive ? 600 : 400
|
||||
}}>
|
||||
{conv.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteConversation(conv.id, e);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--md-sys-color-outline)",
|
||||
cursor: "pointer",
|
||||
padding: "2px",
|
||||
marginLeft: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}}
|
||||
className="sidebar-history-delete"
|
||||
title="Delete Conversation"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
className={`nav-item ${activeTab === "speech" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("speech")}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<Mic size={20} />
|
||||
<span>Speech Transcriber</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSpeechHistory(!showSpeechHistory);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
transition: "background-color 0.2s"
|
||||
}}
|
||||
className="history-toggle-arrow"
|
||||
title={showSpeechHistory ? "Hide Transcriptions" : "Show Transcriptions"}
|
||||
>
|
||||
{showSpeechHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSpeechHistory && (
|
||||
<div
|
||||
className="sidebar-history-list"
|
||||
style={{
|
||||
paddingLeft: "14px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
maxHeight: "220px",
|
||||
overflowY: "auto",
|
||||
borderLeft: "2px solid var(--border-color)"
|
||||
}}
|
||||
>
|
||||
{speechTranscriptions.length === 0 ? (
|
||||
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
|
||||
No saved transcriptions
|
||||
</div>
|
||||
) : (
|
||||
speechTranscriptions.map((item) => {
|
||||
const itemId = item.filename || item.metadata || item.textFile;
|
||||
const isActive = selectedSpeechTranscript && (selectedSpeechTranscript.filename || selectedSpeechTranscript.metadata || selectedSpeechTranscript.textFile) === itemId;
|
||||
const title = item.displayName || item.sourceFilename || item.textFile || item.filename || "Transcript";
|
||||
const date = formatSidebarDate(item.modifiedAt || item.createdAt);
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
onClick={() => {
|
||||
setSelectedSpeechTranscript(item);
|
||||
setActiveTab("speech");
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "7px 8px 7px 10px",
|
||||
borderRadius: "var(--md-shape-corner-small)",
|
||||
fontSize: "0.78rem",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
|
||||
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
|
||||
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
|
||||
transition: "background 0.2s"
|
||||
}}
|
||||
className="sidebar-history-item"
|
||||
title={title}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1, minWidth: 0 }}>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
|
||||
{title}
|
||||
</span>
|
||||
{date && (
|
||||
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSpeechTranscription?.(item, e);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--md-sys-color-outline)",
|
||||
cursor: "pointer",
|
||||
padding: "2px",
|
||||
marginLeft: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}}
|
||||
className="sidebar-history-delete"
|
||||
title="Delete Transcription"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
className={`nav-item ${activeTab === "tts" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("tts")}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<Volume2 size={20} />
|
||||
<span>Text to Speech</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowTtsHistory(!showTtsHistory);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
transition: "background-color 0.2s"
|
||||
}}
|
||||
className="history-toggle-arrow"
|
||||
title={showTtsHistory ? "Hide TTS Outputs" : "Show TTS Outputs"}
|
||||
>
|
||||
{showTtsHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showTtsHistory && (
|
||||
<div
|
||||
className="sidebar-history-list"
|
||||
style={{
|
||||
paddingLeft: "14px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
maxHeight: "220px",
|
||||
overflowY: "auto",
|
||||
borderLeft: "2px solid var(--border-color)"
|
||||
}}
|
||||
>
|
||||
{ttsOutputs.length === 0 ? (
|
||||
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
|
||||
No saved audio
|
||||
</div>
|
||||
) : (
|
||||
ttsOutputs.map((item) => {
|
||||
const itemId = item.filename || item.metadata || item.audioFile;
|
||||
const isActive = selectedTtsOutput && (selectedTtsOutput.filename || selectedTtsOutput.metadata || selectedTtsOutput.audioFile) === itemId;
|
||||
const title = item.displayName || item.text || item.audioFile || item.filename || "TTS Output";
|
||||
const date = formatSidebarDate(item.modifiedAt || item.createdAt);
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
onClick={() => {
|
||||
setSelectedTtsOutput(item);
|
||||
setActiveTab("tts");
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "7px 8px 7px 10px",
|
||||
borderRadius: "var(--md-shape-corner-small)",
|
||||
fontSize: "0.78rem",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
|
||||
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
|
||||
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
|
||||
transition: "background 0.2s"
|
||||
}}
|
||||
className="sidebar-history-item"
|
||||
title={title}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1, minWidth: 0 }}>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isActive ? 600 : 400 }}>
|
||||
{title}
|
||||
</span>
|
||||
{date && (
|
||||
<span style={{ fontSize: "0.68rem", opacity: 0.72, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteTtsOutput?.(item, e);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--md-sys-color-outline)",
|
||||
cursor: "pointer",
|
||||
padding: "2px",
|
||||
marginLeft: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}}
|
||||
className="sidebar-history-delete"
|
||||
title="Delete TTS Output"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`nav-item ${activeTab === "models" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("models")}
|
||||
>
|
||||
<FolderDown size={20} />
|
||||
<span>Model Manager</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`nav-item ${activeTab === "settings" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("settings")}
|
||||
>
|
||||
<Settings size={20} />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer with Host Telemetry System Specs */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-specs-header">
|
||||
<Terminal size={12} />
|
||||
<span>Host Specifications</span>
|
||||
</div>
|
||||
<div className="sidebar-specs-item" title={specs.cpu_name}>
|
||||
CPU: {specs.cpu_name}
|
||||
</div>
|
||||
<div className="sidebar-specs-item" title={specs.gpu_name}>
|
||||
GPU: {specs.gpu_name}
|
||||
</div>
|
||||
<div className="sidebar-specs-item">
|
||||
Memory: {specs.ram_total_gb.toFixed(0)} GB RAM ({specs.cpu_cores_physical} Cores)
|
||||
</div>
|
||||
<div className="sidebar-specs-os">
|
||||
OS: {specs.os_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Sidebar);
|
||||
@@ -290,8 +290,11 @@ export default function SpeechTranscriber({
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".wav")) {
|
||||
showAlert?.({ title: "Unsupported Audio", message: "V1 accepts WAV files only. MP3/WebM/M4A will need the later FFmpeg path.", danger: true });
|
||||
const name = file.name.toLowerCase();
|
||||
const allowedExt = [".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aac", ".m4b", ".opus", ".wma"];
|
||||
const isAllowed = allowedExt.some(ext => name.endsWith(ext));
|
||||
if (!isAllowed) {
|
||||
showAlert?.({ title: "Unsupported Audio", message: "Supported formats: WAV, MP3, M4A, OGG, FLAC, WebM, AAC, M4B, OPUS, WMA", danger: true });
|
||||
return;
|
||||
}
|
||||
setCurrentAudio(file, file.name);
|
||||
@@ -349,14 +352,57 @@ export default function SpeechTranscriber({
|
||||
setIsTranscribing(false);
|
||||
};
|
||||
|
||||
const saveText = () => {
|
||||
if (!resultText.trim()) return;
|
||||
downloadBlob("transcription.txt", resultText, "text/plain;charset=utf-8");
|
||||
const saveText = async () => {
|
||||
if (!audioBlob) return;
|
||||
await downloadTranscription("txt");
|
||||
};
|
||||
|
||||
const saveJson = () => {
|
||||
const payload = { ...(result || {}), text: resultText };
|
||||
downloadBlob("transcription.json", JSON.stringify(payload, null, 2), "application/json;charset=utf-8");
|
||||
const saveJson = async () => {
|
||||
if (!audioBlob) return;
|
||||
await downloadTranscription("json");
|
||||
};
|
||||
|
||||
const saveMarkdown = async () => {
|
||||
if (!audioBlob) return;
|
||||
await downloadTranscription("md");
|
||||
};
|
||||
|
||||
const downloadTranscription = async (format) => {
|
||||
if (!audioBlob) return;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedModel) params.set("model", selectedModel);
|
||||
params.set("language", speechSettings?.language || "auto");
|
||||
params.set("format", format);
|
||||
params.set("threads", String(speechSettings?.threads || 4));
|
||||
params.set("backendPreference", speechSettings?.backendPreference || "auto");
|
||||
if (speechSettings?.translate) params.set("translate", "true");
|
||||
params.set("filename", audioName || "audio.wav");
|
||||
params.set("output_filename", `transcription-${Date.now()}`);
|
||||
|
||||
const res = await fetch(`/api/speech/transcribe-file?${params.toString()}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "audio/wav" },
|
||||
body: audioBlob,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || `Download failed (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `transcription-${Date.now()}.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
showAlert?.({ title: "Download Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -364,7 +410,7 @@ export default function SpeechTranscriber({
|
||||
<div className="workspace-title-section">
|
||||
<h2 className="workspace-title">Speech Transcriber</h2>
|
||||
<p className="workspace-subtitle">
|
||||
Record microphone audio or upload a WAV file, then transcribe locally with whisper.cpp.
|
||||
Record microphone audio or upload audio (WAV, MP3, M4A, OGG, FLAC, WebM, AAC, etc.), then transcribe locally with whisper.cpp.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -464,8 +510,8 @@ export default function SpeechTranscriber({
|
||||
<div className="speech-capture-zone">
|
||||
<FileAudio size={42} />
|
||||
<div>
|
||||
<strong>{audioName || "Record or upload WAV"}</strong>
|
||||
<p>Microphone recordings are converted to 16 kHz mono PCM16 WAV before transcription.</p>
|
||||
<strong>{audioName || "Record or upload audio (WAV, MP3, M4A, OGG, FLAC, WebM, etc.)"}</strong>
|
||||
<p>Audio files are converted to 16 kHz mono PCM16 WAV before transcription.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -485,8 +531,8 @@ export default function SpeechTranscriber({
|
||||
)}
|
||||
<label className="m3-btn m3-btn-outlined">
|
||||
<Upload size={14} />
|
||||
<span>Upload WAV</span>
|
||||
<input type="file" accept=".wav,audio/wav" onChange={handleUploadAudio} hidden />
|
||||
<span>Upload Audio</span>
|
||||
<input type="file" accept=".wav,.mp3,.m4a,.ogg,.flac,.webm,.aac,.m4b,.opus,.wma,audio/*" onChange={handleUploadAudio} hidden />
|
||||
</label>
|
||||
{audioBlob && (
|
||||
<button className="m3-btn m3-btn-error" onClick={clearCurrentAudio} disabled={isTranscribing || isRecording}>
|
||||
@@ -509,7 +555,7 @@ export default function SpeechTranscriber({
|
||||
disabled={!audioBlob || !selectedModel || !status.backendInstalled}
|
||||
>
|
||||
<Mic size={14} />
|
||||
<span>Transcribe</span>
|
||||
<span>Convert</span>
|
||||
</button>
|
||||
)}
|
||||
{isTranscribing && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Transcribing locally...</span>}
|
||||
@@ -538,6 +584,10 @@ export default function SpeechTranscriber({
|
||||
<Save size={14} />
|
||||
<span>JSON</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={saveMarkdown} disabled={!resultText.trim()}>
|
||||
<Save size={14} />
|
||||
<span>MD</span>
|
||||
</button>
|
||||
{status.ready && (
|
||||
<button className="m3-btn m3-btn-error" onClick={() => stopSpeech().then(refresh)}>
|
||||
<Square size={14} />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,317 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
LoaderCircle,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Square,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getTtsStatus,
|
||||
listTtsModels,
|
||||
speakTts,
|
||||
startTts,
|
||||
stopTts,
|
||||
} from "../services/api";
|
||||
|
||||
const FALLBACK_VOICES = [
|
||||
{ id: "af_heart", name: "Heart", language: "en-us", gender: "Female" },
|
||||
{ id: "af_bella", name: "Bella", language: "en-us", gender: "Female" },
|
||||
{ id: "am_michael", name: "Michael", language: "en-us", gender: "Male" },
|
||||
{ id: "bf_emma", name: "Emma", language: "en-gb", gender: "Female" },
|
||||
{ id: "bm_george", name: "George", language: "en-gb", gender: "Male" },
|
||||
];
|
||||
|
||||
function downloadUrl(url, filename) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
export default function TextToSpeech({
|
||||
showAlert,
|
||||
selectedOutput,
|
||||
onOutputsChanged,
|
||||
ttsSettings,
|
||||
setTtsSettings,
|
||||
}) {
|
||||
const [status, setStatus] = useState({ ready: false, running: false, runtimeInstalled: false, settings: {}, voices: [] });
|
||||
const [models, setModels] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [text, setText] = useState("Hello from Local AI Studio text to speech.");
|
||||
const [isLoadingModel, setIsLoadingModel] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [output, setOutput] = useState(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const installedModels = useMemo(() => models.filter((model) => model.installed), [models]);
|
||||
const voices = status.voices?.length ? status.voices : FALLBACK_VOICES;
|
||||
const selectedVoice = ttsSettings?.voice || "af_heart";
|
||||
const speed = ttsSettings?.speed || 1;
|
||||
|
||||
const updateTtsSetting = (key, value) => {
|
||||
setTtsSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextStatus, nextModels] = await Promise.all([
|
||||
getTtsStatus(),
|
||||
listTtsModels(),
|
||||
]);
|
||||
setStatus(nextStatus);
|
||||
setModels(nextModels);
|
||||
setSelectedModel((current) => {
|
||||
const installed = nextModels.filter((model) => model.installed);
|
||||
if (current && installed.some((model) => model.filename === current || model.id === current)) return current;
|
||||
const active = nextStatus.settings?.model;
|
||||
if (active && installed.some((model) => model.filename === active)) return active;
|
||||
const preferred = ttsSettings?.model;
|
||||
if (preferred && installed.some((model) => model.filename === preferred)) return preferred;
|
||||
return installed.find((model) => model.recommended)?.filename || installed[0]?.filename || "";
|
||||
});
|
||||
}, [ttsSettings?.model]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err) => showAlert?.({ title: "TTS Status Failed", message: err.message || String(err), danger: true }));
|
||||
const interval = setInterval(() => {
|
||||
refresh().catch(() => {});
|
||||
}, 2500);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh, showAlert]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOutput) return;
|
||||
setOutput(selectedOutput);
|
||||
setText(selectedOutput.text || "");
|
||||
if (selectedOutput.voice) updateTtsSetting("voice", selectedOutput.voice);
|
||||
if (selectedOutput.speed) updateTtsSetting("speed", selectedOutput.speed);
|
||||
if (selectedOutput.model) setSelectedModel(selectedOutput.model);
|
||||
}, [selectedOutput]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const handleLoadModel = async () => {
|
||||
if (!selectedModel) return;
|
||||
setIsLoadingModel(true);
|
||||
try {
|
||||
await startTts(selectedModel, { voice: selectedVoice, speed });
|
||||
updateTtsSetting("model", selectedModel);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
showAlert?.({ title: "TTS Model Load Failed", message: err.message || String(err), danger: true });
|
||||
} finally {
|
||||
setIsLoadingModel(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await stopTts();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
showAlert?.({ title: "Stop TTS Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!text.trim() || !selectedModel) return;
|
||||
setIsGenerating(true);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
if (!status.ready || status.settings?.model !== selectedModel) {
|
||||
setIsLoadingModel(true);
|
||||
try {
|
||||
await startTts(selectedModel, { voice: selectedVoice, speed });
|
||||
updateTtsSetting("model", selectedModel);
|
||||
await refresh();
|
||||
} finally {
|
||||
setIsLoadingModel(false);
|
||||
}
|
||||
}
|
||||
const generated = await speakTts(text, {
|
||||
model: selectedModel,
|
||||
voice: selectedVoice,
|
||||
speed,
|
||||
signal: controller.signal,
|
||||
});
|
||||
setOutput(generated);
|
||||
await refresh();
|
||||
await onOutputsChanged?.();
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
showAlert?.({ title: "TTS Generation Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
setIsGenerating(false);
|
||||
};
|
||||
|
||||
const isLoaded = status.ready && status.settings?.model === selectedModel;
|
||||
|
||||
return (
|
||||
<div className="workspace-area speech-workspace">
|
||||
<div className="workspace-title-section">
|
||||
<h2 className="workspace-title">Text to Speech</h2>
|
||||
<p className="workspace-subtitle">
|
||||
Generate local WAV narration with Kokoro ONNX.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="speech-grid">
|
||||
<section className="m3-card speech-panel">
|
||||
<div className="speech-panel-header">
|
||||
<h3>Runtime</h3>
|
||||
<span className={`status-chip ${status.runtimeInstalled ? "" : "offline"}`}>
|
||||
{status.runtimeInstalled ? (status.ready ? "Ready" : "Installed") : "Runtime missing"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status.error && <div className="text-progress error">{status.error}</div>}
|
||||
|
||||
<label className="speech-label">
|
||||
Kokoro model
|
||||
<select className="m3-input" value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)}>
|
||||
{installedModels.length === 0 && <option value="">No downloaded TTS models</option>}
|
||||
{installedModels.map((model) => (
|
||||
<option key={model.id || model.filename} value={model.filename}>
|
||||
{model.name || model.filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="speech-controls-row">
|
||||
<label className="speech-label">
|
||||
Voice
|
||||
<select className="m3-input" value={selectedVoice} onChange={(event) => updateTtsSetting("voice", event.target.value)}>
|
||||
{voices.map((voice) => (
|
||||
<option key={voice.id} value={voice.id}>
|
||||
{voice.name} ({voice.gender}, {voice.language})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="speech-label">
|
||||
Speed
|
||||
<input
|
||||
className="m3-input"
|
||||
type="number"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.05"
|
||||
value={speed}
|
||||
onChange={(event) => updateTtsSetting("speed", Math.max(0.5, Math.min(2, Number(event.target.value) || 1)))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="speech-button-row">
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
onClick={handleLoadModel}
|
||||
disabled={!selectedModel || isLoadingModel || isGenerating || isLoaded}
|
||||
>
|
||||
{isLoadingModel ? <LoaderCircle className="progress-spinner" size={14} /> : <Play size={14} />}
|
||||
<span>{isLoaded ? "Loaded" : isLoadingModel ? "Loading" : "Load"}</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={refresh}>
|
||||
<RefreshCw size={14} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
{status.ready && (
|
||||
<button className="m3-btn m3-btn-error" onClick={handleStop}>
|
||||
<Square size={14} />
|
||||
<span>Stop Runtime</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{installedModels.length === 0 && (
|
||||
<div className="text-progress">
|
||||
Download a Kokoro model from Model Manager, TTS Models first.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="m3-card speech-panel">
|
||||
<div className="speech-panel-header">
|
||||
<h3>Input</h3>
|
||||
<span className="status-chip">{text.trim().length} chars</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="m3-input"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={10}
|
||||
maxLength={5000}
|
||||
style={{ width: "100%", resize: "vertical", minHeight: "220px", lineHeight: 1.45 }}
|
||||
placeholder="Type text to turn into speech..."
|
||||
/>
|
||||
<div className="speech-button-row">
|
||||
{isGenerating ? (
|
||||
<button className="m3-btn m3-btn-error" onClick={handleCancel}>
|
||||
<Square size={14} />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
onClick={handleGenerate}
|
||||
disabled={!text.trim() || !selectedModel || !status.runtimeInstalled}
|
||||
>
|
||||
<Volume2 size={14} />
|
||||
<span>Generate WAV</span>
|
||||
</button>
|
||||
)}
|
||||
{isGenerating && <span className="text-progress"><LoaderCircle className="progress-spinner" size={14} /> Generating locally...</span>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="m3-card speech-result-panel">
|
||||
<div className="speech-panel-header">
|
||||
<h3>Output</h3>
|
||||
<div className="speech-button-row">
|
||||
<button className="m3-btn m3-btn-outlined" onClick={() => output?.url && downloadUrl(output.url, output.audioFile || "tts.wav")} disabled={!output?.url}>
|
||||
<Save size={14} />
|
||||
<span>WAV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{output?.url ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||
<audio controls src={output.url} style={{ width: "100%" }} />
|
||||
<div className="text-progress">
|
||||
{output.voiceName || output.voice} • {output.modelName || output.model} • {new Date(output.createdAt).toLocaleString()}
|
||||
</div>
|
||||
<textarea
|
||||
className="m3-input"
|
||||
value={output.text || text}
|
||||
onChange={(event) => setOutput((prev) => ({ ...(prev || {}), text: event.target.value }))}
|
||||
rows={5}
|
||||
style={{ width: "100%", resize: "vertical", minHeight: "120px", lineHeight: 1.45 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state" style={{ padding: "32px 0" }}>
|
||||
<Volume2 size={42} />
|
||||
<p>Generated speech will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import React, { memo, useState, useRef, useEffect } from "react";
|
||||
import { Square, RefreshCw, Sun, Moon, Palette, Check, PanelLeftClose, PanelLeft } from "lucide-react";
|
||||
import { THEMES } from "../themes";
|
||||
|
||||
const truncateModelName = (name) => {
|
||||
if (!name || typeof name !== "string") return "";
|
||||
let cleanName = name;
|
||||
if (name.includes("--")) {
|
||||
const parts = name.split("--");
|
||||
cleanName = parts[parts.length - 1];
|
||||
}
|
||||
if (cleanName.length > 25) {
|
||||
return cleanName.substring(0, 22) + "...";
|
||||
}
|
||||
return cleanName;
|
||||
};
|
||||
|
||||
function TopStatusBar({
|
||||
telemetry,
|
||||
serverRunning,
|
||||
activeModel,
|
||||
isLlmLoaded = false,
|
||||
onStopServer,
|
||||
isStoppingServer = false,
|
||||
theme,
|
||||
setTheme,
|
||||
sidebarVisible,
|
||||
onToggleSidebar
|
||||
}) {
|
||||
const [showThemeMenu, setShowThemeMenu] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
setShowThemeMenu(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const formatGb = (value, { allowZero = false } = {}) => {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < 0) return "--";
|
||||
if (number === 0 && !allowZero) return "--";
|
||||
return number.toFixed(number >= 10 ? 0 : 1);
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isLlmLoaded) return "Model Loaded (Text)";
|
||||
if (activeModel) return "Model Loaded (Image)";
|
||||
if (serverRunning) return "Server Active";
|
||||
return "Local Mode";
|
||||
};
|
||||
|
||||
const getStatusClass = () => {
|
||||
if (activeModel || isLlmLoaded) return "status-indicator";
|
||||
if (serverRunning) return "status-indicator busy";
|
||||
return "status-indicator offline";
|
||||
};
|
||||
|
||||
const isDark = (theme || "dark").startsWith("dark");
|
||||
|
||||
return (
|
||||
<div className="top-status-bar">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="sidebar-toggle-btn"
|
||||
title={sidebarVisible ? "Collapse Sidebar" : "Expand Sidebar"}
|
||||
>
|
||||
{sidebarVisible ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}
|
||||
</button>
|
||||
|
||||
<div className="current-model-info">
|
||||
<div className={getStatusClass()}></div>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
{(activeModel || (typeof isLlmLoaded === "string" && isLlmLoaded)) && (
|
||||
<>
|
||||
<span style={{ color: "var(--md-sys-color-outline-variant)" }}>|</span>
|
||||
<span
|
||||
style={{ color: "var(--md-sys-color-primary)", fontWeight: 700 }}
|
||||
title={activeModel || isLlmLoaded}
|
||||
>
|
||||
{truncateModelName(activeModel || isLlmLoaded)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="telemetry-group" style={{ position: "relative" }}>
|
||||
<button
|
||||
className="theme-toggle-btn"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={`Switch to ${isDark ? "light" : "dark"} theme`}
|
||||
>
|
||||
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
|
||||
<div ref={menuRef} style={{ position: "relative", display: "inline-block" }}>
|
||||
<button
|
||||
className={`theme-toggle-btn ${showThemeMenu ? "active" : ""}`}
|
||||
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||
title="Choose a custom color theme"
|
||||
style={{ marginRight: "12px" }}
|
||||
>
|
||||
<Palette size={18} />
|
||||
</button>
|
||||
|
||||
{showThemeMenu && (
|
||||
<div className="theme-dropdown-menu">
|
||||
<div className="theme-dropdown-header">Select Theme</div>
|
||||
<div className="theme-dropdown-divider"></div>
|
||||
{THEMES.map((t) => {
|
||||
const isActive = theme === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`theme-dropdown-item ${isActive ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setTheme(t.id);
|
||||
setShowThemeMenu(false);
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", width: "100%" }}>
|
||||
<div style={{ display: "flex", gap: "2px" }}>
|
||||
<div style={{ width: "8px", height: "8px", borderRadius: "50%", background: t.primary }} />
|
||||
<div style={{ width: "8px", height: "8px", borderRadius: "50%", background: t.secondary }} />
|
||||
</div>
|
||||
<span className="theme-name-text">{t.name}</span>
|
||||
{isActive && <Check size={12} style={{ marginLeft: "auto", color: "var(--md-sys-color-primary)" }} />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serverRunning && (
|
||||
<button
|
||||
className="m3-btn m3-btn-error"
|
||||
style={{ height: "34px", padding: "0 14px" }}
|
||||
onClick={onStopServer}
|
||||
disabled={isStoppingServer}
|
||||
title="Stop local model server"
|
||||
>
|
||||
{isStoppingServer ? <RefreshCw className="progress-spinner" size={14} /> : <Square size={14} />}
|
||||
<span>{isStoppingServer ? "Stopping" : "Stop Server"}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* CPU Telemetry Chip */}
|
||||
<div className="telemetry-chip" title="CPU Utilization">
|
||||
<span>CPU: {Number.isFinite(Number(telemetry.cpu_usage)) ? telemetry.cpu_usage : "--"}%</span>
|
||||
</div>
|
||||
|
||||
{/* RAM Telemetry Chip */}
|
||||
<div className="telemetry-chip" title="System Memory Usage">
|
||||
<span>RAM: {formatGb(telemetry.ram_used_gb)} / {formatGb(telemetry.ram_total_gb)} GB</span>
|
||||
</div>
|
||||
|
||||
{/* GPU VRAM Telemetry Chip */}
|
||||
{telemetry.vram_total_gb > 0 && (
|
||||
<div className="telemetry-chip" title={`${telemetry.gpu_name} VRAM`}>
|
||||
<span>VRAM: {formatGb(telemetry.vram_used_gb, { allowZero: true })} / {formatGb(telemetry.vram_total_gb)} GB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TopStatusBar);
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -362,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());
|
||||
@@ -655,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", {
|
||||
@@ -1162,8 +1196,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",
|
||||
|
||||
+13
-1
@@ -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..."
|
||||
+11
-1
@@ -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..."
|
||||
+399
-5
@@ -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"),
|
||||
@@ -587,6 +591,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() {
|
||||
@@ -2377,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;
|
||||
@@ -2401,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, "-");
|
||||
@@ -2552,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) => {
|
||||
@@ -2662,6 +2784,9 @@ function getSetupPaths() {
|
||||
cpuBackend: BACKEND_PATHS.cpu,
|
||||
models: MODELS,
|
||||
outputs: OUTPUTS,
|
||||
chatHistory: CHAT_HISTORY,
|
||||
transcriptions: TRANSCRIPTIONS,
|
||||
ttsOutputs: TTS_OUTPUTS,
|
||||
};
|
||||
} else if (osPlatform === "darwin") {
|
||||
return {
|
||||
@@ -2671,6 +2796,9 @@ function getSetupPaths() {
|
||||
macBackend: BACKEND_PATHS.mac,
|
||||
models: MODELS,
|
||||
outputs: OUTPUTS,
|
||||
chatHistory: CHAT_HISTORY,
|
||||
transcriptions: TRANSCRIPTIONS,
|
||||
ttsOutputs: TTS_OUTPUTS,
|
||||
};
|
||||
} else {
|
||||
// Linux / WSL
|
||||
@@ -2683,6 +2811,9 @@ function getSetupPaths() {
|
||||
linuxRocmBackend: BACKEND_PATHS.linuxRocm,
|
||||
models: MODELS,
|
||||
outputs: OUTPUTS,
|
||||
chatHistory: CHAT_HISTORY,
|
||||
transcriptions: TRANSCRIPTIONS,
|
||||
ttsOutputs: TTS_OUTPUTS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2695,6 +2826,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") {
|
||||
@@ -2767,6 +2901,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,
|
||||
@@ -2783,6 +2927,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;
|
||||
@@ -3285,6 +3431,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 +4479,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 +4508,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 +4527,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 +4667,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();
|
||||
@@ -4829,6 +5016,8 @@ let generationState = {
|
||||
steps: 0,
|
||||
speed: "",
|
||||
decoding: false,
|
||||
backendMode: "",
|
||||
backendDevice: "",
|
||||
};
|
||||
|
||||
function resetGenerationState() {
|
||||
@@ -4838,6 +5027,8 @@ function resetGenerationState() {
|
||||
steps: 0,
|
||||
speed: "",
|
||||
decoding: false,
|
||||
backendMode: "",
|
||||
backendDevice: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5217,6 +5408,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) {
|
||||
@@ -5297,6 +5504,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) {
|
||||
@@ -5483,6 +5785,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") {
|
||||
@@ -5591,6 +5896,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() });
|
||||
}
|
||||
@@ -5697,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",
|
||||
@@ -5712,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;
|
||||
@@ -6589,7 +6979,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") {
|
||||
|
||||
@@ -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()
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+121
-90
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
-14
@@ -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
|
||||
|
||||
+19
-40
@@ -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 ============================================================
|
||||
|
||||
Reference in new issue
Block a user