Initial commit
This commit is contained in:
commit
a9460556e4
24 files changed
+9226
No files matched your search
+27
@@ -0,0 +1,27 @@
|
||||
# Ignore large Stable Diffusion model weights (exceeds GitHub file limits)
|
||||
app/models/*.safetensors
|
||||
app/models/*.gguf
|
||||
app/models/*.ckpt
|
||||
app/models
|
||||
app/outputs/
|
||||
|
||||
# Ignore portable Node.js binaries and packages
|
||||
app/tools/
|
||||
**/node_modules/
|
||||
node_modules/
|
||||
|
||||
# Ignore backend folder completely (contains massive DLLs, EXEs, and ZIPs >1GB)
|
||||
app/backend/
|
||||
|
||||
# Ignore local IDE and OS temp files
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
|
||||
|
||||
# output images folder
|
||||
/app/outputs
|
||||
@@ -0,0 +1,137 @@
|
||||
<div align="center">
|
||||
|
||||
# Local AI Image Generator
|
||||
|
||||
### A fully self-contained, offline-first AI Art Studio for Windows
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 📖 Overview
|
||||
|
||||
**Local AI Image Generator** is a packaged, production-grade desktop environment designed to run Stable Diffusion models natively on Windows with maximum performance and zero installation complexity.
|
||||
|
||||
Say goodbye to manual Python installations, corrupted PyTorch dependencies, global Node.js setups, or broken CUDA toolchains. This workspace automatically downloads, configures, and serves the entire local image generation stack inside a single directory.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
### 1. Launch
|
||||
Double-click **`start.bat`** in the root directory.
|
||||
|
||||
*On your first launch, the automated installer script will download a portable Node.js build, configure dependencies, detect your GPU, download pre-compiled backend binaries, and compile the user interface.*
|
||||
|
||||
### 2. Put Weights in Place
|
||||
Place any `.safetensors`, `.gguf`, or `.ckpt` model file into:
|
||||
```
|
||||
app/models/
|
||||
```
|
||||
*(Or use the **Model Manager** tab in the browser dashboard to download one from our curated library).*
|
||||
|
||||
### 3. Generate Offline
|
||||
Open **`http://localhost:1420`** in your default web browser, select your model, write a prompt, and start creating!
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3> 100% Offline & Private</h3>
|
||||
<p>All inference runs entirely on your local GPU/CPU. Your prompts, images, and data never leave your computer.</p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3> Auto-Detected GPU Acceleration</h3>
|
||||
<p>Seamlessly detects and configures **CUDA** for Nvidia cards, or falls back to the high-performance **Vulkan** engine for AMD & Intel Arc GPUs.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3> No Global Dependencies</h3>
|
||||
<p>Zero system footprint. Node.js is downloaded as a portable binary into a sandbox folder, leaving your environment paths untouched.</p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3> Dynamic Backend Controller</h3>
|
||||
<p>A native Node.js process manager hosts the React frontend, streams system telemetry, manages downloads, and handles inference server lifecycles.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3> Real-time Hardware Telemetry</h3>
|
||||
<p>Monitor your PC's real-time CPU, RAM, GPU, and VRAM utilization directly inside the workspace UI.</p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3> Drag & Drop Model Importing</h3>
|
||||
<p>Upload local model files from other folders on your PC or download weights directly from Hugging Face via the built-in UI downloader.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
```
|
||||
local-ai-image-generator/
|
||||
├── start.bat # main double-click launcher
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── setup.ps1 # dynamic 4-stage dependency manager
|
||||
│ ├── reset.ps1 # repair utility (clears builds, keeps your models/images)
|
||||
│ └── serve.cjs # static UI server & backend controller
|
||||
│
|
||||
└── app/
|
||||
├── frontend/ # React + Vite dashboard source code
|
||||
├── backend/ # pre-compiled stable-diffusion.cpp GPU binaries
|
||||
│ ├── win/cuda/ # CUDA acceleration backend (sd-cuda.exe)
|
||||
│ └── win/vulkan/ # Vulkan acceleration backend (sd-vulkan.exe)
|
||||
├── models/ # weight repository (.safetensors, .gguf, .ckpt)
|
||||
├── outputs/ # generated PNGs and JSON parameters metadata
|
||||
├── tools/node/ # portable Node.js sandbox
|
||||
└── dist/ # built frontend assets (compiled on setup)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🖥️ GPU Compatibility Matrix
|
||||
|
||||
The application server configures itself to utilize the best performance pathways on your PC:
|
||||
|
||||
| GPU Brand | Tech | Status | Setup Output |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Nvidia** | CUDA | ✅ Native Support | Maps `sd-cuda.exe` with Nvidia SDK 12 optimizations. |
|
||||
| **AMD Radeon** | Vulkan | ✅ Native Support | Maps `sd-vulkan.exe` with Vulkan API acceleration. |
|
||||
| **Intel Arc** | Vulkan | ✅ Native Support | Maps `sd-vulkan.exe` for GPU acceleration. |
|
||||
| **None / Integrated** | CPU | ⚠️ Thread Fallback | Runs on logical threads (slow, for testing). |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
### 🔄 Resetting / Repairing
|
||||
If you encounter corrupted downloads or want to rebuild your frontend assets, run:
|
||||
```powershell
|
||||
scripts/reset.ps1
|
||||
```
|
||||
*Note: This cleans the portable environment files and downloads fresh copies, but **safely preserves your downloaded models inside `app/models/` and images in `app/outputs/`**.*
|
||||
|
||||
### 🔌 Port Conflict Warnings
|
||||
The launcher utilizes two ports on local address `127.0.0.1`:
|
||||
- **`1420`** (Frontend Dashboard Server)
|
||||
- **`8080`** (Local Inference C++ API Server)
|
||||
|
||||
Ensure no other applications or development services are binding to these ports before launching.
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project bundles [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp) under the MIT License. Individual model weights are subject to their respective licenses — please check model cards on Hugging Face or Civitai before commercial application.
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
import{i as D,t as i}from"./index-Ddpqcst7.js";var _;(function(r){r.WINDOW_RESIZED="tauri://resize",r.WINDOW_MOVED="tauri://move",r.WINDOW_CLOSE_REQUESTED="tauri://close-requested",r.WINDOW_DESTROYED="tauri://destroyed",r.WINDOW_FOCUS="tauri://focus",r.WINDOW_BLUR="tauri://blur",r.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",r.WINDOW_THEME_CHANGED="tauri://theme-changed",r.WINDOW_CREATED="tauri://window-created",r.WINDOW_SUSPENDED="tauri://suspended",r.WINDOW_RESUMED="tauri://resumed",r.WEBVIEW_CREATED="tauri://webview-created",r.DRAG_ENTER="tauri://drag-enter",r.DRAG_OVER="tauri://drag-over",r.DRAG_DROP="tauri://drag-drop",r.DRAG_LEAVE="tauri://drag-leave"})(_||(_={}));async function l(r,a){window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(r,a),await D("plugin:event|unlisten",{event:r,eventId:a})}async function n(r,a,e){var t;const W=typeof e?.target=="string"?{kind:"AnyLabel",label:e.target}:(t=e?.target)!==null&&t!==void 0?t:{kind:"Any"};return D("plugin:event|listen",{event:r,target:W,handler:i(a)}).then(d=>async()=>l(r,d))}export{_ as TauriEvent,n as listen};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
:root{font-family:Outfit,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--md-shape-corner-small: 8px;--md-shape-corner-medium: 12px;--md-shape-corner-large: 16px;--md-shape-corner-extra-large: 22px;--md-shape-corner-full: 9999px;--md-transition-duration: .25s;--md-transition-easing: cubic-bezier(.4, 0, .2, 1);--md-sys-color-primary: #4f46e5;--md-sys-color-on-primary: #ffffff;--md-sys-color-primary-container: rgba(79, 70, 229, .06);--md-sys-color-on-primary-container: #4338ca;--md-sys-color-secondary: #0ea5e9;--md-sys-color-on-secondary: #ffffff;--md-sys-color-secondary-container: rgba(14, 165, 233, .06);--md-sys-color-on-secondary-container: #0369a1;--md-sys-color-tertiary: #d946ef;--md-sys-color-on-tertiary: #ffffff;--md-sys-color-background: #f8fafc;--md-sys-color-on-background: #0f172a;--md-sys-color-surface: rgba(255, 255, 255, .75);--md-sys-color-on-surface: #0f172a;--md-sys-color-surface-variant: rgba(241, 245, 249, .8);--md-sys-color-on-surface-variant: #475569;--md-sys-color-outline: #64748b;--md-sys-color-outline-variant: #94a3b8;--border-color: rgba(15, 23, 42, .08);--border-color-muted: rgba(15, 23, 42, .05);--md-sys-color-error: #ef4444;--md-sys-color-on-error: #ffffff;--md-sys-color-success: #10b981;--md-sys-color-on-success: #ffffff;--md-sys-color-card-border: rgba(15, 23, 42, .06);--glass-shadow: 0 8px 32px 0 rgba(15, 23, 42, .04);--glass-blur: blur(16px)}html[data-theme=dark]{--md-sys-color-primary: #6366f1;--md-sys-color-on-primary: #ffffff;--md-sys-color-primary-container: rgba(99, 102, 241, .12);--md-sys-color-on-primary-container: #c7d2fe;--md-sys-color-secondary: #38bdf8;--md-sys-color-on-secondary: #0f172a;--md-sys-color-secondary-container: rgba(56, 189, 248, .08);--md-sys-color-on-secondary-container: #bae6fd;--md-sys-color-tertiary: #f472b6;--md-sys-color-on-tertiary: #0f172a;--md-sys-color-background: #090a0f;--md-sys-color-on-background: #f4f4f5;--md-sys-color-surface: rgba(18, 20, 29, .65);--md-sys-color-on-surface: #f4f4f5;--md-sys-color-surface-variant: rgba(26, 29, 41, .55);--md-sys-color-on-surface-variant: #a1a1aa;--md-sys-color-outline: #a1a1aa;--md-sys-color-outline-variant: #71717a;--border-color: rgba(255, 255, 255, .09);--border-color-muted: rgba(255, 255, 255, .05);--md-sys-color-error: #f87171;--md-sys-color-on-error: #ffffff;--md-sys-color-success: #34d399;--md-sys-color-on-success: #090a0f;--md-sys-color-card-border: rgba(255, 255, 255, .04);--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, .3);--glass-blur: blur(16px)}*{box-sizing:border-box;margin:0;padding:0}body{background-color:var(--md-sys-color-background);background-image:radial-gradient(at 0% 0%,var(--md-sys-color-primary-container) 0px,transparent 50%),radial-gradient(at 100% 0%,var(--md-sys-color-secondary-container) 0px,transparent 40%);color:var(--md-sys-color-on-background);overflow:hidden;height:100vh;width:100vw;font-size:16px;transition:background-color var(--md-transition-duration) ease}.app-container{display:flex;flex-direction:row;height:100vh;width:100vw;overflow:hidden}.sidebar{width:280px;background-color:var(--md-sys-color-surface);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border-right:1px solid var(--border-color);display:flex;flex-direction:column;padding:28px 18px;justify-content:space-between;box-shadow:var(--glass-shadow);z-index:11;transition:all var(--md-transition-duration) var(--md-transition-easing)}.sidebar-logo{display:flex;align-items:center;gap:12px;padding:0 12px 28px}.sidebar-logo-icon{color:var(--md-sys-color-primary);width:26px;height:26px;filter:drop-shadow(0 2px 8px rgba(99,102,241,.3))}.sidebar-logo-text{font-size:1.15rem;font-weight:700;letter-spacing:-.02em;color:var(--md-sys-color-on-background)}.nav-list{display:flex;flex-direction:column;gap:6px;margin-bottom:auto}.nav-item{display:flex;align-items:center;gap:12px;padding:10px 16px;height:48px;border-radius:var(--md-shape-corner-medium);color:var(--md-sys-color-on-surface-variant);font-size:.9rem;font-weight:500;cursor:pointer;transition:all var(--md-transition-duration) var(--md-transition-easing);-webkit-user-select:none;user-select:none;text-decoration:none;position:relative}.nav-item:hover{background-color:var(--md-sys-color-surface-variant);color:var(--md-sys-color-primary);transform:translate(2px)}.nav-item.active{background-color:var(--md-sys-color-primary-container);color:var(--md-sys-color-primary);font-weight:600}.nav-item.active:before{content:"";position:absolute;left:0;top:14px;bottom:14px;width:4px;background-color:var(--md-sys-color-primary);border-radius:0 4px 4px 0}.sidebar-footer{padding:18px 12px 0;border-top:1px solid var(--border-color);font-size:.78rem;color:var(--md-sys-color-on-surface-variant);display:flex;flex-direction:column;gap:6px;opacity:.95}.main-content{flex:1;display:flex;flex-direction:column;background-coLine truncated
|
||||
Vendored
+120
@@ -0,0 +1,120 @@
|
||||
(function(){const b=document.createElement("link").relList;if(b&&b.supports&&b.supports("modulepreload"))return;for(const j of document.querySelectorAll('link[rel="modulepreload"]'))r(j);new MutationObserver(j=>{for(const A of j)if(A.type==="childList")for(const Y of A.addedNodes)Y.tagName==="LINK"&&Y.rel==="modulepreload"&&r(Y)}).observe(document,{childList:!0,subtree:!0});function N(j){const A={};return j.integrity&&(A.integrity=j.integrity),j.referrerPolicy&&(A.referrerPolicy=j.referrerPolicy),j.crossOrigin==="use-credentials"?A.credentials="include":j.crossOrigin==="anonymous"?A.credentials="omit":A.credentials="same-origin",A}function r(j){if(j.ep)return;j.ep=!0;const A=N(j);fetch(j.href,A)}})();function sm(f){return f&&f.__esModule&&Object.prototype.hasOwnProperty.call(f,"default")?f.default:f}var Us={exports:{}},Pn={};var Zd;function z0(){if(Zd)return Pn;Zd=1;var f=Symbol.for("react.transitional.element"),b=Symbol.for("react.fragment");function N(r,j,A){var Y=null;if(A!==void 0&&(Y=""+A),j.key!==void 0&&(Y=""+j.key),"key"in j){A={};for(var G in j)G!=="key"&&(A[G]=j[G])}else A=j;return j=A.ref,{$$typeof:f,type:r,key:Y,ref:j!==void 0?j:null,props:A}}return Pn.Fragment=b,Pn.jsx=N,Pn.jsxs=N,Pn}var Vd;function T0(){return Vd||(Vd=1,Us.exports=z0()),Us.exports}var c=T0(),ws={exports:{}},ae={};var Kd;function M0(){if(Kd)return ae;Kd=1;var f=Symbol.for("react.transitional.element"),b=Symbol.for("react.portal"),N=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),j=Symbol.for("react.profiler"),A=Symbol.for("react.consumer"),Y=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),R=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),D=Symbol.for("react.lazy"),U=Symbol.for("react.activity"),Z=Symbol.iterator;function ee(d){return d===null||typeof d!="object"?null:(d=Z&&d[Z]||d["@@iterator"],typeof d=="function"?d:null)}var X={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},F=Object.assign,re={};function I(d,z,B){this.props=d,this.context=z,this.refs=re,this.updater=B||X}I.prototype.isReactComponent={},I.prototype.setState=function(d,z){if(typeof d!="object"&&typeof d!="function"&&d!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,d,z,"setState")},I.prototype.forceUpdate=function(d){this.updater.enqueueForceUpdate(this,d,"forceUpdate")};function ne(){}ne.prototype=I.prototype;function ce(d,z,B){this.props=d,this.context=z,this.refs=re,this.updater=B||X}var Ge=ce.prototype=new ne;Ge.constructor=ce,F(Ge,I.prototype),Ge.isPureReactComponent=!0;var Ve=Array.isArray;function Qe(){}var $={H:null,A:null,T:null,S:null},je=Object.prototype.hasOwnProperty;function ot(d,z,B){var H=B.ref;return{$$typeof:f,type:d,key:z,ref:H!==void 0?H:null,props:B}}function qe(d,z){return ot(d.type,z,d.props)}function Me(d){return typeof d=="object"&&d!==null&&d.$$typeof===f}function Ke(d){var z={"=":"=0",":":"=2"};return"$"+d.replace(/[=:]/g,function(B){return z[B]})}var $e=/\/+/g;function tt(d,z){return typeof d=="object"&&d!==null&&d.key!=null?Ke(""+d.key):z.toString(36)}function Je(d){switch(d.status){case"fulfilled":return d.value;case"rejected":throw d.reason;default:switch(typeof d.status=="string"?d.then(Qe,Qe):(d.status="pending",d.then(function(z){d.status==="pending"&&(d.status="fulfilled",d.value=z)},function(z){d.status==="pending"&&(d.status="rejected",d.reason=z)})),d.status){case"fulfilled":return d.value;case"rejected":throw d.reason}}throw d}function x(d,z,B,H,P){var te=typeof d;(te==="undefined"||te==="boolean")&&(d=null);var le=!1;if(d===null)le=!0;else switch(te){case"bigint":case"string":case"number":le=!0;break;case"object":switch(d.$$typeof){case f:case b:le=!0;break;case D:return le=d._init,x(le(d._payload),z,B,H,P)}}if(le)return P=P(d),le=H===""?"."+tt(d,0):H,Ve(P)?(B="",le!=null&&(B=le.replace($e,"$&/")+"/"),x(P,z,B,"",function(ct){return ct})):P!=null&&(Me(P)&&(P=qe(P,B+(P.key==null||d&&d.key===P.key?"":(""+P.key).replace($e,"$&/")+"/")+le)),z.push(P)),1;le=0;var Ue=H===""?".":H+":";if(Ve(d))for(var ze=0;ze<d.length;ze++)H=d[ze],te=Ue+tt(H,ze),le+=x(H,z,B,te,P);else if(ze=ee(d),typeof ze=="function")for(d=ze.call(d),ze=0;!(H=d.next()).done;)H=H.value,te=Ue+tt(H,ze++),le+=x(H,z,B,te,P);else if(te==="object"){if(typeof d.then=="function")return x(Je(d),z,B,H,P);throw z=String(d),Error("Objects are not valid as a React child (found: "+(z==="[object Object]"?"object with keys {"+Object.keys(d).join(", ")+"}":z)+"). If you meant to render a collection of children, use an array instead.")}return le}function O(d,z,B){if(d==null)return d;var H=[],P=0;return x(d,H,"","",function(te){return z.call(B,te,P++)}),H}function K(d){if(d._status===-1){var z=d._result;z=z(),z.then(function(B){(d._status===0||d._status===-1)&&(d._status=1,d._result=B)},function(B){(d._status===0||d._status===-1)&&(d._status=2,d._result=B)}),Line truncated
|
||||
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+wt+e+Al}var al=!1;function sl(e,t){if(!e||al)return"";al=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(t){var E=function(){throw Error()};if(Object.defineProperty(E.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(E,[])}catch(S){var v=S}Reflect.construct(e,[],E)}else{try{E.call()}catch(S){v=S}e.call(E.prototype)}}else{try{throw Error()}catch(S){v=S}(E=e())&&typeof E.catch=="function"&&E.catch(function(){})}}catch(S){if(S&&v&&typeof S.stack=="string")return[S.stack,v.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var i=a.DetermineComponentFrameRoot(),u=i[0],s=i[1];if(u&&s){var o=u.split(`
|
||||
`),p=s.split(`
|
||||
`);for(n=a=0;a<o.length&&!o[a].includes("DetermineComponentFrameRoot");)a++;for(;n<p.length&&!p[n].includes("DetermineComponentFrameRoot");)n++;if(a===o.length||n===p.length)for(a=o.length-1,n=p.length-1;1<=a&&0<=n&&o[a]!==p[n];)n--;for(;1<=a&&0<=n;a--,n--)if(o[a]!==p[n]){if(a!==1||n!==1)do if(a--,n--,0>n||o[a]!==p[n]){var _=`
|
||||
`+o[a].replace(" at new "," at ");return e.displayName&&_.includes("<anonymous>")&&(_=_.replace("<anonymous>",e.displayName)),_}while(1<=a&&0<=n);break}}}finally{al=!1,Error.prepareStackTrace=l}return(l=e?e.displayName||e.name:"")?rt(l):""}function T(e,t){switch(e.tag){case 26:case 27:case 5:return rt(e.type);case 16:return rt("Lazy");case 13:return e.child!==t&&t!==null?rt("Suspense Fallback"):rt("Suspense");case 19:return rt("SuspenseList");case 0:case 15:return sl(e.type,!1);case 11:return sl(e.type.render,!1);case 1:return sl(e.type,!0);case 31:return rt("Activity");default:return""}}function C(e){try{var t="",l=null;do t+=T(e,l),l=e,e=e.return;while(e);return t}catch(a){return`
|
||||
Error generating stack: `+a.message+`
|
||||
`+a.stack}}var q=Object.prototype.hasOwnProperty,k=f.unstable_scheduleCallback,He=f.unstable_cancelCallback,zt=f.unstable_shouldYield,st=f.unstable_requestPaint,pe=f.unstable_now,sn=f.unstable_getCurrentPriorityLevel,ni=f.unstable_ImmediatePriority,ii=f.unstable_UserBlockingPriority,L=f.unstable_NormalPriority,ve=f.unstable_LowPriority,we=f.unstable_IdlePriority,gt=f.log,Tt=f.unstable_setDisableYieldValue,Bt=null,dt=null;function Rt(e){if(typeof gt=="function"&&Tt(e),dt&&typeof dt.setStrictMode=="function")try{dt.setStrictMode(Bt,e)}catch{}}var Ee=Math.clz32?Math.clz32:ke,Te=Math.log,xe=Math.LN2;function ke(e){return e>>>=0,e===0?32:31-(Te(e)/xe|0)|0}var ye=256,lt=262144,Be=4194304;function We(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function _t(e,t,l){var a=e.pendingLanes;if(a===0)return 0;var n=0,i=e.suspendedLanes,u=e.pingedLanes;e=e.warmLanes;var s=a&134217727;return s!==0?(a=s&~i,a!==0?n=We(a):(u&=s,u!==0?n=We(u):l||(l=s&~e,l!==0&&(n=We(l))))):(s=a&~i,s!==0?n=We(s):u!==0?n=We(u):l||(l=a&~e,l!==0&&(n=We(l)))),n===0?0:t!==0&&t!==n&&(t&i)===0&&(i=n&-n,l=t&-t,i>=l||i===32&&(l&4194048)!==0)?t:n}function fl(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function bu(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Ks(){var e=Be;return Be<<=1,(Be&62914560)===0&&(Be=4194304),e}function xu(e){for(var t=[],l=0;31>l;l++)t.push(e);return t}function fn(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function dm(e,t,l,a,n,i){var u=e.pendingLanes;e.pendingLanes=l,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=l,e.entangledLanes&=l,e.errorRecoveryDisabledLanes&=l,e.shellSuspendCounter=0;var s=e.entanglements,o=e.expirationTimes,p=e.hiddenUpdates;for(l=u&~l;0<l;){var _=31-Ee(l),E=1<<_;s[_]=0,o[_]=-1;var v=p[_];if(v!==null)for(p[_]=null,_=0;_<v.length;_++){var S=v[_];S!==null&&(S.lane&=-536870913)}l&=~E}a!==0&&Js(e,a,0),i!==0&&n===0&&e.tag!==0&&(e.suspendedLanes|=i&~(u&~t))}function Js(e,t,l){e.pendingLanes|=t,e.suspendedLanes&=~t;var a=31-Ee(t);e.entangledLanes|=t,e.entanglements[a]=e.entanglements[a]|1073741824|l&261930}function $s(e,t){var l=e.entangledLanes|=t;for(e=e.entanglements;l;){var a=31-Ee(l),n=1<<a;n&t|e[a]&t&&(e[a]|=t),l&=~n}}function Ws(e,t){var l=t&-t;return l=(l&42)!==0?1:_u(l),(l&(e.suspendedLanes|t))!==0?0:l}function _u(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function ju(e){return e&=-e,2<e?8<e?(e&134217727)!==0?32:268435456:8:2}function Fs(){var e=O.p;return e!==0?e:(e=window.event,e===void 0?32:Gd(e.type))}function Is(e,t){var l=O.p;try{return O.p=e,t()}finally{O.p=l}}var Dl=Math.random().toString(36).slice(2),pt="__reactFiber$"+Dl,Et="__reactProps$"+Dl,_a="__reactContainer$"+Dl,zu="__reactEvents$"+Dl,mm="__reactListeners$"+Dl,hm="__reactHandles$"+Dl,Ps="__reactResources$"+Dl,on="__reactMarker$"+Dl;function Tu(e){delete e[pt],delete e[Et],delete e[zu],delete e[mm],delete e[hm]}function ja(e){var t=e[pt];if(t)return t;for(var l=e.parentNode;l;){if(t=l[_a]||l[pt]){if(l=t.alternate,t.child!==null||l!==null&&l.child!==null)for(e=_d(e);e!==null;){if(l=e[pt])return l;e=_d(e)}return t}e=l,l=e.parentNode}return null}function za(e){if(e=e[pt]||e[_a]){var t=e.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return e}return null}function rn(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e.stateNode;throw Error(r(33))}function Ta(e){var t=e[Ps];return t||(t=e[Ps]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function mt(e){e[on]=!0}var ef=new Set,tf={};function la(e,t){Ma(e,t),Ma(e+"Capture",t)}function Ma(e,t){for(tf[e]=t,e=0;e<t.length;e++)ef.add(t[e])}var ym=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\
Line truncated
|
||||
`).replace(Vh,"")}function dd(e,t){return t=rd(t),rd(e)===t}function Ce(e,t,l,a,n,i){switch(l){case"children":typeof a=="string"?t==="body"||t==="textarea"&&a===""||Na(e,a):(typeof a=="number"||typeof a=="bigint")&&t!=="body"&&Na(e,""+a);break;case"className":ci(e,"class",a);break;case"tabIndex":ci(e,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":ci(e,l,a);break;case"style":rf(e,a,i);break;case"data":if(t!=="object"){ci(e,"data",a);break}case"src":case"href":if(a===""&&(t!=="a"||l!=="href")){e.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){e.removeAttribute(l);break}a=fi(""+a),e.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){e.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof i=="function"&&(l==="formAction"?(t!=="input"&&Ce(e,t,"name",n.name,n,null),Ce(e,t,"formEncType",n.formEncType,n,null),Ce(e,t,"formMethod",n.formMethod,n,null),Ce(e,t,"formTarget",n.formTarget,n,null)):(Ce(e,t,"encType",n.encType,n,null),Ce(e,t,"method",n.method,n,null),Ce(e,t,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){e.removeAttribute(l);break}a=fi(""+a),e.setAttribute(l,a);break;case"onClick":a!=null&&(e.onclick=rl);break;case"onScroll":a!=null&&oe("scroll",e);break;case"onScrollEnd":a!=null&&oe("scrollend",e);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(r(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(r(60));e.innerHTML=l}}break;case"multiple":e.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":e.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){e.removeAttribute("xlink:href");break}l=fi(""+a),e.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,""+a):e.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,""):e.removeAttribute(l);break;case"capture":case"download":a===!0?e.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?e.setAttribute(l,a):e.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?e.setAttribute(l,a):e.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?e.removeAttribute(l):e.setAttribute(l,a);break;case"popover":oe("beforetoggle",e),oe("toggle",e),ui(e,"popover",a);break;case"xlinkActuate":ol(e,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":ol(e,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":ol(e,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":ol(e,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":ol(e,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":ol(e,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":ol(e,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":ol(e,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":ol(e,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":ui(e,"is",a);break;case"innerText":case"textContent":break;default:(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")&&(l=bm.get(l)||l,ui(e,l,a))}}function ys(e,t,l,a,n,i){switch(l){case"style":rf(e,a,i);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(r(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(r(60));e.innerHTML=l}}break;case"children":typeof a=="string"?Na(e,a):(typeof a=="number"||typeof a=="bigint")&&Na(e,""+a);break;case"onScroll":a!=null&&oe("scroll",e);break;case"onScrollEnd":a!=null&&oe("scrollend",e);break;case"onClick":a!=null&&(e.onclick=rl);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inne
Line truncated
|
||||
<!-- Stars -->
|
||||
<circle cx="80" cy="100" r="1.5" fill="#ffffff" opacity="0.6"/>
|
||||
<circle cx="160" cy="60" r="1" fill="#ffffff" opacity="0.5"/>
|
||||
<circle cx="280" cy="120" r="2" fill="#ffffff" opacity="0.8"/>
|
||||
<circle cx="360" cy="80" r="1.5" fill="#ffffff" opacity="0.4"/>
|
||||
<circle cx="440" cy="150" r="1" fill="#ffffff" opacity="0.7"/>
|
||||
<circle cx="400" cy="280" r="2" fill="#ffffff" opacity="0.9"/>
|
||||
<circle cx="100" cy="300" r="1.5" fill="#ffffff" opacity="0.5"/>
|
||||
<!-- Moon -->
|
||||
<circle cx="256" cy="220" r="90" fill="url(#moonGrad)" />
|
||||
<circle cx="226" cy="190" r="80" fill="#0F172A" />
|
||||
`):X.includes("cat")||X.includes("kitten")||X.includes("feline")?(re="#4F46E5",I="#7C3AED",ce="Stylized Cat Portrait",ne=`
|
||||
<!-- Cat head & ears -->
|
||||
<polygon points="176,280 146,160 216,210" fill="#312E81" />
|
||||
<polygon points="336,280 366,160 296,210" fill="#312E81" />
|
||||
<circle cx="256" cy="270" r="80" fill="#4338CA" />
|
||||
<!-- Eyes -->
|
||||
<ellipse cx="226" cy="260" rx="14" ry="8" fill="#10B981" />
|
||||
<ellipse cx="286" cy="260" rx="14" ry="8" fill="#10B981" />
|
||||
<circle cx="226" cy="260" r="4" fill="#000" />
|
||||
<circle cx="286" cy="260" r="4" fill="#000" />
|
||||
<!-- Nose & Whiskers -->
|
||||
<polygon points="256,285 248,275 264,275" fill="#F43F5E" />
|
||||
<line x1="226" y1="290" x2="166" y2="280" stroke="#E2E8F0" stroke-width="2" />
|
||||
<line x1="226" y1="295" x2="156" y2="295" stroke="#E2E8F0" stroke-width="2" />
|
||||
<line x1="286" y1="290" x2="346" y2="280" stroke="#E2E8F0" stroke-width="2" />
|
||||
<line x1="286" y1="295" x2="356" y2="295" stroke="#E2E8F0" stroke-width="2" />
|
||||
`):X.includes("dog")||X.includes("puppy")||X.includes("canine")?(re="#D97706",I="#B45309",ce="Friendly Dog Portrait",ne=`
|
||||
<!-- Dog head & floppy ears -->
|
||||
<ellipse cx="166" cy="260" rx="20" ry="60" fill="#78350F" />
|
||||
<ellipse cx="346" cy="260" rx="20" ry="60" fill="#78350F" />
|
||||
<circle cx="256" cy="250" r="70" fill="#92400E" />
|
||||
<ellipse cx="256" cy="280" rx="40" ry="30" fill="#D97706" />
|
||||
<!-- Eyes -->
|
||||
<circle cx="226" cy="230" r="10" fill="#1E293B" />
|
||||
<circle cx="286" cy="230" r="10" fill="#1E293B" />
|
||||
<circle cx="222" cy="226" r="3" fill="#ffffff" />
|
||||
<circle cx="282" cy="226" r="3" fill="#ffffff" />
|
||||
<!-- Nose -->
|
||||
<ellipse cx="256" cy="275" rx="16" ry="10" fill="#1E293B" />
|
||||
`):X.includes("city")||X.includes("cyberpunk")||X.includes("tokyo")||X.includes("neon")?(re="#111827",I="#311042",ce="Neon Cyberpunk Skyline",ne=`
|
||||
<!-- Skyline -->
|
||||
<rect x="60" y="160" width="80" height="352" fill="#1F2937" opacity="0.9"/>
|
||||
<rect x="180" y="100" width="100" height="412" fill="#111827" />
|
||||
<rect x="320" y="200" width="120" height="312" fill="#1F2937" opacity="0.85"/>
|
||||
<rect x="100" y="240" width="110" height="272" fill="#374151" opacity="0.6"/>
|
||||
<!-- Neon details -->
|
||||
<line x1="230" y1="100" x2="230" y2="40" stroke="#EC4899" stroke-width="3" />
|
||||
<rect x="200" y="140" width="60" height="15" fill="#10B981" opacity="0.7"/>
|
||||
<rect x="200" y="170" width="60" height="15" fill="#10B981" opacity="0.7"/>
|
||||
<rect x="200" y="200" width="60" height="15" fill="#3B82F6" opacity="0.7"/>
|
||||
<rect x="70" y="200" width="15" height="100" fill="#F59E0B" opacity="0.5"/>
|
||||
<rect x="340" y="250" width="20" height="150" fill="#EF4444" opacity="0.6"/>
|
||||
`):X.includes("car")||X.includes("vehicle")||X.includes("supercar")?(re="#DC2626",I="#991B1B",ce="Futuristic Concept Car",ne=`
|
||||
<!-- Road / Perspective grid -->
|
||||
<line x1="256" y1="350" x2="0" y2="512" stroke="#4B5563" stroke-width="3" />
|
||||
<line x1="256" y1="350" x2="512" y2="512" stroke="#4B5563" stroke-width="3" />
|
||||
<!-- Car body -->
|
||||
<polygon points="126,380 256,330 386,380 346,440 166,440" fill="#EF4444" />
|
||||
<polygon points="176,370 256,340 336,370 326,390 186,390" fill="#1E293B" opacity="0.9" />
|
||||
<!-- Headlights -->
|
||||
<polygon points="136,410 176,415 166,425 136,420" fill="#FBBF24" />
|
||||
<polygon points="376,410 336,415 346,425 376,420" fill="#FBBF24" />
|
||||
<!-- Wheels -->
|
||||
<ellipse cx="156" cy="440" rx="30" ry="15" fill="#111" />
|
||||
<ellipse cx="356" cy="440" rx="30" ry="15" fill="#111" />
|
||||
`):(re="#06B6D4",I="#0891B2",ce="Abstract Creative Concept",ne=`
|
||||
<path d="M0,256 C150,150 350,350 512,256 L512,512 L0,512 Z" fill="#0891B2" opacity="0.4" />
|
||||
<path d="M0,350 C200,200 300,450 512,350 L512,512 L0,512 Z" fill="#0E7490" opacity="0.6" />
|
||||
<circle cx="380" cy="180" r="50" fill="#F59E0B" opacity="0.8" />
|
||||
`);const Ge=`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${re}" />
|
||||
<stop offset="100%" stop-color="${I}" />
|
||||
</linearGradient>
|
||||
<linearGradient id="moonGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FEF08A" />
|
||||
<stop offset="100%" stop-color="#FDE047" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" fill="url(#bgGrad)" />
|
||||
|
||||
<!-- Shapes -->
|
||||
${ne}
|
||||
|
||||
<!-- Premium UI Overlay Frame -->
|
||||
<rect x="20" y="20" width="472" height="472" fill="none" stroke="#ffffff" stroke-width="1.5" opacity="0.15" rx="8"/>
|
||||
|
||||
<!-- Prompt & Details text -->
|
||||
<rect x="40" y="410" width="432" height="62" fill="#0F172A" opacity="0.85" rx="6" />
|
||||
<text x="60" y="435" fill="#F1F5F9" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="bold">${ce}</text>
|
||||
<text x="60" y="455" fill="#94A3B8" font-family="system-ui, -apple-system, sans-serif" font-size="11">Seed: ${F} • Steps: ${y.steps} • Scale: ${y.cfg_scale}</text>
|
||||
</svg>
|
||||
`;return{image:`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(Ge)))}`,seed:y.seed,duration_sec:3}},U=!!y.image,Z={prompt:y.prompt,negative_prompt:y.negative_prompt||"",n:1,size:`${y.width}x${y.height}`,response_format:"b64_json",steps:y.steps,cfg_scale:y.cfg_scale,seed:y.seed,sample_method:y.sampler};U&&(Z.init_images=[y.image],Z.denoising_strength=y.denoising_strength);try{const ee=await fetch(`${R}/v1/images/generations`,{method:"POST",headers:{"Content-Type":"application/json"},signal:Y,body:JSON.stringify(Z)});if(ee.ok){const X=await ee.json(),F=X?.data?.[0]?.b64_json??X?.images?.[0];if(F)return{image:`data:image/png;base64,${F}`,seed:X.data?.[0]?.seed??y.seed,duration_sec:4.5}}else{let X=`Generation failed (HTTP ${ee.status})`;try{const F=await ee.json();(F?.detail||F?.error?.message)&&(X=F.detail||F.error.message)}catch{}throw new Error(X)}}catch(ee){if(ee.name==="AbortError"||ee.message.startsWith("Generation failed"))throw ee;console.warn("Could not reach local server. Falling back to simulation mode.",ee)}return await D()}async function Ky(f,b,N){if(f instanceof File){const G=f;if(!ba())throw new Error("File import requires the local image generator server.");return await Jy(G,b,N),{response:"Model imported successfully"}}if(Nl()){const{listen:G}=await Oy(async()=>{const{listen:y}=await import("./event-DL84ZN__.js");return{listen:y}},[]);let R=null;b&&(R=await G("import-progress",y=>{b(y.payload)}));try{return{response:await xa("import_model_file",{sourcePath:f}),unlisten:R}}catch(y){throw R&&R(),y}}const r=f.split(/[\\/]/).pop()||"imported_model.gguf";console.log(`Web Mode: Simulating copying ${r} to USB models folder`);const j=40;for(let G=1;G<=j;G++){if(N?.aborted)throw new DOMException("The user aborted a request.","AbortError");await new Promise(U=>setTimeout(U,150));const R=G/j*100,y=40+Math.sin(G)*5+Math.random()*2,D=(j-G)*.15;b&&b({filename:r,progress:R,speed_mb_s:y,eta_secs:D,status:"Copying to USB..."})}const A=localStorage.getItem("imported-models"),Y=A?JSON.parse(A):[];return Y.includes(r)||(Y.push({filename:r,sizeBytes:0,size:"Unknown"}),localStorage.setItem("imported-models",JSON.stringify(Y))),{response:"Model imported successfully"}}function Jy(f,b,N){return new Promise((r,j)=>{const A=new XMLHttpRequest,Y=Date.now();let G=!1;A.open("POST",`/api/import-model?filename=${encodeURIComponent(f.name)}`),A.setRequestHeader("Content-Type","application/octet-stream");const R=()=>{G=!0,A.abort()};N&&(N.aborted?R():N.addEventListener("abort",R,{once:!0})),A.upload.onprogress=y=>{if(!y.lengthComputable||!b)return;const D=Math.max(.1,(Date.now()-Y)/1e3),U=y.loaded/D,Z=Math.max(0,y.total-y.loaded);b({filename:f.name,progress:y.loaded/y.total*100,speed_mb_s:U/(1024*1024),eta_secs:Z/Math.max(1,U),status:"Copying to models folder..."})},A.onload=()=>{let y={};try{y=JSON.parse(A.responseText||"{}")}catch{}A.status>=200&&A.status<300&&y.ok!==!1?(b&&b({filename:f.name,progress:100,speed_mb_s:0,eta_secs:0,status:"Import complete"}),r(y)):j(new Error(y.error||`Import failed (HTTP ${A.status})`))},A.onerror=()=>j(new Error("Import failed while uploading the file.")),A.onabort=()=>j(new DOMException(G?"Import cancelled by user.":"Import aborted.","AbortError")),A.send(f)})}async function $y(f){if(Nl())return await xa("delete_model_file",{filename:f});if(typeof window<"u"&&(window.location.hostname==="localhost"||window.location.hostname==="127.0.0.1"))try{const j=await fetch("/api/delete-model",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:f})}),A=await j.json();if(!j.ok)throw new Error(A.error||"Failed to delete model file");return A}catch(j){throw console.error("Failed to delete model via local API:",j),j}const N=localStorage.getItem("imported-models");let r=N?JSON.parse(N):[];return r=r.filter(j=>ai(j).filename!==f),localStorage.setItem("imported-models",JSON.stringify(r)),`Simulated deletion of ${f} from browser session`}async function rm(){const b="http://127.0.0.1:8080";try{return(await fetch(`${b}/v1/models`,{method:"GET"})).ok}catch{return!1}}async function Wy(f=30){const b=Nl()||ba()?f:3;for(let N=0;N<b;N++){let r=!1;if(ba()?r=!!(await Sa()).ready:r=await rm(),r)return!0;await new Promise(j=>setTimeout(j,500))}return!1}async function Fy(f){try{return await(await fetch("/api/download-model",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:f})})).json()}catch(b){return console.error("Failed to start model download:",b),{ok:!1,error:b.message}}}async function Iy(){try{return await(await fetch("/api/cancel-download",{method:"POST"})).json()}catch(f){return console.error("Failed to cancel model download:",f),{ok:!1,error:f.message}}}async function um(){try{return await(await fetch("/api/download-progress")).json()}catch(f){return console.error("Failed to get download progress:",f),{active:!1,error:f.message}}}async function Py(){try{return await(await fetch("/api/generation-progress")).json()}catch(f){ret
Line truncated
|
||||
Seed: ${f.seed}`,children:[c.jsx("img",{src:f.url,className:"gallery-thumb",alt:"Thumbnail",loading:"lazy",decoding:"async"}),N&&c.jsx("div",{className:"gallery-select-badge",children:c.jsx(Ls,{size:14})})]})});function t1({prompt:f,setPrompt:b,negativePrompt:N,setNegativePrompt:r,constraints:j,setConstraints:A,activeModel:Y,generatedImages:G,setGeneratedImages:R,isGenerating:y,setIsGenerating:D,generationProgress:U,setGenerationProgress:Z,setActiveTab:ee,inputImage:X,setInputImage:F,showAlert:re=async({message:ne})=>window.alert(ne),showConfirm:I=async({message:ne})=>window.confirm(ne)}){const[ne,ce]=w.useState(null),[Ge,Ve]=w.useState(null),[Qe,$]=w.useState(null),[je,ot]=w.useState(!1),[qe,Me]=w.useState(0),[Ke,$e]=w.useState(0),[tt,Je]=w.useState(0),[x,O]=w.useState(""),[K,se]=w.useState(!1),[de,d]=w.useState(!1),[z,B]=w.useState(!1),[H,P]=w.useState(null),[te,le]=w.useState(null),[Ue,ze]=w.useState([]),ct=w.useRef(null),yt=w.useRef(null),wt=w.useRef(null),Al=()=>{yt.current.click()},rt=L=>{const ve=L.target.files[0];if(ve){const we=new FileReader;we.onload=()=>{F(we.result)},we.readAsDataURL(ve)}},al=L=>{L.stopPropagation(),F(null),yt.current&&(yt.current.value="")},sl=async()=>{if(!f.trim())return;le(null),B(!0),P(null);let L=!1;try{const Te=await Sa();if(Te&&Te.settings){const xe=Te.settings,ke=xe.model?xe.model.split(/[\\/]/).pop():null,ye=Y?Y.split(/[\\/]/).pop():null;(ke!==ye||parseInt(xe.steps)!==parseInt(j.steps)||Math.abs(parseFloat(xe.cfgScale)-parseFloat(j.cfgScale))>.05||xe.sampler!==j.sampler||parseInt(xe.threads)!==parseInt(j.threads)||!!xe.useGpu!=(j.useGpu!==!1)||(xe.backendType||(xe.useGpu===!1?"cpu":"auto"))!==(j.backendType||(j.useGpu===!1?"cpu":"auto")))&&(L=!0)}else L=!0}catch{L=!0}if(L)try{console.log("Settings out of sync, restarting backend..."),await Qs(Y,j);let Te=!1,xe=null;for(let ke=0;ke<240;ke++){const ye=await Sa();if(ye.loading&&P({progress:ye.loading.progress||0,phase:ye.loading.phase||"Loading model...",speed:ye.loading.speed||"",current:ye.loading.current||0,total:ye.loading.total||0,backendMode:ye.loading.backendMode||ye.settings?.backendMode||"",device:ye.loading.device||ye.settings?.backendDevice||""}),ye.ready){Te=!0;break}if(ye.error){xe=ye.error;break}if(!ye.running&&ke>3){xe="The backend process terminated immediately on startup.";break}await new Promise(lt=>setTimeout(lt,500))}if(!Te)throw new Error(xe||"Backend server failed to start after applying configuration changes.")}catch(Te){console.error("Failed to restart backend:",Te),le(Te.message||"Failed to sync settings with backend."),B(!1);return}B(!1),P(null),ct.current&&clearInterval(ct.current),D(!0),Z(0),Me(0),Je(0),O(""),se(!1),$(null),ce(null);const ve=(j.width>=1024?140:35)*.04,we=j.width>=1024?140:35,gt=(j.backendType||(j.useGpu===!1?"cpu":"auto"))!=="cpu";let Tt=gt?ve:we,Bt=!1,dt=0,Rt=0,Ee=0;$e(Math.round(j.steps*Tt)),ct.current=setInterval(async()=>{Ee++,Me(Ee);try{const Te=await Py();if(Te&&Te.active){const xe=Te.step||0,ke=Te.steps||j.steps||20,ye=Te.speed||"";Je(xe),O(ye);let lt=0;ke>0&&(lt=Math.round(xe/ke*100)),lt=Math.min(99,lt),Z(_t=>Math.max(_t,lt));let Be=0;const We=parseFloat(ye);if(!isNaN(We)&&We>0){const _t=Math.max(0,ke-xe);ye.includes("it/s")?Be=_t/We:ye.includes("s/it")&&(Be=_t*We)}else{const _t=gt?ve:we;Be=Math.max(0,(ke-xe)*_t)}if(gt&&!Bt){const _t=ye.includes("it/s")&&We<.2,fl=ye.includes("s/it")&&We>5;(_t||fl)&&(Bt=!0,se(!0))}$e(Math.round(Be))}else Z(xe=>Math.min(15,xe+1)),$e(xe=>Math.max(1,xe-1))}catch(Te){console.warn("Failed to get generation progress, running simulation fallback:",Te),gt&&!Bt&&Ee>j.steps*ve*1.3&&(Bt=!0,se(!0),Tt=we,dt=Ee,Rt=Math.min(95,Math.round(Ee/(j.steps*ve)*100)));let xe=Math.min(j.steps-1,Math.floor(Ee/Tt)),ke=j.steps*Tt;if(Ee>=ke){const Be=Math.ceil(Ee/Math.max(.5,xe));Tt=Math.max(Tt+15,Be+15),ke=j.steps*Tt,xe=Math.min(j.steps-1,Math.floor(Ee/Tt))}Je(xe);const ye=Math.max(1,ke-Ee);$e(Math.round(ye));let lt=0;if(Bt){const Be=99-Rt,We=ke-dt;if(We>0){const _t=Math.min(1,(Ee-dt)/We);if(_t<.85)lt=Math.round(Rt+_t*Be);else{const fl=(Ee-(dt+We*.85))/(Tt*2),bu=1-Math.exp(-fl);lt=Math.min(99,Math.round(Rt+.85*Be+.14*Be*bu))}}else lt=99}else if(Ee<ke*.85)lt=Math.round(Ee/ke*100);else{const Be=(Ee-ke*.85)/(Tt*2),We=1-Math.exp(-Be);lt=Math.min(99,Math.round(85+14*We))}Z(Be=>Math.max(Be,lt))}},1e3);try{wt.current=new AbortController;const Te=await Vy(f,N,j,Y,X,Be=>Z(We=>Math.max(We,Be)),wt.current.signal);ct.current&&clearInterval(ct.current),Z(100),Je(j.steps),$e(0),O("");const xe={prompt:f,negativePrompt:N,seed:Te.seed,steps:j.steps,cfgScale:j.cfgScale,width:j.width,height:j.height,sampler:j.sampler,model:Y,duration_sec:Te.duration_sec,timestamp:new Date().toLocaleTimeString(),inputImage:!!X};let ke=null,ye=null;try{if(ke=await Qy(Te.image,xe),ye=ke.url,!ye)throw new Error("The local server saved the image but did not return a file URL.")}catch(Be){throw console.error("Generated image could not be saved:",Be),new Error(`Image generated but could not be saved to ULine truncated
|
||||
|
||||
${He}
|
||||
|
||||
Update the UI status anyway?`,confirmLabel:"Update UI"})&&(b(T),r(!0)):await Y({title:"Backend Not Running",message:`C++ backend server is not running on port 8080.
|
||||
|
||||
To run locally, start:
|
||||
|
||||
${He}
|
||||
|
||||
Proceed in Simulation Mode instead?`,confirmLabel:"Proceed"})&&(b(T),r(!1));return}re(T),ne({progress:0,phase:"Starting backend...",speed:"",current:0,total:0,model:T,backendMode:"",backendBinary:"",device:""});try{const k=await Qs(T,j);console.log(k);let He=!1,zt=null;for(let st=0;st<240;st++){const pe=await Sa();if(pe.loading&&ne({progress:pe.loading.progress||0,phase:pe.loading.phase||"Loading model...",speed:pe.loading.speed||"",current:pe.loading.current||0,total:pe.loading.total||0,model:pe.loading.model||T,backendMode:pe.loading.backendMode||pe.settings?.backendMode||"",backendBinary:pe.loading.backendBinary||pe.settings?.backendBinary||"",device:pe.loading.device||pe.settings?.backendDevice||""}),pe.ready){He=!0;break}if(pe.error){zt=pe.error;break}if(!pe.running&&st>3){zt="The backend process terminated immediately on startup.";break}await new Promise(sn=>setTimeout(sn,500))}if(He)ne(st=>({...st||{},progress:100,phase:"Model ready",speed:"",current:st?.current||0,total:st?.total||0,model:T})),b(T),r(!0);else throw new Error(zt||"Model server failed to respond on port 8080.")}catch(k){console.error("Failed to load model:",k),A({title:"Model Load Failed",message:k.message||String(k),danger:!0})}finally{setTimeout(()=>ne(null),800),re(null)}},ct=async()=>{const T=Je;x(null),await yt(),T&&await ze(T)},yt=async()=>{if(Me)return;Ke(!0),tt({progress:10,phase:"Stopping backend process..."});const T=setInterval(async()=>{try{const C=await Sa();C.unloading?.active?tt({progress:C.unloading.progress||50,phase:C.unloading.phase||"Unloading model..."}):tt(q=>({progress:Math.min(95,q.progress+10),phase:q.phase||"Unloading model..."}))}catch{}},300);try{await Vs(),tt({progress:100,phase:"Model unloaded"}),b(null),x(null),r(!1)}catch(C){console.error(C),A({title:"Unload Failed",message:C.message||String(C),danger:!0})}finally{clearInterval(T),setTimeout(()=>{Ke(!1),tt({progress:0,phase:""})},500)}},wt=async T=>{if(await Y({title:"Delete Model?",message:`Delete "${T}" from your drive?`,confirmLabel:"Delete",danger:!0}))try{await $y(T),await B(),f===T&&await yt()}catch(C){console.error("Failed to delete model:",C),A({title:"Delete Failed",message:C.message||String(C),danger:!0})}},Al=async(T,C)=>{const q=new AbortController;qe(q),Ge(0),Qe({filename:C,speed:"0.0",eta:0,status:"Initiating file copy..."});try{await Ky(T,k=>{Ge(Math.round(k.progress)),Qe({filename:k.filename,speed:k.speed_mb_s.toFixed(1),eta:Math.round(k.eta_secs),status:k.status})},q.signal),await B()}catch(k){console.error("Import failed:",k),k.name!=="AbortError"&&A({title:"Import Failed",message:k.message||String(k),danger:!0})}finally{Ge(null),qe(null)}},rt=()=>{ot&&ot.abort()},al=T=>{const C=T.target.files[0];C&&Al(C,C.name),T.target.value=""},sl=!!y;return c.jsxs("div",{className:"workspace-area",children:[c.jsxs("div",{className:"workspace-title-section",children:[c.jsx("h2",{className:"workspace-title",children:"Model Manager"}),c.jsx("p",{className:"workspace-subtitle",children:"Manage local files and download recommended weights directly to your local models folder."})]}),f&&c.jsx("div",{className:"m3-card",style:{borderLeft:"4px solid var(--md-sys-color-primary)",background:"var(--md-sys-color-primary-container)",color:"var(--md-sys-color-on-primary-container)"},children:c.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center"},children:[c.jsxs("div",{children:[c.jsxs("h4",{style:{fontWeight:700},children:["Active Model: ",f]}),c.jsx("p",{style:{fontSize:"0.85rem",marginTop:"2px",opacity:.9},children:"The local C++ stable-diffusion server is running. Real-time telemetry is displaying performance status in the top bar."}),(O.backendMode||O.backendDevice||O.backendBinary)&&c.jsxs("div",{style:{display:"flex",flexWrap:"wrap",gap:"8px",marginTop:"10px"},children:[O.backendMode&&c.jsxs("span",{className:"status-chip",style:{cursor:"default"},children:[c.jsx(Su,{size:14}),c.jsxs("span",{children:["Loaded on ",O.backendMode]})]}),O.backendDevice&&c.jsx("span",{className:"status-chip",style:{cursor:"default"},children:c.jsx("span",{children:O.backendDevice})}),O.backendBinary&&c.jsx("span",{className:"status-chip",style:{cursor:"default"},children:c.jsx("span",{children:O.backendBinary})})]})]}),c.jsxs("button",{className:"m3-btn m3-btn-error",onClick:yt,disabled:Me||F!==null,children:[Me?c.jsx(ta,{className:"progress-spinner",size:16}):c.jsx(ti,{size:16}),c.jsx("span",{children:Me?"Unloading":"Unload Server"})]})]})}),Me&&c.jsxs("div",{className:"m3-card",style:{borderLeft:"4px solid var(--md-sys-color-error)",marginTop:"16px"},children:[c.jsx("h4",{style:{fontWeight:700,marginBottom:"8px"},children:"Unloading Model"}),c.jsxs("div",{className:"model-progress-section",style:{margin:"8px 0 0 0"},children:[c.jsxs("div",{className:"model-progress-label",children:[c.jsx("span",{children:$e.phase||"Unloading model..."}),c.jsxs("span",{children:[Math.round($e.progress),"%"]})]}),c.jsx("div",{className:"model-progress-bar",chi
Line truncated
|
||||
|
||||
Models and generated outputs are not included.`,confirmLabel:"Clean",danger:!0})){$e(!0);try{await Hy(qe.map(q=>q.id)),await Ue()}catch(q){A({title:"Cleanup Failed",message:q.message||String(q),danger:!0})}finally{$e(!1)}}},[qe,Ue,A,j]);w.useEffect(()=>{async function T(){const C=await Xy();O(C)}T()},[]),w.useEffect(()=>{re?localStorage.setItem("active-model",re):localStorage.removeItem("active-model")},[re]),w.useEffect(()=>{let T=null;async function C(){const q=await Sa();if(!!q.ready){T&&clearInterval(T);const He=localStorage.getItem("active-model");if(He)I(He),ce(!0);else{try{if(q&&q.settings&&q.settings.model){const zt=q.settings.model.split(/[\\/]/).pop();I(zt)}}catch{}ce(!0)}}else if(!(typeof window<"u"&&window.__TAURI_INTERNALS__!==void 0)){const zt=localStorage.getItem("active-model");zt&&(I(zt),ce(!1))}}return C(),T=setInterval(C,2e3),()=>{T&&clearInterval(T)}},[]),w.useEffect(()=>{async function T(){try{const q=await qy();P(k=>k.cpu_usage===q.cpu_usage&&k.ram_used_gb===q.ram_used_gb&&k.ram_total_gb===q.ram_total_gb&&k.gpu_name===q.gpu_name&&k.vram_used_gb===q.vram_used_gb&&k.vram_total_gb===q.vram_total_gb?k:q)}catch{}}T();const C=setInterval(T,1500);return()=>clearInterval(C)},[]),w.useEffect(()=>{if(re){const T=re.toLowerCase();T.includes("flux")||T.includes("schnell")?le(C=>({...C,steps:4,cfgScale:1,width:1024,height:1024})):T.includes("lightning")||T.includes("turbo")?le(C=>({...C,steps:4,cfgScale:1.5,width:1024,height:1024})):T.includes("sd15")?le(C=>({...C,steps:25,cfgScale:7,width:512,height:512})):T.includes("sd35")?le(C=>({...C,steps:20,cfgScale:4.5,width:1024,height:1024})):le(C=>({...C,steps:20,cfgScale:7,width:512,height:512}))}},[re]);const yt=w.useMemo(()=>{switch(R){case"generator":return c.jsx(l1,{prompt:D,setPrompt:U,negativePrompt:Z,setNegativePrompt:ee,constraints:te,setConstraints:le,activeModel:re,generatedImages:x,setGeneratedImages:O,isGenerating:K,setIsGenerating:se,generationProgress:de,setGenerationProgress:d,setActiveTab:y,inputImage:X,setInputImage:F,showAlert:A,showConfirm:j});case"models":return c.jsx(i1,{activeModel:re,setActiveModel:I,serverRunning:ne,setServerRunning:ce,constraints:te,showAlert:A,showConfirm:j});case"constraints":return c.jsx(s1,{constraints:te,setConstraints:le,activeModel:re,specs:z,backendOptions:Qe});default:return null}},[R,D,Z,te,re,x,K,de,X,ne,z,Qe,A,j]),wt=w.useMemo(()=>c.jsx(Ey,{activeTab:R,setActiveTab:y,specs:z}),[R,z]),Al=w.useCallback(async()=>{if(!(!ne||Ge)){Ve(!0);try{await Vs(),ce(!1),I(null)}catch(T){console.error("Failed to stop server:",T),A({title:"Stop Server Failed",message:T.message||String(T),danger:!0})}finally{Ve(!1)}}},[ne,Ge,A]),rt=[...je?.stale?["Restart Local AI Image Generator so the local server loads the latest API."]:[],...je?.issues||[]],al=qe.reduce((T,C)=>T+Number(C.sizeBytes||0),0),sl=!!(je&&(rt.length>0||qe.length>0));return c.jsxs("div",{className:"app-container",children:[wt,c.jsxs("div",{className:"main-content",children:[c.jsx(Ay,{telemetry:H,serverRunning:ne,activeModel:re,onStopServer:Al,isStoppingServer:Ge,theme:Y,setTheme:G}),sl&&c.jsxs("div",{className:`m3-card readiness-card ${je?.stale||rt.length>0?"readiness-card-warning":""}`,children:[c.jsxs("div",{className:"readiness-header",children:[c.jsxs("div",{children:[c.jsx("h3",{className:"m3-card-title",style:{marginBottom:"4px"},children:je?.stale?"Restart Required":rt.length>0?"System Readiness":"Safe Cleanup Available"}),c.jsx("p",{className:"m3-card-subtitle",style:{margin:0},children:je?.stale?`Running server build: ${je.build||"unknown"}`:rt.length>0?"Local AI Image Generator found setup items that may need attention.":`${qe.length} temporary item${qe.length===1?"":"s"} can be cleaned (${li(al)}).`})]}),c.jsxs("div",{className:"readiness-actions",children:[c.jsx("button",{className:"m3-btn m3-btn-outlined",onClick:Ue,disabled:Ke,children:Ke?"Checking":"Refresh"}),c.jsx("button",{className:"m3-btn m3-btn-tonal",onClick:ze,children:tt?"Copied":"Copy Diagnostics"}),qe.length>0&&c.jsxs("button",{className:"m3-btn m3-btn-error",onClick:ct,disabled:Ke,children:["Clean ",li(al)]})]})]}),rt.length>0&&c.jsx("div",{className:"readiness-issues",children:rt.slice(0,4).map(T=>c.jsx("span",{children:T},T))}),qe.length>0&&c.jsx("div",{className:"readiness-cleanup-list",children:qe.slice(0,3).map(T=>c.jsxs("span",{title:T.path,children:[T.name," · ",T.size," · ",T.reason]},T.id))})]}),yt]}),b&&c.jsx("div",{className:"app-dialog-backdrop",role:"presentation",children:c.jsxs("div",{className:"app-dialog",role:"dialog","aria-modal":"true","aria-labelledby":"app-dialog-title",children:[c.jsx("h3",{id:"app-dialog-title",className:"app-dialog-title",children:b.title}),c.jsx("p",{className:"app-dialog-message",children:b.message}),c.jsxs("div",{className:"app-dialog-actions",children:[b.type==="confirm"&&c.jsx("button",{className:"m3-btn m3-btn-outlined",onClick:()=>r(!1),children:b.cancelLabel}),c.jsx("button",{className:`m3-btn ${b.danger?"m3-btn-error":"m3-btn-fLine truncated
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>Local AI Image Generator</title>
|
||||
<script type="module" crossorigin src="/assets/index-Ddpqcst7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BVK0F0Ah.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>Local AI Image Generator</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2028
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,516 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import TopStatusBar from "./components/TopStatusBar";
|
||||
import Generator from "./components/Generator";
|
||||
import ModelManager from "./components/ModelManager";
|
||||
import ImageConstraints from "./components/ImageConstraints";
|
||||
import { cleanupCandidates, formatBytes, getCleanupCandidates, getDiagnostics, getHardwareSpecs, getHealth, getTelemetry, getBackendOptions, getBackendStatus, listGeneratedOutputs, stopServer } from "./services/api";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
const dialogResolverRef = useRef(null);
|
||||
const [dialog, setDialog] = useState(null);
|
||||
|
||||
const closeDialog = useCallback((value) => {
|
||||
const resolver = dialogResolverRef.current;
|
||||
dialogResolverRef.current = null;
|
||||
setDialog(null);
|
||||
if (resolver) resolver(value);
|
||||
}, []);
|
||||
|
||||
const showConfirm = useCallback(({ title = "Confirm Action", message, confirmLabel = "OK", cancelLabel = "Cancel", danger = false }) => {
|
||||
return new Promise((resolve) => {
|
||||
dialogResolverRef.current = resolve;
|
||||
setDialog({ type: "confirm", title, message, confirmLabel, cancelLabel, danger });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showAlert = useCallback(({ title = "Notice", message, confirmLabel = "OK", danger = false }) => {
|
||||
return new Promise((resolve) => {
|
||||
dialogResolverRef.current = resolve;
|
||||
setDialog({ type: "alert", title, message, confirmLabel, danger });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Theme State
|
||||
const [theme, setTheme] = useState(() => {
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (saved) return saved;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
// Navigation
|
||||
const [activeTab, setActiveTab] = useState("generator");
|
||||
|
||||
// Prompts
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [negativePrompt, setNegativePrompt] = useState("");
|
||||
const [inputImage, setInputImage] = useState(null); // base64 for Image-to-Image
|
||||
|
||||
// Model & Server Status
|
||||
const [activeModel, setActiveModel] = useState(null);
|
||||
const [serverRunning, setServerRunning] = useState(false);
|
||||
const [isStoppingServer, setIsStoppingServer] = 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
|
||||
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,
|
||||
threads: 4,
|
||||
backendType: "auto",
|
||||
});
|
||||
|
||||
// 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",
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Error fetching hardware specs:", err);
|
||||
}
|
||||
}
|
||||
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() {
|
||||
try {
|
||||
const stats = await getTelemetry();
|
||||
setTelemetry((prev) => (
|
||||
prev.cpu_usage === stats.cpu_usage &&
|
||||
prev.ram_used_gb === stats.ram_used_gb &&
|
||||
prev.ram_total_gb === stats.ram_total_gb &&
|
||||
prev.gpu_name === stats.gpu_name &&
|
||||
prev.vram_used_gb === stats.vram_used_gb &&
|
||||
prev.vram_total_gb === stats.vram_total_gb
|
||||
? prev
|
||||
: stats
|
||||
));
|
||||
} catch (err) {
|
||||
// Telemetry errors ignored
|
||||
}
|
||||
}
|
||||
|
||||
updateTelemetry();
|
||||
const interval = setInterval(updateTelemetry, 1500); // Poll every 1.5 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Sync active model settings default parameters
|
||||
useEffect(() => {
|
||||
if (activeModel) {
|
||||
const name = activeModel.toLowerCase();
|
||||
if (name.includes("flux") || name.includes("schnell")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 4,
|
||||
cfgScale: 1.0,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}));
|
||||
} else if (name.includes("lightning") || name.includes("turbo")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 4,
|
||||
cfgScale: 1.5,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}));
|
||||
} else if (name.includes("sd15")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 25,
|
||||
cfgScale: 7.0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
}));
|
||||
} else if (name.includes("sd35")) {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
steps: 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,
|
||||
cfgScale: 7.0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [activeModel]);
|
||||
|
||||
const activeTabContent = useMemo(() => {
|
||||
switch (activeTab) {
|
||||
case "generator":
|
||||
return (
|
||||
<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}
|
||||
inputImage={inputImage}
|
||||
setInputImage={setInputImage}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
/>
|
||||
);
|
||||
case "models":
|
||||
return (
|
||||
<ModelManager
|
||||
activeModel={activeModel}
|
||||
setActiveModel={setActiveModel}
|
||||
serverRunning={serverRunning}
|
||||
setServerRunning={setServerRunning}
|
||||
constraints={constraints}
|
||||
showAlert={showAlert}
|
||||
showConfirm={showConfirm}
|
||||
/>
|
||||
);
|
||||
case "constraints":
|
||||
return (
|
||||
<ImageConstraints
|
||||
constraints={constraints}
|
||||
setConstraints={setConstraints}
|
||||
activeModel={activeModel}
|
||||
specs={specs}
|
||||
backendOptions={backendOptions}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
activeTab,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
constraints,
|
||||
activeModel,
|
||||
generatedImages,
|
||||
isGenerating,
|
||||
generationProgress,
|
||||
inputImage,
|
||||
serverRunning,
|
||||
specs,
|
||||
backendOptions,
|
||||
showAlert,
|
||||
showConfirm,
|
||||
]);
|
||||
|
||||
const sidebarContent = useMemo(() => (
|
||||
<Sidebar
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
specs={specs}
|
||||
/>
|
||||
), [activeTab, specs]);
|
||||
|
||||
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]);
|
||||
|
||||
const readinessIssues = [
|
||||
...(health?.stale ? ["Restart Local AI Image Generator so the local server loads the latest API."] : []),
|
||||
...(health?.issues || []),
|
||||
];
|
||||
const cleanupBytes = cleanupItems.reduce((sum, item) => sum + Number(item.sizeBytes || 0), 0);
|
||||
const showReadinessPanel = Boolean(health && (readinessIssues.length > 0 || cleanupItems.length > 0));
|
||||
|
||||
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}
|
||||
onStopServer={handleStopServer}
|
||||
isStoppingServer={isStoppingServer}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
/>
|
||||
|
||||
{showReadinessPanel && (
|
||||
<div className={`m3-card readiness-card ${health?.stale || readinessIssues.length > 0 ? "readiness-card-warning" : ""}`}>
|
||||
<div className="readiness-header">
|
||||
<div>
|
||||
<h3 className="m3-card-title" style={{ marginBottom: "4px" }}>
|
||||
{health?.stale ? "Restart Required" : readinessIssues.length > 0 ? "System Readiness" : "Safe Cleanup Available"}
|
||||
</h3>
|
||||
<p className="m3-card-subtitle" style={{ margin: 0 }}>
|
||||
{health?.stale
|
||||
? `Running server build: ${health.build || "unknown"}`
|
||||
: readinessIssues.length > 0
|
||||
? "Local AI Image Generator found setup items that may need attention."
|
||||
: `${cleanupItems.length} temporary item${cleanupItems.length === 1 ? "" : "s"} can be cleaned (${formatBytes(cleanupBytes)}).`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="readiness-actions">
|
||||
<button className="m3-btn m3-btn-outlined" onClick={refreshReadiness} disabled={isReadinessBusy}>
|
||||
{isReadinessBusy ? "Checking" : "Refresh"}
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-tonal" onClick={copyDiagnostics}>
|
||||
{diagnosticsCopied ? "Copied" : "Copy Diagnostics"}
|
||||
</button>
|
||||
{cleanupItems.length > 0 && (
|
||||
<button className="m3-btn m3-btn-error" onClick={cleanupSafeItems} disabled={isReadinessBusy}>
|
||||
Clean {formatBytes(cleanupBytes)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{readinessIssues.length > 0 && (
|
||||
<div className="readiness-issues">
|
||||
{readinessIssues.slice(0, 4).map((issue) => (
|
||||
<span key={issue}>{issue}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{cleanupItems.length > 0 && (
|
||||
<div className="readiness-cleanup-list">
|
||||
{cleanupItems.slice(0, 3).map((item) => (
|
||||
<span key={item.id} title={item.path}>
|
||||
{item.name} · {item.size} · {item.reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Workspace Container */}
|
||||
{activeTabContent}
|
||||
</div>
|
||||
|
||||
{dialog && (
|
||||
<div className="app-dialog-backdrop" role="presentation">
|
||||
<div className="app-dialog" role="dialog" aria-modal="true" aria-labelledby="app-dialog-title">
|
||||
<h3 id="app-dialog-title" className="app-dialog-title">{dialog.title}</h3>
|
||||
<p className="app-dialog-message">{dialog.message}</p>
|
||||
<div className="app-dialog-actions">
|
||||
{dialog.type === "confirm" && (
|
||||
<button className="m3-btn m3-btn-outlined" onClick={() => closeDialog(false)}>
|
||||
{dialog.cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`m3-btn ${dialog.danger ? "m3-btn-error" : "m3-btn-filled"}`}
|
||||
onClick={() => closeDialog(true)}
|
||||
>
|
||||
{dialog.confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,878 @@
|
||||
import React, { memo, useState, useRef, useCallback } from "react";
|
||||
import { Sparkles, Download, Copy, RefreshCw, ImagePlus, Check, Sliders, Layers, Trash2 } from "lucide-react";
|
||||
import { generateImage, isTauri, startServer, stopServer, waitForServerReady, getBackendStatus, getGenerationProgress, saveGeneratedOutput, deleteGeneratedOutputs } 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 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,
|
||||
inputImage,
|
||||
setInputImage,
|
||||
showAlert = async ({ message }) => window.alert(message),
|
||||
showConfirm = async ({ message }) => window.confirm(message),
|
||||
}) {
|
||||
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 [selectedGalleryIndexes, setSelectedGalleryIndexes] = useState([]);
|
||||
const timerRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
// Trigger file dialog for Image-to-Image
|
||||
const handleDropzoneClick = () => {
|
||||
fileInputRef.current.click();
|
||||
};
|
||||
|
||||
// Convert uploaded image file to Base64
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setInputImage(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const clearInputImage = (e) => {
|
||||
e.stopPropagation();
|
||||
setInputImage(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
if (currentModelName !== targetModelName ||
|
||||
parseInt(settings.steps) !== parseInt(constraints.steps) ||
|
||||
Math.abs(parseFloat(settings.cfgScale) - parseFloat(constraints.cfgScale)) > 0.05 ||
|
||||
settings.sampler !== constraints.sampler ||
|
||||
parseInt(settings.threads) !== parseInt(constraints.threads) ||
|
||||
Boolean(settings.useGpu) !== (constraints.useGpu !== false) ||
|
||||
(settings.backendType || (settings.useGpu === false ? "cpu" : "auto")) !== (constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto"))) {
|
||||
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;
|
||||
for (let i = 0; i < 240; i++) { // Poll for up to 120 seconds (240 * 500ms)
|
||||
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 && 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("");
|
||||
setIsCpuFallback(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 step = progress.step || 0;
|
||||
const steps = progress.steps || constraints.steps || 20;
|
||||
const speed = progress.speed || "";
|
||||
|
||||
setCurrentStep(step);
|
||||
setGenerationSpeed(speed);
|
||||
|
||||
// Calculate progress percentage
|
||||
let progressPercent = 0;
|
||||
if (steps > 0) {
|
||||
progressPercent = Math.round((step / steps) * 100);
|
||||
}
|
||||
progressPercent = Math.min(99, progressPercent);
|
||||
setGenerationProgress((prev) => Math.max(prev, progressPercent));
|
||||
|
||||
// Calculate remaining time
|
||||
let remaining = 0;
|
||||
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));
|
||||
}
|
||||
setCurrentStep(step);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
setGenerationProgress((p) => Math.max(p, progressPercent));
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
abortControllerRef.current = new AbortController();
|
||||
const result = await generateImage(
|
||||
prompt,
|
||||
negativePrompt,
|
||||
constraints,
|
||||
activeModel,
|
||||
inputImage, // passes base64 if I2I
|
||||
(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,
|
||||
duration_sec: result.duration_sec,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
inputImage: Boolean(inputImage),
|
||||
};
|
||||
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();
|
||||
}
|
||||
|
||||
// In native desktop mode, restart the server process to immediately free CPU threads/memory
|
||||
if (isTauri()) {
|
||||
try {
|
||||
console.log("Cancelling: Stopping server to kill generation threads...");
|
||||
await stopServer();
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
// Browser Mode
|
||||
setIsCancelling(false);
|
||||
setIsGenerating(false);
|
||||
setGenerationProgress(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(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">Positive 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>
|
||||
|
||||
{/* Negative Prompt Text Input */}
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Negative Prompt (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="m3-input"
|
||||
value={negativePrompt}
|
||||
onChange={(e) => setNegativePrompt(e.target.value)}
|
||||
placeholder="Items to avoid (e.g. 'blurry, low quality, deformed hands, texts')..."
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</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" onClick={() => setActiveTab("constraints")}>
|
||||
<Sliders size={14} />
|
||||
<span>Resolution: {constraints.width}x{constraints.height}</span>
|
||||
</div>
|
||||
<div className="status-chip" onClick={() => setActiveTab("constraints")}>
|
||||
<RefreshCw size={14} />
|
||||
<span>Steps: {constraints.steps}</span>
|
||||
</div>
|
||||
<div className="status-chip" onClick={() => setActiveTab("constraints")}>
|
||||
<Layers size={14} />
|
||||
<span>CFG: {constraints.cfgScale.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="status-chip" onClick={() => setActiveTab("constraints")}>
|
||||
<Sparkles size={14} />
|
||||
<span>Sampler: {constraints.sampler}</span>
|
||||
</div>
|
||||
<div className="status-chip" onClick={() => setActiveTab("constraints")}>
|
||||
<span>Seed: {constraints.seed === -1 ? "Random" : constraints.seed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image to Image Dropzone Toggle */}
|
||||
<div className="image-to-image-section">
|
||||
<label className="m3-text-field-label" style={{ display: "block", marginBottom: "8px" }}>
|
||||
Image-to-Image Source (Optional)
|
||||
</label>
|
||||
<div className="image-dropzone" onClick={handleDropzoneClick}>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: "none" }}
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
|
||||
{inputImage ? (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<img src={inputImage} className="dropped-image-preview" alt="Source" />
|
||||
<button
|
||||
className="m3-btn m3-btn-error"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "4px",
|
||||
height: "30px",
|
||||
width: "30px",
|
||||
borderRadius: "50%",
|
||||
padding: 0
|
||||
}}
|
||||
onClick={clearInputImage}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ImagePlus size={24} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>
|
||||
Click or drag image here for Image-to-Image guidance
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</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" : "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 }}>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} ({constraints.steps - currentStep} left)
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<strong>Elapsed:</strong> {elapsedTime}s{generationSpeed && ` (${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);
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { memo } from "react";
|
||||
import { Crop, Sliders, Cpu, Info } from "lucide-react";
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
{ id: "1:1", label: "1:1 Square", width: 512, height: 512, sdxl_width: 1024, sdxl_height: 1024, desc: "Social posts & avatars" },
|
||||
{ id: "4:3", label: "4:3 Photo", width: 640, height: 480, sdxl_width: 1152, sdxl_height: 864, desc: "Classic photo look" },
|
||||
{ id: "16:9", label: "16:9 Landscape", width: 768, height: 432, sdxl_width: 1216, sdxl_height: 684, desc: "Widescreen landscape" },
|
||||
{ id: "9:16", label: "9:16 Portrait", width: 432, height: 768, sdxl_width: 684, sdxl_height: 1216, desc: "Tall phone screen" }
|
||||
];
|
||||
|
||||
const isSD15OrCustomModel = (modelName) => {
|
||||
if (!modelName) return true;
|
||||
const name = modelName.toLowerCase();
|
||||
if (name.includes("flux") || name.includes("schnell")) return false;
|
||||
if (name.includes("sdxl") || name.includes("lightning") || name.includes("turbo")) return false;
|
||||
if (name.includes("sd3")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
function ImageConstraints({ constraints, setConstraints, activeModel, specs, backendOptions }) {
|
||||
const isSD15OrCustom = activeModel ? isSD15OrCustomModel(activeModel) : false;
|
||||
const availableBackends = backendOptions?.options?.length
|
||||
? backendOptions.options
|
||||
: [{ id: "cpu", label: "CPU", available: true }];
|
||||
|
||||
const updateConstraint = (key, value) => {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAspectRatioChange = (ratio, sizeType) => {
|
||||
const isSDXL = sizeType === "sdxl" && !isSD15OrCustom;
|
||||
const selected = ASPECT_RATIOS.find((r) => r.id === ratio);
|
||||
if (selected) {
|
||||
let w = isSDXL ? selected.sdxl_width : selected.width;
|
||||
let h = isSDXL ? selected.sdxl_height : selected.height;
|
||||
if (isSD15OrCustom) {
|
||||
if (w > h) {
|
||||
h = Math.round((h * 512) / w);
|
||||
w = 512;
|
||||
} else {
|
||||
w = Math.round((w * 512) / h);
|
||||
h = 512;
|
||||
}
|
||||
w = Math.round(w / 64) * 64;
|
||||
h = Math.round(h / 64) * 64;
|
||||
}
|
||||
updateConstraint("width", w);
|
||||
updateConstraint("height", h);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackendChange = (backendType) => {
|
||||
setConstraints((prev) => ({
|
||||
...prev,
|
||||
backendType,
|
||||
useGpu: backendType !== "cpu",
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workspace-area">
|
||||
<div className="workspace-title-section">
|
||||
<h2 className="workspace-title">Image Constraints</h2>
|
||||
<p className="workspace-subtitle">
|
||||
Configure size, quality, and performance controls for image generation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="generator-layout">
|
||||
{/* Left Column: Size & Quality */}
|
||||
<div>
|
||||
{/* Card 1: Picture Size & Shape */}
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">
|
||||
<Crop size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
1. Picture Size & Shape
|
||||
</h3>
|
||||
|
||||
{isSD15OrCustom && (
|
||||
<div style={{
|
||||
background: "rgba(251, 191, 36, 0.1)",
|
||||
border: "1px solid rgb(251, 191, 36)",
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "16px",
|
||||
fontSize: "0.85rem",
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "flex-start"
|
||||
}}>
|
||||
<Info size={16} style={{ color: "rgb(251, 191, 36)", flexShrink: 0, marginTop: "2px" }} />
|
||||
<div>
|
||||
<strong>Low-VRAM Protection Active:</strong> Capped at 512x512 for the loaded SD 1.x model (<code>{activeModel}</code>). Generating at larger sizes (like 1024x1024) is disabled to prevent out-of-memory crashes on your {specs && specs.gpu_name && !specs.gpu_name.includes("Loading") ? specs.gpu_name : "graphics processor"}.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="m3-field-group">
|
||||
{/* Size switcher */}
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">AI Generation Engine Optimization</label>
|
||||
<div className="m3-segmented-button">
|
||||
{isSD15OrCustom ? (
|
||||
<div
|
||||
className="m3-segment-item disabled"
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
textDecoration: "line-through",
|
||||
backgroundColor: "var(--md-sys-color-surface-variant)"
|
||||
}}
|
||||
title="High Quality Mode is disabled for SD 1.x models."
|
||||
>
|
||||
High Quality Mode (1024px)
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`m3-segment-item ${constraints.width >= 1024 ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
updateConstraint("width", 1024);
|
||||
updateConstraint("height", 1024);
|
||||
}}
|
||||
>
|
||||
High Quality Mode (1024px)
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`m3-segment-item ${constraints.width < 1024 || isSD15OrCustom ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
updateConstraint("width", 512);
|
||||
updateConstraint("height", 512);
|
||||
}}
|
||||
>
|
||||
Standard Speed Mode (512px)
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "4px" }}>
|
||||
{isSD15OrCustom
|
||||
? "*Standard Speed Mode (512px) is forced for SD 1.5 / custom models to ensure stable local generations."
|
||||
: "*Use High Quality (1024px) for Flux or SDXL models. Use Standard (512px) for SD 1.5."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Aspect Ratio Buttons */}
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Select Shape (Aspect Ratio)</label>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" }}>
|
||||
{ASPECT_RATIOS.map((ratio) => {
|
||||
const isActive = Math.abs((constraints.width / constraints.height) - (ratio.width / ratio.height)) < 0.1;
|
||||
return (
|
||||
<button
|
||||
key={ratio.id}
|
||||
className={`m3-btn m3-btn-outlined aspect-ratio-btn ${isActive ? "active" : ""}`}
|
||||
style={{
|
||||
height: "86px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "8px",
|
||||
borderRadius: "var(--md-shape-corner-medium)",
|
||||
borderColor: "var(--md-sys-color-outline-variant)"
|
||||
}}
|
||||
onClick={() => handleAspectRatioChange(ratio.id, isSD15OrCustom ? "sd15" : (constraints.width >= 1024 ? "sdxl" : "sd15"))}
|
||||
>
|
||||
<div className={`aspect-ratio-preview ratio-${ratio.id.replace(":", "-")}`}></div>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.85rem", marginTop: "2px" }}>{ratio.label}</span>
|
||||
<span style={{ fontSize: "0.68rem", opacity: 0.7 }}>
|
||||
{isSD15OrCustom
|
||||
? `Max: ${ratio.id === "1:1" ? "512x512" : ratio.id === "4:3" ? "512x384" : ratio.id === "16:9" ? "512x288" : "288x512"}`
|
||||
: ratio.desc}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Quality & Logic */}
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">
|
||||
<Sliders size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
2. Quality, Speed & Logic
|
||||
</h3>
|
||||
|
||||
<div className="m3-field-group">
|
||||
{/* Quality Steps Slider */}
|
||||
<div className="m3-slider-group">
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">Detail Steps (Inference speed)</span>
|
||||
<span className="m3-slider-value">{constraints.steps} steps</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={constraints.steps}
|
||||
onChange={(e) => updateConstraint("steps", parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="60"
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", lineHeight: "1.3" }}>
|
||||
The number of times the AI cleans up the image. More steps = sharper details, but takes longer.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CFG Scale Slider */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "8px" }}>
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">Prompt Strictness (Instruction obedience)</span>
|
||||
<span className="m3-slider-value">{constraints.cfgScale.toFixed(1)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={constraints.cfgScale}
|
||||
onChange={(e) => updateConstraint("cfgScale", parseFloat(e.target.value))}
|
||||
min="1"
|
||||
max="15"
|
||||
step="0.5"
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", lineHeight: "1.3" }}>
|
||||
How closely the AI follows your text prompt. Lower = more creative. Higher = more strict.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Random Seed DNA */}
|
||||
<div className="m3-text-field" style={{ marginTop: "8px" }}>
|
||||
<label className="m3-text-field-label">Image Blueprint (DNA Seed)</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<input
|
||||
type="number"
|
||||
className="m3-input"
|
||||
value={constraints.seed}
|
||||
onChange={(e) => updateConstraint("seed", parseInt(e.target.value) || -1)}
|
||||
placeholder="-1 for a brand new image..."
|
||||
style={{ flex: 1, height: "40px" }}
|
||||
/>
|
||||
<button
|
||||
className="m3-btn m3-btn-tonal"
|
||||
onClick={() => updateConstraint("seed", -1)}
|
||||
style={{ height: "40px" }}
|
||||
>
|
||||
Random (-1)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Speed & System */}
|
||||
<div>
|
||||
{/* Card 3: Performance hacks */}
|
||||
<div className="m3-card">
|
||||
<h3 className="m3-card-title">
|
||||
<Cpu size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
3. System Settings
|
||||
</h3>
|
||||
|
||||
<div className="m3-field-group">
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Generation Backend</label>
|
||||
<div className="m3-segmented-button">
|
||||
{availableBackends.map((backend) => (
|
||||
<div
|
||||
key={backend.id}
|
||||
className={`m3-segment-item ${constraints.backendType === backend.id || (!constraints.backendType && backend.id === "cpu") ? "active" : ""}`}
|
||||
onClick={() => handleBackendChange(backend.id)}
|
||||
title={backend.id === "cuda" ? "CUDA appears only when an NVIDIA CUDA backend is available." : undefined}
|
||||
>
|
||||
{backend.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "4px", lineHeight: 1.35 }}>
|
||||
CPU is slow but safest. Vulkan works on supported GPUs. CUDA is shown only when NVIDIA CUDA support is available.
|
||||
</span>
|
||||
{backendOptions?.unavailable?.length > 0 && (
|
||||
<div style={{ marginTop: "8px", display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{backendOptions.unavailable.map((backend) => (
|
||||
<span key={backend.id} style={{ fontSize: "0.75rem", color: "var(--md-sys-color-error)", lineHeight: 1.35 }}>
|
||||
{backend.label} unavailable: {backend.reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ImageConstraints);
|
||||
@@ -0,0 +1,845 @@
|
||||
import React, { memo, useState, useEffect } from "react";
|
||||
import { FolderOpen, DownloadCloud, RefreshCw, Database, Trash2, Square, HardDrive, Library, AlertTriangle } from "lucide-react";
|
||||
import { listLocalModels, startServer, stopServer, importModelFile, deleteModel, downloadModel, cancelModelDownload, getDownloadProgress, getBackendStatus, pingServer, formatBytes, normalizeModel } from "../services/api";
|
||||
|
||||
const MODEL_LIBRARY = [
|
||||
{
|
||||
group: "SDXL - Best Quality",
|
||||
items: [
|
||||
{
|
||||
name: "Juggernaut XL v9 Lightning",
|
||||
filename: "Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors",
|
||||
format: "Safetensors",
|
||||
approxSize: "6.6 GB",
|
||||
resolution: "1024x1024",
|
||||
notes: "Photorealistic SDXL model tuned for 4-8 step Lightning generation.",
|
||||
url: "https://huggingface.co/RunDiffusion/Juggernaut-XL-Lightning/resolve/main/Juggernaut_RunDiffusionPhoto2_Lightning_4Steps.safetensors",
|
||||
},
|
||||
{
|
||||
name: "DreamShaper XL Lightning",
|
||||
filename: "DreamShaperXL_Lightning.safetensors",
|
||||
format: "Safetensors",
|
||||
approxSize: "6.6 GB",
|
||||
resolution: "1024x1024",
|
||||
notes: "All-around SDXL model for fantasy art, renders, illustration, and stylized images.",
|
||||
url: "https://huggingface.co/Lykon/dreamshaper-xl-lightning/resolve/main/DreamShaperXL_Lightning.safetensors",
|
||||
},
|
||||
{
|
||||
name: "SDXL Base 1.0 Q4_0 GGUF",
|
||||
filename: "stable-diffusion-xl-base-1.0-Q4_0.gguf",
|
||||
format: "GGUF",
|
||||
approxSize: "3.8 GB",
|
||||
resolution: "1024x1024",
|
||||
notes: "Quantized SDXL base model for lower VRAM or CPU-friendly generation.",
|
||||
url: "https://huggingface.co/hum-ma/SDXL-models-GGUF/resolve/7e15380138e7069ca3aaef5bf0401c3406e3a593/stable-diffusion-xl-base-1.0-Q4_0.gguf",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "SD 1.5 - Ultra Fast / Low VRAM",
|
||||
items: [
|
||||
{
|
||||
name: "DreamShaper 8",
|
||||
filename: "DreamShaper_8_pruned.safetensors",
|
||||
format: "Safetensors",
|
||||
approxSize: "2.1 GB",
|
||||
resolution: "512x512",
|
||||
notes: "General-purpose SD 1.5 model for illustration, anime, and semi-realistic portraits.",
|
||||
url: "https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors",
|
||||
},
|
||||
{
|
||||
name: "CyberRealistic V8",
|
||||
filename: "CyberRealistic_V8_FP16.safetensors",
|
||||
format: "Safetensors",
|
||||
approxSize: "2.1 GB",
|
||||
resolution: "512x512",
|
||||
notes: "High-fidelity realism and photorealism in the SD 1.5 ecosystem.",
|
||||
url: "https://huggingface.co/cyberdelia/CyberRealistic/resolve/main/CyberRealistic_V8_FP16.safetensors",
|
||||
},
|
||||
{
|
||||
name: "ReV Animated v1.2.2",
|
||||
filename: "rev-animated-v1-2-2.safetensors",
|
||||
format: "Safetensors",
|
||||
approxSize: "2.1 GB",
|
||||
resolution: "512x512",
|
||||
notes: "Semi-realistic, 2.5D, anime, fantasy, and stylized digital art.",
|
||||
url: "https://huggingface.co/danbrown/RevAnimated-v1-2-2/resolve/main/rev-animated-v1-2-2.safetensors",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRunning, constraints, showAlert = async ({ message }) => window.alert(message), showConfirm = async ({ message }) => window.confirm(message) }) {
|
||||
const [localModels, setLocalModels] = useState([]);
|
||||
const [downloadingModelId, setDownloadingModelId] = useState(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [downloadSpeed, setDownloadSpeed] = useState("");
|
||||
const [loadingModelId, setLoadingModelId] = useState(null);
|
||||
const [modelLoadProgress, setModelLoadProgress] = useState(null);
|
||||
const [importProgress, setImportProgress] = useState(null); // null when not importing
|
||||
const [importInfo, setImportInfo] = useState({ filename: "", speed: "0.0", eta: 0, status: "" });
|
||||
const [downloadUrl, setDownloadUrl] = useState("");
|
||||
const [importAbortController, setImportAbortController] = useState(null);
|
||||
const [isUnloading, setIsUnloading] = useState(false);
|
||||
const [unloadProgress, setUnloadProgress] = useState({ progress: 0, phase: "" });
|
||||
const [pendingLoadModel, setPendingLoadModel] = useState(null);
|
||||
const [backendInfo, setBackendInfo] = useState({ backendMode: "", backendBinary: "", backendDevice: "" });
|
||||
|
||||
const modelNames = localModels.map((model) => normalizeModel(model).filename);
|
||||
const isBusy = loadingModelId !== null || isUnloading;
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels();
|
||||
checkActiveDownload();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverRunning && !loadingModelId) {
|
||||
setBackendInfo({ backendMode: "", backendBinary: "", backendDevice: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const updateBackendInfo = async () => {
|
||||
try {
|
||||
const status = await getBackendStatus();
|
||||
if (cancelled) return;
|
||||
setBackendInfo({
|
||||
backendMode: status.settings?.backendMode || status.loading?.backendMode || "",
|
||||
backendBinary: status.settings?.backendBinary || status.loading?.backendBinary || "",
|
||||
backendDevice: status.settings?.backendDevice || status.loading?.device || "",
|
||||
});
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
updateBackendInfo();
|
||||
const interval = setInterval(updateBackendInfo, 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [serverRunning, loadingModelId]);
|
||||
|
||||
const checkActiveDownload = async () => {
|
||||
try {
|
||||
const status = await getDownloadProgress();
|
||||
if (status.active && status.filename) {
|
||||
startProgressPolling(status.filename);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Check active download failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const startProgressPolling = (modelId) => {
|
||||
setDownloadingModelId(modelId);
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const status = await getDownloadProgress();
|
||||
if (status.active) {
|
||||
setDownloadProgress(status.progress === -1 ? 0 : status.progress);
|
||||
setDownloadSpeed(`${status.speed} • ETA ${status.eta}s`);
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setDownloadingModelId(null);
|
||||
if (status.error) {
|
||||
if (!String(status.error).toLowerCase().includes("cancelled")) {
|
||||
showAlert({ title: "Download Failed", message: status.error, danger: true });
|
||||
}
|
||||
} else {
|
||||
fetchModels();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling error:", e);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const list = await listLocalModels();
|
||||
setLocalModels(list.map(normalizeModel).filter((model) => model.filename));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadByUrl = async (url, expectedFilename) => {
|
||||
if (downloadingModelId) return;
|
||||
|
||||
if (modelNames.includes(expectedFilename)) {
|
||||
if (!(await showConfirm({
|
||||
title: "Overwrite Model?",
|
||||
message: `"${expectedFilename}" is already downloaded. Do you want to download and overwrite it?`,
|
||||
confirmLabel: "Overwrite",
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||
const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
||||
|
||||
if (!isTauriDesktop && !isLocalServerMode) {
|
||||
showAlert({ title: "Server Required", message: "Model download requires the local image generator server.", danger: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await downloadModel(url);
|
||||
if (res.ok) {
|
||||
startProgressPolling(expectedFilename);
|
||||
} else {
|
||||
showAlert({ title: "Download Failed", message: res.error || "Unknown error", danger: true });
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert({ title: "Download Error", message: err.message, danger: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Download custom model from URL
|
||||
const handleUrlDownloadSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!downloadUrl.trim()) return;
|
||||
|
||||
let modelName = "model.gguf";
|
||||
try {
|
||||
const parsed = new URL(downloadUrl.trim());
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("URL protocol must be http or https");
|
||||
}
|
||||
modelName = parsed.pathname.split("/").pop() || "";
|
||||
if (!modelName) throw new Error("Could not extract filename from URL");
|
||||
|
||||
const lowerName = modelName.toLowerCase();
|
||||
if (!lowerName.endsWith(".gguf") && !lowerName.endsWith(".safetensors") && !lowerName.endsWith(".ckpt")) {
|
||||
throw new Error("URL must point to a .gguf, .safetensors, or .ckpt weights file");
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert({ title: "Invalid URL", message: err.message, danger: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadByUrl(downloadUrl.trim(), modelName);
|
||||
setDownloadUrl("");
|
||||
};
|
||||
|
||||
const handleLibraryDownload = (model) => {
|
||||
downloadByUrl(model.url, model.filename);
|
||||
};
|
||||
|
||||
const handleCancelDownload = async () => {
|
||||
await cancelModelDownload();
|
||||
setDownloadingModelId(null);
|
||||
setDownloadProgress(0);
|
||||
setDownloadSpeed("");
|
||||
await fetchModels();
|
||||
};
|
||||
|
||||
// Start the model server backend
|
||||
const handleLoadModel = async (modelId) => {
|
||||
if (activeModel && activeModel !== modelId && serverRunning) {
|
||||
setPendingLoadModel(modelId);
|
||||
return;
|
||||
}
|
||||
await performLoadModel(modelId);
|
||||
};
|
||||
|
||||
const performLoadModel = async (modelId) => {
|
||||
const isTauriDesktop = typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||
const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
||||
|
||||
if (!isTauriDesktop && !isLocalServerMode) {
|
||||
const isAlreadyRunning = await pingServer();
|
||||
const cmd = `.\\backend\\win\\vulkan\\sd-vulkan.exe --listen-port 8080 --model .\\models\\${modelId} --threads 8`;
|
||||
|
||||
if (isAlreadyRunning) {
|
||||
if (await showConfirm({ title: "Web Browser Mode", message: `To load "${modelId}" on the active GPU server, restart your terminal backend with:\n\n${cmd}\n\nUpdate the UI status anyway?`, confirmLabel: "Update UI" })) {
|
||||
setActiveModel(modelId);
|
||||
setServerRunning(true);
|
||||
}
|
||||
} else {
|
||||
if (await showConfirm({ title: "Backend Not Running", message: `C++ backend server is not running on port 8080.\n\nTo run locally, start:\n\n${cmd}\n\nProceed in Simulation Mode instead?`, confirmLabel: "Proceed" })) {
|
||||
setActiveModel(modelId);
|
||||
setServerRunning(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingModelId(modelId);
|
||||
setModelLoadProgress({
|
||||
progress: 0,
|
||||
phase: "Starting backend...",
|
||||
speed: "",
|
||||
current: 0,
|
||||
total: 0,
|
||||
model: modelId,
|
||||
backendMode: "",
|
||||
backendBinary: "",
|
||||
device: "",
|
||||
});
|
||||
try {
|
||||
const response = await startServer(modelId, constraints);
|
||||
console.log(response);
|
||||
|
||||
let isReady = false;
|
||||
let crashError = null;
|
||||
for (let i = 0; i < 240; i++) { // Poll for up to 120 seconds (240 * 500ms)
|
||||
const status = await getBackendStatus();
|
||||
if (status.loading) {
|
||||
setModelLoadProgress({
|
||||
progress: status.loading.progress || 0,
|
||||
phase: status.loading.phase || "Loading model...",
|
||||
speed: status.loading.speed || "",
|
||||
current: status.loading.current || 0,
|
||||
total: status.loading.total || 0,
|
||||
model: status.loading.model || modelId,
|
||||
backendMode: status.loading.backendMode || status.settings?.backendMode || "",
|
||||
backendBinary: status.loading.backendBinary || status.settings?.backendBinary || "",
|
||||
device: status.loading.device || status.settings?.backendDevice || "",
|
||||
});
|
||||
}
|
||||
if (status.ready) {
|
||||
isReady = true;
|
||||
break;
|
||||
}
|
||||
if (status.error) {
|
||||
crashError = status.error;
|
||||
break;
|
||||
}
|
||||
if (!status.running && i > 3) {
|
||||
crashError = "The backend process terminated immediately on startup.";
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
if (isReady) {
|
||||
setModelLoadProgress((prev) => ({
|
||||
...(prev || {}),
|
||||
progress: 100,
|
||||
phase: "Model ready",
|
||||
speed: "",
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
model: modelId,
|
||||
}));
|
||||
setActiveModel(modelId);
|
||||
setServerRunning(true);
|
||||
} else {
|
||||
throw new Error(crashError || "Model server failed to respond on port 8080.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load model:", e);
|
||||
showAlert({ title: "Model Load Failed", message: e.message || String(e), danger: true });
|
||||
} finally {
|
||||
setTimeout(() => setModelLoadProgress(null), 800);
|
||||
setLoadingModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnloadThenLoad = async () => {
|
||||
const nextModel = pendingLoadModel;
|
||||
setPendingLoadModel(null);
|
||||
await handleUnloadModel();
|
||||
if (nextModel) {
|
||||
await performLoadModel(nextModel);
|
||||
}
|
||||
};
|
||||
|
||||
// Kill/Unload model server
|
||||
const handleUnloadModel = async () => {
|
||||
if (isUnloading) return;
|
||||
setIsUnloading(true);
|
||||
setUnloadProgress({ progress: 10, phase: "Stopping backend process..." });
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const status = await getBackendStatus();
|
||||
if (status.unloading?.active) {
|
||||
setUnloadProgress({
|
||||
progress: status.unloading.progress || 50,
|
||||
phase: status.unloading.phase || "Unloading model...",
|
||||
});
|
||||
} else {
|
||||
setUnloadProgress((prev) => ({
|
||||
progress: Math.min(95, prev.progress + 10),
|
||||
phase: prev.phase || "Unloading model...",
|
||||
}));
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 300);
|
||||
|
||||
try {
|
||||
await stopServer();
|
||||
setUnloadProgress({ progress: 100, phase: "Model unloaded" });
|
||||
setActiveModel(null);
|
||||
setPendingLoadModel(null);
|
||||
setServerRunning(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert({ title: "Unload Failed", message: e.message || String(e), danger: true });
|
||||
} finally {
|
||||
clearInterval(poll);
|
||||
setTimeout(() => {
|
||||
setIsUnloading(false);
|
||||
setUnloadProgress({ progress: 0, phase: "" });
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete/Remove model file
|
||||
const handleDeleteModel = async (filename) => {
|
||||
if (await showConfirm({
|
||||
title: "Delete Model?",
|
||||
message: `Delete "${filename}" from your drive?`,
|
||||
confirmLabel: "Delete",
|
||||
danger: true,
|
||||
})) {
|
||||
try {
|
||||
await deleteModel(filename);
|
||||
// Refresh local models list
|
||||
await fetchModels();
|
||||
// If it was the active model, unload the server
|
||||
if (activeModel === filename) {
|
||||
await handleUnloadModel();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete model:", err);
|
||||
showAlert({ title: "Delete Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const executeImport = async (sourcePath, displayName) => {
|
||||
const controller = new AbortController();
|
||||
setImportAbortController(controller);
|
||||
setImportProgress(0);
|
||||
setImportInfo({
|
||||
filename: displayName,
|
||||
speed: "0.0",
|
||||
eta: 0,
|
||||
status: "Initiating file copy..."
|
||||
});
|
||||
|
||||
try {
|
||||
await importModelFile(sourcePath, (progressData) => {
|
||||
setImportProgress(Math.round(progressData.progress));
|
||||
setImportInfo({
|
||||
filename: progressData.filename,
|
||||
speed: progressData.speed_mb_s.toFixed(1),
|
||||
eta: Math.round(progressData.eta_secs),
|
||||
status: progressData.status
|
||||
});
|
||||
}, controller.signal);
|
||||
|
||||
// Refresh list
|
||||
await fetchModels();
|
||||
} catch (err) {
|
||||
console.error("Import failed:", err);
|
||||
if (err.name !== "AbortError") {
|
||||
showAlert({ title: "Import Failed", message: err.message || String(err), danger: true });
|
||||
}
|
||||
} finally {
|
||||
setImportProgress(null);
|
||||
setImportAbortController(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelImport = () => {
|
||||
if (importAbortController) {
|
||||
importAbortController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFile = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
executeImport(file, file.name);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const isUrlDownloading = Boolean(downloadingModelId);
|
||||
|
||||
return (
|
||||
<div className="workspace-area">
|
||||
<div className="workspace-title-section">
|
||||
<h2 className="workspace-title">Model Manager</h2>
|
||||
<p className="workspace-subtitle">
|
||||
Manage local files and download recommended weights directly to your local models folder.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Active Model Status Tonal Box */}
|
||||
{activeModel && (
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", background: "var(--md-sys-color-primary-container)", color: "var(--md-sys-color-on-primary-container)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<h4 style={{ fontWeight: 700 }}>Active Model: {activeModel}</h4>
|
||||
<p style={{ fontSize: "0.85rem", marginTop: "2px", opacity: 0.9 }}>
|
||||
The local C++ stable-diffusion server is running. Real-time telemetry is displaying performance status in the top bar.
|
||||
</p>
|
||||
{(backendInfo.backendMode || backendInfo.backendDevice || backendInfo.backendBinary) && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", marginTop: "10px" }}>
|
||||
{backendInfo.backendMode && (
|
||||
<span className="status-chip" style={{ cursor: "default" }}>
|
||||
<HardDrive size={14} />
|
||||
<span>Loaded on {backendInfo.backendMode}</span>
|
||||
</span>
|
||||
)}
|
||||
{backendInfo.backendDevice && (
|
||||
<span className="status-chip" style={{ cursor: "default" }}>
|
||||
<span>{backendInfo.backendDevice}</span>
|
||||
</span>
|
||||
)}
|
||||
{backendInfo.backendBinary && (
|
||||
<span className="status-chip" style={{ cursor: "default" }}>
|
||||
<span>{backendInfo.backendBinary}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="m3-btn m3-btn-error" onClick={handleUnloadModel} disabled={isUnloading || loadingModelId !== null}>
|
||||
{isUnloading ? <RefreshCw className="progress-spinner" size={16} /> : <Trash2 size={16} />}
|
||||
<span>{isUnloading ? "Unloading" : "Unload Server"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnloading && (
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-error)", marginTop: "16px" }}>
|
||||
<h4 style={{ fontWeight: 700, marginBottom: "8px" }}>Unloading Model</h4>
|
||||
<div className="model-progress-section" style={{ margin: "8px 0 0 0" }}>
|
||||
<div className="model-progress-label">
|
||||
<span>{unloadProgress.phase || "Unloading model..."}</span>
|
||||
<span>{Math.round(unloadProgress.progress)}%</span>
|
||||
</div>
|
||||
<div className="model-progress-bar">
|
||||
<div className="model-progress-fill" style={{ width: `${Math.min(100, Math.max(0, unloadProgress.progress))}%`, transition: "width 0.25s ease" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modelLoadProgress && (
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-secondary)", marginTop: "16px" }}>
|
||||
<h4 style={{ fontWeight: 700, marginBottom: "8px" }}>
|
||||
Loading Model: {modelLoadProgress.model || loadingModelId}
|
||||
</h4>
|
||||
{(modelLoadProgress.backendMode || modelLoadProgress.backendBinary || modelLoadProgress.device) && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", marginBottom: "10px" }}>
|
||||
{modelLoadProgress.backendMode && (
|
||||
<span className="status-chip" style={{ cursor: "default" }}>
|
||||
<HardDrive size={14} />
|
||||
<span>Loading on {modelLoadProgress.backendMode}</span>
|
||||
</span>
|
||||
)}
|
||||
{modelLoadProgress.device && (
|
||||
<span className="status-chip" style={{ cursor: "default" }}>
|
||||
<span>{modelLoadProgress.device}</span>
|
||||
</span>
|
||||
)}
|
||||
{modelLoadProgress.backendBinary && (
|
||||
<span className="status-chip" style={{ cursor: "default" }}>
|
||||
<span>{modelLoadProgress.backendBinary}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="model-progress-section" style={{ margin: "8px 0 0 0" }}>
|
||||
<div className="model-progress-label">
|
||||
<span>
|
||||
{modelLoadProgress.phase}
|
||||
{modelLoadProgress.speed ? ` (${modelLoadProgress.speed})` : ""}
|
||||
</span>
|
||||
<span>
|
||||
{modelLoadProgress.total > 0
|
||||
? `Loaded ${modelLoadProgress.current} / ${modelLoadProgress.total} tensors`
|
||||
: `${Math.round(modelLoadProgress.progress)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="model-progress-bar">
|
||||
<div
|
||||
className="model-progress-fill"
|
||||
style={{ width: `${Math.min(100, Math.max(0, modelLoadProgress.progress))}%`, transition: "width 0.25s ease" }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detected Local Models Section */}
|
||||
<div className="m3-card" style={{ marginTop: "24px" }}>
|
||||
<h3 className="m3-card-title">
|
||||
<Database size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
Local Models ({localModels.length})
|
||||
</h3>
|
||||
|
||||
{localModels.length === 0 ? (
|
||||
<p style={{ fontSize: "0.9rem", color: "var(--md-sys-color-outline)", textAlign: "center", padding: "16px 0" }}>
|
||||
No models detected in app/models/. Download from the library below or import a file.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{localModels.map((model) => {
|
||||
const filename = model.filename;
|
||||
const isActive = activeModel === filename;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filename}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "12px 16px",
|
||||
background: isActive ? "var(--md-sys-color-primary-container)" : "var(--md-sys-color-surface-variant)",
|
||||
color: isActive ? "var(--md-sys-color-on-primary-container)" : "var(--md-sys-color-on-surface-variant)",
|
||||
borderRadius: "var(--md-shape-corner-medium)",
|
||||
border: "1px solid var(--md-sys-color-outline-variant)"
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>{filename}</span>
|
||||
<span style={{ fontSize: "0.75rem", opacity: 0.8 }}>
|
||||
Local Weights File • {model.size || formatBytes(model.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
{isActive ? (
|
||||
<button className="m3-btn m3-btn-error" style={{ height: "36px", padding: "0 16px" }} onClick={handleUnloadModel} disabled={isUnloading}>
|
||||
{isUnloading ? <RefreshCw className="progress-spinner" size={14} /> : <Trash2 size={14} />}
|
||||
<span>{isUnloading ? "Unloading" : "Unload"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="m3-btn m3-btn-filled"
|
||||
style={{ height: "36px", padding: "0 16px" }}
|
||||
onClick={() => handleLoadModel(filename)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{loadingModelId === filename ? (
|
||||
<RefreshCw className="progress-spinner" size={14} />
|
||||
) : (
|
||||
<Database size={14} />
|
||||
)}
|
||||
<span>{loadingModelId === filename ? "Loading" : "Load"}</span>
|
||||
</button>
|
||||
<button
|
||||
className="m3-btn m3-btn-error"
|
||||
style={{ height: "36px", width: "36px", padding: 0, minWidth: "36px" }}
|
||||
onClick={() => handleDeleteModel(filename)}
|
||||
disabled={isBusy}
|
||||
title="Delete from models folder"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
|
||||
<h3 className="m3-card-title">
|
||||
<Library size={20} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
Model Library
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
{MODEL_LIBRARY.map((section) => (
|
||||
<div key={section.group} className="m3-card" style={{ margin: 0 }}>
|
||||
<h4 style={{ fontWeight: 700, marginBottom: "12px" }}>{section.group}</h4>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "12px" }}>
|
||||
{section.items.map((model) => {
|
||||
const installed = modelNames.includes(model.filename);
|
||||
const downloading = downloadingModelId === model.filename;
|
||||
return (
|
||||
<div key={model.filename} style={{ display: "flex", flexDirection: "column", gap: "10px", padding: "14px", background: "var(--md-sys-color-surface-variant)", border: "1px solid var(--md-sys-color-outline-variant)", borderRadius: "var(--md-shape-corner-medium)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<span style={{ fontWeight: 700 }}>{model.name}</span>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
|
||||
{model.format} • approx. {model.approxSize} • {model.resolution}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: "0.78rem", color: "var(--md-sys-color-on-surface-variant)", lineHeight: 1.35, margin: 0 }}>
|
||||
{model.notes}
|
||||
</p>
|
||||
{downloading && (
|
||||
<div className="model-progress-section" style={{ marginTop: "2px" }}>
|
||||
<div className="model-progress-label">
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "8px" }}>
|
||||
<span className="loading-dot"></span>
|
||||
Downloading
|
||||
</span>
|
||||
<span>{downloadProgress > 0 ? `${downloadProgress}%` : "Preparing"}</span>
|
||||
</div>
|
||||
<div className="model-progress-bar">
|
||||
<div className="model-progress-fill" style={{ width: `${Math.min(100, Math.max(0, downloadProgress))}%`, transition: "width 0.2s ease" }}></div>
|
||||
</div>
|
||||
{downloadSpeed && (
|
||||
<div style={{ fontSize: "0.7rem", color: "var(--md-sys-color-outline)", marginTop: "4px" }}>
|
||||
{downloadSpeed}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={installed ? "m3-btn m3-btn-tonal" : "m3-btn m3-btn-filled"}
|
||||
style={{ height: "36px", marginTop: "auto" }}
|
||||
onClick={() => handleLibraryDownload(model)}
|
||||
disabled={installed || downloadingModelId !== null}
|
||||
>
|
||||
{installed ? <HardDrive size={14} /> : downloading ? <RefreshCw className="progress-spinner" size={14} /> : <DownloadCloud size={14} />}
|
||||
<span>{installed ? "Downloaded" : downloading ? "Downloading" : "Download"}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Local Drag and Drop Importer */}
|
||||
<div className="workspace-title-section" style={{ marginTop: "32px", marginBottom: "16px" }}>
|
||||
<h3 className="m3-card-title">
|
||||
<FolderOpen size={20} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
Local Model Import
|
||||
</h3>
|
||||
</div>
|
||||
{/* Importing Progress Bar */}
|
||||
{importProgress !== null && (
|
||||
<div className="m3-card" style={{ borderLeft: "4px solid var(--md-sys-color-primary)", marginTop: "24px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "12px" }}>
|
||||
<h4 style={{ fontWeight: 600 }}>Copying Model to local folder: {importInfo.filename}</h4>
|
||||
<button className="m3-btn m3-btn-error" style={{ height: "34px", padding: "0 14px" }} onClick={handleCancelImport}>
|
||||
<Square size={14} />
|
||||
<span>Stop Import</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="model-progress-section" style={{ margin: "12px 0 6px 0" }}>
|
||||
<div className="model-progress-label">
|
||||
<span>{importInfo.status} ({importInfo.speed} MB/s)</span>
|
||||
<span>{importProgress}% ({importInfo.eta}s remaining)</span>
|
||||
</div>
|
||||
<div className="model-progress-bar">
|
||||
<div className="model-progress-fill" style={{ width: `${importProgress}%`, transition: "width 0.15s ease" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "16px", marginTop: "16px" }}>
|
||||
<label className="import-box" style={{ margin: 0, height: "100%", justifyContent: "center" }}>
|
||||
<input
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
accept=".gguf,.safetensors,.ckpt"
|
||||
onChange={handleImportFile}
|
||||
disabled={importProgress !== null}
|
||||
/>
|
||||
<FolderOpen className="import-icon" />
|
||||
<span style={{ fontWeight: 600 }}>Choose weights file</span>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", textAlign: "center" }}>
|
||||
Select `.gguf`, `.safetensors` or `.ckpt` weights.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="m3-card" style={{ display: "flex", flexDirection: "column", justifyContent: "center", margin: 0, padding: "20px" }}>
|
||||
<h4 style={{ fontWeight: 600, marginBottom: "8px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<DownloadCloud size={16} />
|
||||
Download Model from URL
|
||||
</h4>
|
||||
<p style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginBottom: "12px" }}>
|
||||
Download any GGUF or Safetensors model from Hugging Face or other sites directly to your models folder.
|
||||
</p>
|
||||
{isUrlDownloading ? (
|
||||
<div className="model-progress-section" style={{ marginTop: "0px" }}>
|
||||
<div className="model-progress-label">
|
||||
<span style={{ fontSize: "0.75rem", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: "200px" }} title={downloadingModelId}>
|
||||
{downloadingModelId}
|
||||
</span>
|
||||
<span>{downloadProgress}%</span>
|
||||
</div>
|
||||
<div className="model-progress-bar" style={{ margin: "4px 0" }}>
|
||||
<div className="model-progress-fill" style={{ width: `${downloadProgress}%`, transition: "width 0.15s ease" }}></div>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.7rem", color: "var(--md-sys-color-outline)", marginTop: "2px" }}>
|
||||
{downloadSpeed}
|
||||
</div>
|
||||
<button className="m3-btn m3-btn-error" style={{ height: "34px", padding: "0 14px", marginTop: "10px" }} onClick={handleCancelDownload}>
|
||||
<Square size={14} />
|
||||
<span>Stop Download</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleUrlDownloadSubmit} style={{ display: "flex", gap: "8px" }}>
|
||||
<input
|
||||
type="text"
|
||||
className="m3-input"
|
||||
style={{ flex: 1, height: "40px" }}
|
||||
placeholder="e.g. https://huggingface.co/..."
|
||||
value={downloadUrl}
|
||||
onChange={(e) => setDownloadUrl(e.target.value)}
|
||||
disabled={downloadingModelId !== null}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="m3-btn m3-btn-filled"
|
||||
style={{ height: "40px" }}
|
||||
disabled={downloadingModelId !== null}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingLoadModel && (
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 50, background: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px" }}>
|
||||
<div className="m3-card" style={{ maxWidth: "460px", width: "100%", margin: 0, border: "1px solid var(--md-sys-color-outline-variant)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "10px" }}>
|
||||
<AlertTriangle size={22} style={{ color: "var(--md-sys-color-error)" }} />
|
||||
<h3 style={{ fontSize: "1.05rem", fontWeight: 700 }}>Unload Current Model?</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: "0.9rem", color: "var(--md-sys-color-on-surface-variant)", lineHeight: 1.45, marginBottom: "16px" }}>
|
||||
"{activeModel}" is already loaded. Unload it before loading "{pendingLoadModel}".
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px" }}>
|
||||
<button className="m3-btn m3-btn-outlined" onClick={() => setPendingLoadModel(null)} disabled={isUnloading}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-error" onClick={handleUnloadModel} disabled={isUnloading}>
|
||||
<Trash2 size={14} />
|
||||
<span>Unload Only</span>
|
||||
</button>
|
||||
<button className="m3-btn m3-btn-filled" onClick={handleUnloadThenLoad} disabled={isUnloading}>
|
||||
{isUnloading ? <RefreshCw className="progress-spinner" size={14} /> : <RefreshCw size={14} />}
|
||||
<span>Unload and Load</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ModelManager);
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { memo } from "react";
|
||||
import { Image, FolderDown, Sliders, Shield, Terminal } from "lucide-react";
|
||||
|
||||
function Sidebar({ activeTab, setActiveTab, specs }) {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<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 ${activeTab === "models" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("models")}
|
||||
>
|
||||
<FolderDown size={20} />
|
||||
<span>Model Manager</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`nav-item ${activeTab === "constraints" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("constraints")}
|
||||
>
|
||||
<Sliders size={20} />
|
||||
<span>Image Constraints</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);
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { memo } from "react";
|
||||
import { Cpu, HardDrive, Database, Square, RefreshCw, Sun, Moon } from "lucide-react";
|
||||
|
||||
function TopStatusBar({ telemetry, serverRunning, activeModel, onStopServer, isStoppingServer = false, theme, setTheme }) {
|
||||
const formatGb = (value) => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number.toFixed(number >= 10 ? 0 : 1) : "--";
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (serverRunning) return "Server Active";
|
||||
return "Local Mode";
|
||||
};
|
||||
|
||||
const getStatusClass = () => {
|
||||
if (serverRunning) return "status-indicator busy";
|
||||
return "status-indicator offline";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="top-status-bar">
|
||||
<div className="current-model-info">
|
||||
<div className={getStatusClass()}></div>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
{activeModel && (
|
||||
<>
|
||||
<span style={{ color: "var(--md-sys-color-outline-variant)" }}>|</span>
|
||||
<span style={{ color: "var(--md-sys-color-primary)", fontWeight: 700 }}>
|
||||
{activeModel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="telemetry-group">
|
||||
<button
|
||||
className="theme-toggle-btn"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
|
||||
>
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
|
||||
{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">
|
||||
<Cpu className="telemetry-chip-icon" style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
<span>CPU: {Number.isFinite(Number(telemetry.cpu_usage)) ? telemetry.cpu_usage : "--"}%</span>
|
||||
</div>
|
||||
|
||||
{/* RAM Telemetry Chip */}
|
||||
<div className="telemetry-chip" title="System Memory Usage">
|
||||
<HardDrive className="telemetry-chip-icon" style={{ color: "var(--md-sys-color-secondary)" }} />
|
||||
<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`}>
|
||||
<Database className="telemetry-chip-icon" style={{ color: "var(--md-sys-color-tertiary)" }} />
|
||||
<span>VRAM: {formatGb(telemetry.vram_used_gb)} / {formatGb(telemetry.vram_total_gb)} GB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TopStatusBar);
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,810 @@
|
||||
// Tauri API helper for desktop mode, falling back to HTTP/Mock in browser
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// Helper to check if running inside Tauri desktop container
|
||||
export const isTauri = () => {
|
||||
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||
};
|
||||
|
||||
// Cached hardware specs
|
||||
let cachedSpecs = null;
|
||||
export const EXPECTED_SERVER_BUILD = "polish-setup-v1";
|
||||
|
||||
const isLocalServerMode = () => {
|
||||
return typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
||||
};
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
const value = Number(bytes) || 0;
|
||||
if (value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const idx = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
|
||||
return `${(value / (1024 ** idx)).toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
}
|
||||
|
||||
export function normalizeModel(model) {
|
||||
if (typeof model === "string") {
|
||||
return { filename: model, sizeBytes: 0, size: "Unknown" };
|
||||
}
|
||||
return {
|
||||
filename: model?.filename || model?.name || "",
|
||||
sizeBytes: Number(model?.sizeBytes || 0),
|
||||
size: model?.size || (model?.sizeBytes ? formatBytes(model.sizeBytes) : "Unknown"),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonResponse(res, fallbackMessage = "The local server returned an invalid response.") {
|
||||
const text = await res.text();
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(text || "{}");
|
||||
} catch (_) {
|
||||
const looksLikeHtml = text.trim().startsWith("<!doctype") || text.trim().startsWith("<html");
|
||||
throw new Error(looksLikeHtml ? "The local server is serving an older frontend/API. Restart the image generator." : fallbackMessage);
|
||||
}
|
||||
|
||||
if (!res.ok || data.ok === false) {
|
||||
if (data.error === "Unknown API endpoint") {
|
||||
throw new Error("Restart the image generator so the local server loads the latest API.");
|
||||
}
|
||||
throw new Error(data.error || `Request failed (HTTP ${res.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getHealth() {
|
||||
try {
|
||||
const res = await fetch("/api/health");
|
||||
const data = await readJsonResponse(res, "The local server returned an invalid health response.");
|
||||
return {
|
||||
...data,
|
||||
stale: data.build !== EXPECTED_SERVER_BUILD,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
stale: true,
|
||||
build: "unknown",
|
||||
issues: [err.message || "Could not reach the local server."],
|
||||
checks: [],
|
||||
ports: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDiagnostics() {
|
||||
const res = await fetch("/api/diagnostics");
|
||||
return await readJsonResponse(res, "The local server returned invalid diagnostics.");
|
||||
}
|
||||
|
||||
export async function getCleanupCandidates() {
|
||||
const res = await fetch("/api/cleanup-candidates");
|
||||
const data = await readJsonResponse(res, "The local server returned invalid cleanup data.");
|
||||
return data.candidates || [];
|
||||
}
|
||||
|
||||
export async function cleanupCandidates(ids) {
|
||||
const res = await fetch("/api/cleanup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
return await readJsonResponse(res, "The local server returned invalid cleanup data.");
|
||||
}
|
||||
|
||||
// Get CPU and GPU specifications
|
||||
export async function getHardwareSpecs() {
|
||||
if (cachedSpecs) return cachedSpecs;
|
||||
|
||||
if (isTauri()) {
|
||||
try {
|
||||
cachedSpecs = await invoke("get_hardware_specs");
|
||||
return cachedSpecs;
|
||||
} catch (e) {
|
||||
console.warn("Failed to get hardware specs via Tauri, using fallback:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalServerMode()) {
|
||||
try {
|
||||
const res = await fetch("/api/hardware-specs");
|
||||
if (res.ok) {
|
||||
cachedSpecs = await res.json();
|
||||
return cachedSpecs;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to get hardware specs from local server:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Static preview fallback. Do not invent host hardware.
|
||||
cachedSpecs = {
|
||||
os_name: "Unavailable",
|
||||
cpu_name: "Unavailable",
|
||||
cpu_cores_physical: 4,
|
||||
cpu_cores_logical: 4,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Unavailable",
|
||||
};
|
||||
return cachedSpecs;
|
||||
}
|
||||
|
||||
// Get CPU/RAM/VRAM real-time utilization
|
||||
export async function getTelemetry() {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const stats = await invoke("get_telemetry");
|
||||
return stats;
|
||||
} catch (e) {
|
||||
// Ignore and use fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalServerMode()) {
|
||||
try {
|
||||
const res = await fetch("/api/telemetry");
|
||||
if (res.ok) {
|
||||
return await res.json();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return {
|
||||
cpu_usage: 0,
|
||||
ram_used_gb: 0,
|
||||
ram_total_gb: 0,
|
||||
gpu_name: "Unavailable",
|
||||
vram_used_gb: 0,
|
||||
vram_total_gb: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBackendOptions() {
|
||||
if (isLocalServerMode()) {
|
||||
try {
|
||||
const res = await fetch("/api/backend-options");
|
||||
if (res.ok) {
|
||||
return await res.json();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to get backend options from local server:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options: [{ id: "cpu", label: "CPU", available: true }],
|
||||
cudaAvailable: false,
|
||||
vulkanAvailable: false,
|
||||
defaultBackendType: "cpu",
|
||||
};
|
||||
}
|
||||
|
||||
// Get list of model files on the USB
|
||||
export async function listLocalModels() {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
return await invoke("list_local_models");
|
||||
} catch (e) {
|
||||
console.warn("Failed to list local models via Tauri:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const isLocalServerMode = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
||||
if (isLocalServerMode) {
|
||||
return await listModelsFromDisk();
|
||||
}
|
||||
|
||||
// Static preview fallback: show only models the user imported in this browser.
|
||||
let saved = localStorage.getItem("imported-models");
|
||||
if (saved === null) {
|
||||
localStorage.setItem("imported-models", JSON.stringify([]));
|
||||
saved = "[]";
|
||||
}
|
||||
const imported = JSON.parse(saved);
|
||||
return imported.map(normalizeModel);
|
||||
}
|
||||
|
||||
// Start (or restart) backend stable-diffusion.cpp server with correct CLI flags
|
||||
// In web/portable mode this calls serve.cjs management API which restarts the
|
||||
// sd-vulkan.exe process with --steps, --cfg-scale, --sampling-method flags.
|
||||
export async function startServer(modelPath, constraints) {
|
||||
if (isTauri()) {
|
||||
const launchParams = {
|
||||
model_path: modelPath,
|
||||
port: 8080,
|
||||
use_gpu: constraints.useGpu !== false,
|
||||
backend_type: constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto"),
|
||||
threads: constraints.threads || 8,
|
||||
};
|
||||
return await invoke("start_server", { params: launchParams });
|
||||
}
|
||||
|
||||
// Web/portable mode — call serve.cjs management API
|
||||
const modelName = modelPath ? modelPath.split(/[\\/]/).pop() : null;
|
||||
try {
|
||||
const res = await fetch("/api/restart-backend", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
steps: constraints.steps || 20,
|
||||
cfgScale: constraints.cfgScale || 7.0,
|
||||
sampler: constraints.sampler || "euler_a",
|
||||
threads: constraints.threads || 8,
|
||||
use_gpu: constraints.useGpu !== false,
|
||||
backend_type: constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto"),
|
||||
}),
|
||||
});
|
||||
const text = await res.text();
|
||||
const data = JSON.parse(text || "{}");
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.error || `Backend restart failed (HTTP ${res.status})`);
|
||||
}
|
||||
console.log("Backend restart:", data.message);
|
||||
return data.message;
|
||||
} catch (e) {
|
||||
console.warn("Could not reach management API:", e.message);
|
||||
if (isLocalServerMode()) {
|
||||
throw e;
|
||||
}
|
||||
return "Backend management unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
// Stop backend server
|
||||
export async function stopServer() {
|
||||
if (isTauri()) {
|
||||
return await invoke("stop_server");
|
||||
}
|
||||
try {
|
||||
await fetch("/api/stop-backend", { method: "POST" });
|
||||
} catch (_) {}
|
||||
return "Backend stopped";
|
||||
}
|
||||
|
||||
// Get current backend status and active settings
|
||||
export async function getBackendStatus() {
|
||||
try {
|
||||
const r = await fetch("/api/backend-status");
|
||||
return await r.json();
|
||||
} catch (_) {
|
||||
return { ready: false, settings: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGeneratedOutputs() {
|
||||
try {
|
||||
const res = await fetch("/api/outputs");
|
||||
const data = await res.json();
|
||||
return data.outputs || [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGeneratedOutput(image, metadata) {
|
||||
const res = await fetch("/api/save-output", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image, metadata }),
|
||||
});
|
||||
const data = await readJsonResponse(res, "The local server returned an invalid save response.");
|
||||
return data.output;
|
||||
}
|
||||
|
||||
export async function deleteGeneratedOutputs(outputs) {
|
||||
const res = await fetch("/api/delete-outputs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ outputs }),
|
||||
});
|
||||
return await readJsonResponse(res, "The local server returned an invalid delete response.");
|
||||
}
|
||||
|
||||
// List model files from the models folder (via management API in web mode)
|
||||
export async function listModelsFromDisk() {
|
||||
try {
|
||||
const r = await fetch("/api/models");
|
||||
const data = await r.json();
|
||||
return (data.models || []).map(normalizeModel);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate image (T2I / I2I)
|
||||
// Handles API calls to sd-server or mocks them if server is unreachable
|
||||
export async function generateImage(prompt, negativePrompt, constraints, activeModelName, inputImageBase64, onProgress, signal) {
|
||||
console.log("Initiating image generation:", { prompt, negativePrompt, constraints, activeModelName });
|
||||
|
||||
const port = 8080;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
// Prepare payload based on standard stable-diffusion.cpp REST endpoint schemas
|
||||
const payload = {
|
||||
prompt: prompt,
|
||||
negative_prompt: negativePrompt || "",
|
||||
width: constraints.width || 512,
|
||||
height: constraints.height || 512,
|
||||
steps: constraints.steps || 20,
|
||||
cfg_scale: constraints.cfgScale || 7.0,
|
||||
seed: constraints.seed === -1 ? Math.floor(Math.random() * 1000000) : constraints.seed,
|
||||
sampler: constraints.sampler || "euler_a",
|
||||
image: inputImageBase64 || null, // Image to image source (base64)
|
||||
denoising_strength: constraints.denoisingStrength || 0.7,
|
||||
};
|
||||
|
||||
// Mock generation helper for offline/browser execution
|
||||
const runMockGeneration = async () => {
|
||||
// Simulate generation steps progress
|
||||
const stepsCount = payload.steps;
|
||||
for (let i = 1; i <= stepsCount; i++) {
|
||||
if (signal && signal.aborted) {
|
||||
throw new DOMException("The user aborted a request.", "AbortError");
|
||||
}
|
||||
await new Promise(r => setTimeout(r, (3000 / stepsCount))); // 3 seconds total
|
||||
if (onProgress) {
|
||||
onProgress(Math.round((i / stepsCount) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
const pLower = prompt.toLowerCase();
|
||||
const seedVal = Math.abs(payload.seed) || Math.floor(Math.random() * 1000);
|
||||
|
||||
// Generate a beautiful, local vector SVG representing the requested subject, completely offline-compatible
|
||||
const primaryBg = "#0F172A"; // dark premium background
|
||||
let gradientStart = "#3B82F6";
|
||||
let gradientEnd = "#8B5CF6";
|
||||
let sceneShapes = "";
|
||||
let keywordLabel = "Abstract Generation";
|
||||
|
||||
if (pLower.includes("moon") || pLower.includes("space") || pLower.includes("astronomy") || pLower.includes("sky")) {
|
||||
gradientStart = "#1E293B";
|
||||
gradientEnd = "#0F172A";
|
||||
keywordLabel = "Luminous Moon in Starry Sky";
|
||||
sceneShapes = `
|
||||
<!-- Stars -->
|
||||
<circle cx="80" cy="100" r="1.5" fill="#ffffff" opacity="0.6"/>
|
||||
<circle cx="160" cy="60" r="1" fill="#ffffff" opacity="0.5"/>
|
||||
<circle cx="280" cy="120" r="2" fill="#ffffff" opacity="0.8"/>
|
||||
<circle cx="360" cy="80" r="1.5" fill="#ffffff" opacity="0.4"/>
|
||||
<circle cx="440" cy="150" r="1" fill="#ffffff" opacity="0.7"/>
|
||||
<circle cx="400" cy="280" r="2" fill="#ffffff" opacity="0.9"/>
|
||||
<circle cx="100" cy="300" r="1.5" fill="#ffffff" opacity="0.5"/>
|
||||
<!-- Moon -->
|
||||
<circle cx="256" cy="220" r="90" fill="url(#moonGrad)" />
|
||||
<circle cx="226" cy="190" r="80" fill="#0F172A" />
|
||||
`;
|
||||
} else if (pLower.includes("cat") || pLower.includes("kitten") || pLower.includes("feline")) {
|
||||
gradientStart = "#4F46E5";
|
||||
gradientEnd = "#7C3AED";
|
||||
keywordLabel = "Stylized Cat Portrait";
|
||||
sceneShapes = `
|
||||
<!-- Cat head & ears -->
|
||||
<polygon points="176,280 146,160 216,210" fill="#312E81" />
|
||||
<polygon points="336,280 366,160 296,210" fill="#312E81" />
|
||||
<circle cx="256" cy="270" r="80" fill="#4338CA" />
|
||||
<!-- Eyes -->
|
||||
<ellipse cx="226" cy="260" rx="14" ry="8" fill="#10B981" />
|
||||
<ellipse cx="286" cy="260" rx="14" ry="8" fill="#10B981" />
|
||||
<circle cx="226" cy="260" r="4" fill="#000" />
|
||||
<circle cx="286" cy="260" r="4" fill="#000" />
|
||||
<!-- Nose & Whiskers -->
|
||||
<polygon points="256,285 248,275 264,275" fill="#F43F5E" />
|
||||
<line x1="226" y1="290" x2="166" y2="280" stroke="#E2E8F0" stroke-width="2" />
|
||||
<line x1="226" y1="295" x2="156" y2="295" stroke="#E2E8F0" stroke-width="2" />
|
||||
<line x1="286" y1="290" x2="346" y2="280" stroke="#E2E8F0" stroke-width="2" />
|
||||
<line x1="286" y1="295" x2="356" y2="295" stroke="#E2E8F0" stroke-width="2" />
|
||||
`;
|
||||
} else if (pLower.includes("dog") || pLower.includes("puppy") || pLower.includes("canine")) {
|
||||
gradientStart = "#D97706";
|
||||
gradientEnd = "#B45309";
|
||||
keywordLabel = "Friendly Dog Portrait";
|
||||
sceneShapes = `
|
||||
<!-- Dog head & floppy ears -->
|
||||
<ellipse cx="166" cy="260" rx="20" ry="60" fill="#78350F" />
|
||||
<ellipse cx="346" cy="260" rx="20" ry="60" fill="#78350F" />
|
||||
<circle cx="256" cy="250" r="70" fill="#92400E" />
|
||||
<ellipse cx="256" cy="280" rx="40" ry="30" fill="#D97706" />
|
||||
<!-- Eyes -->
|
||||
<circle cx="226" cy="230" r="10" fill="#1E293B" />
|
||||
<circle cx="286" cy="230" r="10" fill="#1E293B" />
|
||||
<circle cx="222" cy="226" r="3" fill="#ffffff" />
|
||||
<circle cx="282" cy="226" r="3" fill="#ffffff" />
|
||||
<!-- Nose -->
|
||||
<ellipse cx="256" cy="275" rx="16" ry="10" fill="#1E293B" />
|
||||
`;
|
||||
} else if (pLower.includes("city") || pLower.includes("cyberpunk") || pLower.includes("tokyo") || pLower.includes("neon")) {
|
||||
gradientStart = "#111827";
|
||||
gradientEnd = "#311042";
|
||||
keywordLabel = "Neon Cyberpunk Skyline";
|
||||
sceneShapes = `
|
||||
<!-- Skyline -->
|
||||
<rect x="60" y="160" width="80" height="352" fill="#1F2937" opacity="0.9"/>
|
||||
<rect x="180" y="100" width="100" height="412" fill="#111827" />
|
||||
<rect x="320" y="200" width="120" height="312" fill="#1F2937" opacity="0.85"/>
|
||||
<rect x="100" y="240" width="110" height="272" fill="#374151" opacity="0.6"/>
|
||||
<!-- Neon details -->
|
||||
<line x1="230" y1="100" x2="230" y2="40" stroke="#EC4899" stroke-width="3" />
|
||||
<rect x="200" y="140" width="60" height="15" fill="#10B981" opacity="0.7"/>
|
||||
<rect x="200" y="170" width="60" height="15" fill="#10B981" opacity="0.7"/>
|
||||
<rect x="200" y="200" width="60" height="15" fill="#3B82F6" opacity="0.7"/>
|
||||
<rect x="70" y="200" width="15" height="100" fill="#F59E0B" opacity="0.5"/>
|
||||
<rect x="340" y="250" width="20" height="150" fill="#EF4444" opacity="0.6"/>
|
||||
`;
|
||||
} else if (pLower.includes("car") || pLower.includes("vehicle") || pLower.includes("supercar")) {
|
||||
gradientStart = "#DC2626";
|
||||
gradientEnd = "#991B1B";
|
||||
keywordLabel = "Futuristic Concept Car";
|
||||
sceneShapes = `
|
||||
<!-- Road / Perspective grid -->
|
||||
<line x1="256" y1="350" x2="0" y2="512" stroke="#4B5563" stroke-width="3" />
|
||||
<line x1="256" y1="350" x2="512" y2="512" stroke="#4B5563" stroke-width="3" />
|
||||
<!-- Car body -->
|
||||
<polygon points="126,380 256,330 386,380 346,440 166,440" fill="#EF4444" />
|
||||
<polygon points="176,370 256,340 336,370 326,390 186,390" fill="#1E293B" opacity="0.9" />
|
||||
<!-- Headlights -->
|
||||
<polygon points="136,410 176,415 166,425 136,420" fill="#FBBF24" />
|
||||
<polygon points="376,410 336,415 346,425 376,420" fill="#FBBF24" />
|
||||
<!-- Wheels -->
|
||||
<ellipse cx="156" cy="440" rx="30" ry="15" fill="#111" />
|
||||
<ellipse cx="356" cy="440" rx="30" ry="15" fill="#111" />
|
||||
`;
|
||||
} else {
|
||||
// General gradient waves
|
||||
gradientStart = "#06B6D4";
|
||||
gradientEnd = "#0891B2";
|
||||
keywordLabel = "Abstract Creative Concept";
|
||||
sceneShapes = `
|
||||
<path d="M0,256 C150,150 350,350 512,256 L512,512 L0,512 Z" fill="#0891B2" opacity="0.4" />
|
||||
<path d="M0,350 C200,200 300,450 512,350 L512,512 L0,512 Z" fill="#0E7490" opacity="0.6" />
|
||||
<circle cx="380" cy="180" r="50" fill="#F59E0B" opacity="0.8" />
|
||||
`;
|
||||
}
|
||||
|
||||
const svgString = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${gradientStart}" />
|
||||
<stop offset="100%" stop-color="${gradientEnd}" />
|
||||
</linearGradient>
|
||||
<linearGradient id="moonGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FEF08A" />
|
||||
<stop offset="100%" stop-color="#FDE047" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" fill="url(#bgGrad)" />
|
||||
|
||||
<!-- Shapes -->
|
||||
${sceneShapes}
|
||||
|
||||
<!-- Premium UI Overlay Frame -->
|
||||
<rect x="20" y="20" width="472" height="472" fill="none" stroke="#ffffff" stroke-width="1.5" opacity="0.15" rx="8"/>
|
||||
|
||||
<!-- Prompt & Details text -->
|
||||
<rect x="40" y="410" width="432" height="62" fill="#0F172A" opacity="0.85" rx="6" />
|
||||
<text x="60" y="435" fill="#F1F5F9" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="bold">${keywordLabel}</text>
|
||||
<text x="60" y="455" fill="#94A3B8" font-family="system-ui, -apple-system, sans-serif" font-size="11">Seed: ${seedVal} • Steps: ${payload.steps} • Scale: ${payload.cfg_scale}</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const base64Svg = btoa(unescape(encodeURIComponent(svgString)));
|
||||
const mockImageUrl = `data:image/svg+xml;base64,${base64Svg}`;
|
||||
|
||||
return {
|
||||
image: mockImageUrl,
|
||||
seed: payload.seed,
|
||||
duration_sec: 3.0,
|
||||
};
|
||||
};
|
||||
|
||||
// stable-diffusion.cpp server only exposes /v1/images/generations (OpenAI-compat)
|
||||
// Steps, cfg_scale, seed, sample_method are passed in the body and read by the server
|
||||
const isImg2Img = !!payload.image;
|
||||
|
||||
const genBody = {
|
||||
prompt: payload.prompt,
|
||||
negative_prompt: payload.negative_prompt || "",
|
||||
n: 1,
|
||||
size: `${payload.width}x${payload.height}`,
|
||||
response_format: "b64_json",
|
||||
// Generation parameters — read by stable-diffusion.cpp from the request body
|
||||
steps: payload.steps,
|
||||
cfg_scale: payload.cfg_scale,
|
||||
seed: payload.seed,
|
||||
sample_method: payload.sampler || "euler_a",
|
||||
};
|
||||
|
||||
// img2img extra fields
|
||||
if (isImg2Img) {
|
||||
genBody.init_images = [payload.image];
|
||||
genBody.denoising_strength = payload.denoising_strength || 0.7;
|
||||
}
|
||||
|
||||
// Attempt real HTTP call
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/images/generations`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: signal,
|
||||
body: JSON.stringify(genBody),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Response: { data: [{ b64_json: "..." }] }
|
||||
const imgB64 = data?.data?.[0]?.b64_json ?? data?.images?.[0];
|
||||
if (imgB64) {
|
||||
return {
|
||||
image: `data:image/png;base64,${imgB64}`,
|
||||
seed: data.data?.[0]?.seed ?? payload.seed,
|
||||
duration_sec: 4.5,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
let errMsg = `Generation failed (HTTP ${response.status})`;
|
||||
try {
|
||||
const errJson = await response.json();
|
||||
if (errJson?.detail || errJson?.error?.message) {
|
||||
errMsg = errJson.detail || errJson.error.message;
|
||||
}
|
||||
} catch (_) {}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError" || err.message.startsWith("Generation failed")) throw err;
|
||||
console.warn("Could not reach local server. Falling back to simulation mode.", err);
|
||||
}
|
||||
|
||||
// Fallback to mock generation if server is offline/not reachable
|
||||
return await runMockGeneration();
|
||||
}
|
||||
|
||||
// Perform model file import (copy to USB in Tauri, simulated in Web mode)
|
||||
export async function importModelFile(sourcePath, onProgress, signal) {
|
||||
if (sourcePath instanceof File) {
|
||||
const file = sourcePath;
|
||||
const isLocalMode = isLocalServerMode();
|
||||
if (!isLocalMode) {
|
||||
throw new Error("File import requires the local image generator server.");
|
||||
}
|
||||
|
||||
await uploadModelFile(file, onProgress, signal);
|
||||
return { response: "Model imported successfully" };
|
||||
}
|
||||
|
||||
if (isTauri()) {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
|
||||
let unlisten = null;
|
||||
if (onProgress) {
|
||||
unlisten = await listen("import-progress", (event) => {
|
||||
onProgress(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await invoke("import_model_file", { sourcePath });
|
||||
return { response, unlisten };
|
||||
} catch (e) {
|
||||
if (unlisten) unlisten();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback simulation in browser
|
||||
const filename = sourcePath.split(/[\\/]/).pop() || "imported_model.gguf";
|
||||
console.log(`Web Mode: Simulating copying ${filename} to USB models folder`);
|
||||
|
||||
const totalSteps = 40;
|
||||
const start = Date.now();
|
||||
|
||||
for (let i = 1; i <= totalSteps; i++) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("The user aborted a request.", "AbortError");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 150)); // ~6 seconds copy total
|
||||
const progress = (i / totalSteps) * 100;
|
||||
const elapsedSecs = (Date.now() - start) / 1000;
|
||||
const speed = 40 + Math.sin(i) * 5 + Math.random() * 2; // ~42 MB/s
|
||||
const eta = (totalSteps - i) * 0.15;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
filename,
|
||||
progress,
|
||||
speed_mb_s: speed,
|
||||
eta_secs: eta,
|
||||
status: "Copying to USB..."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
const saved = localStorage.getItem("imported-models");
|
||||
const imported = saved ? JSON.parse(saved) : [];
|
||||
if (!imported.includes(filename)) {
|
||||
imported.push({ filename, sizeBytes: 0, size: "Unknown" });
|
||||
localStorage.setItem("imported-models", JSON.stringify(imported));
|
||||
}
|
||||
|
||||
return { response: "Model imported successfully" };
|
||||
}
|
||||
|
||||
function uploadModelFile(file, onProgress, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const startedAt = Date.now();
|
||||
let abortedByUser = false;
|
||||
|
||||
xhr.open("POST", `/api/import-model?filename=${encodeURIComponent(file.name)}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
|
||||
const abortUpload = () => {
|
||||
abortedByUser = true;
|
||||
xhr.abort();
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
abortUpload();
|
||||
} else {
|
||||
signal.addEventListener("abort", abortUpload, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable || !onProgress) return;
|
||||
|
||||
const elapsedSecs = Math.max(0.1, (Date.now() - startedAt) / 1000);
|
||||
const speedBytes = event.loaded / elapsedSecs;
|
||||
const remainingBytes = Math.max(0, event.total - event.loaded);
|
||||
onProgress({
|
||||
filename: file.name,
|
||||
progress: (event.loaded / event.total) * 100,
|
||||
speed_mb_s: speedBytes / (1024 * 1024),
|
||||
eta_secs: remainingBytes / Math.max(1, speedBytes),
|
||||
status: "Copying to models folder..."
|
||||
});
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText || "{}");
|
||||
} catch (_) {}
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300 && data.ok !== false) {
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
filename: file.name,
|
||||
progress: 100,
|
||||
speed_mb_s: 0,
|
||||
eta_secs: 0,
|
||||
status: "Import complete"
|
||||
});
|
||||
}
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error(data.error || `Import failed (HTTP ${xhr.status})`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error("Import failed while uploading the file."));
|
||||
xhr.onabort = () => reject(new DOMException(abortedByUser ? "Import cancelled by user." : "Import aborted.", "AbortError"));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete model file (Tauri disk deletion or localStorage cleanup)
|
||||
export async function deleteModel(filename) {
|
||||
if (isTauri()) {
|
||||
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) {
|
||||
try {
|
||||
const res = await fetch("/api/delete-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to delete model file");
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Failed to delete model via local API:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Browser Mode
|
||||
const saved = localStorage.getItem("imported-models");
|
||||
let imported = saved ? JSON.parse(saved) : [];
|
||||
imported = imported.filter((model) => normalizeModel(model).filename !== filename);
|
||||
localStorage.setItem("imported-models", JSON.stringify(imported));
|
||||
return `Simulated deletion of ${filename} from browser session`;
|
||||
}
|
||||
|
||||
// Ping the server to check if it is active and responding
|
||||
export async function pingServer() {
|
||||
const port = 8080;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/models`, { method: "GET" });
|
||||
return response.ok;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the server to be ready by polling
|
||||
export async function waitForServerReady(maxAttempts = 30) {
|
||||
const attempts = isTauri() || isLocalServerMode() ? maxAttempts : 3;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
let ok = false;
|
||||
if (isLocalServerMode()) {
|
||||
const status = await getBackendStatus();
|
||||
ok = Boolean(status.ready);
|
||||
} else {
|
||||
ok = await pingServer();
|
||||
}
|
||||
if (ok) return true;
|
||||
await new Promise((r) => setTimeout(r, 500)); // check every 500ms
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start model file download from a URL on the server
|
||||
export async function downloadModel(url) {
|
||||
try {
|
||||
const res = await fetch("/api/download-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to start model download:", e);
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelModelDownload() {
|
||||
try {
|
||||
const res = await fetch("/api/cancel-download", { method: "POST" });
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to cancel model download:", e);
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Get the server-side model download progress
|
||||
export async function getDownloadProgress() {
|
||||
try {
|
||||
const res = await fetch("/api/download-progress");
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to get download progress:", e);
|
||||
return { active: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Get the server-side image generation progress
|
||||
export async function getGenerationProgress() {
|
||||
try {
|
||||
const res = await fetch("/api/generation-progress");
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to get generation progress:", e);
|
||||
return { active: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
|
||||
build: {
|
||||
// Output to app/dist/ so serve.cjs can find it
|
||||
outDir: "../dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
proxy: {
|
||||
"/txt2img": { target: "http://127.0.0.1:8080", changeOrigin: true },
|
||||
"/img2img": { target: "http://127.0.0.1:8080", changeOrigin: true },
|
||||
"/v1": { target: "http://127.0.0.1:8080", changeOrigin: true },
|
||||
"/api": { target: "http://127.0.0.1:1422", changeOrigin: true },
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,60 @@
|
||||
# scripts/reset.ps1
|
||||
# Resets portable app dependencies/builds while preserving user models and outputs.
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$rootDir = Split-Path -Parent $scriptDir
|
||||
$appDir = Join-Path $rootDir "app"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================" -ForegroundColor Yellow
|
||||
Write-Host " Resetting Local-AI-Image-Generator..." -ForegroundColor Yellow
|
||||
Write-Host " ============================================================" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# Delete tools/node
|
||||
$toolsDir = Join-Path $appDir "tools"
|
||||
if (Test-Path $toolsDir) {
|
||||
Write-Host " >> Removing portable tools/ node folder..." -ForegroundColor Cyan
|
||||
Remove-Item $toolsDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Delete backend
|
||||
$backendDir = Join-Path $appDir "backend"
|
||||
if (Test-Path $backendDir) {
|
||||
Write-Host " >> Removing backend binaries..." -ForegroundColor Cyan
|
||||
Remove-Item $backendDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Delete dist
|
||||
$distDir = Join-Path $appDir "dist"
|
||||
if (Test-Path $distDir) {
|
||||
Write-Host " >> Removing dist/ build folder..." -ForegroundColor Cyan
|
||||
Remove-Item $distDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Preserve models
|
||||
$modelsDir = Join-Path $appDir "models"
|
||||
if (Test-Path $modelsDir) {
|
||||
Write-Host " >> Preserving downloaded models in app/models." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Delete node_modules in frontend
|
||||
$nodeModulesDir = Join-Path $appDir "frontend\node_modules"
|
||||
if (Test-Path $nodeModulesDir) {
|
||||
Write-Host " >> Removing frontend node_modules..." -ForegroundColor Cyan
|
||||
Remove-Item $nodeModulesDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Delete package-lock.json in frontend
|
||||
$lockFile = Join-Path $appDir "frontend\package-lock.json"
|
||||
if (Test-Path $lockFile) {
|
||||
Write-Host " >> Removing frontend package-lock.json..." -ForegroundColor Cyan
|
||||
Remove-Item $lockFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host " Reset complete. Models and generated outputs were preserved." -ForegroundColor Green
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Read-Host " Press Enter to close..."
|
||||
+1433
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,401 @@
|
||||
# Local AI Image Generator - Setup Script
|
||||
# scripts/ lives at root, app/ is a sibling folder
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$rootDir = Split-Path -Parent $scriptDir
|
||||
$appDir = Join-Path $rootDir "app"
|
||||
$frontendDir = Join-Path $appDir "frontend"
|
||||
$toolsDir = Join-Path $appDir "tools"
|
||||
$nodeDir = Join-Path $toolsDir "node"
|
||||
$nodeExe = Join-Path $nodeDir "node.exe"
|
||||
$npmCmd = Join-Path $nodeDir "npm.cmd"
|
||||
$distDir = Join-Path $appDir "dist"
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function Print-Header {
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================" -ForegroundColor Cyan
|
||||
Write-Host " LOCAL AI IMAGE GENERATOR - First-Time Setup" -ForegroundColor Cyan
|
||||
Write-Host " 100% Self-Contained | No System Install Required" -ForegroundColor DarkCyan
|
||||
Write-Host " ============================================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Print-Step {
|
||||
param([int]$n, [int]$total, [string]$title)
|
||||
Write-Host ""
|
||||
Write-Host " [$n/$total] $title" -ForegroundColor White
|
||||
Write-Host (" " + "-" * 56) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Print-OK { param([string]$m); Write-Host " OK $m" -ForegroundColor Green }
|
||||
function Print-Info { param([string]$m); Write-Host " >> $m" -ForegroundColor Cyan }
|
||||
function Print-Warn { param([string]$m); Write-Host " !! $m" -ForegroundColor Yellow }
|
||||
function Print-Fail { param([string]$m); Write-Host " XX $m" -ForegroundColor Red }
|
||||
|
||||
function Format-Bytes {
|
||||
param([long]$b)
|
||||
if ($b -gt 1GB) { return "{0:N2} GB" -f ($b / 1GB) }
|
||||
if ($b -gt 1MB) { return "{0:N1} MB" -f ($b / 1MB) }
|
||||
return "{0:N0} KB" -f ($b / 1KB)
|
||||
}
|
||||
|
||||
function Format-Speed {
|
||||
param([double]$bps)
|
||||
if ($bps -gt 1MB) { return "{0:N1} MB/s" -f ($bps / 1MB) }
|
||||
return "{0:N0} KB/s" -f ($bps / 1KB)
|
||||
}
|
||||
|
||||
function Invoke-RichDownload {
|
||||
param([string]$Url, [string]$Dest, [string]$Label)
|
||||
Print-Info "Downloading: $Label"
|
||||
Write-Host ""
|
||||
|
||||
$barWidth = 48
|
||||
$lastBytes = [long]0
|
||||
$lastTime = [DateTime]::Now
|
||||
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::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
|
||||
|
||||
while ($true) {
|
||||
$read = $stream.Read($buf, 0, $buf.Length)
|
||||
if ($read -le 0) { break }
|
||||
$out.Write($buf, 0, $read)
|
||||
$done += $read
|
||||
|
||||
$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 "
|
||||
}
|
||||
}
|
||||
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
||||
function Expand-WithProgress {
|
||||
param([string]$ZipPath, [string]$Destination, [string]$Label)
|
||||
Print-Info "Extracting $Label..."
|
||||
Write-Host ""
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||
$total = $zip.Entries.Count
|
||||
$barW = 48
|
||||
$i = 0
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
|
||||
|
||||
foreach ($entry in $zip.Entries) {
|
||||
$i++
|
||||
$pct = [int](($i / $total) * 100)
|
||||
$fill = [int](($pct / 100) * $barW)
|
||||
$bar = ("#" * $fill) + ("-" * ($barW - $fill))
|
||||
$name = $entry.Name
|
||||
if ($name.Length -gt 28) { $name = "..." + $name.Substring($name.Length - 28) }
|
||||
Write-Host -NoNewline "`r [$bar] $pct% $name "
|
||||
|
||||
if ($entry.FullName.EndsWith("/") -or $entry.FullName.EndsWith("\")) {
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $Destination $entry.FullName) | Out-Null
|
||||
} else {
|
||||
$destFile = Join-Path $Destination $entry.FullName
|
||||
$destDir = Split-Path -Parent $destFile
|
||||
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destFile, $true)
|
||||
}
|
||||
}
|
||||
|
||||
$zip.Dispose()
|
||||
Write-Host "`r [$("#" * $barW)] 100% $total files extracted! " -ForegroundColor Green
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
Print-Header
|
||||
|
||||
$steps = 4
|
||||
|
||||
# ── Step 1: Portable Node.js ──────────────────────────────────────────────────
|
||||
Print-Step 1 $steps "Setting up portable Node.js (app/tools/node/)"
|
||||
|
||||
if ((Test-Path $nodeExe) -and (Test-Path $npmCmd)) {
|
||||
$v = & $nodeExe --version
|
||||
Print-OK "Portable Node.js already ready: $v"
|
||||
} else {
|
||||
$nodeZip = Join-Path $toolsDir "node.zip"
|
||||
New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null
|
||||
|
||||
$ok = Invoke-RichDownload `
|
||||
-Url "https://nodejs.org/dist/v22.12.0/node-v22.12.0-win-x64.zip" `
|
||||
-Dest $nodeZip `
|
||||
-Label "Node.js v22.12.0 LTS (Portable ZIP)"
|
||||
|
||||
if (-not $ok) { Print-Fail "Cannot download Node.js."; Read-Host; exit 1 }
|
||||
|
||||
Expand-WithProgress -ZipPath $nodeZip -Destination $toolsDir -Label "Node.js"
|
||||
Remove-Item $nodeZip -Force
|
||||
|
||||
$extracted = Get-ChildItem $toolsDir -Directory | Where-Object { $_.Name -like "node-v*" } | Select-Object -First 1
|
||||
if ($extracted) {
|
||||
if (Test-Path $nodeDir) { Remove-Item $nodeDir -Recurse -Force }
|
||||
Rename-Item $extracted.FullName "node"
|
||||
}
|
||||
|
||||
if (-not ((Test-Path $nodeExe) -and (Test-Path $npmCmd))) {
|
||||
Print-Fail "Portable Node.js install is incomplete. Close any running Local AI Image Generator windows, delete app/tools/node, then run setup again."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
|
||||
$v = & $nodeExe --version
|
||||
Print-OK "Portable Node.js ready: $v"
|
||||
}
|
||||
|
||||
# ── Step 2: stable-diffusion.cpp GPU Backend (Dynamic Detection) ──────────────
|
||||
$hasNvidia = $false
|
||||
try {
|
||||
$gpus = Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue
|
||||
foreach ($gpu in $gpus) {
|
||||
if ($gpu.Name -like "*NVIDIA*") {
|
||||
$hasNvidia = $true
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
if (-not $hasNvidia) {
|
||||
try {
|
||||
& nvidia-smi *> $null
|
||||
if ($LASTEXITCODE -eq 0) { $hasNvidia = $true }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if ($hasNvidia) {
|
||||
Print-Step 2 $steps "Setting up stable-diffusion.cpp CUDA GPU backend (app/backend/win/cuda/)"
|
||||
$backendDest = Join-Path $appDir "backend\win\cuda"
|
||||
$backendExe = Join-Path $backendDest "sd-cuda.exe"
|
||||
$backendDll = Join-Path $backendDest "stable-diffusion.dll"
|
||||
|
||||
if ((Test-Path $backendExe) -and (Test-Path $backendDll)) {
|
||||
Print-OK "CUDA GPU backend binaries already ready."
|
||||
} else {
|
||||
$backendZip = Join-Path $toolsDir "sd-cuda.zip"
|
||||
New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $backendDest | Out-Null
|
||||
|
||||
$ok = Invoke-RichDownload `
|
||||
-Url "https://github.com/leejet/stable-diffusion.cpp/releases/download/master-669-2d40a8b/sd-master-2d40a8b-bin-win-cuda12-x64.zip" `
|
||||
-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!"
|
||||
} else {
|
||||
Print-Fail "Failed to copy backend binaries to app/backend/win/cuda/."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Print-Step 2 $steps "Setting up stable-diffusion.cpp Vulkan GPU backend for comparison (app/backend/win/vulkan/)"
|
||||
$backendDest = Join-Path $appDir "backend\win\vulkan"
|
||||
$backendExe = Join-Path $backendDest "sd-vulkan.exe"
|
||||
$backendDll = Join-Path $backendDest "stable-diffusion.dll"
|
||||
|
||||
if ((Test-Path $backendExe) -and (Test-Path $backendDll)) {
|
||||
Print-OK "Vulkan GPU backend binaries already ready."
|
||||
} else {
|
||||
$backendZip = Join-Path $toolsDir "sd-vulkan.zip"
|
||||
New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $backendDest | Out-Null
|
||||
|
||||
$ok = Invoke-RichDownload `
|
||||
-Url "https://github.com/leejet/stable-diffusion.cpp/releases/download/master-669-2d40a8b/sd-master-2d40a8b-bin-win-vulkan-x64.zip" `
|
||||
-Dest $backendZip `
|
||||
-Label "stable-diffusion.cpp Vulkan Backend (Windows x64)"
|
||||
|
||||
if (-not $ok) { Print-Fail "Cannot download Vulkan backend binaries."; Read-Host; exit 1 }
|
||||
|
||||
$tempExt = Join-Path $toolsDir "sd-vulkan-temp"
|
||||
Expand-WithProgress -ZipPath $backendZip -Destination $tempExt -Label "Vulkan Backend"
|
||||
Remove-Item $backendZip -Force
|
||||
|
||||
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 }
|
||||
|
||||
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 "Vulkan GPU backend binaries installed successfully!"
|
||||
} else {
|
||||
Print-Fail "Failed to copy backend binaries to app/backend/win/vulkan/."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Print-Step 2 $steps "Setting up stable-diffusion.cpp Vulkan GPU backend (app/backend/win/vulkan/)"
|
||||
$backendDest = Join-Path $appDir "backend\win\vulkan"
|
||||
$backendExe = Join-Path $backendDest "sd-vulkan.exe"
|
||||
$backendDll = Join-Path $backendDest "stable-diffusion.dll"
|
||||
|
||||
if ((Test-Path $backendExe) -and (Test-Path $backendDll)) {
|
||||
Print-OK "Vulkan GPU backend binaries already ready."
|
||||
} else {
|
||||
$backendZip = Join-Path $toolsDir "sd-vulkan.zip"
|
||||
New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $backendDest | Out-Null
|
||||
|
||||
$ok = Invoke-RichDownload `
|
||||
-Url "https://github.com/leejet/stable-diffusion.cpp/releases/download/master-669-2d40a8b/sd-master-2d40a8b-bin-win-vulkan-x64.zip" `
|
||||
-Dest $backendZip `
|
||||
-Label "stable-diffusion.cpp Vulkan Backend (Windows x64)"
|
||||
|
||||
if (-not $ok) { Print-Fail "Cannot download Vulkan backend binaries."; Read-Host; exit 1 }
|
||||
|
||||
$tempExt = Join-Path $toolsDir "sd-vulkan-temp"
|
||||
Expand-WithProgress -ZipPath $backendZip -Destination $tempExt -Label "Vulkan Backend"
|
||||
Remove-Item $backendZip -Force
|
||||
|
||||
# Move files and rename sd.exe/sd-server.exe to sd-vulkan.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 "Vulkan GPU backend binaries installed successfully!"
|
||||
} else {
|
||||
Print-Fail "Failed to copy backend binaries to app/backend/win/vulkan/."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── Step 3: npm install ───────────────────────────────────────────────────────
|
||||
Print-Step 3 $steps "Installing frontend dependencies (app/frontend/)"
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $npmCmd)) {
|
||||
Print-Fail "npm.cmd was not found at $npmCmd"
|
||||
Print-Fail "Close any running Local AI Image Generator windows, delete app/tools/node, then run setup again."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
|
||||
Push-Location $frontendDir
|
||||
try {
|
||||
& $npmCmd install --prefer-offline 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Print-Fail "npm install failed."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
Print-OK "Dependencies installed!"
|
||||
|
||||
# ── Step 4: Build frontend ────────────────────────────────────────────────
|
||||
Print-Step 4 $steps "Building frontend -> app/dist/"
|
||||
Write-Host ""
|
||||
|
||||
& $npmCmd run build 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Print-Fail "Frontend build failed."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
Print-OK "Frontend built!"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host " Setup complete! Just double-click start.bat to launch." -ForegroundColor Green
|
||||
Write-Host " ============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Read-Host " Press Enter to close..."
|
||||
@@ -0,0 +1,106 @@
|
||||
@echo off
|
||||
title Local AI Image Generator
|
||||
cd /d "%~dp0"
|
||||
|
||||
set APP=%~dp0app
|
||||
set NODE=%APP%\tools\node\node.exe
|
||||
set NPM=%APP%\tools\node\npm.cmd
|
||||
set DIST=%APP%\dist\index.html
|
||||
set SETUP=%~dp0scripts\setup.ps1
|
||||
set CUDA_BACKEND=%APP%\backend\win\cuda\sd-cuda.exe
|
||||
set VULKAN_BACKEND=%APP%\backend\win\vulkan\sd-vulkan.exe
|
||||
set SERVE=%~dp0scripts\serve.cjs
|
||||
set SETUP_REASON=
|
||||
set SETUP_MODE=Repair
|
||||
|
||||
:: ── First-time setup check ────────────────────────────────────────────────────
|
||||
if not exist "%APP%\tools\node" set SETUP_MODE=First-Time Setup
|
||||
if not exist "%NODE%" (
|
||||
set SETUP_REASON=Portable Node.js is missing.
|
||||
goto :run_setup
|
||||
)
|
||||
if not exist "%NPM%" (
|
||||
set SETUP_REASON=Portable npm is missing.
|
||||
goto :run_setup
|
||||
)
|
||||
if not exist "%DIST%" (
|
||||
set SETUP_REASON=Frontend build 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
|
||||
|
||||
:run_setup
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo LOCAL AI IMAGE GENERATOR ^| %SETUP_MODE%
|
||||
echo ============================================================
|
||||
echo.
|
||||
if "%SETUP_MODE%"=="First-Time Setup" (
|
||||
echo This looks like your first run. Setting up automatically...
|
||||
) else (
|
||||
echo Local AI Image Generator 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.
|
||||
echo Press any key to continue, or Ctrl+C to cancel.
|
||||
pause >nul
|
||||
|
||||
:: Clear old local server processes before setup so app/tools/node can be replaced
|
||||
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 ":1420 "') do taskkill /f /pid %%a >nul 2>nul
|
||||
|
||||
powershell -ExecutionPolicy Bypass -File "%SETUP%"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [ERROR] Setup failed. Please check the output above.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: After setup, continue to launch
|
||||
goto :launch
|
||||
|
||||
:: ── Launch ────────────────────────────────────────────────────────────────────
|
||||
:launch
|
||||
cls
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo LOCAL AI IMAGE GENERATOR ^| Launching...
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
:: Kill anything on our ports
|
||||
echo Clearing ports 8080 and 1420...
|
||||
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 ":1420 "') do taskkill /f /pid %%a >nul 2>nul
|
||||
|
||||
:: Start frontend server + backend manager (serve.cjs manages sd-vulkan.exe)
|
||||
echo Starting Local AI Image Generator...
|
||||
start "SD-Server" /min "%NODE%" "%SERVE%"
|
||||
|
||||
:: Wait for server to bind
|
||||
timeout /t 2 >nul
|
||||
|
||||
:: Open browser
|
||||
echo Opening browser at http://localhost:1420
|
||||
start http://localhost:1420
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Running!
|
||||
echo Web UI: http://localhost:1420
|
||||
echo GPU API: http://127.0.0.1:8080
|
||||
echo.
|
||||
echo Close this window to stop all services.
|
||||
echo ============================================================
|
||||
echo.
|
||||
pause >nul
|
||||
|
||||
:: Cleanup on exit
|
||||
echo Shutting down...
|
||||
taskkill /fi "WINDOWTITLE eq SD-Server*" /f >nul 2>nul
|
||||
echo Done. Goodbye!
|
||||
Reference in new issue
Block a user