Status LEDs, image model selection, backup restore, provider test, monthly versions

- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status)
- Images: preview stage, purpose-based model choice and reference images
  (Nano Banana Pro edit refines the chosen preview)
- Settings: restore a stored or uploaded backup (staged, applied on restart)
- Provider form: test connection; saving or reloading re-checks the status at once
- opencode Zen/Go: send x-opencode-session with a stable id per conversation
- Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes
- Fix: backups made in the same second no longer overwrite each other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 09:31:24 +01:00
1 parent 49d404eec7
commit 1f69d63b09
35 files changed
+1156 -88

No files matched your search

+178 -17
View File
@@ -34,6 +34,8 @@ pub struct GenerateRequest {
pub dir: PathBuf,
pub user_id: Option<String>,
pub session_id: Option<String>,
/// Images to refine (sent to models with a `reference_param`).
pub references: Vec<PathBuf>,
}
fn slug(s: &str) -> String {
@@ -47,14 +49,90 @@ fn slug(s: &str) -> String {
out.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string()
}
/// Purposes the agent can ask for; models list theirs in `good_for`.
pub const PURPOSES: &[&str] = &["photo", "illustration", "text", "design"];
/// Defaults for well-known fal.ai models: (id, stage, good_for, reference_param, size_param, price).
const KNOWN: &[(&str, &str, &[&str], Option<&str>, Option<&str>, f64)] = &[
("fal-ai/flux/schnell", "preview", &["photo", "illustration"], None, None, 0.003),
("fal-ai/nano-banana-pro", "final", &["photo", "illustration", "text"], None, Some("aspect_ratio"), 0.15),
("fal-ai/nano-banana-pro/edit", "final", &["photo", "illustration", "text", "design"], Some("image_urls"), Some("aspect_ratio"), 0.15),
("fal-ai/flux-2-pro", "final", &["photo"], None, None, 0.03),
("fal-ai/ideogram/v3", "final", &["text", "design"], None, Some("aspect_ratio"), 0.06),
("fal-ai/recraft/v4/pro/text-to-image", "final", &["design", "illustration"], None, None, 0.05),
];
/// A configured model with the known defaults filled in where the config leaves them out.
fn with_defaults(mut m: ImageModel) -> ImageModel {
if let Some((_, stage, good_for, reference, size, price)) = KNOWN.iter().find(|k| k.0 == m.id) {
m.stage.get_or_insert_with(|| stage.to_string());
if m.good_for.is_empty() {
m.good_for = good_for.iter().map(|s| s.to_string()).collect();
}
if m.reference_param.is_none() {
m.reference_param = reference.map(String::from);
}
if m.size_param.is_none() {
m.size_param = size.map(String::from);
}
m.price.get_or_insert(*price);
}
m
}
fn models(app: &App) -> Vec<ImageModel> {
app.config().images.models.iter().cloned().map(with_defaults).collect()
}
fn model_config(app: &App, id: &str) -> ImageModel {
app.config()
.images
.models
.iter()
.find(|m| m.id == id)
.cloned()
.unwrap_or_else(|| ImageModel { id: id.to_string(), ..Default::default() })
models(app).into_iter().find(|m| m.id == id).unwrap_or_else(|| with_defaults(ImageModel { id: id.to_string(), ..Default::default() }))
}
/// Pick the model for a request: an explicit model wins; previews use the first preview model
/// (else the default); final images use the best-matching final model, preferring one that can
/// take the reference image when there is one.
pub fn choose_model(app: &App, stage: &str, purpose: Option<&str>, with_reference: bool) -> ImageModel {
let default = app.config().images.default_model.clone();
pick(&models(app), stage, purpose, with_reference).unwrap_or_else(|| model_config(app, &default))
}
fn pick(all: &[ImageModel], stage: &str, purpose: Option<&str>, with_reference: bool) -> Option<ImageModel> {
if stage == "preview" && !with_reference {
return all.iter().find(|m| m.stage.as_deref() == Some("preview")).cloned();
}
let score = |m: &ImageModel| {
let mut s = 0;
if m.stage.as_deref() == Some("final") {
s += 4;
}
match (with_reference, m.reference_param.is_some()) {
(true, true) => s += 8,
// Without a reference, plain text-to-image endpoints come first.
(false, true) => s -= 1,
_ => {}
}
if purpose.is_some_and(|p| m.good_for.iter().any(|g| g == p)) {
s += 2;
}
s
};
// max_by_key keeps the last of equal scores; reverse so the config order wins ties.
all.iter().rev().max_by_key(|m| score(m)).cloned()
}
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
let mut out = Vec::new();
for p in paths {
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
let mime = match p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
_ => "image/png",
};
out.push(format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)));
}
Ok(out)
}
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
@@ -82,15 +160,22 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
crate::budget::check(app, "fal", req.user_id.as_deref())?;
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
let model = model_config(app, &model_id);
let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into());
let explicit_size = req.size.clone().filter(|s| SIZES.contains(&s.as_str()));
let size = explicit_size.clone().unwrap_or_else(|| "landscape_4_3".into());
let count = req.count.clamp(1, 4);
let mut body = json!({"prompt": req.prompt, "num_images": count, "output_format": "png"});
let refining = !req.references.is_empty() && model.reference_param.is_some();
match model.size_param.as_deref() {
// When refining without an explicit size, keep the reference's aspect ratio.
Some("aspect_ratio") if refining && explicit_size.is_none() => body["aspect_ratio"] = json!("auto"),
Some("aspect_ratio") => body["aspect_ratio"] = json!(aspect_ratio(&size)),
Some("none") => {}
_ => body["image_size"] = json!(size),
}
if refining && let Some(param) = &model.reference_param {
body[param.as_str()] = json!(reference_uris(&req.references).await?);
}
for (k, v) in &model.options {
body[k] = v.clone();
}
@@ -184,9 +269,13 @@ impl Tool for GenerateImage {
}
fn description(&self) -> String {
"Generate images from a text prompt (fal.ai) and save them in the project. Use for \
illustrations, icons, backgrounds or placeholder photos the user needs. Write a detailed \
visual prompt in English (subject, style, composition, colours, lighting). Each image \
costs money – generate only what is needed. Returns the file paths and a preview."
illustrations, icons, backgrounds or photos the user needs. Write a detailed visual prompt \
in English (subject, style, composition, colours, lighting). The server picks the model. \
Work in two steps: first stage \"preview\" (cheap drafts, count 2-4); show them and ask the \
user with ask_user which one to refine – include the final model and price the preview \
result names. Then call again with stage \"final\", the chosen file in \"reference\" and \
the same or an improved prompt. Skip the preview only if the user asks for a final image \
directly. Each image costs money – generate only what is needed."
.into()
}
fn schema(&self) -> Value {
@@ -194,37 +283,55 @@ impl Tool for GenerateImage {
"type": "object",
"properties": {
"prompt": {"type": "string"},
"size": {"type": "string", "enum": SIZES},
"stage": {"type": "string", "enum": ["preview", "final"], "description": "preview = cheap drafts (default), final = best model for the purpose"},
"purpose": {"type": "string", "enum": PURPOSES, "description": "What the image is: photo (realistic), illustration, text (lettering, posters, signs), design (logos, icons, UI, vector style)"},
"reference": {"type": "array", "items": {"type": "string"}, "maxItems": 4, "description": "Image files (relative paths) to refine, e.g. the preview the user chose"},
"size": {"type": "string", "enum": SIZES, "description": "Default: landscape_4_3; when refining, the reference's aspect ratio"},
"count": {"type": "integer", "minimum": 1, "maximum": 4},
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: images.output_dir)"},
"model": {"type": "string", "description": "fal.ai model id (optional, default from the settings)"}
"model": {"type": "string", "description": "fal.ai model id – only if the user asks for a specific model"}
},
"required": ["prompt"]
})
}
fn title(&self, input: &Value) -> String {
format!("Generate image: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
let stage = if input["stage"].as_str() == Some("final") { "Generate final image" } else { "Generate image" };
format!("{stage}: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let prompt = arg_str(&input, "prompt")?.to_string();
let stage = if input["stage"].as_str() == Some("final") { "final" } else { "preview" };
let purpose = input["purpose"].as_str().filter(|p| PURPOSES.contains(p));
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| ctx.app.config().images.output_dir.clone());
let dir = ctx.resolve(&dir_arg)?;
let mut references = Vec::new();
for r in input["reference"].as_array().into_iter().flatten().filter_map(|v| v.as_str()).take(4) {
references.push(ctx.resolve(r)?);
}
let model = match input["model"].as_str().filter(|m| !m.is_empty()) {
Some(m) => model_config(&ctx.app, m),
None => choose_model(&ctx.app, stage, purpose, !references.is_empty()),
};
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
let refined = !references.is_empty() && model.reference_param.is_some();
let records = generate(
&ctx.app,
GenerateRequest {
prompt,
model: input["model"].as_str().map(String::from),
model: Some(model.id.clone()),
size: input["size"].as_str().map(String::from),
count: input["count"].as_u64().unwrap_or(1) as u32,
count: input["count"].as_u64().unwrap_or(if stage == "preview" { 2 } else { 1 }) as u32,
dir,
user_id: owner,
session_id: Some(ctx.session_id.clone()),
references,
},
)
.await
.map_err(|e| anyhow!("{e:#}"))?;
let mut text = String::from("Generated:\n");
let price = |m: &ImageModel| m.price.map(|p| format!(" (${p:.3} per image)")).unwrap_or_default();
let label = |m: &ImageModel| m.name.clone().unwrap_or_else(|| m.id.clone());
let mut text = format!("Model: {}{}\nGenerated:\n", label(&model), price(&model));
let mut images = Vec::new();
for r in &records {
let rel = Path::new(&r.path).strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| r.path.clone());
@@ -233,7 +340,61 @@ impl Tool for GenerateImage {
images.push(p);
}
}
if stage == "preview" && !refined {
let fin = choose_model(&ctx.app, "final", purpose, true);
if fin.id != model.id {
let how = if fin.reference_param.is_some() { "refines the chosen preview" } else { "generates it again from the prompt" };
text.push_str(&format!(
"\nThese are previews. Ask the user (ask_user) which one to refine. The final image would use {}{} and {how}.",
label(&fin),
price(&fin)
));
}
} else if !input["reference"].as_array().is_none_or(|a| a.is_empty()) && !refined {
text.push_str("\nNote: this model takes no reference image, so it was generated from the prompt only.");
}
let ids: Vec<&str> = records.iter().map(|r| r.id.as_str()).collect();
Ok(ToolOutput { content: text, images, meta: Some(json!({"image_ids": ids})), ..Default::default() })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn m(id: &str) -> ImageModel {
with_defaults(ImageModel { id: id.into(), ..Default::default() })
}
#[test]
fn picks_by_stage_purpose_and_reference() {
let all = vec![
m("fal-ai/flux/schnell"),
m("fal-ai/nano-banana-pro/edit"),
m("fal-ai/nano-banana-pro"),
m("fal-ai/flux-2-pro"),
m("fal-ai/ideogram/v3"),
m("fal-ai/recraft/v4/pro/text-to-image"),
];
let id = |stage, purpose, r| pick(&all, stage, purpose, r).unwrap().id;
assert_eq!(id("preview", Some("photo"), false), "fal-ai/flux/schnell");
// Refining a preview: the edit model, whatever the purpose.
assert_eq!(id("final", Some("photo"), true), "fal-ai/nano-banana-pro/edit");
assert_eq!(id("final", Some("design"), true), "fal-ai/nano-banana-pro/edit");
// From the prompt only: the first fitting text-to-image model (config order).
assert_eq!(id("final", Some("photo"), false), "fal-ai/nano-banana-pro");
assert_eq!(id("final", Some("text"), false), "fal-ai/nano-banana-pro");
assert_eq!(id("final", Some("design"), false), "fal-ai/ideogram/v3");
let no_edit: Vec<_> = all.iter().filter(|m| m.reference_param.is_none()).cloned().collect();
assert_eq!(pick(&no_edit, "final", Some("design"), true).unwrap().id, "fal-ai/ideogram/v3");
assert_eq!(pick(&no_edit, "final", Some("photo"), false).unwrap().id, "fal-ai/nano-banana-pro");
}
#[test]
fn config_overrides_known_defaults() {
let custom = with_defaults(ImageModel { id: "fal-ai/flux/schnell".into(), stage: Some("final".into()), price: Some(0.01), ..Default::default() });
assert_eq!(custom.stage.as_deref(), Some("final"));
assert_eq!(custom.price, Some(0.01));
assert!(m("some/unknown-model").stage.is_none());
}
}