Initial commit

This commit is contained in:
techjarves committed 2026-06-04 09:15:28 +05:30
commit a9460556e4
24 files changed
+9226

No files matched your search

+27
View File
@@ -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
+137
View File
@@ -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.
+1
View File
@@ -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};
+1
View File
@@ -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
+120
View File
@@ -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
+18
View File
@@ -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>
+24
View File
@@ -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?
+17
View File
@@ -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>
+2028
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -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
+516
View File
@@ -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;
+878
View File
@@ -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);
+65
View File
@@ -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);
+9
View File
@@ -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>,
);
+810
View File
@@ -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 };
}
}
+38
View File
@@ -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 },
}
},
}));
+60
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+401
View File
@@ -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..."
+106
View File
@@ -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!