diff --git a/.env.example b/.env.example index 7305a83..328e87f 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ OPENROUTER_API_KEY= # Time zone for automations (cron schedules) and timestamps, e.g. Europe/Berlin TZ=UTC -# Image version for docker-compose.release.yml (e.g. 0.1.0); default: latest +# Image version for docker-compose.release.yml (e.g. v202611-00001); default: latest CODECRAFT_VERSION=latest # Log format: empty for text, "json" for one JSON object per line diff --git a/.gitignore b/.gitignore index b66ac31..b1eac4a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ *.db-wal *.db-shm docker-compose.override.yml +.env.bak* diff --git a/CHANGELOG.md b/CHANGELOG.md index 0596c98..1234a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,35 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Image generation picks the model:** the agent drafts cheap previews, asks which one to + refine (with the price of the final model), then creates the final image with the best model + for the purpose (photo, illustration, text, design). Models that take a reference image + (Nano Banana Pro edit) refine the chosen draft. Known fal.ai models come with sensible + defaults; `stage`, `good_for` and `reference_param` in `[images].models` override them. +- **Restore a backup** in *Settings → Audit*: a stored backup or an uploaded zip. The current + state is backed up first (`…-before-restore.zip`), then CodeCraft restarts with the restored + database and configuration. +- **Test connection** in the provider form checks URL and key before saving. +- **Status LEDs** in the sidebar for server connection, models and MCP servers (green, yellow, + red in vivid variants of the theme colors); a click opens an overview of every provider and + MCP server with its state, latency and tool count. +- `scripts/release.sh`: tag, multi-arch image and Gitea release in one step. + +### Changed + +- Saving a provider (or reloading providers) checks its status right away instead of at the + next five-minute check. + +### Fixed + +- opencode Zen/Go: requests carry the session in `x-opencode-session`; opencode Go rejected + them without it ("Request is missing x-opencode-session"). +- Two backups in the same second no longer overwrite each other. + ## [0.1.0] – 2026-11-09 First public release. diff --git a/Dockerfile b/Dockerfile index f3a4a1a..98ee4a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,9 @@ COPY server/resources-cli ./server/resources-cli COPY cli/src ./cli/src ARG GIT_COMMIT="" ENV CODECRAFT_COMMIT=${GIT_COMMIT} +# Release version (YYYYMM-NNNNN, set by scripts/release.sh); empty = development build. +ARG CODECRAFT_VERSION="" +ENV CODECRAFT_VERSION=${CODECRAFT_VERSION} RUN touch core/src/lib.rs server/src/main.rs \ && cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \ && cp "target/$(cat /target)/release/codecraft" /codecraft diff --git a/README.md b/README.md index 9acc778..cdc1859 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,8 @@ provider under *Settings → Providers & models* – or put the keys into `.env` them in the config as `${NAME}`. **Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`. -Pin a version with `CODECRAFT_VERSION=0.1.0` in `.env`. Admins see a notice when a new release is out. +Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with +`CODECRAFT_VERSION=v202611-00001` in `.env`. Admins see a notice when a new release is out. **From source:** `git clone https://git.familie-zink.org/zink/codecraft.git && cd codecraft`, `cp .env.example .env`, then `docker compose up -d --build`. @@ -153,7 +154,11 @@ output limit, reasoning effort, extra request options and prices per million tok - **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that process one at a time; waiting time is shown and never counted as model latency. - **Status:** every five minutes each provider's model list is fetched (no tokens used), so - you see at a glance which hosts answer and which keys are rejected. + you see at a glance which hosts answer and which keys are rejected. Saving a provider checks + it right away, and *Test connection* checks URL and key before you save. +- **Images (fal.ai):** the agent drafts cheap previews (FLUX schnell), asks which one to + refine, and creates the final image with the best model for the purpose – Nano Banana Pro + refines the chosen draft, Ideogram handles lettering, Recraft design work. ## Terminal client @@ -199,7 +204,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t - **JSON logs:** `CODECRAFT_LOG_FORMAT=json` writes one JSON object per line. - **Backups:** daily at 03:00 into `/data/backups` (the last 30 are kept), optionally copied to any S3-compatible bucket (AWS, MinIO, Garage, Backblaze B2, Hetzner). *Settings → Audit* - shows them, creates one on demand and downloads them. + shows them, creates one on demand, downloads them and restores one – a stored backup or an + uploaded zip, e.g. to move to another server. The current state is backed up first, then + CodeCraft restarts with the restored database and configuration. - **Health:** `GET /api/health` for monitoring, with version and commit. ## Users and security @@ -245,6 +252,7 @@ place and keep your comments. Secrets belong in the environment and are referenc | `[auth]`, `[auth.oidc]` | sign-in, public URL, single sign-on | | `[sandbox]` | shell sandbox for non-admins (`auto`, `always`, `off`) | | `[backup]`, `[backup.s3]` | daily backups, retention, S3 target | +| `[images]` | fal.ai key, budget, `models` with `stage` (`preview` / `final`), `good_for` and `reference_param` | | `[craft]` | headless browser, screen widths, clean room | Compound shell commands (`&&`, `;`, `|`) are only auto-allowed when every part is allowed. @@ -329,6 +337,12 @@ The Docker build cross-compiles the server with quickly on either architecture: `docker buildx build --platform linux/amd64,linux/arm64 -t /codecraft: --push .` +Releases are named `vYYYYMM-NNNNN`: year and month, then a counter that starts at 00001 every +month. Collect changes under `## [Unreleased]` in the changelog (and under `unreleased` in +`web/src/whatsnew.ts`), then `scripts/release.sh` picks the next number, renames both, commits, +tags, builds and pushes the image (`:vYYYYMM-NNNNN` and `:latest`) and publishes the Gitea +release (`--dry-run` shows the steps). The `Cargo.toml` versions are not used for releases. + Translations: copy `web/src/i18n/en.ts`, translate the values and register the language in `web/src/i18n/index.ts` – the type checker reports missing keys. diff --git a/cli/src/main.rs b/cli/src/main.rs index bea0da8..e8f3ada 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -29,7 +29,7 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; #[derive(Parser)] -#[command(name = "codecraft", version, about = "CodeCraft in the terminal – a local coding agent")] +#[command(name = "codecraft", version = codecraft_core::version::VERSION, about = "CodeCraft in the terminal – a local coding agent")] struct Cli { /// Start with this prompt prompt: Option, @@ -164,8 +164,7 @@ fn prompt_line(label: &str, secret: bool) -> Result { } fn version_newer(server: &str, ours: &str) -> bool { - let parse = |v: &str| v.split('.').map(|p| p.parse::().unwrap_or(0)).collect::>(); - parse(server) > parse(ours) + codecraft_core::version::newer(server, ours) } fn target() -> String { @@ -178,8 +177,8 @@ async fn self_update() -> Result<()> { let http = server::http(); let info: Value = http.get(format!("{}/api/info", login.url)).send().await?.json().await?; let latest = info["version"].as_str().unwrap_or("0"); - if !version_newer(latest, env!("CARGO_PKG_VERSION")) { - println!("codecraft {} is up to date.", env!("CARGO_PKG_VERSION")); + if !version_newer(latest, codecraft_core::version::VERSION) { + println!("codecraft {} is up to date.", codecraft_core::version::VERSION); return Ok(()); } let url = format!("{}/cli/download/{}", login.url, target()); @@ -365,7 +364,7 @@ async fn main() -> Result<()> { // Notice when the server has a newer CLI. let mut status = String::new(); if let Some(v) = env.server_cfg.as_ref().and_then(|s| s["version"].as_str()) { - if version_newer(v, env!("CARGO_PKG_VERSION")) { + if version_newer(v, codecraft_core::version::VERSION) { status = format!("update available: {v} – run `codecraft update`"); } } diff --git a/cli/src/models.rs b/cli/src/models.rs index 0ecac7f..f890fb5 100644 --- a/cli/src/models.rs +++ b/cli/src/models.rs @@ -105,7 +105,7 @@ pub async fn stream( mc.effort = Some(e); } let max_tokens = mc.max_output.unwrap_or(16_000); - let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true }; + let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true, session_id: Some(session.to_string()) }; provider.stream(req, tx, cancel).await } diff --git a/cli/src/server.rs b/cli/src/server.rs index 903df75..480c0db 100644 --- a/cli/src/server.rs +++ b/cli/src/server.rs @@ -19,7 +19,7 @@ pub struct Server { pub fn http() -> reqwest::Client { reqwest::Client::builder() - .user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION"))) + .user_agent(format!("codecraft-cli/{}", codecraft_core::version::VERSION)) .connect_timeout(std::time::Duration::from_secs(15)) .build() .expect("http client") diff --git a/config.example.toml b/config.example.toml index 4fde92d..3c33729 100644 --- a/config.example.toml +++ b/config.example.toml @@ -246,8 +246,15 @@ base_url = "https://fal.run" api_key = "${FAL_KEY}" default_model = "fal-ai/flux/schnell" output_dir = "generated" +# The agent drafts with a "preview" model, asks which draft to refine, then +# creates the final image with the best "final" model for the purpose +# (good_for: photo, illustration, text, design), passing the draft as reference +# to models with reference_param. The models below have these values built in; +# set stage / good_for / reference_param / price to override or for other models. models = [ - { id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast", price = 0.003 }, + { id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast previews", price = 0.003 }, + { id = "fal-ai/nano-banana-pro/edit", name = "Nano Banana Pro – refine an image", price = 0.15 }, + { id = "fal-ai/nano-banana-pro", name = "Nano Banana Pro", price = 0.15 }, { id = "fal-ai/flux-2-pro", name = "FLUX.2 pro" }, { id = "fal-ai/ideogram/v3", name = "Ideogram 3 – text in images", size_param = "aspect_ratio" }, { id = "fal-ai/recraft/v4/pro/text-to-image", name = "Recraft V4 pro – design" }, diff --git a/core/src/lib.rs b/core/src/lib.rs index 07a8016..4ecbaa9 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -6,3 +6,4 @@ pub mod permission; pub mod prompts; pub mod provider; pub mod types; +pub mod version; diff --git a/core/src/provider/anthropic.rs b/core/src/provider/anthropic.rs index 3b6f297..e6ef439 100644 --- a/core/src/provider/anthropic.rs +++ b/core/src/provider/anthropic.rs @@ -168,7 +168,8 @@ impl Provider for Anthropic { for (k, v) in &req.model.options { body[k] = v.clone(); } - let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?; + let rb = self.request(reqwest::Method::POST, "/messages").json(&body); + let resp = self.ep.with_session(rb, req.session_id.as_deref()).send().await?; if !resp.status().is_success() { return Err(http_error(resp).await); } diff --git a/core/src/provider/mod.rs b/core/src/provider/mod.rs index bbd6a78..f709f56 100644 --- a/core/src/provider/mod.rs +++ b/core/src/provider/mod.rs @@ -30,6 +30,9 @@ pub struct ChatRequest { pub max_tokens: u64, /// Enable extended thinking / reasoning when the model supports it. pub reasoning: bool, + /// Stable id of the conversation; sent to gateways that route by session + /// (opencode: `x-opencode-session`) for routing and prompt caching. + pub session_id: Option, } #[derive(Debug, Clone)] @@ -84,6 +87,16 @@ impl Endpoint { } } + /// Session header for gateways that need one (opencode Zen/Go reject requests without it). + pub fn with_session(&self, rb: reqwest::RequestBuilder, session_id: Option<&str>) -> reqwest::RequestBuilder { + match session_id { + Some(id) if self.base_url.contains("opencode.ai") && !self.headers.keys().any(|k| k.eq_ignore_ascii_case("x-opencode-session")) => { + rb.header("x-opencode-session", id) + } + _ => rb, + } + } + pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { if !self.api_key.is_empty() { rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key)); @@ -99,7 +112,7 @@ impl Endpoint { pub fn http_client() -> reqwest::Client { reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(20)) - .user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION"))) + .user_agent(format!("codecraft/{}", crate::version::VERSION)) .build() .expect("http client") } @@ -128,3 +141,27 @@ pub async fn http_error(resp: reqwest::Response) -> anyhow::Error { .unwrap_or_else(|| body.chars().take(500).collect()); anyhow!("provider returned HTTP {status}: {detail}") } + +#[cfg(test)] +mod session_header_tests { + use super::*; + + fn ep(base: &str) -> Endpoint { + Endpoint { base_url: base.into(), api_key: String::new(), auth_header: "Authorization".into(), auth_prefix: "Bearer ".into(), headers: BTreeMap::new() } + } + + fn header(ep: &Endpoint, id: Option<&str>) -> Option { + let rb = ep.with_session(reqwest::Client::new().post(format!("{}/chat/completions", ep.base_url)), id); + rb.build().unwrap().headers().get("x-opencode-session").map(|v| v.to_str().unwrap().to_string()) + } + + #[test] + fn opencode_gets_the_session_header() { + assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), Some("s1")).as_deref(), Some("s1")); + assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), None), None); + assert_eq!(header(&ep("https://api.openai.com/v1"), Some("s1")), None); + let mut custom = ep("https://opencode.ai/zen/v1"); + custom.headers.insert("X-Opencode-Session".into(), "fixed".into()); + assert_eq!(header(&custom, Some("s1")), None, "a configured header wins"); + } +} diff --git a/core/src/provider/openai.rs b/core/src/provider/openai.rs index 364dbb1..6005640 100644 --- a/core/src/provider/openai.rs +++ b/core/src/provider/openai.rs @@ -198,7 +198,7 @@ impl Provider for OpenAi { body[k] = v.clone(); } let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body); - let resp = self.ep.apply(rb).send().await?; + let resp = self.ep.with_session(self.ep.apply(rb), req.session_id.as_deref()).send().await?; if !resp.status().is_success() { return Err(http_error(resp).await); } diff --git a/core/src/version.rs b/core/src/version.rs new file mode 100644 index 0000000..8aa3a55 --- /dev/null +++ b/core/src/version.rs @@ -0,0 +1,43 @@ +//! Release version. Releases are named vYYYYMM-NNNNN (a counter per month, from 00001); +//! the release build sets CODECRAFT_VERSION (without the leading "v"). Builds without it +//! (development) fall back to the crate version. + +pub const VERSION: &str = match option_env!("CODECRAFT_VERSION") { + Some(v) if !v.is_empty() => v, + _ => env!("CARGO_PKG_VERSION"), +}; + +/// Sort key: monthly releases (YYYYMM-NNNNN) come after the old semantic versions. +fn key(v: &str) -> (u8, Vec) { + let v = v.trim().trim_start_matches('v'); + if let Some((month, n)) = v.split_once('-') + && month.len() == 6 + && month.chars().all(|c| c.is_ascii_digit()) + && !n.is_empty() + && n.chars().all(|c| c.is_ascii_digit()) + { + return (1, vec![month.parse().unwrap_or(0), n.parse().unwrap_or(0)]); + } + (0, v.split(['.', '-']).take(3).map(|x| x.parse::().unwrap_or(0)).collect()) +} + +/// Is version `a` newer than `b`? Accepts both schemes, with or without "v". +pub fn newer(a: &str, b: &str) -> bool { + key(a) > key(b) +} + +#[cfg(test)] +mod tests { + use super::newer; + + #[test] + fn compares_monthly_and_semver() { + assert!(newer("v202611-00002", "202611-00001")); + assert!(newer("v202612-00001", "v202611-00042")); + assert!(!newer("v202611-00001", "202611-00001")); + assert!(newer("v202611-00001", "0.1.0")); + assert!(!newer("0.9.9", "v202611-00001")); + assert!(newer("v0.2.0", "0.1.9")); + assert!(newer("1.0.0", "0.9.9")); + } +} diff --git a/docker-compose.release.yml b/docker-compose.release.yml index 714750c..9303b38 100644 --- a/docker-compose.release.yml +++ b/docker-compose.release.yml @@ -15,7 +15,7 @@ # /data/home home directory of the agent (git config, caches, ...) services: codecraft: - # Pin a version (e.g. :0.1.0) or follow :latest. + # Pin a version (e.g. :v202611-00001) or follow :latest. image: git.familie-zink.org/zink/codecraft:${CODECRAFT_VERSION:-latest} container_name: codecraft restart: unless-stopped diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..4aa4183 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Publish a CodeCraft release named vYYYYMM-NNNNN (year, month, then a counter that +# starts at 00001 every month): +# 1. pick the next number from the existing tags of this month, +# 2. rename "## [Unreleased]" in CHANGELOG.md and the "unreleased" entry in +# web/src/whatsnew.ts to the version, update the README badge, commit, +# 3. tag and push branch and tag, +# 4. build the multi-arch image (amd64 + arm64) with the version embedded and push +# :vYYYYMM-NNNNN (and :latest), +# 5. create the Gitea release with the changelog section as notes. +# +# Usage: scripts/release.sh [vYYYYMM-NNNNN] [--dry-run] [--no-latest] [--skip-image] +# (without a version the next number of the current month is used) +# +# Environment: +# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft) +# GITEA_URL Gitea base URL (default https://git.familie-zink.org) +# GITEA_REPO owner/repo (default zink/codecraft) +# GITEA_TOKEN API token; if empty, the git credential helper is asked +# PLATFORMS buildx platforms (default linux/amd64,linux/arm64) +# BUILDER buildx builder name (default codecraft-multi, created if missing) +# +# Requires: git, docker with buildx (logged in to the registry), curl, jq. +# Building for a foreign architecture needs QEMU/binfmt: +# docker run --privileged --rm tonistiigi/binfmt --install all +set -euo pipefail + +REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}" +GITEA_URL="${GITEA_URL:-https://git.familie-zink.org}" +GITEA_REPO="${GITEA_REPO:-zink/codecraft}" +PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}" +BUILDER="${BUILDER:-codecraft-multi}" + +tag="" dry=0 latest=1 image=1 +for arg in "$@"; do + case "$arg" in + --dry-run) dry=1 ;; + --no-latest) latest=0 ;; + --skip-image) image=0 ;; + -h|--help) sed -n '2,26p' "$0"; exit 0 ;; + -*) echo "unknown option $arg" >&2; exit 2 ;; + *) tag="$arg" ;; + esac +done + +cd "$(git rev-parse --show-toplevel)" +step() { printf '\n==> %s\n' "$*"; } +run() { if (( dry )); then echo "[dry-run] $*"; else "$@"; fi; } +fail() { echo "error: $*" >&2; exit 1; } + +step "Checking the working tree" +for cmd in git docker curl jq; do command -v "$cmd" >/dev/null || fail "$cmd is not installed"; done +[[ -z "$(git status --porcelain --untracked-files=no)" ]] || fail "uncommitted changes – commit or stash them first" +run git fetch --tags --quiet origin + +if [[ -z "$tag" ]]; then + month=$(date +%Y%m) + last=$(git tag -l "v$month-*" | sed -n "s/^v$month-\([0-9]\{5\}\)$/\1/p" | sort -n | tail -1) + tag=$(printf 'v%s-%05d' "$month" $((10#${last:-0} + 1))) +fi +[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag" +git rev-parse -q --verify "refs/tags/$tag" >/dev/null && fail "tag $tag already exists" +version=${tag#v} +today=$(date +%Y-%m-%d) +echo "release $tag" + +# Release notes: the [Unreleased] section of the changelog (without its heading). +notes=$(awk '/^## \[Unreleased\]/ { found=1; next } found && /^## \[/ { exit } found { print }' CHANGELOG.md) +[[ -n "${notes//[[:space:]]/}" ]] || fail "CHANGELOG.md has no (or an empty) \"## [Unreleased]\" section" +notes+=$'\n\n'"Image: \`$REGISTRY:$tag\` ($(tr ',' ' ' <<<"$PLATFORMS"))." + +step "Naming the release in CHANGELOG.md, whatsnew.ts and README.md" +if (( dry )); then + echo "[dry-run] ## [Unreleased] -> ## [$tag] – $today; whatsnew unreleased -> \"$version\"; README badge" +else + sed -i "s/^## \[Unreleased\].*/## [$tag] – $today/" CHANGELOG.md + grep -q '^ unreleased: {' web/src/whatsnew.ts && sed -i "s/^ unreleased: {/ \"$version\": {/" web/src/whatsnew.ts + # shields.io escapes "-" as "--". + sed -i -E "s|(alt=\"Version )[^\"]*(\" src=\"https://img.shields.io/badge/version-)[^?]*-([0-9a-f]{6}\?)|\1$tag\2${tag//-/--}-\3|" README.md + git add CHANGELOG.md web/src/whatsnew.ts README.md + git commit -q -m "Release $tag" +fi + +step "Tagging $tag and pushing" +branch=$(git rev-parse --abbrev-ref HEAD) +run git tag -a "$tag" -m "CodeCraft $tag" +run git push origin "$branch" "$tag" + +if (( image )); then + step "Building and pushing $REGISTRY:$tag for $PLATFORMS" + docker buildx inspect "$BUILDER" >/dev/null 2>&1 || run docker buildx create --name "$BUILDER" --driver docker-container >/dev/null + tags=(-t "$REGISTRY:$tag") + (( latest )) && tags+=(-t "$REGISTRY:latest") + run docker buildx build --builder "$BUILDER" --platform "$PLATFORMS" \ + --build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" --build-arg CODECRAFT_VERSION="$version" \ + "${tags[@]}" --push . +fi + +step "Creating the Gitea release $tag" +token="${GITEA_TOKEN:-}" +user="" +if [[ -z "$token" ]]; then + host=${GITEA_URL#*://} + creds=$(printf 'protocol=%s\nhost=%s\n\n' "${GITEA_URL%%://*}" "${host%%/*}" | git credential fill 2>/dev/null || true) + user=$(sed -n 's/^username=//p' <<<"$creds") + token=$(sed -n 's/^password=//p' <<<"$creds") +fi +[[ -n "$token" ]] || fail "no Gitea credentials – set GITEA_TOKEN" +auth=(-H "Authorization: token $token") +[[ -n "$user" ]] && auth=(-u "$user:$token") +body=$(jq -n --arg tag "$tag" --arg name "CodeCraft $tag" --arg body "$notes" \ + '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}') +if (( dry )); then + echo "[dry-run] POST $GITEA_URL/api/v1/repos/$GITEA_REPO/releases" +else + url=$(curl -fsS "${auth[@]}" -H 'Content-Type: application/json' -X POST \ + "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" -d "$body" | jq -r .html_url) + echo "published: $url" +fi + +step "Done" +echo "Update a deployment: set CODECRAFT_VERSION=$tag in .env, then docker compose pull && docker compose up -d" diff --git a/server/src/api.rs b/server/src/api.rs index 6ded720..34ab8e2 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -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, State(app): State, 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, State(app): State, Path(name): Path) -> 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, State(app): State, 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, State(app): State, Path(name): Path) -> Result { require_admin(&user)?; if !crate::backup::list(&app).iter().any(|b| b.name == name) { @@ -1138,10 +1169,85 @@ async fn health(State(app): State) -> Json { 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, State(app): State) -> Json { + let admin = user.is_admin(); + let cfg = app.config(); + let registry = app.registry(); + let health = app.health.all(); + let providers: serde_json::Map = 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 = 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) -> Json { 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, + kind: String, + base_url: String, + #[serde(default)] + api_key: String, + #[serde(default)] + auth_prefix: Option, +} + +/// Check URL and key from the edit form without saving them. +async fn test_provider(Extension(user): Extension, State(app): State, Json(body): Json) -> 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, State(app): State) -> ApiResult { require_admin(&user)?; crate::health::check_all(&app).await; @@ -1220,6 +1326,7 @@ async fn create_images(Extension(user): Extension, State(app): State Result> { .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)> { + 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) -> 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 { + 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 { /// Create a backup now: local file (+ pruning) and S3 upload if configured. pub async fn run(app: &App) -> Result { + 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 { 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. diff --git a/server/src/cli_api.rs b/server/src/cli_api.rs index d6985e6..6c74b06 100644 --- a/server/src/cli_api.rs +++ b/server/src/cli_api.rs @@ -47,7 +47,7 @@ pub async fn config(Extension(user): Extension, State(app): State, State(app): State>(256); diff --git a/server/src/config.rs b/server/src/config.rs index e25bb21..57749e2 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -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, + /// "preview" (cheap drafts) or "final" (best quality). Known fal.ai models have defaults. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stage: Option, + /// What the model is good at: photo, illustration, text, design. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub good_for: Vec, + /// 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, } impl Default for ImagesConfig { diff --git a/server/src/health.rs b/server/src/health.rs index ed7a8a0..0c5c7bd 100644 --- a/server/src/health.rs +++ b/server/src/health.rs @@ -33,29 +33,32 @@ impl Health { } } +/// Fetch the model list once and classify the answer. +pub async fn probe(provider: Arc) -> (ProviderStatus, usize) { + let start = Instant::now(); + let res = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await; + let (error, count): (Option, 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 = 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 { diff --git a/server/src/images.rs b/server/src/images.rs index e34b4a1..7e86764 100644 --- a/server/src/images.rs +++ b/server/src/images.rs @@ -34,6 +34,8 @@ pub struct GenerateRequest { pub dir: PathBuf, pub user_id: Option, pub session_id: Option, + /// Images to refine (sent to models with a `reference_param`). + pub references: Vec, } fn slug(s: &str) -> String { @@ -47,14 +49,90 @@ fn slug(s: &str) -> String { out.trim_matches('-').chars().take(40).collect::().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 { + 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 { + 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> { + 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, String)> { @@ -82,15 +160,22 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result 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::()) + 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::()) } async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { 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()); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 419adea..4335948 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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; diff --git a/server/src/mcp.rs b/server/src/mcp.rs index 9ec72dc..6663f5d 100644 --- a/server/src/mcp.rs +++ b/server/src/mcp.rs @@ -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 { + self.states.lock().unwrap().clone() + } + pub fn status_json(&self) -> Value { let states = self.states.lock().unwrap(); let servers = self.servers.lock().unwrap(); diff --git a/server/src/runner.rs b/server/src/runner.rs index 4eb6a2f..9bd98fa 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -298,6 +298,7 @@ pub async fn run_session(app: Arc, 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, 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, session_id: &str, model_spec: Option, 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, 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?; diff --git a/server/src/update.rs b/server/src/update.rs index 99296c3..fad25be 100644 --- a/server/src/update.rs +++ b/server/src/update.rs @@ -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>, } -fn newer(a: &str, b: &str) -> bool { - let p = |v: &str| v.trim_start_matches('v').split(['.', '-']).take(3).map(|x| x.parse::().unwrap_or(0)).collect::>(); - p(a) > p(b) -} +use codecraft_core::version::newer; async fn check(app: &App) -> Option { let url = app.config().server.update_url.clone(); @@ -46,12 +43,4 @@ pub fn spawn_checker(app: Arc) { }); } -#[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")); - } -} + diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index c8ace06..4e9ff3e 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -461,6 +461,33 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re return savePatches(patches); }); + // Check URL and key as entered, without saving. + const [testResult, setTestResult] = useState<{ ok: boolean; text: string }>(); + const [testing, setTesting] = useState(false); + const test = async () => { + setTesting(true); + setTestResult(undefined); + try { + const r = await post<{ status: ProviderStatus; models: number }>("/providers/test", { + name, + kind: form.kind, + base_url: form.base_url, + api_key: form.api_key, + auth_prefix: form.auth_prefix ?? "", + }); + const st = r.status; + setTestResult( + st.up + ? { ok: true, text: t("settings.testOk", { ms: st.latency_ms, n: r.models }) } + : { ok: false, text: `${st.state === "auth" ? t("settings.providerAuth") : t("settings.providerDown")}: ${st.error ?? ""}` }, + ); + } catch (e: any) { + setTestResult({ ok: false, text: e.message }); + } finally { + setTesting(false); + } + }; + const remove = async () => { if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return; void run(() => savePatches([{ path: ["providers", name], value: null }])); @@ -614,7 +641,15 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re {models.length === 0 &&
{t("settings.modelsEmptyHint")}
} {view} + {testResult && ( +
+ {testResult.text} +
+ )}
+ @@ -637,9 +672,10 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) const providers = cfg.config.providers ?? {}; const [status, setStatus] = useState>({}); const [checking, setChecking] = useState(false); + // Saving a provider re-checks it on the server, so refresh whenever the config changes. useEffect(() => { get>("/providers/status").then(setStatus).catch(() => {}); - }, []); + }, [cfg]); const recheck = () => { setChecking(true); post>("/providers/status") diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index b466f4b..228838e 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import { Icon } from "./Icon"; +import { StatusLeds } from "./StatusLeds"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { get, patch, post } from "../api"; @@ -102,7 +103,6 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: const t = useT(); const sessions = useStore((s) => s.sessions); const busy = useStore((s) => s.busy); - const connected = useStore((s) => s.connected); const permissions = useStore((s) => s.permissions); const questions = useStore((s) => s.questions); const me = useStore((s) => s.me); @@ -187,7 +187,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: CodeCraft
- +
+ {open && ( +
+
+
+ {t("status.server")} +
+
+ {connected ? t("sidebar.connected") : t("sidebar.disconnected")} + {data && v{data.version}} +
+
+ +
+
+ {t("status.models")} + + {admin && ( + + )} +
+ {providers.length === 0 &&
{t("status.noProviders")}
} + {providers.map(([name, p]) => ( +
+ + {name} + {providerText(p)} +
+ ))} +
+ +
+
+ {t("status.mcp")} +
+ {mcp.length === 0 &&
{t("status.noMcp")}
} + {mcp.map(([name, m]) => ( +
+ + {name} + {m.state === "connected" ? t("mcp.tools", { n: m.tools ?? 0 }) : t(`mcp.state.${m.state}` as Key)} +
+ ))} +
+ + {admin && ( +
+ + +
+ )} +
+ )} +
+ ); +} diff --git a/web/src/components/settings/Backups.tsx b/web/src/components/settings/Backups.tsx index 89d2073..45d4027 100644 --- a/web/src/components/settings/Backups.tsx +++ b/web/src/components/settings/Backups.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react"; -import { get, patch, post } from "../../api"; -import { toast } from "../../dialogs"; +import { useEffect, useRef, useState } from "react"; +import { api, get, patch, post } from "../../api"; +import { confirmDialog, toast } from "../../dialogs"; import { useFetch } from "../../hooks"; import { fmtDate, useT } from "../../i18n"; import { formatSize } from "../FileFigure"; @@ -90,6 +90,32 @@ export function Backups() { } }; + // Restore: the server stages the backup, restarts and applies it before opening the database. + const fileInput = useRef(null); + const [restoring, setRestoring] = useState(false); + const restore = async (source: { name: string } | { file: File }) => { + const label = "name" in source ? source.name : source.file.name; + const ok = await confirmDialog({ title: t("backup.restoreTitle"), message: t("backup.restoreConfirm", { file: label }), confirmLabel: t("backup.restore"), danger: true }); + if (!ok) return; + setRestoring(true); + try { + if ("name" in source) await post(`/backups/${encodeURIComponent(source.name)}/restore`); + else await api("/backups/restore", { method: "POST", body: source.file, headers: { "Content-Type": "application/zip" } }); + toast(t("backup.restarting"), "info"); + // Wait until the server is gone and back, then reload with the restored data. + await new Promise((r) => setTimeout(r, 2500)); + for (let i = 0; i < 120; i++) { + if (await fetch("/api/health").then((r) => r.ok).catch(() => false)) return location.reload(); + await new Promise((r) => setTimeout(r, 1000)); + } + toast(t("backup.notBack")); + } catch (e: any) { + toast(e.message); + } finally { + setRestoring(false); + } + }; + const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") => form && (