From f3e06d2bd3e2a6fc3bea5768e5fa17cee354dbbf Mon Sep 17 00:00:00 2001 From: itsamejms Date: Mon, 24 Aug 2026 11:23:10 +0100 Subject: [PATCH] updating to use stable-diffusion.cpp for the image generation as ollama has since removed it --- README.md | 5 +- src-tauri/src/commands/image_commands.rs | 342 +++++++++++------------ src-tauri/src/lib.rs | 1 + src-tauri/src/llm/mod.rs | 7 +- src/components/FirstRunWizard.tsx | 6 +- src/components/GeneratedImage.tsx | 35 +-- src/components/ImageGenerator.tsx | 48 +--- src/components/ImageSetupGuide.tsx | 185 ++++++++++++ src/components/SettingsPanel.tsx | 57 +++- 9 files changed, 430 insertions(+), 256 deletions(-) create mode 100644 src/components/ImageSetupGuide.tsx diff --git a/README.md b/README.md index e67f982..d85e7b2 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,11 @@ ollama pull llama3.2 ollama pull nomic-embed-text ``` -Optionally, for image generation (macOS / Apple Silicon only): +For image generation, DM-Pal talks to a [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp) `sd-server` (cross-platform) via its AUTOMATIC1111-compatible API. Download a model (e.g. Z-Image-Turbo or FLUX.2-klein weights), start the server, then point DM-Pal at it under **Settings → Image Server** (default `http://localhost:1234`): ```bash -ollama pull x/flux2-klein:4b +# build sd-server, then run with your chosen model +sd-server -m path/to/model.safetensors --vae path/to/vae.safetensors ``` ## Where your data lives diff --git a/src-tauri/src/commands/image_commands.rs b/src-tauri/src/commands/image_commands.rs index bad9324..c648d3c 100644 --- a/src-tauri/src/commands/image_commands.rs +++ b/src-tauri/src/commands/image_commands.rs @@ -1,66 +1,32 @@ use crate::commands::emit_busy; use crate::llm::AppState; -use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; use tauri::ipc::Channel; use tauri::AppHandle; +// ponytail: we talk to a stable-diffusion.cpp `sd-server` over HTTP using its +// AUTOMATIC1111-compatible API (/sdapi/v1/txt2img + /sdapi/v1/progress). This +// reuses the existing HTTP+channel plumbing and drops the old Ollama/macOS-only +// limitation — sd-server runs on macOS, Linux, and Windows. The model is chosen +// at server startup, so there's no per-request model field anymore. + // ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key. #[derive(Debug, Deserialize)] pub struct ImageRequest { pub prompt: String, - /// Override the configured image model for this call. - pub model: Option, -} - -/// One line of Ollama's NDJSON image-generation response. -/// `step`/`total` carry progress; the final `done: true` line carries the -/// singular `image` base64 field. All fields optional per-line. -#[derive(Debug, Default, Deserialize)] -struct OllamaImageLine { - #[serde(default)] - done: bool, - #[serde(default)] - step: Option, - #[serde(default)] - total: Option, - #[serde(default)] - image: Option, -} - -/// Parsed view of one NDJSON line used by both the streaming and the -/// buffered paths. Extracted so the parsing logic is unit-testable without -/// touching the network. -#[derive(Debug, PartialEq)] -struct ImageProgress { - done: bool, - step: Option, - total: Option, - image: Option, -} - -fn parse_image_line(line: &str) -> Option { - let line = line.trim(); - if line.is_empty() { - return None; - } - let parsed: OllamaImageLine = serde_json::from_str(line).ok()?; - Some(ImageProgress { - done: parsed.done, - step: parsed.step, - total: parsed.total, - image: parsed.image, - }) } /// Channel events for streaming image generation. #[derive(Clone, Serialize)] #[serde(tag = "type", content = "data", rename_all = "camelCase")] pub enum ImageEvent { - /// Progress update: (step, total). Either may be None if Ollama omits it. + /// Progress update: (step, total). Either may be None if the server omits it. Progress { step: Option, total: Option }, /// Final result: a `data:image/png;base64,...` URL ready for ``. Done(String), @@ -68,11 +34,23 @@ pub enum ImageEvent { Error(String), } -/// Generate (or fetch from disk cache) an image for `prompt` via the configured -/// Ollama image model. Returns a `data:image/png;base64,...` URL ready for ``. -/// -/// Ollama image models are macOS-only today; on other platforms we return an error -/// so the front-end can fall back to a placeholder instead of a confusing timeout. +/// A1111 `/sdapi/v1/txt2img` response. Only the base64 PNG list matters to us. +#[derive(Debug, Deserialize)] +struct Txt2ImgResponse { + #[serde(default)] + images: Vec, +} + +/// A1111 `/sdapi/v1/progress` response. `progress` is 0.0–1.0 of the current job. +#[derive(Debug, Deserialize)] +struct ProgressResponse { + #[serde(default)] + progress: f32, +} + +/// Generate (or fetch from disk cache) an image for `prompt` via a local +/// stable-diffusion.cpp `sd-server`. Returns a `data:image/png;base64,...` URL +/// ready for ``. #[tauri::command] pub async fn generate_image( state: tauri::State<'_, AppState>, @@ -88,27 +66,23 @@ pub async fn generate_image( } async fn generate_image_inner(state: tauri::State<'_, AppState>, req: ImageRequest) -> Result { - if cfg!(not(target_os = "macos")) { - return Err("image generation is macOS-only via Ollama (for now)".into()); - } - let config = state.config.lock().map_err(|e| e.to_string())?.clone(); - let model = req.model.unwrap_or(config.image_model.clone()); + let api_url = config.image_api_url.clone(); - let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?; + let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &api_url, &req.prompt)?; if cache_hit { let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?; return Ok(data_url(&bytes)); } - let png_bytes = request_image_bytes(&config, &model, &req.prompt, None).await?; + let png_bytes = txt2img(&api_url, &req.prompt).await?; std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?; Ok(data_url(&png_bytes)) } -/// Streaming variant: emits `ImageEvent::Progress` as Ollama reports `step`/`total`, -/// then `ImageEvent::Done` with the data URL (or `Error`). Reuses the same disk cache -/// as `generate_image`. The DM gets a real progress bar for the multi-second wait. +/// Streaming variant: emits `ImageEvent::Progress` (from `/sdapi/v1/progress`) +/// while the txt2img POST is in flight, then `ImageEvent::Done` with the data +/// URL (or `Error`). Reuses the same disk cache as `generate_image`. #[tauri::command] pub async fn generate_image_stream( state: tauri::State<'_, AppState>, @@ -116,17 +90,10 @@ pub async fn generate_image_stream( req: ImageRequest, channel: Channel, ) -> Result<(), String> { - if cfg!(not(target_os = "macos")) { - let _ = channel.send(ImageEvent::Error( - "image generation is macOS-only via Ollama (for now)".into(), - )); - return Ok(()); - } - let config = state.config.lock().map_err(|e| e.to_string())?.clone(); - let model = req.model.unwrap_or(config.image_model.clone()); + let api_url = config.image_api_url.clone(); - let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?; + let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &api_url, &req.prompt)?; if cache_hit { let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?; let _ = channel.send(ImageEvent::Done(data_url(&bytes))); @@ -135,19 +102,41 @@ pub async fn generate_image_stream( // Drive the request on a background task so the command returns immediately // and progress flows through the channel. Errors become ImageEvent::Error. - let channel = std::sync::Arc::new(channel); + let channel = Arc::new(channel); let ch = channel.clone(); // ponytail: only emit busy around the actual generation (not cache hits), // and always balance it in the spawn — even on error. emit_busy(&app, true); tauri::async_runtime::spawn(async move { - match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await { + // Poll /sdapi/v1/progress while the txt2img POST runs so the DM sees a + // real progress bar. Best-effort: poll errors are silent (bar falls + // back to indeterminate). 200ms is smooth without spamming the server. + let poll_client = reqwest::Client::new(); + let done = Arc::new(AtomicBool::new(false)); + let done_p = done.clone(); + let api_p = api_url.clone(); + let ch_p = ch.clone(); + let poller = tauri::async_runtime::spawn(async move { + while !done_p.load(Ordering::SeqCst) { + if let Ok(p) = poll_progress(&poll_client, &api_p).await { + let step = (p.clamp(0.0, 1.0) * 100.0) as u32; + let _ = ch_p.send(ImageEvent::Progress { step: Some(step), total: Some(100) }); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + }); + + let result = txt2img(&api_url, &req.prompt).await; + done.store(true, Ordering::SeqCst); + let _ = poller.await; // let the poller flush its last iteration + + match result { Ok(png_bytes) => { let _ = std::fs::write(&cache_path, &png_bytes); - let _ = channel.send(ImageEvent::Done(data_url(&png_bytes))); + let _ = ch.send(ImageEvent::Done(data_url(&png_bytes))); } Err(e) => { - let _ = channel.send(ImageEvent::Error(e)); + let _ = ch.send(ImageEvent::Error(e)); } } emit_busy(&app, false); @@ -156,43 +145,61 @@ pub async fn generate_image_stream( Ok(()) } -/// Resolve the on-disk cache path for (model, prompt) and report a cache hit. +/// Resolve the on-disk cache path for (api_url, prompt) and report a cache hit. /// `base` is the configured data dir (AppState.data_dir). fn prepare_cache( base: &std::path::Path, - model: &str, + api_url: &str, prompt: &str, ) -> Result<(std::path::PathBuf, bool), String> { let cache_dir = base.join("images"); std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; let mut hasher = DefaultHasher::new(); - model.hash(&mut hasher); + api_url.hash(&mut hasher); prompt.hash(&mut hasher); let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish())); let hit = cache_path.exists(); Ok((cache_path, hit)) } -/// POST the image request to Ollama and collect the PNG bytes. When a channel -/// is given, parse the NDJSON body incrementally and emit progress; otherwise -/// read the whole body at once (legacy buffered path). -async fn request_image_bytes( - config: &crate::llm::LlmConfig, - model: &str, - prompt: &str, - channel: Option>>, -) -> Result, String> { - let client = reqwest::Client::new(); - let url = format!("{}/api/generate", config.api_url.trim_end_matches('/')); - let body = serde_json::json!({ "model": model, "prompt": prompt, "stream": false }); +/// POST `/sdapi/v1/txt2img` to the sd-server and return the raw PNG bytes of +/// the first generated image. A1111 returns base64 PNGs *without* a `data:` +/// prefix, so we decode to bytes for the disk cache. +async fn txt2img(api_url: &str, prompt: &str) -> Result, String> { + // ponytail: width/height/steps/cfg are fixed — diffusion can take tens of + // seconds; add config knobs only if the DM wants to tune them. + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .map_err(|e| e.to_string())?; + let url = format!("{}/sdapi/v1/txt2img", api_url.trim_end_matches('/')); + let body = serde_json::json!({ + "prompt": prompt, + "width": 1024, + "height": 1024, + "steps": 20, + "cfg_scale": 7.0, + "seed": -1, + "batch_size": 1, + }); let res = client .post(&url) .json(&body) .send() .await - .map_err(|e| format!("image request failed: {e}"))?; + .map_err(|e| { + // ponytail: non-technical DMs see a connect error as a mystery — + // name the fix (start sd-server) and point at the in-app guide. + if e.is_connect() || e.is_timeout() { + format!( + "Can't reach the image server at {api_url}. Is sd-server running? Open the Image tab → 'First time setup' for step-by-step instructions. ({e})" + ) + } else { + format!("image request failed: {e}") + } + })?; if !res.status().is_success() { let status = res.status(); @@ -200,56 +207,71 @@ async fn request_image_bytes( return Err(format!("image error {status}: {text}")); } - // ponytail: Ollama image models emit NDJSON even with stream:false — - // progress lines (step/total) then a final done:true carrying the image. - // The buffered path reads it all at once; the streaming path splits lines - // as they arrive so the bar animates. - let mut png_b64: Option = None; - let mut buffer = String::new(); + let parsed: Txt2ImgResponse = res + .json() + .await + .map_err(|e| format!("image parse error: {e}"))?; + let b64 = parsed + .images + .into_iter() + .next() + .ok_or_else(|| "no image in sd-server response".to_string())?; + base64_decode(&b64) +} - let apply_line = |line: &str, png: &mut Option, ch: Option<&Channel>| { - if let Some(p) = parse_image_line(line) { - if let Some(c) = ch { - let _ = c.send(ImageEvent::Progress { step: p.step, total: p.total }); - } - if let Some(b64) = p.image { - *png = Some(b64); - } - } - }; - - if let Some(ch) = channel { - let mut stream = res.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| format!("image read error: {e}"))?; - buffer.push_str(&String::from_utf8_lossy(&chunk)); - // Process complete lines; keep the trailing partial line in buffer. - while let Some(idx) = buffer.find('\n') { - let line = buffer.split_off(idx + 1); - let complete = std::mem::replace(&mut buffer, line); - apply_line(&complete, &mut png_b64, Some(&ch)); - } - } - if !buffer.trim().is_empty() { - apply_line(&buffer, &mut png_b64, Some(&ch)); - } - } else { - let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?; - for line in body_text.lines() { - apply_line(line, &mut png_b64, None); - // ponytail: stop after the done line in the buffered path — the - // final image is the one we want. - if let Some(p) = parse_image_line(line) { - if p.done && p.image.is_some() { - break; - } - } - } +/// GET `/sdapi/v1/progress` → fraction (0.0–1.0) of the current job. Best-effort. +async fn poll_progress(client: &reqwest::Client, api_url: &str) -> Result { + let url = format!("{}/sdapi/v1/progress", api_url.trim_end_matches('/')); + let res = client + .get(&url) + .send() + .await + .map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("progress HTTP {}", res.status())); } + let p: ProgressResponse = res.json().await.map_err(|e| e.to_string())?; + Ok(p.progress) +} - let b64 = png_b64.ok_or_else(|| "no image data in Ollama response".to_string())?; - let png_bytes = base64_decode(&b64)?; - Ok(png_bytes) +/// GET `/sdapi/v1/progress` as a liveness probe for the configured sd-server. +/// Returns ok=true when the server responds (even idle — progress is 0.0). +/// Mirrors the LLM `test_connection` so Settings can show a green check. +#[derive(serde::Serialize)] +pub struct ImageConnectionTest { + pub ok: bool, + pub error: String, +} + +#[tauri::command] +pub async fn test_image_connection( + state: tauri::State<'_, AppState>, +) -> Result { + let api_url = state + .config + .lock() + .map_err(|e| e.to_string())? + .image_api_url + .clone(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|e| e.to_string())?; + let url = format!("{}/sdapi/v1/progress", api_url.trim_end_matches('/')); + match client.get(&url).send().await { + Ok(res) if res.status().is_success() => Ok(ImageConnectionTest { + ok: true, + error: String::new(), + }), + Ok(res) => Ok(ImageConnectionTest { + ok: false, + error: format!("HTTP {} — is this an sd-server?", res.status()), + }), + Err(e) => Ok(ImageConnectionTest { + ok: false, + error: format!("Can't reach {api_url} — is sd-server running? ({e})"), + }), + } } fn data_url(png: &[u8]) -> String { @@ -336,55 +358,19 @@ mod tests { #[test] fn cache_key_is_stable() { - // sanity: same inputs → same filename shape (16 hex digits) + // sanity: same inputs → same 16-hex-digit filename shape let mut h = DefaultHasher::new(); - "x/flux2-klein:4b".hash(&mut h); + "http://localhost:1234".hash(&mut h); "prompt".hash(&mut h); let s = format!("{:016x}", h.finish()); assert_eq!(s.len(), 16); } #[test] - fn parse_progress_and_image_lines() { - // intermediate progress line, no image - let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":false,"total":4,"step":1}"#).unwrap(); - assert_eq!(p, ImageProgress { done: false, step: Some(1), total: Some(4), image: None }); - - // final done line carries the image - let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":true,"image":"iVBORw0KGgoAAAANSUhEUgAA"}"#).unwrap(); - assert!(p.done); - assert_eq!(p.image.as_deref(), Some("iVBORw0KGgoAAAANSUhEUgAA")); - assert!(p.total.is_none() && p.step.is_none()); - - // blank/garbage lines are ignored, not errors - assert!(parse_image_line("").is_none()); - assert!(parse_image_line("not json").is_none()); - } - - #[test] - fn split_ndjson_buffer_keeps_trailing_partial() { - // Simulate two chunks arriving separately where the split falls mid-line. - let chunk1 = "{\"done\":false,\"step\":1,\"total\":4}\n{\"done\":tru"; - let chunk2 = "e,\"image\":\"abc\"}\n"; - let mut buffer = String::new(); - let mut png: Option = None; - let whole = format!("{chunk1}{chunk2}"); - // emulate the streaming loop over the concatenated body - buffer.push_str(&whole); - let mut lines = Vec::new(); - while let Some(idx) = buffer.find('\n') { - let line = buffer.split_off(idx + 1); - let complete = std::mem::replace(&mut buffer, line); - lines.push(complete); - } - if !buffer.trim().is_empty() { - lines.push(std::mem::take(&mut buffer)); - } - for line in &lines { - if let Some(p) = parse_image_line(line) { - if let Some(b) = p.image { png = Some(b); } - } - } - assert_eq!(png.as_deref(), Some("abc")); + fn progress_fraction_maps_to_step() { + // The streaming poller emits step = clamp(progress,0,1)*100. + assert_eq!((0.0f32 * 100.0) as u32, 0); + assert_eq!((0.5f32 * 100.0) as u32, 50); + assert_eq!((1.5f32.clamp(0.0, 1.0) * 100.0) as u32, 100); } } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca0a181..01fe0f1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,6 +63,7 @@ pub fn run() { commands::llm_commands::test_connection, commands::image_commands::generate_image, commands::image_commands::generate_image_stream, + commands::image_commands::test_image_connection, commands::rag_commands::rag_add, commands::rag_commands::rag_search, commands::rag_commands::rag_list, diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index 8cc099f..8fffdef 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -31,8 +31,9 @@ pub struct LlmConfig { pub temperature: f32, pub max_tokens: u32, pub top_p: f32, - /// Ollama image-generation model (e.g. `x/flux2-klein:4b`). macOS-only via Ollama. - pub image_model: String, + /// Base URL of a stable-diffusion.cpp `sd-server` (A1111-compatible API), + /// e.g. `http://localhost:1234`. Cross-platform — no longer Ollama/macOS-only. + pub image_api_url: String, /// Ollama embedding model for lore RAG (e.g. `nomic-embed-text`). pub embed_model: String, } @@ -47,7 +48,7 @@ impl Default for LlmConfig { temperature: 0.7, max_tokens: 512, top_p: 0.9, - image_model: "x/flux2-klein:4b".to_string(), + image_api_url: "http://localhost:1234".to_string(), embed_model: "nomic-embed-text".to_string(), } } diff --git a/src/components/FirstRunWizard.tsx b/src/components/FirstRunWizard.tsx index 3d0ac63..ee195be 100644 --- a/src/components/FirstRunWizard.tsx +++ b/src/components/FirstRunWizard.tsx @@ -26,7 +26,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) { // Test connection to the default Ollama URL const result = await invoke<{ ok: boolean; models: string[]; error: string }>( "test_connection", - { config: { api_url: apiUrl, api_key: apiKey, model: "", temperature: 0.7, max_tokens: 512, top_p: 0.9, image_model: "", embed_model: "" } } + { config: { api_url: apiUrl, api_key: apiKey, model: "", temperature: 0.7, max_tokens: 512, top_p: 0.9, image_api_url: "", embed_model: "" } } ); if (result.ok) { setModels(result.models); @@ -58,7 +58,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) { temperature: 0.7, max_tokens: 512, top_p: 0.9, - image_model: "x/flux2-klein:4b", + image_api_url: "http://localhost:1234", embed_model: "nomic-embed-text", } }); @@ -244,7 +244,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {

DM-Pal is now configured to use {selectedModel} for text generation. - Image generation uses x/flux2-klein:4b (macOS-only via Ollama). + Image generation runs via a stable-diffusion.cpp `sd-server` at http://localhost:1234 (cross-platform). Embedding model for lore search is set to nomic-embed-text.

diff --git a/src/components/GeneratedImage.tsx b/src/components/GeneratedImage.tsx index 6108aee..05213b1 100644 --- a/src/components/GeneratedImage.tsx +++ b/src/components/GeneratedImage.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from "react"; import { invoke, Channel } from "@tauri-apps/api/core"; -import { ImageOff } from "lucide-react"; import { useToast } from "./Toast"; interface Props { @@ -20,22 +19,20 @@ type ImageEvent = | { type: "error"; data: string }; /** - * Generates and caches an image for `prompt` via the Ollama image model. - * Results are cached on disk by the backend, so re-renders are instant. - * macOS-only: on other OSes the backend returns an error and we show a - * placeholder instead of a confusing timeout. + * Generates and caches an image for `prompt` via a stable-diffusion.cpp + * `sd-server` (A1111-compatible API). Results are cached on disk by the + * backend, so re-renders are instant. Cross-platform. * * Uses the streaming command so the DM sees a real progress bar (driven from - * Ollama's NDJSON step/total) during the multi-second wait. The image still - * loads even if no progress events arrive — Done carries the data URL. + * `/sdapi/v1/progress`) during the multi-second wait. The image still loads + * even if no progress events arrive — Done carries the data URL. */ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "aspect-square" }: Props) { const [dataUrl, setDataUrl] = useState(null); const [loading, setLoading] = useState(false); - const [unsupported, setUnsupported] = useState(false); const [expanded, setExpanded] = useState(false); // ponytail: progress for the in-flight generation. step/total may be null - // if Ollama omits them; we show an indeterminate shimmer then. + // if the server omits them; we show an indeterminate shimmer then. const [progress, setProgress] = useState<{ step: number | null; total: number | null } | null>(null); const { addToast } = useToast(); @@ -43,7 +40,6 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as if (!prompt) return; let cancelled = false; setLoading(true); - setUnsupported(false); setProgress(null); const channel = new Channel(); @@ -56,9 +52,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as setLoading(false); setProgress(null); } else if (event.type === "error") { - const msg = String(event.data); - if (msg.includes("macOS-only")) setUnsupported(true); - else addToast(`Image failed: ${msg}`, "error"); + addToast(`Image failed: ${event.data}`, "error"); setLoading(false); setProgress(null); } @@ -66,9 +60,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as invoke("generate_image_stream", { req: { prompt }, channel }).catch((e) => { if (!cancelled) { - const msg = String(e); - if (msg.includes("macOS-only")) setUnsupported(true); - else addToast(`Image failed: ${msg}`, "error"); + addToast(`Image failed: ${e}`, "error"); setLoading(false); setProgress(null); } @@ -83,17 +75,6 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as const box = `relative ${aspect} w-full rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center ${className}`; - if (unsupported) { - return ( -
-
- - Image gen is macOS-only via Ollama -
-
- ); - } - if (loading) { // ponytail: determinate bar when step/total are known, indeterminate shimmer otherwise. const pct = progress && progress.step != null && progress.total ? Math.round((progress.step / progress.total) * 100) : null; diff --git a/src/components/ImageGenerator.tsx b/src/components/ImageGenerator.tsx index 9e7b63c..1ba36aa 100644 --- a/src/components/ImageGenerator.tsx +++ b/src/components/ImageGenerator.tsx @@ -4,8 +4,7 @@ import { ImagePlus, Sparkles } from "lucide-react"; import { useToast } from "./Toast"; import { addGeneration, type Generation } from "../lib/generations"; import { usePrefillEffect } from "../lib/usePrefill"; - -const DEFAULT_MODEL = "x/flux2-klein:4b"; +import { ImageSetupGuide } from "./ImageSetupGuide"; interface Props { prefill?: Generation | null; @@ -14,12 +13,11 @@ interface Props { export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { const [prompt, setPrompt] = useState(""); - const [model, setModel] = useState(""); const [dataUrl, setDataUrl] = useState(null); const [variants, setVariants] = useState([]); const [loading, setLoading] = useState(false); const [variant, setVariant] = useState(0); - const [unsupported, setUnsupported] = useState(false); + const [guideOpen, setGuideOpen] = useState(false); const { addToast } = useToast(); usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => { @@ -38,23 +36,21 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { // so "regenerate" actually produces a new image instead of the cached one. const v = variant > 0 ? `\n\n(variation ${variant})` : ""; const url = await invoke("generate_image", { - req: { prompt: `${prompt}${v}`, model: model.trim() || null }, + req: { prompt: `${prompt}${v}` }, }); setDataUrl(url); - setUnsupported(false); addToast("Image generated", "success"); // ponytail: persist the PNG data URL so the DM can re-open past renders. - void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL }); + void addGeneration({ kind: "image", title: prompt, data: url, source: "sd-server" }); } catch (e) { - const msg = String(e); - if (msg.toLowerCase().includes("macos-only")) setUnsupported(true); - addToast(`Image generation failed: ${msg}`, "error"); + addToast(`Image generation failed: ${String(e)}`, "error"); + setGuideOpen(true); // ponytail: a failed generate usually means sd-server isn't running — pop the guide. } setLoading(false); } // ponytail: batch = 4 concurrent generates with distinct variation tags so - // the backend cache yields 4 different images. Ollama serializes them + // the backend cache yields 4 different images. sd-server processes them // server-side anyway, so concurrency is just cleaner code than a loop with // awaits. Each variant is persisted so the gallery gets all four. async function generateBatch() { @@ -62,12 +58,11 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { setLoading(true); setVariants([]); setDataUrl(null); - setUnsupported(false); const tags = [1, 2, 3, 4]; const results = await Promise.allSettled( tags.map((t) => invoke("generate_image", { - req: { prompt: `${prompt}\n\n(variation ${t})`, model: model.trim() || null }, + req: { prompt: `${prompt}\n\n(variation ${t})` }, }), ), ); @@ -75,14 +70,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { for (const r of results) { if (r.status === "fulfilled" && r.value) { ok.push(r.value); - void addGeneration({ kind: "image", title: prompt, data: r.value, source: model.trim() || DEFAULT_MODEL }); + void addGeneration({ kind: "image", title: prompt, data: r.value, source: "sd-server" }); } } if (ok.length === 0) { const firstErr = results.find((r) => r.status === "rejected"); const msg = firstErr ? String((firstErr as PromiseRejectedResult).reason) : ""; - if (msg.toLowerCase().includes("macos-only")) setUnsupported(true); addToast(`Batch failed: ${msg || "no images returned"}`, "error"); + setGuideOpen(true); // ponytail: likely sd-server not running — show the guide. } else { setVariants(ok); setDataUrl(ok[0]); @@ -99,6 +94,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { return (
+