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:
1 parent
49d404eec7
commit
1f69d63b09
35 files changed
+1156
-88
No files matched your search
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user