Compare commits
2
Commits
v0.1.0
...
v202611-00001
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70367211c1 | ||
|
|
1f69d63b09 |
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
docker-compose.override.yml
|
||||
.env.bak*
|
||||
@@ -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.
|
||||
|
||||
## [v202611-00001] – 2026-11-09
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version 0.1.0" src="https://img.shields.io/badge/version-0.1.0-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00001" src="https://img.shields.io/badge/version-v202611--00001-d69e2e?style=flat-square">
|
||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
@@ -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 <registry>/codecraft:<version> --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.
|
||||
|
||||
|
||||
+5
-6
@@ -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<String>,
|
||||
@@ -164,8 +164,7 @@ fn prompt_line(label: &str, secret: bool) -> Result<String> {
|
||||
}
|
||||
|
||||
fn version_newer(server: &str, ours: &str) -> bool {
|
||||
let parse = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
|
||||
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`");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+8
-1
@@ -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" },
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod permission;
|
||||
pub mod prompts;
|
||||
pub mod provider;
|
||||
pub mod types;
|
||||
pub mod version;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<u64>) {
|
||||
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::<u64>().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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Executable
+122
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
+15
-12
@@ -33,19 +33,14 @@ impl Health {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
/// 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: Option<String> = match res {
|
||||
Ok(Ok(_)) => None,
|
||||
Ok(Err(e)) => Some(e.to_string().chars().take(300).collect()),
|
||||
Err(_) => Some("timed out".to_string()),
|
||||
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")));
|
||||
@@ -54,8 +49,16 @@ pub async fn check_all(app: &App) {
|
||||
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() })
|
||||
(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 { (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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
|
||||
{view}
|
||||
{testResult && (
|
||||
<div className={`notice ${testResult.ok ? "ok" : "err"}`} style={{ marginTop: 12 }}>
|
||||
{testResult.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button onClick={test} disabled={testing || !form.base_url}>
|
||||
<Icon name="plug" /> {testing ? t("settings.testing") : t("settings.testConnection")}
|
||||
</button>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
@@ -637,9 +672,10 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
|
||||
const providers = cfg.config.providers ?? {};
|
||||
const [status, setStatus] = useState<Record<string, ProviderStatus>>({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
// Saving a provider re-checks it on the server, so refresh whenever the config changes.
|
||||
useEffect(() => {
|
||||
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
}, [cfg]);
|
||||
const recheck = () => {
|
||||
setChecking(true);
|
||||
post<Record<string, ProviderStatus>>("/providers/status")
|
||||
|
||||
@@ -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?:
|
||||
<img src="/icon.svg" alt="" />
|
||||
CodeCraft
|
||||
</div>
|
||||
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
|
||||
<StatusLeds />
|
||||
</div>
|
||||
<div style={{ padding: "0 12px 8px" }}>
|
||||
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { get, post } from "../api";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
type Level = "ok" | "warn" | "err" | "off";
|
||||
|
||||
interface ProviderState {
|
||||
state: "ok" | "auth" | "down" | "unknown";
|
||||
latency_ms?: number;
|
||||
checked_at?: number;
|
||||
error?: string | null;
|
||||
models: number;
|
||||
}
|
||||
interface McpEntry {
|
||||
state: "connected" | "connecting" | "failed";
|
||||
tools?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
interface Overview {
|
||||
version: string;
|
||||
providers: Record<string, ProviderState>;
|
||||
mcp: Record<string, McpEntry>;
|
||||
}
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
|
||||
const RECONNECT_GRACE_MS = 10_000;
|
||||
|
||||
/** Combined level of a group: all fine → ok, some broken → warn, none working → err. */
|
||||
function combine(levels: Level[]): Level {
|
||||
const active = levels.filter((l) => l !== "off");
|
||||
if (active.length === 0) return "off";
|
||||
if (active.every((l) => l === "ok")) return "ok";
|
||||
if (active.some((l) => l === "ok" || l === "warn")) return "warn";
|
||||
return "err";
|
||||
}
|
||||
|
||||
const providerLevel = (p: ProviderState): Level => (p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err");
|
||||
const mcpLevel = (m: McpEntry): Level => (m.state === "connected" ? "ok" : m.state === "connecting" ? "warn" : "err");
|
||||
|
||||
function Led({ level, title }: { level: Level; title?: string }) {
|
||||
return <span className={`led led-${level}`} title={title} />;
|
||||
}
|
||||
|
||||
/** Three status LEDs (server, models, MCP) in the sidebar; a click opens the details. */
|
||||
export function StatusLeds() {
|
||||
const t = useT();
|
||||
const connected = useStore((s) => s.connected);
|
||||
const admin = useStore((s) => s.me?.role === "admin");
|
||||
const [data, setData] = useState<Overview>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [lostAt, setLostAt] = useState<number>();
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>();
|
||||
const [, tick] = useState(0);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(() => get<Overview>("/status").then(setData).catch(() => {}), []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Track when the event connection dropped (yellow while reconnecting, red after a while).
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
setLostAt(undefined);
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
setLostAt((v) => v ?? Date.now());
|
||||
const id = setTimeout(() => tick((n) => n + 1), RECONNECT_GRACE_MS + 100);
|
||||
return () => clearTimeout(id);
|
||||
}, [connected, load]);
|
||||
|
||||
// Close on outside click and Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => !root.current?.contains(e.target as Node) && setOpen(false);
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const server: Level = connected ? "ok" : lostAt && Date.now() - lostAt > RECONNECT_GRACE_MS ? "err" : "warn";
|
||||
const providers = Object.entries(data?.providers ?? {});
|
||||
const mcp = Object.entries(data?.mcp ?? {});
|
||||
const models: Level = data ? (providers.length ? combine(providers.map(([, p]) => providerLevel(p))) : "err") : "off";
|
||||
const tools: Level = combine(mcp.map(([, m]) => mcpLevel(m)));
|
||||
|
||||
const label = (l: Level) => t(`status.level.${l}` as Key);
|
||||
const summary = `${t("status.server")}: ${label(server)} · ${t("status.models")}: ${label(models)} · ${t("status.mcp")}: ${label(tools)}`;
|
||||
|
||||
const recheck = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
await post("/providers/status");
|
||||
await load();
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
const go = (tab: string) => {
|
||||
setOpen(false);
|
||||
navigate(`/settings/${tab}`);
|
||||
};
|
||||
|
||||
const providerText = (p: ProviderState) =>
|
||||
p.state === "ok"
|
||||
? t("status.providerOk", { n: p.models, ms: p.latency_ms ?? 0 })
|
||||
: p.state === "auth"
|
||||
? t("settings.providerAuth")
|
||||
: p.state === "down"
|
||||
? t("settings.providerDown")
|
||||
: t("status.notChecked");
|
||||
|
||||
return (
|
||||
<div className="status-leds" ref={root}>
|
||||
<button
|
||||
className="ghost status-leds-btn"
|
||||
onClick={(e) => {
|
||||
// Fixed position below the LEDs, kept inside the window.
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - 328)), top: r.bottom + 6 });
|
||||
setOpen(!open);
|
||||
}} title={summary} aria-label={summary} aria-expanded={open}>
|
||||
<Led level={server} />
|
||||
<Led level={models} />
|
||||
<Led level={tools} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="status-pop" role="dialog" aria-label={t("status.title")} style={pos}>
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={server} /> {t("status.server")}
|
||||
</div>
|
||||
<div className="status-row">
|
||||
<span className="grow">{connected ? t("sidebar.connected") : t("sidebar.disconnected")}</span>
|
||||
{data && <span className="hint">v{data.version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={models} /> {t("status.models")}
|
||||
<span className="spacer" />
|
||||
{admin && (
|
||||
<button className="small ghost" onClick={recheck} disabled={checking} title={t("status.recheck")}>
|
||||
<Icon name="refresh" size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{providers.length === 0 && <div className="status-row hint">{t("status.noProviders")}</div>}
|
||||
{providers.map(([name, p]) => (
|
||||
<div key={name} className="status-row" title={p.error ?? undefined}>
|
||||
<Led level={providerLevel(p)} />
|
||||
<span className="grow mono">{name}</span>
|
||||
<span className="hint">{providerText(p)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={tools} /> {t("status.mcp")}
|
||||
</div>
|
||||
{mcp.length === 0 && <div className="status-row hint">{t("status.noMcp")}</div>}
|
||||
{mcp.map(([name, m]) => (
|
||||
<div key={name} className="status-row" title={m.error ?? undefined}>
|
||||
<Led level={mcpLevel(m)} />
|
||||
<span className="grow mono">{name}</span>
|
||||
<span className="hint">{m.state === "connected" ? t("mcp.tools", { n: m.tools ?? 0 }) : t(`mcp.state.${m.state}` as Key)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{admin && (
|
||||
<div className="status-foot">
|
||||
<button className="small ghost" onClick={() => go("providers")}>
|
||||
<Icon name="brain" size={13} /> {t("status.providerSettings")}
|
||||
</button>
|
||||
<button className="small ghost" onClick={() => go("mcp")}>
|
||||
<Icon name="plug" size={13} /> {t("status.mcpSettings")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>(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 && (
|
||||
<label className="field">
|
||||
@@ -111,6 +137,20 @@ export function Backups() {
|
||||
<a className="button small" href="/api/backup">
|
||||
<Icon name="download" /> {t("audit.download")}
|
||||
</a>
|
||||
<button className="small" onClick={() => fileInput.current?.click()} disabled={restoring}>
|
||||
<Icon name="upload" /> {restoring ? t("backup.restoring") : t("backup.restoreFile")}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void restore({ file });
|
||||
}}
|
||||
/>
|
||||
<button className="small primary" onClick={runNow} disabled={busy}>
|
||||
{busy ? t("backup.running") : t("backup.now")}
|
||||
</button>
|
||||
@@ -176,12 +216,17 @@ export function Backups() {
|
||||
{data && data.files.length > 0 && (
|
||||
<div className="backup-list">
|
||||
{data.files.map((f) => (
|
||||
<a key={f.name} href={`/api/backups/${encodeURIComponent(f.name)}`} className="backup-file">
|
||||
<div key={f.name} className="backup-file">
|
||||
<Icon name="file" />
|
||||
<span className="grow">{f.name}</span>
|
||||
<span className="hint">{formatSize(f.size)}</span>
|
||||
<button className="small" title={t("backup.restore")} onClick={() => void restore({ name: f.name })} disabled={restoring}>
|
||||
<Icon name="undo" />
|
||||
</button>
|
||||
<a className="button small" title={t("audit.download")} href={`/api/backups/${encodeURIComponent(f.name)}`}>
|
||||
<Icon name="download" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -204,6 +204,21 @@ export const de: Messages = {
|
||||
"sidebar.yesterday": "Gestern",
|
||||
"sidebar.week": "Letzte 7 Tage",
|
||||
"sidebar.older": "Älter",
|
||||
"status.title": "Status",
|
||||
"status.server": "Server",
|
||||
"status.models": "Modelle",
|
||||
"status.mcp": "MCP",
|
||||
"status.level.ok": "alles in Ordnung",
|
||||
"status.level.warn": "teilweise nicht verfügbar",
|
||||
"status.level.err": "nicht verfügbar",
|
||||
"status.level.off": "keine konfiguriert",
|
||||
"status.providerOk": "{n} Modelle · {ms} ms",
|
||||
"status.notChecked": "noch nicht geprüft",
|
||||
"status.recheck": "Erneut prüfen",
|
||||
"status.noProviders": "Keine Provider aktiv.",
|
||||
"status.noMcp": "Keine MCP-Server konfiguriert.",
|
||||
"status.providerSettings": "Provider",
|
||||
"status.mcpSettings": "MCP-Server",
|
||||
"sidebar.connected": "Verbunden",
|
||||
"sidebar.disconnected": "Getrennt – verbinde neu…",
|
||||
"sidebar.needsInput": "Wartet auf deine Eingabe",
|
||||
@@ -464,6 +479,13 @@ export const de: Messages = {
|
||||
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
|
||||
"sidebar.folderName": "Ordnername",
|
||||
"sidebar.move": "Verschieben",
|
||||
"backup.restore": "Wiederherstellen",
|
||||
"backup.restoreFile": "Aus Datei wiederherstellen…",
|
||||
"backup.restoring": "Wird wiederhergestellt…",
|
||||
"backup.restoreTitle": "Backup wiederherstellen?",
|
||||
"backup.restoreConfirm": "Datenbank und Konfiguration durch {file} ersetzen? Alle Sitzungen, Benutzer, Statistiken und Einstellungen werden durch den Stand im Backup ersetzt; Dateien im Workspace bleiben unverändert. Der aktuelle Stand wird vorher gesichert. CodeCraft startet neu – laufende Agents werden beendet.",
|
||||
"backup.restarting": "Backup übernommen – CodeCraft startet neu…",
|
||||
"backup.notBack": "Der Server ist nicht zurückgekommen. Er braucht eine Restart-Policy (Docker: restart: unless-stopped) – bitte von Hand starten.",
|
||||
"backup.now": "Jetzt sichern",
|
||||
"backup.running": "Sichere…",
|
||||
"backup.created": "Backup {file} erstellt.",
|
||||
@@ -647,6 +669,9 @@ export const de: Messages = {
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.providerUp": "erreichbar · {ms} ms",
|
||||
"settings.providerDown": "nicht erreichbar",
|
||||
"settings.testConnection": "Verbindung testen",
|
||||
"settings.testing": "Teste…",
|
||||
"settings.testOk": "Verbindung klappt: {n} Modelle, {ms} ms. Noch nicht gespeichert.",
|
||||
"settings.providerAuth": "API-Key fehlt oder ungültig",
|
||||
"settings.providerCheck": "Provider prüfen",
|
||||
"settings.providerChecking": "Prüfe…",
|
||||
|
||||
@@ -203,6 +203,21 @@ export const en = {
|
||||
"sidebar.yesterday": "Yesterday",
|
||||
"sidebar.week": "Last 7 days",
|
||||
"sidebar.older": "Older",
|
||||
"status.title": "Status",
|
||||
"status.server": "Server",
|
||||
"status.models": "Models",
|
||||
"status.mcp": "MCP",
|
||||
"status.level.ok": "all fine",
|
||||
"status.level.warn": "partly unavailable",
|
||||
"status.level.err": "unavailable",
|
||||
"status.level.off": "none configured",
|
||||
"status.providerOk": "{n} models · {ms} ms",
|
||||
"status.notChecked": "not checked yet",
|
||||
"status.recheck": "Check again",
|
||||
"status.noProviders": "No providers enabled.",
|
||||
"status.noMcp": "No MCP servers configured.",
|
||||
"status.providerSettings": "Providers",
|
||||
"status.mcpSettings": "MCP servers",
|
||||
"sidebar.connected": "Connected",
|
||||
"sidebar.disconnected": "Disconnected – reconnecting…",
|
||||
"sidebar.needsInput": "Waiting for your input",
|
||||
@@ -463,6 +478,13 @@ export const en = {
|
||||
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
|
||||
"sidebar.folderName": "Folder name",
|
||||
"sidebar.move": "Move",
|
||||
"backup.restore": "Restore",
|
||||
"backup.restoreFile": "Restore from file…",
|
||||
"backup.restoring": "Restoring…",
|
||||
"backup.restoreTitle": "Restore backup?",
|
||||
"backup.restoreConfirm": "Replace the database and the configuration with {file}? All sessions, users, statistics and settings are replaced by the state in the backup; files in the workspace stay as they are. The current state is backed up first. CodeCraft restarts – running agents are stopped.",
|
||||
"backup.restarting": "Backup accepted – CodeCraft is restarting…",
|
||||
"backup.notBack": "The server did not come back. It needs a restart policy (Docker: restart: unless-stopped) – start it again by hand.",
|
||||
"backup.now": "Back up now",
|
||||
"backup.running": "Backing up…",
|
||||
"backup.created": "Backup {file} created.",
|
||||
@@ -646,6 +668,9 @@ export const en = {
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.providerUp": "reachable · {ms} ms",
|
||||
"settings.providerDown": "unreachable",
|
||||
"settings.testConnection": "Test connection",
|
||||
"settings.testing": "Testing…",
|
||||
"settings.testOk": "Connection works: {n} models, {ms} ms. Not saved yet.",
|
||||
"settings.providerAuth": "API key missing or rejected",
|
||||
"settings.providerCheck": "Check providers",
|
||||
"settings.providerChecking": "Checking…",
|
||||
|
||||
+117
-7
@@ -132,14 +132,120 @@ textarea:focus {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.conn-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
/* --- status LEDs (sidebar) ------------------------------------------------------ */
|
||||
/* Theme hues, pushed to a vivid chroma and a readable lightness so the state is
|
||||
obvious in every theme; warn takes the red's lightness/chroma at an amber hue. */
|
||||
:root {
|
||||
--led-ok: #22c55e;
|
||||
--led-warn: #f5a700;
|
||||
--led-err: #ef4444;
|
||||
}
|
||||
.conn-dot.on {
|
||||
background: var(--green);
|
||||
@supports (color: oklch(from red l c h)) {
|
||||
:root {
|
||||
--led-ok: oklch(from var(--green) clamp(0.7, l, 0.8) max(c, 0.2) clamp(138, h, 158));
|
||||
--led-warn: oklch(from var(--red) clamp(0.78, l, 0.85) max(c, 0.17) 75);
|
||||
--led-err: oklch(from var(--red) clamp(0.6, l, 0.7) max(c, 0.21) h);
|
||||
}
|
||||
}
|
||||
.status-leds {
|
||||
position: relative;
|
||||
}
|
||||
.status-leds-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg2);
|
||||
}
|
||||
.led {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.led-ok {
|
||||
background: var(--led-ok);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--led-ok) 70%, transparent);
|
||||
}
|
||||
.led-warn {
|
||||
background: var(--led-warn);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--led-warn) 70%, transparent);
|
||||
}
|
||||
.led-err {
|
||||
background: var(--led-err);
|
||||
box-shadow: 0 0 7px color-mix(in srgb, var(--led-err) 80%, transparent);
|
||||
}
|
||||
.status-leds-btn .led-err {
|
||||
animation: led-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
.led-off {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1.5px var(--muted);
|
||||
}
|
||||
@keyframes led-blink {
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-leds-btn .led-err {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.status-pop {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.status-section + .status-section {
|
||||
border-top: 1px solid var(--bg3);
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.status-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 4px;
|
||||
font-weight: 600;
|
||||
color: var(--fg0);
|
||||
}
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px 4px 25px;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.status-row .led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-left: -15px;
|
||||
}
|
||||
.status-row .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-row .hint {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-foot {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--bg3);
|
||||
margin-top: 4px;
|
||||
padding: 6px 4px 2px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@@ -3413,6 +3519,10 @@ button.danger-solid {
|
||||
.backup-file .grow {
|
||||
flex: 1;
|
||||
}
|
||||
.backup-file button,
|
||||
.backup-file .button {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
/* --- automation templates ------------------------------------------------------ */
|
||||
.job-tpl {
|
||||
|
||||
+20
-1
@@ -1,5 +1,24 @@
|
||||
/** "What's new" entries per version (shown once after an update). */
|
||||
/**
|
||||
* "What's new" entries per version (shown once after an update). Add new entries
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00001": {
|
||||
en: [
|
||||
"Status LEDs in the sidebar for server, models and MCP – click them for details",
|
||||
"Images: cheap previews first, then the final image with the best model (Nano Banana Pro refines the chosen preview)",
|
||||
"Settings → Audit: restore a backup (stored or uploaded)",
|
||||
"Provider form: test the connection before saving; saving checks the status right away",
|
||||
"opencode Go works again (session header)",
|
||||
],
|
||||
de: [
|
||||
"Status-LEDs in der Seitenleiste für Server, Modelle und MCP – ein Klick zeigt die Details",
|
||||
"Bilder: erst günstige Vorschauen, dann das Endbild mit dem besten Modell (Nano Banana Pro verfeinert die gewählte Vorschau)",
|
||||
"Einstellungen → Audit: Backup wiederherstellen (gespeichert oder hochgeladen)",
|
||||
"Provider-Formular: Verbindung vor dem Speichern testen; Speichern prüft den Status sofort",
|
||||
"opencode Go funktioniert wieder (Session-Header)",
|
||||
],
|
||||
},
|
||||
"0.1.0": {
|
||||
en: [
|
||||
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",
|
||||
|
||||
Reference in new issue
Block a user