updating to use stable-diffusion.cpp for the image generation as ollama has since removed it
This commit is contained in:
@@ -52,10 +52,11 @@ ollama pull llama3.2
|
|||||||
ollama pull nomic-embed-text
|
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
|
```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
|
## Where your data lives
|
||||||
|
|||||||
@@ -1,66 +1,32 @@
|
|||||||
use crate::commands::emit_busy;
|
use crate::commands::emit_busy;
|
||||||
use crate::llm::AppState;
|
use crate::llm::AppState;
|
||||||
use futures_util::StreamExt;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
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::ipc::Channel;
|
||||||
use tauri::AppHandle;
|
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.
|
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ImageRequest {
|
pub struct ImageRequest {
|
||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
/// Override the configured image model for this call.
|
|
||||||
pub model: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
total: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
image: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<u32>,
|
|
||||||
total: Option<u32>,
|
|
||||||
image: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_image_line(line: &str) -> Option<ImageProgress> {
|
|
||||||
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.
|
/// Channel events for streaming image generation.
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
|
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
|
||||||
pub enum ImageEvent {
|
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<u32>, total: Option<u32> },
|
Progress { step: Option<u32>, total: Option<u32> },
|
||||||
/// Final result: a `data:image/png;base64,...` URL ready for `<img src>`.
|
/// Final result: a `data:image/png;base64,...` URL ready for `<img src>`.
|
||||||
Done(String),
|
Done(String),
|
||||||
@@ -68,11 +34,23 @@ pub enum ImageEvent {
|
|||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate (or fetch from disk cache) an image for `prompt` via the configured
|
/// A1111 `/sdapi/v1/txt2img` response. Only the base64 PNG list matters to us.
|
||||||
/// Ollama image model. Returns a `data:image/png;base64,...` URL ready for `<img src>`.
|
#[derive(Debug, Deserialize)]
|
||||||
///
|
struct Txt2ImgResponse {
|
||||||
/// Ollama image models are macOS-only today; on other platforms we return an error
|
#[serde(default)]
|
||||||
/// so the front-end can fall back to a placeholder instead of a confusing timeout.
|
images: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 `<img src>`.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn generate_image(
|
pub async fn generate_image(
|
||||||
state: tauri::State<'_, AppState>,
|
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<String, String> {
|
async fn generate_image_inner(state: tauri::State<'_, AppState>, req: ImageRequest) -> Result<String, String> {
|
||||||
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 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 {
|
if cache_hit {
|
||||||
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
|
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
|
||||||
return Ok(data_url(&bytes));
|
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())?;
|
std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?;
|
||||||
Ok(data_url(&png_bytes))
|
Ok(data_url(&png_bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streaming variant: emits `ImageEvent::Progress` as Ollama reports `step`/`total`,
|
/// Streaming variant: emits `ImageEvent::Progress` (from `/sdapi/v1/progress`)
|
||||||
/// then `ImageEvent::Done` with the data URL (or `Error`). Reuses the same disk cache
|
/// while the txt2img POST is in flight, then `ImageEvent::Done` with the data
|
||||||
/// as `generate_image`. The DM gets a real progress bar for the multi-second wait.
|
/// URL (or `Error`). Reuses the same disk cache as `generate_image`.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn generate_image_stream(
|
pub async fn generate_image_stream(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
@@ -116,17 +90,10 @@ pub async fn generate_image_stream(
|
|||||||
req: ImageRequest,
|
req: ImageRequest,
|
||||||
channel: Channel<ImageEvent>,
|
channel: Channel<ImageEvent>,
|
||||||
) -> Result<(), String> {
|
) -> 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 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 {
|
if cache_hit {
|
||||||
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
|
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
|
||||||
let _ = channel.send(ImageEvent::Done(data_url(&bytes)));
|
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
|
// Drive the request on a background task so the command returns immediately
|
||||||
// and progress flows through the channel. Errors become ImageEvent::Error.
|
// 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();
|
let ch = channel.clone();
|
||||||
// ponytail: only emit busy around the actual generation (not cache hits),
|
// ponytail: only emit busy around the actual generation (not cache hits),
|
||||||
// and always balance it in the spawn — even on error.
|
// and always balance it in the spawn — even on error.
|
||||||
emit_busy(&app, true);
|
emit_busy(&app, true);
|
||||||
tauri::async_runtime::spawn(async move {
|
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) => {
|
Ok(png_bytes) => {
|
||||||
let _ = std::fs::write(&cache_path, &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) => {
|
Err(e) => {
|
||||||
let _ = channel.send(ImageEvent::Error(e));
|
let _ = ch.send(ImageEvent::Error(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
emit_busy(&app, false);
|
emit_busy(&app, false);
|
||||||
@@ -156,43 +145,61 @@ pub async fn generate_image_stream(
|
|||||||
Ok(())
|
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).
|
/// `base` is the configured data dir (AppState.data_dir).
|
||||||
fn prepare_cache(
|
fn prepare_cache(
|
||||||
base: &std::path::Path,
|
base: &std::path::Path,
|
||||||
model: &str,
|
api_url: &str,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
) -> Result<(std::path::PathBuf, bool), String> {
|
) -> Result<(std::path::PathBuf, bool), String> {
|
||||||
let cache_dir = base.join("images");
|
let cache_dir = base.join("images");
|
||||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
model.hash(&mut hasher);
|
api_url.hash(&mut hasher);
|
||||||
prompt.hash(&mut hasher);
|
prompt.hash(&mut hasher);
|
||||||
let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish()));
|
let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish()));
|
||||||
let hit = cache_path.exists();
|
let hit = cache_path.exists();
|
||||||
Ok((cache_path, hit))
|
Ok((cache_path, hit))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST the image request to Ollama and collect the PNG bytes. When a channel
|
/// POST `/sdapi/v1/txt2img` to the sd-server and return the raw PNG bytes of
|
||||||
/// is given, parse the NDJSON body incrementally and emit progress; otherwise
|
/// the first generated image. A1111 returns base64 PNGs *without* a `data:`
|
||||||
/// read the whole body at once (legacy buffered path).
|
/// prefix, so we decode to bytes for the disk cache.
|
||||||
async fn request_image_bytes(
|
async fn txt2img(api_url: &str, prompt: &str) -> Result<Vec<u8>, String> {
|
||||||
config: &crate::llm::LlmConfig,
|
// ponytail: width/height/steps/cfg are fixed — diffusion can take tens of
|
||||||
model: &str,
|
// seconds; add config knobs only if the DM wants to tune them.
|
||||||
prompt: &str,
|
let client = reqwest::Client::builder()
|
||||||
channel: Option<std::sync::Arc<Channel<ImageEvent>>>,
|
.timeout(Duration::from_secs(300))
|
||||||
) -> Result<Vec<u8>, String> {
|
.build()
|
||||||
let client = reqwest::Client::new();
|
.map_err(|e| e.to_string())?;
|
||||||
let url = format!("{}/api/generate", config.api_url.trim_end_matches('/'));
|
let url = format!("{}/sdapi/v1/txt2img", api_url.trim_end_matches('/'));
|
||||||
let body = serde_json::json!({ "model": model, "prompt": prompt, "stream": false });
|
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
|
let res = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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() {
|
if !res.status().is_success() {
|
||||||
let status = res.status();
|
let status = res.status();
|
||||||
@@ -200,56 +207,71 @@ async fn request_image_bytes(
|
|||||||
return Err(format!("image error {status}: {text}"));
|
return Err(format!("image error {status}: {text}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: Ollama image models emit NDJSON even with stream:false —
|
let parsed: Txt2ImgResponse = res
|
||||||
// progress lines (step/total) then a final done:true carrying the image.
|
.json()
|
||||||
// The buffered path reads it all at once; the streaming path splits lines
|
.await
|
||||||
// as they arrive so the bar animates.
|
.map_err(|e| format!("image parse error: {e}"))?;
|
||||||
let mut png_b64: Option<String> = None;
|
let b64 = parsed
|
||||||
let mut buffer = String::new();
|
.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<String>, ch: Option<&Channel<ImageEvent>>| {
|
/// GET `/sdapi/v1/progress` → fraction (0.0–1.0) of the current job. Best-effort.
|
||||||
if let Some(p) = parse_image_line(line) {
|
async fn poll_progress(client: &reqwest::Client, api_url: &str) -> Result<f32, String> {
|
||||||
if let Some(c) = ch {
|
let url = format!("{}/sdapi/v1/progress", api_url.trim_end_matches('/'));
|
||||||
let _ = c.send(ImageEvent::Progress { step: p.step, total: p.total });
|
let res = client
|
||||||
}
|
.get(&url)
|
||||||
if let Some(b64) = p.image {
|
.send()
|
||||||
*png = Some(b64);
|
.await
|
||||||
}
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
if !res.status().is_success() {
|
||||||
};
|
return Err(format!("progress HTTP {}", res.status()));
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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())?;
|
/// GET `/sdapi/v1/progress` as a liveness probe for the configured sd-server.
|
||||||
let png_bytes = base64_decode(&b64)?;
|
/// Returns ok=true when the server responds (even idle — progress is 0.0).
|
||||||
Ok(png_bytes)
|
/// 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<ImageConnectionTest, String> {
|
||||||
|
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 {
|
fn data_url(png: &[u8]) -> String {
|
||||||
@@ -336,55 +358,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cache_key_is_stable() {
|
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();
|
let mut h = DefaultHasher::new();
|
||||||
"x/flux2-klein:4b".hash(&mut h);
|
"http://localhost:1234".hash(&mut h);
|
||||||
"prompt".hash(&mut h);
|
"prompt".hash(&mut h);
|
||||||
let s = format!("{:016x}", h.finish());
|
let s = format!("{:016x}", h.finish());
|
||||||
assert_eq!(s.len(), 16);
|
assert_eq!(s.len(), 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_progress_and_image_lines() {
|
fn progress_fraction_maps_to_step() {
|
||||||
// intermediate progress line, no image
|
// The streaming poller emits step = clamp(progress,0,1)*100.
|
||||||
let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":false,"total":4,"step":1}"#).unwrap();
|
assert_eq!((0.0f32 * 100.0) as u32, 0);
|
||||||
assert_eq!(p, ImageProgress { done: false, step: Some(1), total: Some(4), image: None });
|
assert_eq!((0.5f32 * 100.0) as u32, 50);
|
||||||
|
assert_eq!((1.5f32.clamp(0.0, 1.0) * 100.0) as u32, 100);
|
||||||
// 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<String> = 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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,7 @@ pub fn run() {
|
|||||||
commands::llm_commands::test_connection,
|
commands::llm_commands::test_connection,
|
||||||
commands::image_commands::generate_image,
|
commands::image_commands::generate_image,
|
||||||
commands::image_commands::generate_image_stream,
|
commands::image_commands::generate_image_stream,
|
||||||
|
commands::image_commands::test_image_connection,
|
||||||
commands::rag_commands::rag_add,
|
commands::rag_commands::rag_add,
|
||||||
commands::rag_commands::rag_search,
|
commands::rag_commands::rag_search,
|
||||||
commands::rag_commands::rag_list,
|
commands::rag_commands::rag_list,
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ pub struct LlmConfig {
|
|||||||
pub temperature: f32,
|
pub temperature: f32,
|
||||||
pub max_tokens: u32,
|
pub max_tokens: u32,
|
||||||
pub top_p: f32,
|
pub top_p: f32,
|
||||||
/// Ollama image-generation model (e.g. `x/flux2-klein:4b`). macOS-only via Ollama.
|
/// Base URL of a stable-diffusion.cpp `sd-server` (A1111-compatible API),
|
||||||
pub image_model: String,
|
/// 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`).
|
/// Ollama embedding model for lore RAG (e.g. `nomic-embed-text`).
|
||||||
pub embed_model: String,
|
pub embed_model: String,
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,7 @@ impl Default for LlmConfig {
|
|||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 512,
|
max_tokens: 512,
|
||||||
top_p: 0.9,
|
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(),
|
embed_model: "nomic-embed-text".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
|||||||
// Test connection to the default Ollama URL
|
// Test connection to the default Ollama URL
|
||||||
const result = await invoke<{ ok: boolean; models: string[]; error: string }>(
|
const result = await invoke<{ ok: boolean; models: string[]; error: string }>(
|
||||||
"test_connection",
|
"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) {
|
if (result.ok) {
|
||||||
setModels(result.models);
|
setModels(result.models);
|
||||||
@@ -58,7 +58,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
|||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 512,
|
max_tokens: 512,
|
||||||
top_p: 0.9,
|
top_p: 0.9,
|
||||||
image_model: "x/flux2-klein:4b",
|
image_api_url: "http://localhost:1234",
|
||||||
embed_model: "nomic-embed-text",
|
embed_model: "nomic-embed-text",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -244,7 +244,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||||
DM-Pal is now configured to use {selectedModel} for text generation.
|
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.
|
Embedding model for lore search is set to nomic-embed-text.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||||
import { ImageOff } from "lucide-react";
|
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,22 +19,20 @@ type ImageEvent =
|
|||||||
| { type: "error"; data: string };
|
| { type: "error"; data: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates and caches an image for `prompt` via the Ollama image model.
|
* Generates and caches an image for `prompt` via a stable-diffusion.cpp
|
||||||
* Results are cached on disk by the backend, so re-renders are instant.
|
* `sd-server` (A1111-compatible API). Results are cached on disk by the
|
||||||
* macOS-only: on other OSes the backend returns an error and we show a
|
* backend, so re-renders are instant. Cross-platform.
|
||||||
* placeholder instead of a confusing timeout.
|
|
||||||
*
|
*
|
||||||
* Uses the streaming command so the DM sees a real progress bar (driven from
|
* 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
|
* `/sdapi/v1/progress`) during the multi-second wait. The image still loads
|
||||||
* loads even if no progress events arrive — Done carries the data URL.
|
* even if no progress events arrive — Done carries the data URL.
|
||||||
*/
|
*/
|
||||||
export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "aspect-square" }: Props) {
|
export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "aspect-square" }: Props) {
|
||||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [unsupported, setUnsupported] = useState(false);
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
// ponytail: progress for the in-flight generation. step/total may be null
|
// 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 [progress, setProgress] = useState<{ step: number | null; total: number | null } | null>(null);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
@@ -43,7 +40,6 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
|||||||
if (!prompt) return;
|
if (!prompt) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setUnsupported(false);
|
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
|
|
||||||
const channel = new Channel<ImageEvent>();
|
const channel = new Channel<ImageEvent>();
|
||||||
@@ -56,9 +52,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
} else if (event.type === "error") {
|
} else if (event.type === "error") {
|
||||||
const msg = String(event.data);
|
addToast(`Image failed: ${event.data}`, "error");
|
||||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
|
||||||
else addToast(`Image failed: ${msg}`, "error");
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
}
|
}
|
||||||
@@ -66,9 +60,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
|||||||
|
|
||||||
invoke("generate_image_stream", { req: { prompt }, channel }).catch((e) => {
|
invoke("generate_image_stream", { req: { prompt }, channel }).catch((e) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const msg = String(e);
|
addToast(`Image failed: ${e}`, "error");
|
||||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
|
||||||
else addToast(`Image failed: ${msg}`, "error");
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setProgress(null);
|
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}`;
|
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 (
|
|
||||||
<div className={box}>
|
|
||||||
<div className="flex flex-col items-center gap-1 text-[var(--color-text-dim)]">
|
|
||||||
<ImageOff size={20} />
|
|
||||||
<span className="text-[10px] text-center px-2">Image gen is macOS-only via Ollama</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
// ponytail: determinate bar when step/total are known, indeterminate shimmer otherwise.
|
// 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;
|
const pct = progress && progress.step != null && progress.total ? Math.round((progress.step / progress.total) * 100) : null;
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { ImagePlus, Sparkles } from "lucide-react";
|
|||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { addGeneration, type Generation } from "../lib/generations";
|
import { addGeneration, type Generation } from "../lib/generations";
|
||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
import { ImageSetupGuide } from "./ImageSetupGuide";
|
||||||
const DEFAULT_MODEL = "x/flux2-klein:4b";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
prefill?: Generation | null;
|
prefill?: Generation | null;
|
||||||
@@ -14,12 +13,11 @@ interface Props {
|
|||||||
|
|
||||||
export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [model, setModel] = useState("");
|
|
||||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
const [variants, setVariants] = useState<string[]>([]);
|
const [variants, setVariants] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [variant, setVariant] = useState(0);
|
const [variant, setVariant] = useState(0);
|
||||||
const [unsupported, setUnsupported] = useState(false);
|
const [guideOpen, setGuideOpen] = useState(false);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
|
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.
|
// so "regenerate" actually produces a new image instead of the cached one.
|
||||||
const v = variant > 0 ? `\n\n(variation ${variant})` : "";
|
const v = variant > 0 ? `\n\n(variation ${variant})` : "";
|
||||||
const url = await invoke<string>("generate_image", {
|
const url = await invoke<string>("generate_image", {
|
||||||
req: { prompt: `${prompt}${v}`, model: model.trim() || null },
|
req: { prompt: `${prompt}${v}` },
|
||||||
});
|
});
|
||||||
setDataUrl(url);
|
setDataUrl(url);
|
||||||
setUnsupported(false);
|
|
||||||
addToast("Image generated", "success");
|
addToast("Image generated", "success");
|
||||||
// ponytail: persist the PNG data URL so the DM can re-open past renders.
|
// 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) {
|
} catch (e) {
|
||||||
const msg = String(e);
|
addToast(`Image generation failed: ${String(e)}`, "error");
|
||||||
if (msg.toLowerCase().includes("macos-only")) setUnsupported(true);
|
setGuideOpen(true); // ponytail: a failed generate usually means sd-server isn't running — pop the guide.
|
||||||
addToast(`Image generation failed: ${msg}`, "error");
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: batch = 4 concurrent generates with distinct variation tags so
|
// 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
|
// server-side anyway, so concurrency is just cleaner code than a loop with
|
||||||
// awaits. Each variant is persisted so the gallery gets all four.
|
// awaits. Each variant is persisted so the gallery gets all four.
|
||||||
async function generateBatch() {
|
async function generateBatch() {
|
||||||
@@ -62,12 +58,11 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setVariants([]);
|
setVariants([]);
|
||||||
setDataUrl(null);
|
setDataUrl(null);
|
||||||
setUnsupported(false);
|
|
||||||
const tags = [1, 2, 3, 4];
|
const tags = [1, 2, 3, 4];
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
tags.map((t) =>
|
tags.map((t) =>
|
||||||
invoke<string>("generate_image", {
|
invoke<string>("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) {
|
for (const r of results) {
|
||||||
if (r.status === "fulfilled" && r.value) {
|
if (r.status === "fulfilled" && r.value) {
|
||||||
ok.push(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) {
|
if (ok.length === 0) {
|
||||||
const firstErr = results.find((r) => r.status === "rejected");
|
const firstErr = results.find((r) => r.status === "rejected");
|
||||||
const msg = firstErr ? String((firstErr as PromiseRejectedResult).reason) : "";
|
const msg = firstErr ? String((firstErr as PromiseRejectedResult).reason) : "";
|
||||||
if (msg.toLowerCase().includes("macos-only")) setUnsupported(true);
|
|
||||||
addToast(`Batch failed: ${msg || "no images returned"}`, "error");
|
addToast(`Batch failed: ${msg || "no images returned"}`, "error");
|
||||||
|
setGuideOpen(true); // ponytail: likely sd-server not running — show the guide.
|
||||||
} else {
|
} else {
|
||||||
setVariants(ok);
|
setVariants(ok);
|
||||||
setDataUrl(ok[0]);
|
setDataUrl(ok[0]);
|
||||||
@@ -99,6 +94,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 text-sm">
|
<div className="flex flex-col gap-3 text-sm">
|
||||||
|
<ImageSetupGuide forcedOpen={guideOpen} />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Prompt</label>
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Prompt</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -110,18 +106,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
|
||||||
Model (optional — defaults to {DEFAULT_MODEL})
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
|
||||||
value={model}
|
|
||||||
onChange={(e) => setModel(e.target.value)}
|
|
||||||
placeholder="x/flux2-klein:4b · x/z-image-turbo for speed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
@@ -155,14 +139,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{unsupported && (
|
|
||||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs">
|
|
||||||
Image generation is macOS-only via Ollama (for now). On other platforms the
|
|
||||||
✨ buttons elsewhere fall back to a placeholder.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{variants.length > 1 && !loading && (
|
{variants.length > 1 && !loading && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Variants — click to select</span>
|
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Variants — click to select</span>
|
||||||
@@ -215,7 +191,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!dataUrl && !loading && !unsupported && (
|
{!dataUrl && !loading && (
|
||||||
<div className="flex flex-col items-center justify-center aspect-square max-w-md mx-auto text-center text-[var(--color-text-dim)] text-xs">
|
<div className="flex flex-col items-center justify-center aspect-square max-w-md mx-auto text-center text-[var(--color-text-dim)] text-xs">
|
||||||
<span>No image yet.</span>
|
<span>No image yet.</span>
|
||||||
<span className="mt-1">Describe a scene or portrait, then Generate.</span>
|
<span className="mt-1">Describe a scene or portrait, then Generate.</span>
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { ChevronDown, ExternalLink, CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
|
// ponytail: a non-technical DM won't read the README or build sd-server from
|
||||||
|
// source. This is the one place they'll look — the Image tab — so the full
|
||||||
|
// step-by-step lives here as a collapsible panel. The "Test" button mirrors
|
||||||
|
// Settings so they get a green check without leaving the tab.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Control from the parent so a failed generate can pop this open. */
|
||||||
|
forcedOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageSetupGuide({ forcedOpen = false }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ ok: boolean; error: string } | null>(null);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
// ponytail: when the parent reports a connection failure, expand so the DM
|
||||||
|
// sees the fix immediately instead of staring at a toast.
|
||||||
|
const isOpen = open || forcedOpen;
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
setTesting(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const r = await invoke<{ ok: boolean; error: string }>("test_image_connection");
|
||||||
|
setResult(r);
|
||||||
|
if (r.ok) addToast("sd-server reachable — image generation is ready", "success");
|
||||||
|
else addToast(`Not reachable: ${r.error}`, "error");
|
||||||
|
} catch (e) {
|
||||||
|
setResult({ ok: false, error: String(e) });
|
||||||
|
addToast(`Not reachable: ${e}`, "error");
|
||||||
|
}
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<span>⚙️ First time? Set up image generation (5-minute guide)</span>
|
||||||
|
<ChevronDown size={14} className={`transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-[var(--color-border-subtle)] flex flex-col gap-3 text-xs leading-relaxed text-[var(--color-text-secondary)]">
|
||||||
|
<p>
|
||||||
|
DM-Pal makes images through a small free helper program called{" "}
|
||||||
|
<strong className="text-[var(--color-text-primary)]">sd-server</strong> (part of{" "}
|
||||||
|
<a href="https://github.com/leejet/stable-diffusion.cpp" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||||
|
stable-diffusion.cpp
|
||||||
|
</a>
|
||||||
|
). It runs on your computer alongside Ollama. Set it up once and you're done.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Step n={1} title="Download sd-server">
|
||||||
|
Go to the{" "}
|
||||||
|
<a href="https://github.com/leejet/stable-diffusion.cpp/releases" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||||
|
releases page <ExternalLink size={10} className="inline" />
|
||||||
|
</a>{" "}
|
||||||
|
and download the latest build for your system:
|
||||||
|
<ul className="list-disc pl-5 mt-1 space-y-0.5">
|
||||||
|
<li><strong className="text-[var(--color-text-primary)]">Windows</strong> — the <code className="font-mono">sd-server-win</code> zip</li>
|
||||||
|
<li><strong className="text-[var(--color-text-primary)]">Mac (Apple Silicon)</strong> — the <code className="font-mono">sd-server-macos</code> zip</li>
|
||||||
|
<li><strong className="text-[var(--color-text-primary)]">Linux</strong> — the <code className="font-mono">sd-server-linux</code> build</li>
|
||||||
|
</ul>
|
||||||
|
Unzip it into a permanent folder, e.g. <code className="font-mono">sd-server</code> in your home folder.
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step n={2} title="Download a model (recommended: SDXL — great fantasy art, 2 files)">
|
||||||
|
Put these two files in your <code className="font-mono">sd-server</code> folder:
|
||||||
|
<ul className="list-disc pl-5 mt-1 space-y-0.5">
|
||||||
|
<li>
|
||||||
|
Model (~6.5 GB):{" "}
|
||||||
|
<a href="https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/blob/main/sd_xl_base_1.0.safetensors" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||||
|
sd_xl_base_1.0.safetensors <ExternalLink size={10} className="inline" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
VAE (~0.6 GB):{" "}
|
||||||
|
<a href="https://huggingface.co/madebyollin/sdxl-vae-fp16-fix/blob/main/sdxl_vae-fp16-fix.safetensors" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||||
|
sdxl_vae-fp16-fix.safetensors <ExternalLink size={10} className="inline" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1 text-[var(--color-text-dim)]">
|
||||||
|
On the HuggingFace page click the ↓ next to the file to download it.
|
||||||
|
</p>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step n={3} title="Start the server (leave the window open)">
|
||||||
|
Open a terminal — <em>Mac:</em> Terminal (Applications → Utilities); <em>Windows:</em> Command Prompt.
|
||||||
|
Move into your folder, then run:
|
||||||
|
<pre className="mt-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[10px] font-mono overflow-x-auto whitespace-pre">
|
||||||
|
{`# Mac / Linux
|
||||||
|
./sd-server -m sd_xl_base_1.0.safetensors \\
|
||||||
|
--vae sdxl_vae-fp16-fix.safetensors --offload-to-cpu
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
sd-server.exe -m sd_xl_base_1.0.safetensors \\
|
||||||
|
--vae sdxl_vae-fp16-fix.safetensors --offload-to-cpu`}</pre>
|
||||||
|
<p className="mt-1 text-[var(--color-text-dim)]">
|
||||||
|
<code className="font-mono">--offload-to-cpu</code> lets it run on machines without a strong GPU (slower). You'll see it listening on <code className="font-mono">http://127.0.0.1:1234</code>.
|
||||||
|
</p>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step n={4} title="Point DM-Pal at it & test">
|
||||||
|
In <strong className="text-[var(--color-text-primary)]">Settings → Image Server</strong>, make sure the URL is{" "}
|
||||||
|
<code className="font-mono">http://localhost:1234</code>. Then test it right here:
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={test}
|
||||||
|
disabled={testing}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testing ? "Testing…" : "Test sd-server connection"}
|
||||||
|
</button>
|
||||||
|
{result && (
|
||||||
|
<span className={`flex items-center gap-1 text-xs ${result.ok ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"}`}>
|
||||||
|
{result.ok ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
|
||||||
|
{result.ok ? "Connected — ready to generate" : result.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<details className="rounded-lg border border-[var(--color-border-subtle)] px-3 py-2">
|
||||||
|
<summary className="text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)] select-none">
|
||||||
|
Slower laptop? Try the lightweight option (Z-Image-Turbo, ~4 GB VRAM, 3 files)
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 flex flex-col gap-1.5">
|
||||||
|
<p>Download these three files into your <code className="font-mono">sd-server</code> folder:</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-0.5">
|
||||||
|
<li>Diffusion model:{" "}
|
||||||
|
<a href="https://huggingface.co/leejet/Z-Image-Turbo-GGUF" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||||
|
z_image_turbo-Q4_K_M.gguf <ExternalLink size={10} className="inline" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>VAE:{" "}
|
||||||
|
<a href="https://huggingface.co/black-forest-labs/FLUX.1-schnell/blob/main/ae.sft" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||||
|
ae.sft <ExternalLink size={10} className="inline" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>Text encoder:{" "}
|
||||||
|
<a href="https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||||
|
Qwen3-4B-Instruct-2507-Q4_K_M.gguf <ExternalLink size={10} className="inline" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<pre className="mt-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[10px] font-mono overflow-x-auto whitespace-pre">
|
||||||
|
{`./sd-server --diffusion-model z_image_turbo-Q4_K_M.gguf \\
|
||||||
|
--vae ae.sft --llm Qwen3-4B-Instruct-2507-Q4_K_M.gguf \\
|
||||||
|
--cfg-scale 1.0 --diffusion-fa --offload-to-cpu`}</pre>
|
||||||
|
<p className="text-[var(--color-text-dim)]">
|
||||||
|
Z-Image-Turbo is built for speed (few steps), so it generates faster than SDXL on modest hardware.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-[var(--color-text-primary)]">
|
||||||
|
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] text-[10px] font-bold mr-1.5 align-middle">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div className="pl-[26px]">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ interface LlmConfig {
|
|||||||
temperature: number;
|
temperature: number;
|
||||||
max_tokens: number;
|
max_tokens: number;
|
||||||
top_p: number;
|
top_p: number;
|
||||||
image_model: string;
|
image_api_url: string;
|
||||||
embed_model: string;
|
embed_model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +30,9 @@ export function SettingsPanel() {
|
|||||||
const [conn, setConn] = useState<{ ok: boolean; models: string[]; error: string } | null>(null);
|
const [conn, setConn] = useState<{ ok: boolean; models: string[]; error: string } | null>(null);
|
||||||
const [dataDir, setDataDir] = useState<DataDirInfo | null>(null);
|
const [dataDir, setDataDir] = useState<DataDirInfo | null>(null);
|
||||||
const [relocating, setRelocating] = useState(false);
|
const [relocating, setRelocating] = useState(false);
|
||||||
|
// ponytail: probe the sd-server so a non-technical DM gets a green check, same pattern as the LLM test.
|
||||||
|
const [imgTesting, setImgTesting] = useState(false);
|
||||||
|
const [imgConn, setImgConn] = useState<{ ok: boolean; error: string } | null>(null);
|
||||||
// ponytail: advanced SQLite viewer — read-only, two DBs (lore.db / generations.db).
|
// ponytail: advanced SQLite viewer — read-only, two DBs (lore.db / generations.db).
|
||||||
const [sqlDb, setSqlDb] = useState<"lore" | "generations">("generations");
|
const [sqlDb, setSqlDb] = useState<"lore" | "generations">("generations");
|
||||||
const [sqlInput, setSqlInput] = useState("SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name");
|
const [sqlInput, setSqlInput] = useState("SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name");
|
||||||
@@ -153,6 +156,24 @@ export function SettingsPanel() {
|
|||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: probe the configured sd-server. Save first so it uses the latest URL.
|
||||||
|
async function testImage() {
|
||||||
|
if (!config) return;
|
||||||
|
setImgTesting(true);
|
||||||
|
setImgConn(null);
|
||||||
|
try {
|
||||||
|
await invoke("set_llm_config", { config });
|
||||||
|
const r = await invoke<{ ok: boolean; error: string }>("test_image_connection");
|
||||||
|
setImgConn(r);
|
||||||
|
if (r.ok) addToast("sd-server reachable", "success");
|
||||||
|
else addToast(`Image server not reachable: ${r.error}`, "error");
|
||||||
|
} catch (e) {
|
||||||
|
setImgConn({ ok: false, error: String(e) });
|
||||||
|
addToast(`Image server not reachable: ${e}`, "error");
|
||||||
|
}
|
||||||
|
setImgTesting(false);
|
||||||
|
}
|
||||||
|
|
||||||
// ponytail: provider presets fill in the API URL pattern + default model.
|
// ponytail: provider presets fill in the API URL pattern + default model.
|
||||||
const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [
|
const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [
|
||||||
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2" },
|
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2" },
|
||||||
@@ -177,7 +198,7 @@ export function SettingsPanel() {
|
|||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 512,
|
max_tokens: 512,
|
||||||
top_p: 0.9,
|
top_p: 0.9,
|
||||||
image_model: "x/flux2-klein:4b",
|
image_api_url: "http://localhost:1234",
|
||||||
embed_model: "nomic-embed-text",
|
embed_model: "nomic-embed-text",
|
||||||
};
|
};
|
||||||
function resetDefaults() {
|
function resetDefaults() {
|
||||||
@@ -386,17 +407,39 @@ export function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Image Model</h3>
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Image Server</h3>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
Image model
|
sd-server URL
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-dim)] leading-relaxed">
|
||||||
|
Base URL of a <a href="https://github.com/leejet/stable-diffusion.cpp" target="_blank" rel="noreferrer" className="underline">stable-diffusion.cpp</a> `sd-server` (A1111-compatible API). Start it with your chosen model, e.g.<br /><code className="font-mono">sd-server -m model.safetensors</code>
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
|
||||||
value={config.image_model}
|
value={config.image_api_url}
|
||||||
onChange={(e) => setConfig({ ...config, image_model: e.target.value })}
|
onChange={(e) => setConfig({ ...config, image_api_url: e.target.value })}
|
||||||
placeholder="x/flux2-klein:4b"
|
placeholder="http://localhost:1234"
|
||||||
/>
|
/>
|
||||||
|
{/* ponytail: mirror the LLM test button so a non-technical DM gets a green check */}
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={testImage}
|
||||||
|
disabled={imgTesting}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{imgTesting ? "Testing…" : "Test connection"}
|
||||||
|
</button>
|
||||||
|
{imgConn && (
|
||||||
|
<span className={`text-xs ${imgConn.ok ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"}`}>
|
||||||
|
{imgConn.ok ? "✓ sd-server reachable" : `✕ ${imgConn.error}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-dim)]">
|
||||||
|
New to this? Open the <strong>Image</strong> tab → "First time? Set up image generation" for a full step-by-step.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Embedding Model (Lore RAG)</h3>
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Embedding Model (Lore RAG)</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user