diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c10b6a..2342885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Chats side by side:** *Open beside* in a chat's sidebar menu shows up to three chats as + columns, each with its own tabs and composer (desktop). +- **My modes:** every user can create personal modes under *Settings → My modes* (kind, tools, + read-only, prompt, model); they appear only for that user. +- **Budget warnings** as push notifications for admins (warning share and 100 %) for the total, + provider and image budgets and the opencode Go windows; the status overlay shows all budgets. +- `scripts/move-server.sh` moves an installation to another Docker host with bind mounts + (e.g. Unraid), `scripts/smoke-test.sh` checks a running instance. + +### Changed + +- File manager tabs of a chat are stored on the server, so they are the same on every device. + ## [v202611-00003] – 2026-11-09 ### Added diff --git a/README.md b/README.md index ff16ce8..d35110a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,13 @@ volume `codecraft-data`; recreating the container keeps it. Your projects are mo WebSocket support) and set `auth.public_url` – installing the app and push notifications require HTTPS. +**Moving to another host** (for example Unraid with bind mounts instead of the Docker volume): +`scripts/move-server.sh root@newhost:/mnt/user/appdata/codecraft --workspace /mnt/user/projects` +copies the workspace, stops CodeCraft, streams the data volume to `/data`, writes a +`docker-compose.yml` (and a `start.sh` for hosts without Compose) with bind mounts, starts the +published image there and runs `scripts/smoke-test.sh`. The old container is only stopped, so +you can roll back. `--help` lists the options (owner, port, bind address, dry run). + ## How it works CodeCraft runs on your server, next to your projects. You talk to it from a browser, the @@ -154,6 +161,9 @@ output limit, reasoning effort, extra request options and prices per million tok model's fallback. - **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. +- **Budget warnings:** admins get a push notification when the total, a provider's or the image + budget, or an opencode Go window reaches the warning share (default 80 %) and when it is used + up; the status overlay shows all budgets with bars. - **Prices:** *Fetch prices* fills in prices for OpenRouter and opencode (Zen and Go), including long-context tiers and peak-hour factors, which are applied per request. - **Status:** every five minutes each provider's model list is fetched (no tokens used), so diff --git a/scripts/move-server.sh b/scripts/move-server.sh new file mode 100755 index 0000000..24db8a5 --- /dev/null +++ b/scripts/move-server.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Move this CodeCraft installation to another Docker host (e.g. Unraid) that keeps its +# data in bind mounts instead of the Docker volume "codecraft-data". +# +# Run it on the CURRENT host, in the repository folder (where .env lives): +# +# scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \ +# --workspace /mnt/user/projects --uid 99 --gid 100 --bind 0.0.0.0 +# +# What it does: +# 1. copies the workspace while CodeCraft keeps running (rsync, first pass), +# 2. stops the CodeCraft container here (it is kept for a rollback), +# 3. streams the data volume (database, config, backups, agent home) to /data, +# 4. syncs the workspace again (only what changed), +# 5. writes /docker-compose.yml, /.env and /start.sh (docker run, for hosts +# without Compose) with bind mounts, and starts the published image there, +# 6. runs scripts/smoke-test.sh on the new host. +# Afterwards point the reverse proxy to the new host. To roll back: start the old container +# here again (docker compose start) – nothing is deleted on this host. +# +# Target: user@host:/dir (SSH; rsync and docker on the target), or local:/dir to try it on +# this host (use --name/--port so it does not clash with the running installation). +# +# Options: +# --workspace DIR project folder on the target (default /workspace) +# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env) +# --port N host port on the target (default: PORT of .env) +# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1) +# --name NAME container name on the target (default codecraft) +# --source-container C container to stop here (default codecraft) +# --source-volume V data volume here (default codecraft-data) +# --source-workspace D project folder here (default: WORKSPACE of .env) +# --no-stop do not stop the source (only for tests – the copy may be inconsistent) +# --dry-run show the steps only +set -euo pipefail + +target="" ws_target="" uid="" gid="" port="" bind="" name="codecraft" +src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0 +while (($#)); do + case "$1" in + --workspace) ws_target="$2"; shift ;; + --uid) uid="$2"; shift ;; + --gid) gid="$2"; shift ;; + --port) port="$2"; shift ;; + --bind) bind="$2"; shift ;; + --name) name="$2"; shift ;; + --source-container) src_container="$2"; shift ;; + --source-volume) src_volume="$2"; shift ;; + --source-workspace) ws_source="$2"; shift ;; + --no-stop) stop=0 ;; + --dry-run) dry=1 ;; + -h|--help) sed -n '2,36p' "$0"; exit 0 ;; + -*) echo "unknown option $1" >&2; exit 2 ;; + *) target="$1" ;; + esac + shift +done +[[ "$target" == *:/* ]] || { echo "usage: $0 user@host:/dir | local:/dir [options] (see --help)" >&2; exit 2; } + +cd "$(git rev-parse --show-toplevel)" +[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; } +env_get() { sed -n "s/^$1=//p" .env | tail -1; } + +host="${target%%:*}" +dir="${target#*:}" +dir="${dir%/}" +ws_target="${ws_target:-$dir/workspace}" +uid="${uid:-$(env_get UID)}"; uid="${uid:-1000}" +gid="${gid:-$(env_get GID)}"; gid="${gid:-1000}" +port="${port:-$(env_get PORT)}"; port="${port:-8787}" +bind="${bind:-$(env_get BIND_ADDR)}"; bind="${bind:-127.0.0.1}" +version="$(env_get CODECRAFT_VERSION)"; version="${version:-latest}" +ws_source="${ws_source:-$(env_get WORKSPACE)}"; ws_source="${ws_source:-./workspace}" +image="$(docker inspect "$src_container" --format '{{.Config.Image}}' 2>/dev/null || true)" +image_repo="${image%:*}" +[[ -n "$image_repo" && "$image_repo" != codecraft ]] || image_repo="git.familie-zink.org/zink/codecraft" + +step() { printf '\n==> %s\n' "$*"; } +fail() { echo "error: $*" >&2; exit 1; } +# Run a shell command on the target. +remote() { + if (( dry )); then echo "[dry-run] on $host: $1"; return 0; fi + if [[ "$host" == local ]]; then bash -c "$1"; else ssh -o BatchMode=yes "$host" "$1"; fi +} +q() { printf '%q' "$1"; } +sync_workspace() { + if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi + if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/" + else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi +} +put_file() { # put_file < content + if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi + if [[ "$host" == local ]]; then cat >"$1"; else ssh -o BatchMode=yes "$host" "cat > $(q "$1")"; fi +} + +step "Checking source and target" +docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found" +[[ -d "$ws_source" ]] || fail "workspace $ws_source not found" +echo "source: container $src_container, volume $src_volume, workspace $ws_source" +echo "target: $host:$dir (data in $dir/data, workspace $ws_target), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version" +remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync" +remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \ + || fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)" + +step "Copying the workspace (first pass, CodeCraft keeps running)" +sync_workspace + +if (( stop )); then + step "Stopping $src_container here (running agents and terminals end)" + (( dry )) && echo "[dry-run] docker stop $src_container" || docker stop "$src_container" >/dev/null +fi + +step "Copying the data volume" +if (( dry )); then + echo "[dry-run] tar of volume $src_volume -> $host:$dir/data" +else + docker run --rm --entrypoint tar --user 0:0 -v "$src_volume:/data:ro" "${image:-$image_repo:$version}" -C /data -cf - . \ + | remote "tar -C $(q "$dir/data") -xpf -" +fi + +step "Copying the workspace (changes since the first pass)" +sync_workspace +remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") 2>/dev/null || echo 'note: could not chown (not root) – check file owners'" + +step "Writing docker-compose.yml, .env and start.sh" +put_file "$dir/docker/seccomp-sandbox.json" /dev/null 2>&1 || true +exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\ + -p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\ + -v $dir/data:/data -v "\$(val WORKSPACE):/workspace" \\ + --add-host host.docker.internal:host-gateway --shm-size 1g \\ + --security-opt seccomp=$dir/docker/seccomp-sandbox.json \\ + --security-opt apparmor=unconfined --security-opt systempaths=unconfined \\ + $image_repo:\$(val CODECRAFT_VERSION) +EOF +remote "chmod +x $(q "$dir/start.sh")" + +step "Starting CodeCraft on the target" +remote "cd $(q "$dir") && docker pull -q $image_repo:$version >/dev/null && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" \ + || fail "start failed – if the image pull was refused, run 'docker login ${image_repo%%/*}' on the target and start again with: cd $dir && docker compose up -d (or ./start.sh)" + +step "Smoke test on the target" +put_file "$dir/smoke-test.sh" /dev/null) && break + sleep 2 +done +[[ -n "$health" ]] && check "health" ok || check "health" "no answer after 2 minutes" +if [[ -n "$health" ]]; then + version=$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' <<<"$health") + if [[ -n "$want" && "$version" != "$want" ]]; then check "version $version" "expected $want"; else check "version ${version:-?}" ok; fi + grep -q '"ok":true' <<<"$health" && check "server reports ok" ok || check "server reports ok" "$health" +fi +code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/") +[[ "$code" == 200 ]] && check "web UI" ok || check "web UI" "HTTP $code" +code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/cli/install.sh") +[[ "$code" == 200 ]] && check "terminal client install script" ok || check "terminal client install script" "HTTP $code" +code=$(curl -s -o /dev/null -m 60 -w '%{http_code}' "$url/cli/download/linux-x86_64") +[[ "$code" == 200 ]] && check "terminal client download" ok || check "terminal client download" "HTTP $code" +exit $fail diff --git a/server/src/api.rs b/server/src/api.rs index f16afc6..4583a79 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -15,7 +15,7 @@ use axum::middleware; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post, put}; use axum::{Extension, Json, Router}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use std::path::PathBuf; use std::sync::Arc; @@ -78,6 +78,7 @@ pub fn router(app: AppState) -> Router { .route("/sessions/{id}/title", post(suggest_title)) .route("/sessions/{id}/regenerate", post(regenerate)) .route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action)) + .route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs)) .route("/sessions/{id}/export", get(export_session)) .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) .route("/sessions/{id}/prompt", post(prompt)) @@ -136,6 +137,7 @@ pub fn router(app: AppState) -> Router { .route("/account/git", get(get_git_settings).put(put_git_settings)) .route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs)) .route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory)) + .route("/account/modes", get(get_personal_modes).put(put_personal_modes)) .route("/templates", get(crate::cli_api::get_templates)) .route("/templates/{scope}", put(crate::cli_api::put_templates)) .route("/cli/config", get(crate::cli_api::config)) @@ -322,13 +324,43 @@ fn scope(app: &App, user: &User, cwd: Option) -> (PathBuf, PathBuf) { async fn agents(Extension(user): Extension, State(app): State, Query(q): Query) -> Json { let (root, cwd) = scope(&app, &user, q.cwd); - Json(json!(prompts::agents(&app, &root, &cwd))) + Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id)))) } -/// Delete a user-defined agent: its config entry and/or its Markdown file. -/// Built-in tools for the mode editor (name, read-only, first line of the description). -async fn agent_tools(Extension(user): Extension) -> ApiResult { - require_admin(&user)?; +/// The user's personal modes (name -> settings). +async fn get_personal_modes(Extension(user): Extension, State(app): State) -> ApiResult { + Ok(Json(json!(prompts::personal_modes(&app, &user.id)))) +} + +/// Replace the user's personal modes. Names of built-in, admin or project modes are +/// taken; tools and read-only work like in admin modes (the user's sandbox still applies). +async fn put_personal_modes(Extension(user): Extension, State(app): State, Json(modes): Json>) -> ApiResult { + if modes.len() > 30 { + return Err(ApiError(StatusCode::BAD_REQUEST, "too many personal modes (max 30)".into())); + } + let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace()); + let taken: Vec = prompts::agents(&app, &root, &root).into_iter().map(|a| a.name).collect(); + let mut clean = std::collections::BTreeMap::new(); + for (name, mut m) in modes { + if name.is_empty() || name.len() > 40 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid mode name '{name}' (letters, digits, - and _)"))); + } + if taken.contains(&name) { + return Err(ApiError(StatusCode::BAD_REQUEST, format!("the name '{name}' is already used by another mode"))); + } + if m.prompt.as_deref().is_some_and(|p| p.len() > 20_000) { + return Err(ApiError(StatusCode::BAD_REQUEST, "prompt too long (max 20,000 characters)".into())); + } + m.mode = Some("primary".into()); + m.max_parallel = None; + clean.insert(name, m); + } + app.store.set_user_setting(&user.id, "modes", &clean)?; + Ok(Json(json!(clean))) +} + +/// Built-in tools for the mode editors (name, read-only, first line of the description). +async fn agent_tools() -> ApiResult { let list: Vec = crate::tools::builtin_tools() .iter() .map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()})) @@ -336,6 +368,7 @@ async fn agent_tools(Extension(user): Extension) -> ApiResult { Ok(Json(json!(list))) } +/// Delete a user-defined agent: its config entry and/or its Markdown file. async fn delete_agent(Extension(user): Extension, State(app): State, Path(name): Path, Query(q): Query) -> ApiResult { let (root, cwd) = scope(&app, &user, q.cwd); let agent = prompts::agents(&app, &root, &cwd).into_iter().find(|a| a.name == name).ok_or_else(|| not_found("agent"))?; @@ -756,6 +789,40 @@ async fn update_session(Extension(user): Extension, State(app): State String { + format!("file_tabs:{session_id}") +} + +/// Tabs of a chat for this user: its terminals and file managers (kept on the server, +/// so they are the same on every device). +async fn session_tabs(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + readable(&app, &user, &id)?; + let files: Vec = app.store.user_setting(&user.id, &file_tabs_key(&id))?; + Ok(Json(json!({"terminals": app.terminals.for_session(&id, &user.id), "files": files}))) +} + +async fn put_file_tabs(Extension(user): Extension, State(app): State, Path(id): Path, Json(tabs): Json>) -> ApiResult { + readable(&app, &user, &id)?; + if tabs.len() > 20 { + return Err(ApiError(StatusCode::BAD_REQUEST, "too many file manager tabs".into())); + } + let roots = app.user_roots(Some(&user.id)); + for t in &tabs { + let p = crate::tools::normalize(&PathBuf::from(&t.dir)); + if t.id.len() > 64 || !roots.iter().any(|r| p.starts_with(r)) { + return Err(ApiError(StatusCode::FORBIDDEN, "folder is outside your folders".into())); + } + } + app.store.set_user_setting(&user.id, &file_tabs_key(&id), &tabs)?; + Ok(Json(json!({"ok": true}))) +} + async fn delete_session(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { writable(&app, &user, &id)?; // Notify before deleting: visibility is checked against the stored session. @@ -1225,7 +1292,9 @@ async fn status_overview(Extension(user): Extension, State(app): State) -> Json { diff --git a/server/src/budget.rs b/server/src/budget.rs index 09cdc29..2f66101 100644 --- a/server/src/budget.rs +++ b/server/src/budget.rs @@ -151,6 +151,124 @@ pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result { })) } +/// One budget gauge: what is used of a limit (monthly budgets, Go plan windows). +#[derive(Debug, Clone, serde::Serialize)] +pub struct Meter { + /// Stable id for alert state, e.g. "provider:anthropic" or "plan:opencode-go:5h". + pub key: String, + pub provider: String, + /// "total", "provider", "images" or the plan window ("5h", "week", "month"). + pub kind: String, + pub used: f64, + pub limit: f64, + pub ratio: f64, +} + +/// All configured budgets and plan windows with their current use (no remote calls). +pub fn meters(app: &App) -> Result> { + let cfg = app.config(); + let spent = app.store.cost_by_provider(month_start())?; + let mut out = Vec::new(); + let mut push = |key: String, provider: &str, kind: &str, used: f64, limit: f64| { + if limit > 0.0 { + out.push(Meter { key, provider: provider.into(), kind: kind.into(), used, limit, ratio: used / limit }); + } + }; + push("total".into(), "total", "total", spent.values().sum(), cfg.server.monthly_budget); + for (name, p) in cfg.providers.iter().filter(|(_, p)| p.enabled) { + push(format!("provider:{name}"), name, "provider", spent.get(name).copied().unwrap_or(0.0), p.monthly_budget.unwrap_or(0.0)); + if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) { + let plan = plan_usage(app, name, limit)?; + for w in plan["windows"].as_array().into_iter().flatten() { + let window = w["window"].as_str().unwrap_or(""); + push(format!("plan:{name}:{window}"), name, window, w["used"].as_f64().unwrap_or(0.0), w["limit"].as_f64().unwrap_or(0.0)); + } + } + } + if cfg.images.enabled { + push("images".into(), "fal", "images", spent.get("fal").copied().unwrap_or(0.0), cfg.images.monthly_budget.unwrap_or(0.0)); + } + Ok(out) +} + +const ALERT_STATE: &str = "budget_alert_state"; +/// Alert state is global; it is kept under a pseudo user id. +const SYSTEM: &str = "_system"; + +fn alert_text(m: &Meter, level: u8, de: bool) -> String { + let pct = (m.ratio * 100.0).round() as i64; + let what = match (m.kind.as_str(), de) { + ("total", true) => "Gesamtbudget des Monats".to_string(), + ("total", false) => "Monthly total budget".to_string(), + ("provider", true) => format!("Monatsbudget {}", m.provider), + ("provider", false) => format!("Monthly budget of {}", m.provider), + ("images", true) => "Bildbudget des Monats".to_string(), + ("images", false) => "Monthly image budget".to_string(), + ("5h", true) => format!("{}: 5-Stunden-Limit", m.provider), + ("5h", false) => format!("{}: 5-hour limit", m.provider), + ("week", true) => format!("{}: Wochenlimit", m.provider), + ("week", false) => format!("{}: weekly limit", m.provider), + (_, true) => format!("{}: Monatslimit des Plans", m.provider), + (_, false) => format!("{}: monthly plan limit", m.provider), + }; + match (level, de) { + (2, true) => format!("{what} ausgeschöpft ({pct} %)"), + (2, false) => format!("{what} used up ({pct} %)"), + (_, true) => format!("{what} zu {pct} % verbraucht"), + (_, false) => format!("{what} {pct} % used"), + } +} + +/// Push admins when a budget or plan window crosses the warning share or 100 %. Each +/// level is sent once; dropping below (new month, rolling window) re-arms it. +pub async fn check_alerts(app: &App) -> Result<()> { + let warn = app.config().server.budget_warning; + let mut state: std::collections::BTreeMap = app.store.user_setting(SYSTEM, ALERT_STATE)?; + let mut alerts = Vec::new(); + let meters = meters(app)?; + for m in &meters { + let level = if m.ratio >= 1.0 { 2 } else if warn > 0.0 && m.ratio >= warn { 1 } else { 0 }; + let before = state.get(&m.key).copied().unwrap_or(0); + if level > before { + alerts.push((m.clone(), level)); + } + state.insert(m.key.clone(), level); + } + state.retain(|k, _| meters.iter().any(|m| &m.key == k)); + app.store.set_user_setting(SYSTEM, ALERT_STATE, &state)?; + if alerts.is_empty() { + return Ok(()); + } + for user in app.store.users()?.into_iter().filter(|u| u.is_admin() && !u.disabled) { + let prefs: Value = app.store.user_setting(&user.id, "prefs").unwrap_or_default(); + let de = prefs["lang"].as_str().unwrap_or("").starts_with("de"); + let body = alerts.iter().map(|(m, l)| alert_text(m, *l, de)).collect::>().join("\n"); + let used_up = alerts.iter().any(|(_, l)| *l == 2); + let title = match (used_up, de) { + (true, true) => "CodeCraft – Budget ausgeschöpft", + (true, false) => "CodeCraft – budget used up", + (false, true) => "CodeCraft – Budget fast verbraucht", + (false, false) => "CodeCraft – budget nearly used", + }; + let payload = json!({"kind": "budget", "title": title, "body": body, "tag": "budget", "url": "/#/stats"}); + let (sent, _) = crate::push::send_to_user(app, &user.id, &payload).await; + tracing::info!(user = %user.username, sent, alerts = alerts.len(), "budget alert"); + } + Ok(()) +} + +pub fn spawn_alerts(app: std::sync::Arc) { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(120)).await; + loop { + if let Err(e) = check_alerts(&app).await { + tracing::warn!("budget alerts: {e:#}"); + } + tokio::time::sleep(Duration::from_secs(600)).await; + } + }); +} + pub async fn overview(app: &App) -> Result { let cfg = app.config(); let spent = app.store.cost_by_provider(month_start())?; @@ -384,6 +502,14 @@ mod tests { assert_eq!(mc.price_tiers[0].above_context, 200000); } + #[test] + fn alert_texts() { + let m = super::Meter { key: "plan:go:5h".into(), provider: "opencode-go".into(), kind: "5h".into(), used: 1.7, limit: 2.0, ratio: 0.85 }; + assert_eq!(super::alert_text(&m, 1, true), "opencode-go: 5-Stunden-Limit zu 85 % verbraucht"); + let t = super::Meter { key: "total".into(), provider: "total".into(), kind: "total".into(), used: 10.0, limit: 10.0, ratio: 1.0 }; + assert_eq!(super::alert_text(&t, 2, false), "Monthly total budget used up (100 %)"); + } + #[test] fn budget_entry_flags() { let e = super::entry("x", Some(10.0), 8.5, 0.8); diff --git a/server/src/cli_api.rs b/server/src/cli_api.rs index e3f3201..580e82f 100644 --- a/server/src/cli_api.rs +++ b/server/src/cli_api.rs @@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken; pub async fn config(Extension(user): Extension, State(app): State) -> ApiResult { let cfg = app.config(); let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace()); - let agents: Vec = crate::prompts::agents(&app, &root, &root) + let agents: Vec = crate::prompts::agents_for(&app, &root, &root, Some(&user.id)) .into_iter() .map(|a| { let mut v = json!(a); diff --git a/server/src/main.rs b/server/src/main.rs index 4335948..6323f0d 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -72,6 +72,7 @@ async fn main() -> Result<()> { update::spawn_checker(app.clone()); health::spawn_checker(app.clone()); digest::spawn(app.clone()); + budget::spawn_alerts(app.clone()); backup::spawn(app.clone()); let router = api::router(app); let listener = tokio::net::TcpListener::bind(&bind).await?; diff --git a/server/src/prompts.rs b/server/src/prompts.rs index 05bebc0..a61d834 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -147,8 +147,40 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec { map.into_values().collect() } -pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent { - agents(app, root, cwd) +/// Personal modes a user created for themselves (user setting "modes"). +pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap { + app.store.user_setting(user_id, "modes").unwrap_or_default() +} + +/// All agents plus the user's personal modes. Personal modes never replace built-in, +/// admin or project ones of the same name. +pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> Vec { + let mut list = agents(app, root, cwd); + let Some(uid) = user_id else { return list }; + for (name, a) in personal_modes(app, uid) { + if list.iter().any(|x| x.name == name) { + continue; + } + list.push(Agent { + name, + description: a.description.unwrap_or_default(), + mode: "primary".into(), + prompt: a.prompt.unwrap_or_default(), + tools: a.tools, + read_only: a.read_only.unwrap_or(false), + style: a.style.filter(|s| s == "chat").unwrap_or_else(|| "coding".into()), + model: a.model.filter(|m| !m.is_empty()), + source: "personal".into(), + max_parallel: None, + refine: a.refine, + path: None, + }); + } + list +} + +pub fn find_agent_for(app: &App, root: &Path, cwd: &Path, name: &str, user_id: Option<&str>) -> Agent { + agents_for(app, root, cwd, user_id) .into_iter() .find(|a| a.name == name) .unwrap_or_else(|| builtin_agents(true).remove(0)) diff --git a/server/src/runner.rs b/server/src/runner.rs index bec87fc..467f7c0 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -268,7 +268,7 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo let cwd = PathBuf::from(&session.cwd); let roots = app.user_roots(session.owner_id.as_deref()); let root = app.root_for(session.owner_id.as_deref(), &cwd); - let mut agent = prompts::find_agent(&app, &root, &cwd, &session.agent); + let mut agent = prompts::find_agent_for(&app, &root, &cwd, &session.agent, session.owner_id.as_deref()); let clean_room = session.clean_room(app.config().craft.clean_room); // The craft prompt follows the session's clean room / deep switch. if agent.name == "craft" && agent.source == "builtin" { @@ -667,7 +667,7 @@ pub async fn run_subagent(app: Arc, ctx: &ToolCtx, agent: &str, description fn create_child(app: &Arc, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> { let parent = app.session(&ctx.session_id)?; let root = ctx.root(); - let parent_agent = prompts::find_agent(app, &root, &ctx.cwd, &parent.agent); + let parent_agent = prompts::find_agent_for(app, &root, &ctx.cwd, &parent.agent, parent.owner_id.as_deref()); let agents = prompts::agents(app, &root, &ctx.cwd); let mut agent = agents .iter() diff --git a/web/src/App.tsx b/web/src/App.tsx index 32474cb..addcfc1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { connectEvents, get, setUnauthorizedHandler } from "./api"; import { useT } from "./i18n"; -import { navigate, useRoute } from "./router"; +import { navigate, useRoute, closePane, MAX_PANES } from "./router"; import { applyEvent, loadSessions, resync, setState } from "./store"; import type { Info } from "./types"; import { Sidebar } from "./components/Sidebar"; @@ -80,7 +80,18 @@ export function App() { const [section, arg] = route; let content; - if (section === "s" && arg) content = setMenuOpen(true)} />; + const chatIds = section === "s" && arg ? arg.split("+").filter(Boolean).slice(0, MAX_PANES) : []; + if (chatIds.length === 1) content = setMenuOpen(true)} />; + else if (chatIds.length > 1) + content = ( +
+ {chatIds.map((id) => ( +
+ setMenuOpen(true)} onClosePane={() => closePane(id)} /> +
+ ))} +
+ ); else if (section === "settings") content = setMenuOpen(true)} />; else if (section === "stats") content = setMenuOpen(true)} />; else if (section === "images") content = setMenuOpen(true)} />; @@ -102,7 +113,7 @@ export function App() { return (
{menuOpen &&
setMenuOpen(false)} />} - +
{content}
diff --git a/web/src/components/Budget.tsx b/web/src/components/Budget.tsx index 16f96c3..93b74d6 100644 --- a/web/src/components/Budget.tsx +++ b/web/src/components/Budget.tsx @@ -44,7 +44,7 @@ interface BudgetData { providers: BudgetRow[]; } -function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) { +export function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) { const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)"; return (
diff --git a/web/src/components/ChatTabs.tsx b/web/src/components/ChatTabs.tsx index 7069e78..8ffa11e 100644 --- a/web/src/components/ChatTabs.tsx +++ b/web/src/components/ChatTabs.tsx @@ -1,5 +1,5 @@ -import { lazy, Suspense, useCallback, useEffect, useState } from "react"; -import { del, get, post } from "../api"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { del, get, post, put } from "../api"; import { confirmDialog, toast } from "../dialogs"; import { useT } from "../i18n"; import { Icon } from "./Icon"; @@ -11,31 +11,24 @@ const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files }))); const POLL_MS = 15_000; const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`; -const filesKey = (sessionId: string) => `codecraft.chatFiles.${sessionId}`; -/** A file manager tab: lives in this browser (it has no server state). */ +/** A file manager tab (stored per user and chat on the server). */ interface FileTab { id: string; dir: string; } -const loadFileTabs = (sessionId: string): FileTab[] => { - try { - return JSON.parse(localStorage.getItem(filesKey(sessionId)) ?? "[]"); - } catch { - return []; - } -}; - /** - * Tabs of a chat: the chat itself, its terminals and file managers. Terminals live on - * the server until closed, so they show up again after a reload or on another device - * (the list is refreshed periodically); file manager tabs are kept per browser. + * Tabs of a chat: the chat itself, its terminals and file managers. Both are kept on the + * server, so they show up again after a reload or on another device (the list is + * refreshed periodically); which tab is active is per browser tab. */ export function useChatTabs(sessionId: string, cwd: string) { const t = useT(); const [terms, setTerms] = useState([]); - const [files, setFilesState] = useState(() => loadFileTabs(sessionId)); + const [files, setFilesState] = useState([]); + // Local file tab changes not yet saved: polling must not undo them. + const pending = useRef | undefined>(undefined); const [tab, setTabState] = useState(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat"); const setTab = useCallback( @@ -46,10 +39,14 @@ export function useChatTabs(sessionId: string, cwd: string) { [sessionId], ); const setFiles = useCallback( - (update: (f: FileTab[]) => FileTab[]) => + (update: (f: FileTab[]) => FileTab[], delay = 0) => setFilesState((cur) => { const next = update(cur); - localStorage.setItem(filesKey(sessionId), JSON.stringify(next)); + clearTimeout(pending.current); + pending.current = setTimeout(() => { + pending.current = undefined; + void put(`/sessions/${sessionId}/tabs`, next).catch((e) => toast(e)); + }, delay); return next; }), [sessionId], @@ -57,15 +54,18 @@ export function useChatTabs(sessionId: string, cwd: string) { const reload = useCallback( () => - get(`/terminals?session=${encodeURIComponent(sessionId)}`) - .then(setTerms) + get<{ terminals: TermInfo[]; files: FileTab[] }>(`/sessions/${sessionId}/tabs`) + .then((r) => { + setTerms(r.terminals); + if (!pending.current) setFilesState(r.files); + }) .catch(() => undefined), [sessionId], ); useEffect(() => { setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat"); - setFilesState(loadFileTabs(sessionId)); + setFilesState([]); setTerms([]); void reload(); const id = setInterval(reload, POLL_MS); @@ -110,7 +110,8 @@ export function useChatTabs(sessionId: string, cwd: string) { if (tab === id) setTab("chat"); }; - const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x))), [setFiles]); + // Browsing saves the folder after a short pause, not on every click. + const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x)), 1000), [setFiles]); return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged }; } diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index accff79..fa7bb82 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -136,7 +136,7 @@ function RefineFeedback({ text }: { text: string }) { ); } -export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) { +export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) { const t = useT(); const session = useStore((s) => s.sessionById[sessionId]); const messages = useStore((s) => s.messages[sessionId] ?? EMPTY); @@ -368,6 +368,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () )} + {onClosePane && ( + + )}
diff --git a/web/src/components/Palette.tsx b/web/src/components/Palette.tsx index 596f97e..834b330 100644 --- a/web/src/components/Palette.tsx +++ b/web/src/components/Palette.tsx @@ -28,7 +28,7 @@ function recent() { /** Move to the next/previous session relative to the open one. */ function step(dir: 1 | -1) { const list = recent(); - const cur = location.hash.match(/^#\/s\/([^/]+)/)?.[1]; + const cur = location.hash.match(/^#\/s\/([^/+]+)/)?.[1]; const i = list.findIndex((s) => s.id === cur); const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)]; if (next) navigate(`/s/${next.id}`); diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index 02d6d8b..879387a 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -12,6 +12,7 @@ import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs import { ModelSelect } from "./ModelSelect"; import { Account } from "./settings/Account"; import { Templates } from "./settings/Templates"; +import { PersonalModes } from "./settings/PersonalModes"; import { AgentsEditor } from "./settings/AgentsEditor"; import { Folders } from "./settings/Folders"; import { Users } from "./settings/Users"; @@ -1039,8 +1040,8 @@ function Raw({ reload }: { reload: () => void }) { // --- page ------------------------------------------------------------------- -const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "account", "raw"] as const; -const USER_TABS = ["general", "templates", "account"] as const; +const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "modes", "account", "raw"] as const; +const USER_TABS = ["general", "templates", "modes", "account"] as const; const TAB_ICONS: Record = { general: "gear", providers: "brain", @@ -1052,6 +1053,7 @@ const TAB_ICONS: Record = { tools: "toolbox", audit: "scroll", templates: "template", + modes: "sparkle", account: "user", raw: "file-text", }; @@ -1088,6 +1090,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {!isAdmin && active === "general" && } {active === "account" && } {active === "templates" && } + {active === "modes" && } {cfg && active === "users" && } {cfg && active === "folders" && } {cfg && active === "audit" && } diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 228838e..4373784 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { get, patch, post } from "../api"; import { promptDialog, toast } from "../dialogs"; import { fmtRelative, useT, type Key } from "../i18n"; -import { navigate } from "../router"; +import { navigate, openBeside } from "../router"; import { useStore } from "../store"; import type { Session } from "../types"; @@ -82,6 +82,9 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")} )} + @@ -99,7 +102,7 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ ); } -export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) { +export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) { const t = useT(); const sessions = useStore((s) => s.sessions); const busy = useStore((s) => s.busy); @@ -210,7 +213,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
{t("sidebar.searchResults", { n: hits.length })}
{hits.length === 0 &&
{t("sidebar.noResults")}
} {hits.map((h) => ( - {!collapsed.has(g.id) && g.items.map((s) => ( - + ))}
))} diff --git a/web/src/components/StatusLeds.tsx b/web/src/components/StatusLeds.tsx index ea00b17..d1cd420 100644 --- a/web/src/components/StatusLeds.tsx +++ b/web/src/components/StatusLeds.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { get, post } from "../api"; -import { fmtPercent, useT, type Key } from "../i18n"; +import { fmtCost, fmtPercent, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { useStore } from "../store"; import { Icon } from "./Icon"; -import type { Plan } from "./Budget"; +import { Bar, type Plan } from "./Budget"; type Level = "ok" | "warn" | "err" | "off"; @@ -21,12 +21,25 @@ interface McpEntry { tools?: number; error?: string | null; } +interface Meter { + key: string; + provider: string; + kind: "total" | "provider" | "images" | "5h" | "week" | "month"; + used: number; + limit: number; + ratio: number; +} interface Overview { version: string; providers: Record; mcp: Record; + /** Budgets and plan windows (admins only). */ + budgets?: Meter[]; + currency?: string; } +const meterLevel = (m: Meter): Level => (m.ratio >= 1 ? "err" : m.ratio >= 0.8 ? "warn" : "ok"); + const POLL_MS = 30_000; /** A dropped connection shows yellow (reconnecting) for this long, then red. */ const RECONNECT_GRACE_MS = 10_000; @@ -197,6 +210,22 @@ export function StatusLeds() { ))}
+ {!!data?.budgets?.length && ( +
+
+ {t("status.budgets")} +
+ {data.budgets.map((m) => ( +
+ + {m.kind === "total" ? t("stats.total") : m.kind === "images" ? t("nav.images") : m.kind === "provider" ? m.provider : `${m.provider} · ${t(`budget.window.${m.kind}` as Key)}`} + = 0.8} exceeded={m.ratio >= 1} /> + {fmtPercent(m.ratio)} +
+ ))} +
+ )} + {admin && (
+
+ + + ); +} + +function ModeRow({ name, mode, tools, open, onToggle, onSave, onRemove }: { name: string; mode: Mode; tools: ToolInfo[]; open: boolean; onToggle: () => void; onSave: (m: Mode) => Promise; onRemove: () => void }) { + const t = useT(); + const [form, setForm] = useState({ + description: mode.description ?? "", + prompt: mode.prompt ?? "", + model: mode.model ?? "", + tools: mode.tools ?? [], + read_only: !!mode.read_only, + style: (mode.style ?? "coding") as "coding" | "chat", + }); + const submit = () => + void onSave({ + description: form.description || null, + prompt: form.prompt || null, + model: form.model || null, + tools: form.tools.length ? form.tools : null, + read_only: form.read_only || null, + style: form.style === "chat" ? "chat" : null, + }); + return ( +
+

+ {name} + {form.style === "chat" ? t("agents.styleChatShort") : t("agents.styleCodingShort")} + {form.read_only && {t("agents.readOnly")}} + + + +

+ {!open && mode.description &&
{mode.description}
} + {open && ( + <> +
+ + setForm({ ...form, description: e.target.value })} /> + + + + setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} /> + + + + setForm({ ...form, tools: v })} readOnly={form.read_only} /> + +