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

+107
View File
@@ -119,11 +119,15 @@ pub fn router(app: AppState) -> Router {
.route("/backup", get(backup))
.route("/backups", get(list_backups).post(run_backup))
.route("/backups/{name}", get(download_backup))
.route("/backups/{name}/restore", post(restore_stored_backup))
.route("/backups/restore", post(restore_uploaded_backup).layer(axum::extract::DefaultBodyLimit::max(4 * 1024 * 1024 * 1024)))
.route("/health", get(health))
.route("/tools", get(crate::reverse::status))
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
.route("/providers/{name}/prices", get(provider_prices))
.route("/providers/status", get(provider_status).post(recheck_providers))
.route("/providers/test", post(test_provider))
.route("/status", get(status_overview))
.route("/images", get(list_images).post(create_images))
.route("/images/config", get(images_config))
.route("/images/{id}", delete(delete_image))
@@ -1116,6 +1120,33 @@ async fn run_backup(Extension(user): Extension<User>, State(app): State<AppState
Ok(Json(json!(res)))
}
/// Stage a restore and restart: the database is replaced before it is opened again.
/// In Docker the restart policy brings the server back up.
async fn restore_and_restart(app: &App, user: &User, bytes: Vec<u8>, source: &str) -> ApiResult {
crate::backup::stage_restore(app, bytes).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(user), "backup.restore", source, "");
tracing::warn!(source, "backup staged for restore – restarting");
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
std::process::exit(0);
});
Ok(Json(json!({"ok": true, "restart": true})))
}
async fn restore_stored_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
return Err(ApiError(StatusCode::NOT_FOUND, "no such backup".into()));
}
let bytes = tokio::fs::read(crate::backup::backup_dir(&app).join(&name)).await?;
restore_and_restart(&app, &user, bytes, &name).await
}
async fn restore_uploaded_backup(Extension(user): Extension<User>, State(app): State<AppState>, body: axum::body::Bytes) -> ApiResult {
require_admin(&user)?;
restore_and_restart(&app, &user, body.to_vec(), &format!("upload ({} bytes)", body.len())).await
}
async fn download_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> Result<Response, ApiError> {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
@@ -1138,10 +1169,85 @@ async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
}
/// Status overlay: every enabled provider and MCP server with its state. Error texts can
/// contain URLs or hosts, so only admins get them.
async fn status_overview(Extension(user): Extension<User>, State(app): State<AppState>) -> Json<Value> {
let admin = user.is_admin();
let cfg = app.config();
let registry = app.registry();
let health = app.health.all();
let providers: serde_json::Map<String, Value> = cfg
.providers
.iter()
.filter(|(_, p)| p.enabled)
.map(|(name, _)| {
let models = registry.providers.get(name).map(|e| e.models.len()).unwrap_or(0);
let v = match health.get(name) {
Some(h) => json!({
"state": h.state,
"latency_ms": h.latency_ms,
"checked_at": h.checked_at,
"error": if admin { h.error.clone() } else { None },
"models": models,
}),
None => json!({"state": "unknown", "models": models}),
};
(name.clone(), v)
})
.collect();
let mcp: serde_json::Map<String, Value> = app
.mcp
.states()
.into_iter()
.filter(|(_, st)| !matches!(st, crate::mcp::McpState::Disabled))
.map(|(name, st)| {
let v = match st {
crate::mcp::McpState::Connected { tools } => json!({"state": "connected", "tools": tools}),
crate::mcp::McpState::Failed { error } => json!({"state": "failed", "error": if admin { Some(error) } else { None }}),
_ => json!({"state": "connecting"}),
};
(name, v)
})
.collect();
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp}))
}
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
Json(json!(app.health.all()))
}
#[derive(Deserialize)]
struct TestProviderBody {
/// Existing provider: its stored key is used when `api_key` is empty.
#[serde(default)]
name: Option<String>,
kind: String,
base_url: String,
#[serde(default)]
api_key: String,
#[serde(default)]
auth_prefix: Option<String>,
}
/// Check URL and key from the edit form without saving them.
async fn test_provider(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<TestProviderBody>) -> ApiResult {
require_admin(&user)?;
let stored = body.name.as_ref().and_then(|n| app.config().providers.get(n).cloned());
let mut value = stored.as_ref().map(|p| serde_json::to_value(p)).transpose()?.unwrap_or_else(|| json!({}));
value["kind"] = json!(body.kind);
value["base_url"] = json!(body.base_url);
if !body.api_key.is_empty() {
value["api_key"] = json!(body.api_key);
}
if let Some(prefix) = body.auth_prefix {
value["auth_prefix"] = if prefix.is_empty() { Value::Null } else { json!(prefix) };
}
let pc: crate::config::ProviderConfig = serde_json::from_value(value).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("invalid provider: {e}")))?;
let provider = crate::provider::build(crate::provider::http_client(), &pc);
let (status, models) = crate::health::probe(provider).await;
Ok(Json(json!({"status": status, "models": models})))
}
async fn recheck_providers(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
crate::health::check_all(&app).await;
@@ -1220,6 +1326,7 @@ async fn create_images(Extension(user): Extension<User>, State(app): State<AppSt
dir,
user_id: Some(user.id.clone()),
session_id: None,
references: vec![],
},
)
.await?;
+6
View File
@@ -247,11 +247,16 @@ impl App {
std::fs::rename(&tmp, &self.config_path)?;
let registry = Registry::build(&cfg).await;
let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?;
let providers_changed = serde_json::to_value(&cfg.providers)? != serde_json::to_value(&self.config().providers)?;
*self.config.write().unwrap() = Arc::new(cfg);
*self.registry.write().unwrap() = Arc::new(registry);
if mcp_changed {
self.clone().start_mcp();
}
// A new key or URL should show its status right away, not at the next 5-minute check.
if providers_changed {
crate::health::check_all(self).await;
}
Ok(())
}
@@ -263,6 +268,7 @@ impl App {
pub async fn reload_providers(&self) {
let registry = Registry::build(&self.config()).await;
*self.registry.write().unwrap() = Arc::new(registry);
crate::health::check_all(self).await;
}
// --- events ---------------------------------------------------------
+63 -1
View File
@@ -56,6 +56,56 @@ pub async fn create_zip(app: &App) -> Result<Vec<u8>> {
.await?
}
/// Directory a restore is staged in; applied at the next start (see `apply_pending_restore`).
fn pending_dir(data_dir: &std::path::Path) -> PathBuf {
data_dir.join("restore-pending")
}
/// Read config.toml and codecraft.db from a backup zip and check both.
fn unpack(bytes: &[u8]) -> Result<(String, Vec<u8>)> {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).context("not a zip file")?;
let mut config = String::new();
z.by_name("config.toml").context("config.toml missing in the backup")?.read_to_string(&mut config)?;
let mut db = Vec::new();
z.by_name("codecraft.db").context("codecraft.db missing in the backup")?.read_to_end(&mut db)?;
if !db.starts_with(b"SQLite format 3\0") {
bail!("codecraft.db in the backup is not an SQLite database");
}
crate::config::Config::parse(&config).context("config.toml in the backup is invalid")?;
Ok((config, db))
}
/// Check a backup, back up the current state, and stage the backup for the next start.
pub async fn stage_restore(app: &App, bytes: Vec<u8>) -> Result<()> {
let (config, db) = tokio::task::spawn_blocking(move || unpack(&bytes)).await??;
// Safety net: the current state as a normal backup file.
run_named(app, "-before-restore").await.context("could not back up the current state first")?;
let dir = pending_dir(&app.config().data_dir());
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await?;
tokio::fs::write(dir.join("codecraft.db"), db).await?;
tokio::fs::write(dir.join("config.toml"), config).await?;
Ok(())
}
/// Called at startup before the database is opened: move a staged restore into place.
pub fn apply_pending_restore(data_dir: &std::path::Path, config_path: &std::path::Path) -> Result<bool> {
let dir = pending_dir(data_dir);
let (db, cfg) = (dir.join("codecraft.db"), dir.join("config.toml"));
if !db.is_file() || !cfg.is_file() {
return Ok(false);
}
let target = data_dir.join("codecraft.db");
for ext in ["db-wal", "db-shm"] {
let _ = std::fs::remove_file(target.with_extension(ext));
}
std::fs::rename(&db, &target)?;
std::fs::copy(&cfg, config_path)?;
std::fs::remove_dir_all(&dir)?;
Ok(true)
}
pub fn backup_dir(app: &App) -> PathBuf {
let cfg = app.config();
if cfg.backup.dir.is_empty() { cfg.data_dir().join("backups") } else { PathBuf::from(expand_env(&cfg.backup.dir)) }
@@ -89,12 +139,24 @@ pub fn list(app: &App) -> Vec<BackupFile> {
/// Create a backup now: local file (+ pruning) and S3 upload if configured.
pub async fn run(app: &App) -> Result<LastRun> {
run_named(app, "").await
}
/// Like `run`, with a suffix in the file name (e.g. "-before-restore").
pub async fn run_named(app: &App, suffix: &str) -> Result<LastRun> {
let _guard = app.backup.running.lock().await;
let cfg = app.config().backup.clone();
let dir = backup_dir(app);
tokio::fs::create_dir_all(&dir).await.with_context(|| format!("create {}", dir.display()))?;
let bytes = create_zip(app).await?;
let name = format!("codecraft-backup-{}.zip", Local::now().format("%Y%m%d-%H%M%S"));
// Names have second resolution: never overwrite a backup made in the same second.
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let mut name = format!("codecraft-backup-{stamp}{suffix}.zip");
let mut n = 1;
while dir.join(&name).exists() {
n += 1;
name = format!("codecraft-backup-{stamp}{suffix}-{n}.zip");
}
let path = dir.join(&name);
tokio::fs::write(&path, &bytes).await.with_context(|| format!("write {}", path.display()))?;
// Restrictive permissions: the zip contains the config with API keys.
+2 -2
View File
@@ -47,7 +47,7 @@ pub async fn config(Extension(user): Extension<User>, State(app): State<AppState
let memory: String = app.store.user_setting(&user.id, "memory")?;
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
Ok(Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"version": codecraft_core::version::VERSION,
"user": user,
"models": app.registry().models_json(),
"default_model": default_model,
@@ -115,8 +115,8 @@ pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppS
let provider_name = b.model.split_once('/').map(|(p, _)| p.to_string()).unwrap_or_default();
crate::budget::check(&app, &provider_name, Some(&user.id)).map_err(|e| ApiError(StatusCode::PAYMENT_REQUIRED, format!("{e:#}")))?;
let max_tokens = b.max_tokens.or(model.max_output).unwrap_or(16_000);
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning };
let session = format!("cli:{}", b.session.unwrap_or_else(|| "default".into()));
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning, session_id: Some(session.clone()) };
let agent = format!("cli:{}", b.agent.unwrap_or_else(|| "build".into()));
let spec = b.model.clone();
let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
+9
View File
@@ -193,6 +193,15 @@ pub struct ImageModel {
/// Extra request fields sent as-is.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub options: serde_json::Map<String, serde_json::Value>,
/// "preview" (cheap drafts) or "final" (best quality). Known fal.ai models have defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage: Option<String>,
/// What the model is good at: photo, illustration, text, design.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub good_for: Vec<String>,
/// Request field for reference images (e.g. "image_urls"); set for models that refine an image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_param: Option<String>,
}
impl Default for ImagesConfig {
+20 -17
View File
@@ -33,29 +33,32 @@ impl Health {
}
}
/// Fetch the model list once and classify the answer.
pub async fn probe(provider: Arc<dyn crate::provider::Provider>) -> (ProviderStatus, usize) {
let start = Instant::now();
let res = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await;
let (error, count): (Option<String>, usize) = match res {
Ok(Ok(m)) => (None, m.len()),
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
Err(_) => (Some("timed out".to_string()), 0),
};
// The host answered but has no model list: reachable.
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
let state = match &error {
None => "ok",
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
Some(_) => "down",
};
(ProviderStatus { up: error.is_none(), state, error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() }, count)
}
pub async fn check_all(app: &App) {
let registry = app.registry();
let mut found = BTreeMap::new();
let checks = registry.providers.iter().map(|(name, entry)| {
let provider = entry.provider.clone();
let name = name.clone();
async move {
let start = Instant::now();
let res = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await;
let error: Option<String> = match res {
Ok(Ok(_)) => None,
Ok(Err(e)) => Some(e.to_string().chars().take(300).collect()),
Err(_) => Some("timed out".to_string()),
};
// The host answered but has no model list: reachable.
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
let state = match &error {
None => "ok",
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
Some(_) => "down",
};
(name, ProviderStatus { up: error.is_none(), state, error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() })
}
async move { (name, probe(provider).await.0) }
});
for (name, st) in futures::future::join_all(checks).await {
if !st.up {
+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());
}
}
+6 -1
View File
@@ -54,7 +54,12 @@ async fn main() -> Result<()> {
}
let path = config::config_path();
let (cfg, _) = config::load_or_init(&path)?;
let (mut cfg, _) = config::load_or_init(&path)?;
// A backup restored from the settings is applied here, before the database is opened.
if backup::apply_pending_restore(&cfg.data_dir(), &path)? {
tracing::warn!("restored database and config from a backup");
cfg = config::load_or_init(&path)?.0;
}
tracing::info!("config: {}", path.display());
tracing::info!("workspace: {}", cfg.workspace().display());
let _ = sandbox::probe().await;
+6 -1
View File
@@ -160,7 +160,7 @@ impl McpServer {
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"elicitation": {}, "roots": {"listChanged": false}},
"clientInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")}
"clientInfo": {"name": "codecraft", "version": codecraft_core::version::VERSION}
}),
)
.await?;
@@ -452,6 +452,11 @@ pub struct McpManager {
}
impl McpManager {
/// Connection state per server (for the status overlay).
pub fn states(&self) -> BTreeMap<String, McpState> {
self.states.lock().unwrap().clone()
}
pub fn status_json(&self) -> Value {
let states = self.states.lock().unwrap();
let servers = self.servers.lock().unwrap();
+5 -1
View File
@@ -298,6 +298,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
tools: tools.iter().map(|t| t.def()).collect(),
max_tokens: model.max_output.unwrap_or(16_000),
reasoning: true,
session_id: Some(session_id.to_string()),
model: model.clone(),
};
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
@@ -985,6 +986,7 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
tools: vec![],
max_tokens: 2000,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
match complete_text(&app, &spec, provider, req, CancellationToken::new()).await {
@@ -1032,6 +1034,7 @@ pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<S
tools: vec![],
max_tokens: 2000,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(&app, &spec, provider, req, CancellationToken::new()).await?;
@@ -1159,7 +1162,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
agent.name,
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
);
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone() };
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) };
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
record_side_usage(app, session, &spec, &model, &usage, "review");
let text = strip_think(&text);
@@ -1254,6 +1257,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
tools: vec![],
max_tokens: 16_000,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(&app, &spec, provider.clone(), req, cancel.clone()).await?;
+3 -14
View File
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub use codecraft_core::version::VERSION;
/// Git commit the image was built from (Docker build arg GIT_COMMIT).
pub fn commit() -> &'static str {
@@ -17,10 +17,7 @@ pub struct UpdateState {
pub latest: Mutex<Option<Value>>,
}
fn newer(a: &str, b: &str) -> bool {
let p = |v: &str| v.trim_start_matches('v').split(['.', '-']).take(3).map(|x| x.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
p(a) > p(b)
}
use codecraft_core::version::newer;
async fn check(app: &App) -> Option<Value> {
let url = app.config().server.update_url.clone();
@@ -46,12 +43,4 @@ pub fn spawn_checker(app: Arc<App>) {
});
}
#[cfg(test)]
mod tests {
#[test]
fn version_compare() {
assert!(super::newer("v0.2.0", "0.1.9"));
assert!(super::newer("1.0.0", "0.9.9"));
assert!(!super::newer("v0.1.0", "0.1.0"));
}
}