Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1d7f91b8 | ||
|
|
0f4bfa0ab0 | ||
|
|
85b68d98ef | ||
|
|
d152ad836d |
No files matched your search
@@ -3,6 +3,32 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00005] – 2026-11-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Commits by agents, terminals and the git panel always use the name and e-mail from
|
||||||
|
*Settings → Account → Git* – also in repositories that have their own `user.name`/`user.email`
|
||||||
|
(agents had set those as a workaround while no identity was configured). Agents are told not
|
||||||
|
to change the git identity.
|
||||||
|
|
||||||
|
## [v202611-00004] – 2026-11-09
|
||||||
|
|
||||||
|
### 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
|
## [v202611-00003] – 2026-11-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00003" src="https://img.shields.io/badge/version-v202611--00003-d69e2e?style=flat-square">
|
<img alt="Version v202611-00005" src="https://img.shields.io/badge/version-v202611--00005-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?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="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">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
@@ -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
|
WebSocket support) and set `auth.public_url` – installing the app and push notifications
|
||||||
require HTTPS.
|
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 `<dir>/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
|
## How it works
|
||||||
|
|
||||||
CodeCraft runs on your server, next to your projects. You talk to it from a browser, the
|
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.
|
model's fallback.
|
||||||
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
|
- **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.
|
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
|
- **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.
|
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
|
- **Status:** every five minutes each provider's model list is fetched (no tokens used), so
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent w
|
|||||||
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
|
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
|
||||||
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
|
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
|
||||||
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
||||||
|
- Git identity and credentials come from the user's settings: never set user.name, user.email or credential helpers yourself (no `git config user.*`, no `-c user.*`).
|
||||||
- Reference code as path:line.
|
- Reference code as path:line.
|
||||||
- Answer in the language the user writes in."#;
|
- Answer in the language the user writes in."#;
|
||||||
|
|
||||||
|
|||||||
Executable
+190
@@ -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 <dir>/data,
|
||||||
|
# 4. syncs the workspace again (only what changed),
|
||||||
|
# 5. writes <dir>/docker-compose.yml, <dir>/.env and <dir>/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 <dir>/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 <remote path> < 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" <docker/seccomp-sandbox.json
|
||||||
|
grep -vE '^(COMPOSE_FILE|WORKSPACE|UID|GID|PORT|BIND_ADDR)=' .env | {
|
||||||
|
cat
|
||||||
|
printf '\n# Written by scripts/move-server.sh\nWORKSPACE=%s\nUID=%s\nGID=%s\nPORT=%s\nBIND_ADDR=%s\n' "$ws_target" "$uid" "$gid" "$port" "$bind"
|
||||||
|
} | put_file "$dir/.env"
|
||||||
|
put_file "$dir/docker-compose.yml" <<EOF
|
||||||
|
# CodeCraft with bind mounts (written by scripts/move-server.sh).
|
||||||
|
# Update: set CODECRAFT_VERSION in .env, then docker compose pull && docker compose up -d
|
||||||
|
services:
|
||||||
|
codecraft:
|
||||||
|
image: $image_repo:\${CODECRAFT_VERSION:-latest}
|
||||||
|
container_name: $name
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "\${UID}:\${GID}"
|
||||||
|
ports:
|
||||||
|
- "\${BIND_ADDR}:\${PORT}:8787"
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
volumes:
|
||||||
|
- $dir/data:/data
|
||||||
|
- \${WORKSPACE}:/workspace
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
shm_size: "1gb"
|
||||||
|
security_opt:
|
||||||
|
- seccomp=$dir/docker/seccomp-sandbox.json
|
||||||
|
- apparmor=unconfined
|
||||||
|
- systempaths=unconfined
|
||||||
|
EOF
|
||||||
|
put_file "$dir/start.sh" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
# Same as docker-compose.yml, for hosts without Docker Compose.
|
||||||
|
set -e
|
||||||
|
cd "\$(dirname "\$0")"
|
||||||
|
# Read values instead of sourcing .env: UID is read-only in bash.
|
||||||
|
val() { sed -n "s/^\$1=//p" .env | tail -1; }
|
||||||
|
docker rm -f $name >/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" <scripts/smoke-test.sh
|
||||||
|
remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${version#v}" || fail "smoke test failed – the old installation here is stopped but intact (docker start $src_container)"
|
||||||
|
|
||||||
|
step "Done"
|
||||||
|
cat <<EOF
|
||||||
|
CodeCraft runs on $host ($bind:$port). Next:
|
||||||
|
- point the reverse proxy (e.g. Nginx Proxy Manager) for your domain to the new host and port,
|
||||||
|
- check the agents' tools in a chat (git credentials, MCP servers, LAN addresses),
|
||||||
|
- when everything works, remove the old container here: docker rm $src_container
|
||||||
|
(the volume $src_volume stays until you delete it: docker volume rm $src_volume).
|
||||||
|
Rollback: on the target 'docker stop $name', here 'docker start $src_container'.
|
||||||
|
EOF
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Quick check of a running CodeCraft: health, version, web UI, terminal client download.
|
||||||
|
#
|
||||||
|
# Usage: scripts/smoke-test.sh [URL] [EXPECTED_VERSION]
|
||||||
|
# URL base URL (default http://127.0.0.1:8787)
|
||||||
|
# EXPECTED_VERSION e.g. 202611-00003 (without "v"); checked when given
|
||||||
|
#
|
||||||
|
# Exit code 0 when everything answers, 1 otherwise.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
url="${1:-http://127.0.0.1:8787}"
|
||||||
|
url="${url%/}"
|
||||||
|
want="${2:-}"
|
||||||
|
want="${want#v}"
|
||||||
|
fail=0
|
||||||
|
check() { if [[ "$2" == ok ]]; then printf ' ok %s\n' "$1"; else printf ' FAIL %s (%s)\n' "$1" "$2"; fail=1; fi; }
|
||||||
|
|
||||||
|
echo "smoke test: $url"
|
||||||
|
health=""
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
health=$(curl -fsS -m 5 "$url/api/health" 2>/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
|
||||||
+76
-7
@@ -15,7 +15,7 @@ use axum::middleware;
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{delete, get, post, put};
|
use axum::routing::{delete, get, post, put};
|
||||||
use axum::{Extension, Json, Router};
|
use axum::{Extension, Json, Router};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -78,6 +78,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions/{id}/title", post(suggest_title))
|
.route("/sessions/{id}/title", post(suggest_title))
|
||||||
.route("/sessions/{id}/regenerate", post(regenerate))
|
.route("/sessions/{id}/regenerate", post(regenerate))
|
||||||
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
|
.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}/export", get(export_session))
|
||||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||||
.route("/sessions/{id}/prompt", post(prompt))
|
.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/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/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/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", get(crate::cli_api::get_templates))
|
||||||
.route("/templates/{scope}", put(crate::cli_api::put_templates))
|
.route("/templates/{scope}", put(crate::cli_api::put_templates))
|
||||||
.route("/cli/config", get(crate::cli_api::config))
|
.route("/cli/config", get(crate::cli_api::config))
|
||||||
@@ -322,13 +324,43 @@ fn scope(app: &App, user: &User, cwd: Option<String>) -> (PathBuf, PathBuf) {
|
|||||||
|
|
||||||
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
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.
|
/// The user's personal modes (name -> settings).
|
||||||
/// Built-in tools for the mode editor (name, read-only, first line of the description).
|
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
async fn agent_tools(Extension(user): Extension<User>) -> ApiResult {
|
Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
|
||||||
require_admin(&user)?;
|
}
|
||||||
|
|
||||||
|
/// 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<User>, State(app): State<AppState>, Json(modes): Json<std::collections::BTreeMap<String, crate::config::AgentConfig>>) -> 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<String> = 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<Value> = crate::tools::builtin_tools()
|
let list: Vec<Value> = crate::tools::builtin_tools()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()}))
|
.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<User>) -> ApiResult {
|
|||||||
Ok(Json(json!(list)))
|
Ok(Json(json!(list)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
||||||
async fn delete_agent(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>, Query(q): Query<CwdQuery>) -> ApiResult {
|
async fn delete_agent(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>, Query(q): Query<CwdQuery>) -> ApiResult {
|
||||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
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"))?;
|
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<User>, State(app): State<AppS
|
|||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
struct FileTab {
|
||||||
|
id: String,
|
||||||
|
dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_tabs_key(session_id: &str) -> 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<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
let files: Vec<FileTab> = 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<User>, State(app): State<AppState>, Path(id): Path<String>, Json(tabs): Json<Vec<FileTab>>) -> 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<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
writable(&app, &user, &id)?;
|
writable(&app, &user, &id)?;
|
||||||
// Notify before deleting: visibility is checked against the stored session.
|
// Notify before deleting: visibility is checked against the stored session.
|
||||||
@@ -1225,7 +1292,9 @@ async fn status_overview(Extension(user): Extension<User>, State(app): State<App
|
|||||||
(name, v)
|
(name, v)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp}))
|
// Budgets and plan windows (admins: all; plan windows are shown per provider anyway).
|
||||||
|
let budgets = if admin { crate::budget::meters(&app).unwrap_or_default() } else { vec![] };
|
||||||
|
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp, "budgets": budgets, "currency": cfg.server.currency}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
|
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
|
||||||
|
|||||||
@@ -151,6 +151,124 @@ pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<Meter>> {
|
||||||
|
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<String, u8> = 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::<Vec<_>>().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<App>) {
|
||||||
|
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<Value> {
|
pub async fn overview(app: &App) -> Result<Value> {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let spent = app.store.cost_by_provider(month_start())?;
|
let spent = app.store.cost_by_provider(month_start())?;
|
||||||
@@ -384,6 +502,14 @@ mod tests {
|
|||||||
assert_eq!(mc.price_tiers[0].above_context, 200000);
|
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]
|
#[test]
|
||||||
fn budget_entry_flags() {
|
fn budget_entry_flags() {
|
||||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||||
let agents: Vec<Value> = crate::prompts::agents(&app, &root, &root)
|
let agents: Vec<Value> = crate::prompts::agents_for(&app, &root, &root, Some(&user.id))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
let mut v = json!(a);
|
let mut v = json!(a);
|
||||||
|
|||||||
+7
-2
@@ -44,6 +44,8 @@ tokio::task_local! {
|
|||||||
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
||||||
/// The user's git config (credentials, identity) for unsandboxed runs.
|
/// The user's git config (credentials, identity) for unsandboxed runs.
|
||||||
static GITCONFIG: Option<std::path::PathBuf>;
|
static GITCONFIG: Option<std::path::PathBuf>;
|
||||||
|
/// The user's commit identity (overrides a repository's own user.name/email).
|
||||||
|
static GITENV: Vec<(String, String)>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||||
@@ -63,6 +65,7 @@ async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
|||||||
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
|
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
|
||||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||||
}
|
}
|
||||||
|
c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default());
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -171,7 +174,8 @@ fn parse_status(raw: &str) -> Value {
|
|||||||
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
||||||
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
||||||
let cfg = crate::gitcreds::global_config(&app, &user);
|
let cfg = crate::gitcreds::global_config(&app, &user);
|
||||||
SANDBOX.scope(spec, GITCONFIG.scope(cfg, handle_inner(user, app, b))).await
|
let env = crate::gitcreds::identity_env(&app, &user.id);
|
||||||
|
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
||||||
@@ -388,8 +392,9 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
|
|||||||
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
||||||
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
||||||
let cfg = crate::gitcreds::global_config(app, user);
|
let cfg = crate::gitcreds::global_config(app, user);
|
||||||
|
let env = crate::gitcreds::identity_env(app, &user.id);
|
||||||
let s = session.clone();
|
let s = session.clone();
|
||||||
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
|
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
|
||||||
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
||||||
session.cwd = cwd.to_string();
|
session.cwd = cwd.to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,24 @@ pub struct GitSettings {
|
|||||||
pub credentials: Vec<GitCredential>,
|
pub credentials: Vec<GitCredential>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's commit identity as environment variables. They take precedence over a
|
||||||
|
/// repository's own `user.name`/`user.email`, so commits made by agents, terminals and
|
||||||
|
/// the git panel always carry the identity from the settings.
|
||||||
|
pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
|
||||||
|
let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default();
|
||||||
|
let mut env = Vec::new();
|
||||||
|
let (name, email) = (s.name.trim(), s.email.trim());
|
||||||
|
if !name.is_empty() {
|
||||||
|
env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string()));
|
||||||
|
env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string()));
|
||||||
|
}
|
||||||
|
if !email.is_empty() {
|
||||||
|
env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string()));
|
||||||
|
env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string()));
|
||||||
|
}
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
||||||
app.data_dir().join("users").join(&user.username).join("home")
|
app.data_dir().join("users").join(&user.username).join("home")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ async fn main() -> Result<()> {
|
|||||||
update::spawn_checker(app.clone());
|
update::spawn_checker(app.clone());
|
||||||
health::spawn_checker(app.clone());
|
health::spawn_checker(app.clone());
|
||||||
digest::spawn(app.clone());
|
digest::spawn(app.clone());
|
||||||
|
budget::spawn_alerts(app.clone());
|
||||||
backup::spawn(app.clone());
|
backup::spawn(app.clone());
|
||||||
let router = api::router(app);
|
let router = api::router(app);
|
||||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||||
|
|||||||
+34
-2
@@ -147,8 +147,40 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
map.into_values().collect()
|
map.into_values().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent {
|
/// Personal modes a user created for themselves (user setting "modes").
|
||||||
agents(app, root, cwd)
|
pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap<String, crate::config::AgentConfig> {
|
||||||
|
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<Agent> {
|
||||||
|
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()
|
.into_iter()
|
||||||
.find(|a| a.name == name)
|
.find(|a| a.name == name)
|
||||||
.unwrap_or_else(|| builtin_agents(true).remove(0))
|
.unwrap_or_else(|| builtin_agents(true).remove(0))
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
let cwd = PathBuf::from(&session.cwd);
|
let cwd = PathBuf::from(&session.cwd);
|
||||||
let roots = app.user_roots(session.owner_id.as_deref());
|
let roots = app.user_roots(session.owner_id.as_deref());
|
||||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
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);
|
let clean_room = session.clean_room(app.config().craft.clean_room);
|
||||||
// The craft prompt follows the session's clean room / deep switch.
|
// The craft prompt follows the session's clean room / deep switch.
|
||||||
if agent.name == "craft" && agent.source == "builtin" {
|
if agent.name == "craft" && agent.source == "builtin" {
|
||||||
@@ -667,7 +667,7 @@ pub async fn run_subagent(app: Arc<App>, ctx: &ToolCtx, agent: &str, description
|
|||||||
fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> {
|
fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> {
|
||||||
let parent = app.session(&ctx.session_id)?;
|
let parent = app.session(&ctx.session_id)?;
|
||||||
let root = ctx.root();
|
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 agents = prompts::agents(app, &root, &ctx.cwd);
|
||||||
let mut agent = agents
|
let mut agent = agents
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+25
-1
@@ -18,6 +18,8 @@ pub struct SandboxSpec {
|
|||||||
pub roots: Vec<PathBuf>,
|
pub roots: Vec<PathBuf>,
|
||||||
/// Host directory mounted as the user's home.
|
/// Host directory mounted as the user's home.
|
||||||
pub home: PathBuf,
|
pub home: PathBuf,
|
||||||
|
/// Extra environment (the user's git identity).
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SandboxSpec {
|
impl SandboxSpec {
|
||||||
@@ -69,7 +71,7 @@ pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
|
|||||||
}
|
}
|
||||||
let home = app.data_dir().join("users").join(&user.username).join("home");
|
let home = app.data_dir().join("users").join(&user.username).join("home");
|
||||||
let _ = std::fs::create_dir_all(&home);
|
let _ = std::fs::create_dir_all(&home);
|
||||||
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
|
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home, env: crate::gitcreds::identity_env(app, &user.id) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a command that runs `program args` inside the sandbox in `cwd`.
|
/// Build a command that runs `program args` inside the sandbox in `cwd`.
|
||||||
@@ -148,9 +150,31 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
|
|||||||
] {
|
] {
|
||||||
push(&[&"--setenv", &k, &v]);
|
push(&[&"--setenv", &k, &v]);
|
||||||
}
|
}
|
||||||
|
for (k, v) in &spec.env {
|
||||||
|
push(&[&"--setenv", k, v]);
|
||||||
|
}
|
||||||
push(&[&"--chdir", &cwd, &program]);
|
push(&[&"--chdir", &cwd, &program]);
|
||||||
for a in args {
|
for a in args {
|
||||||
push(&[a]);
|
push(&[a]);
|
||||||
}
|
}
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_identity_reaches_the_sandbox() {
|
||||||
|
let spec = SandboxSpec {
|
||||||
|
username: "ann".into(),
|
||||||
|
roots: vec![],
|
||||||
|
home: PathBuf::from("/tmp/ann"),
|
||||||
|
env: vec![("GIT_AUTHOR_NAME".into(), "Ann Example".into())],
|
||||||
|
};
|
||||||
|
let args: Vec<String> = bwrap_args(&spec, Path::new("/tmp"), &[], true, "bash", &[]).into_iter().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
|
let i = args.iter().position(|a| a == "GIT_AUTHOR_NAME").expect("identity set");
|
||||||
|
assert_eq!(args[i - 1], "--setenv");
|
||||||
|
assert_eq!(args[i + 1], "Ann Example");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,7 +157,12 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
|
|||||||
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
|
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let c = pty_process::Command::new(shell[0]).args(&shell[1..]).current_dir(cwd).env("TERM", "xterm-256color").env("CODECRAFT", "1");
|
let c = pty_process::Command::new(shell[0])
|
||||||
|
.args(&shell[1..])
|
||||||
|
.current_dir(cwd)
|
||||||
|
.env("TERM", "xterm-256color")
|
||||||
|
.env("CODECRAFT", "1")
|
||||||
|
.envs(crate::gitcreds::identity_env(app, &user.id));
|
||||||
match crate::gitcreds::global_config(app, user) {
|
match crate::gitcreds::global_config(app, user) {
|
||||||
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
||||||
None => c,
|
None => c,
|
||||||
|
|||||||
@@ -150,9 +150,12 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
|||||||
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||||
// The session owner's git credentials and identity.
|
// The session owner's git credentials and identity.
|
||||||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
|
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
|
||||||
if let Some(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) {
|
if let Some(u) = owner {
|
||||||
|
if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) {
|
||||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||||
}
|
}
|
||||||
|
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
|
||||||
|
}
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+14
-3
@@ -1,7 +1,7 @@
|
|||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
||||||
import { useT } from "./i18n";
|
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 { applyEvent, loadSessions, resync, setState } from "./store";
|
||||||
import type { Info } from "./types";
|
import type { Info } from "./types";
|
||||||
import { Sidebar } from "./components/Sidebar";
|
import { Sidebar } from "./components/Sidebar";
|
||||||
@@ -80,7 +80,18 @@ export function App() {
|
|||||||
|
|
||||||
const [section, arg] = route;
|
const [section, arg] = route;
|
||||||
let content;
|
let content;
|
||||||
if (section === "s" && arg) content = <ChatView key={arg} sessionId={arg} onMenu={() => setMenuOpen(true)} />;
|
const chatIds = section === "s" && arg ? arg.split("+").filter(Boolean).slice(0, MAX_PANES) : [];
|
||||||
|
if (chatIds.length === 1) content = <ChatView key={chatIds[0]} sessionId={chatIds[0]} onMenu={() => setMenuOpen(true)} />;
|
||||||
|
else if (chatIds.length > 1)
|
||||||
|
content = (
|
||||||
|
<div className="split-view">
|
||||||
|
{chatIds.map((id) => (
|
||||||
|
<div key={id} className="split-pane">
|
||||||
|
<ChatView sessionId={id} onMenu={() => setMenuOpen(true)} onClosePane={() => closePane(id)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||||
@@ -102,7 +113,7 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
||||||
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
|
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
|
||||||
<main className="main">{content}</main>
|
<main className="main">{content}</main>
|
||||||
<WhatsNew version={info.version} />
|
<WhatsNew version={info.version} />
|
||||||
<Palette />
|
<Palette />
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ interface BudgetData {
|
|||||||
providers: BudgetRow[];
|
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)";
|
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
|
||||||
return (
|
return (
|
||||||
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
|
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { del, get, post } from "../api";
|
import { del, get, post, put } from "../api";
|
||||||
import { confirmDialog, toast } from "../dialogs";
|
import { confirmDialog, toast } from "../dialogs";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
@@ -11,31 +11,24 @@ const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files })));
|
|||||||
|
|
||||||
const POLL_MS = 15_000;
|
const POLL_MS = 15_000;
|
||||||
const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`;
|
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 {
|
interface FileTab {
|
||||||
id: string;
|
id: string;
|
||||||
dir: 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
|
* Tabs of a chat: the chat itself, its terminals and file managers. Both are kept on the
|
||||||
* the server until closed, so they show up again after a reload or on another device
|
* server, so they show up again after a reload or on another device (the list is
|
||||||
* (the list is refreshed periodically); file manager tabs are kept per browser.
|
* refreshed periodically); which tab is active is per browser tab.
|
||||||
*/
|
*/
|
||||||
export function useChatTabs(sessionId: string, cwd: string) {
|
export function useChatTabs(sessionId: string, cwd: string) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [terms, setTerms] = useState<TermInfo[]>([]);
|
const [terms, setTerms] = useState<TermInfo[]>([]);
|
||||||
const [files, setFilesState] = useState<FileTab[]>(() => loadFileTabs(sessionId));
|
const [files, setFilesState] = useState<FileTab[]>([]);
|
||||||
|
// Local file tab changes not yet saved: polling must not undo them.
|
||||||
|
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const [tab, setTabState] = useState<string>(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
const [tab, setTabState] = useState<string>(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
||||||
|
|
||||||
const setTab = useCallback(
|
const setTab = useCallback(
|
||||||
@@ -46,10 +39,14 @@ export function useChatTabs(sessionId: string, cwd: string) {
|
|||||||
[sessionId],
|
[sessionId],
|
||||||
);
|
);
|
||||||
const setFiles = useCallback(
|
const setFiles = useCallback(
|
||||||
(update: (f: FileTab[]) => FileTab[]) =>
|
(update: (f: FileTab[]) => FileTab[], delay = 0) =>
|
||||||
setFilesState((cur) => {
|
setFilesState((cur) => {
|
||||||
const next = update(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;
|
return next;
|
||||||
}),
|
}),
|
||||||
[sessionId],
|
[sessionId],
|
||||||
@@ -57,15 +54,18 @@ export function useChatTabs(sessionId: string, cwd: string) {
|
|||||||
|
|
||||||
const reload = useCallback(
|
const reload = useCallback(
|
||||||
() =>
|
() =>
|
||||||
get<TermInfo[]>(`/terminals?session=${encodeURIComponent(sessionId)}`)
|
get<{ terminals: TermInfo[]; files: FileTab[] }>(`/sessions/${sessionId}/tabs`)
|
||||||
.then(setTerms)
|
.then((r) => {
|
||||||
|
setTerms(r.terminals);
|
||||||
|
if (!pending.current) setFilesState(r.files);
|
||||||
|
})
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
[sessionId],
|
[sessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
||||||
setFilesState(loadFileTabs(sessionId));
|
setFilesState([]);
|
||||||
setTerms([]);
|
setTerms([]);
|
||||||
void reload();
|
void reload();
|
||||||
const id = setInterval(reload, POLL_MS);
|
const id = setInterval(reload, POLL_MS);
|
||||||
@@ -110,7 +110,8 @@ export function useChatTabs(sessionId: string, cwd: string) {
|
|||||||
if (tab === id) setTab("chat");
|
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 };
|
return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 t = useT();
|
||||||
const session = useStore((s) => s.sessionById[sessionId]);
|
const session = useStore((s) => s.sessionById[sessionId]);
|
||||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||||
@@ -368,6 +368,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{onClosePane && (
|
||||||
|
<button className="small ghost" onClick={onClosePane} title={t("chat.closePane")} aria-label={t("chat.closePane")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChatTabBar state={tabs} />
|
<ChatTabBar state={tabs} />
|
||||||
<ChatTabPanes state={tabs} />
|
<ChatTabPanes state={tabs} />
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function recent() {
|
|||||||
/** Move to the next/previous session relative to the open one. */
|
/** Move to the next/previous session relative to the open one. */
|
||||||
function step(dir: 1 | -1) {
|
function step(dir: 1 | -1) {
|
||||||
const list = recent();
|
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 i = list.findIndex((s) => s.id === cur);
|
||||||
const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)];
|
const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)];
|
||||||
if (next) navigate(`/s/${next.id}`);
|
if (next) navigate(`/s/${next.id}`);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs
|
|||||||
import { ModelSelect } from "./ModelSelect";
|
import { ModelSelect } from "./ModelSelect";
|
||||||
import { Account } from "./settings/Account";
|
import { Account } from "./settings/Account";
|
||||||
import { Templates } from "./settings/Templates";
|
import { Templates } from "./settings/Templates";
|
||||||
|
import { PersonalModes } from "./settings/PersonalModes";
|
||||||
import { AgentsEditor } from "./settings/AgentsEditor";
|
import { AgentsEditor } from "./settings/AgentsEditor";
|
||||||
import { Folders } from "./settings/Folders";
|
import { Folders } from "./settings/Folders";
|
||||||
import { Users } from "./settings/Users";
|
import { Users } from "./settings/Users";
|
||||||
@@ -1039,8 +1040,8 @@ function Raw({ reload }: { reload: () => void }) {
|
|||||||
|
|
||||||
// --- page -------------------------------------------------------------------
|
// --- page -------------------------------------------------------------------
|
||||||
|
|
||||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "account", "raw"] 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", "account"] as const;
|
const USER_TABS = ["general", "templates", "modes", "account"] as const;
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
general: "gear",
|
general: "gear",
|
||||||
providers: "brain",
|
providers: "brain",
|
||||||
@@ -1052,6 +1053,7 @@ const TAB_ICONS: Record<string, string> = {
|
|||||||
tools: "toolbox",
|
tools: "toolbox",
|
||||||
audit: "scroll",
|
audit: "scroll",
|
||||||
templates: "template",
|
templates: "template",
|
||||||
|
modes: "sparkle",
|
||||||
account: "user",
|
account: "user",
|
||||||
raw: "file-text",
|
raw: "file-text",
|
||||||
};
|
};
|
||||||
@@ -1088,6 +1090,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
|||||||
{!isAdmin && active === "general" && <InterfaceSettings />}
|
{!isAdmin && active === "general" && <InterfaceSettings />}
|
||||||
{active === "account" && <Account />}
|
{active === "account" && <Account />}
|
||||||
{active === "templates" && <Templates />}
|
{active === "templates" && <Templates />}
|
||||||
|
{active === "modes" && <PersonalModes />}
|
||||||
{cfg && active === "users" && <Users />}
|
{cfg && active === "users" && <Users />}
|
||||||
{cfg && active === "folders" && <Folders />}
|
{cfg && active === "folders" && <Folders />}
|
||||||
{cfg && active === "audit" && <Audit />}
|
{cfg && active === "audit" && <Audit />}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|||||||
import { get, patch, post } from "../api";
|
import { get, patch, post } from "../api";
|
||||||
import { promptDialog, toast } from "../dialogs";
|
import { promptDialog, toast } from "../dialogs";
|
||||||
import { fmtRelative, useT, type Key } from "../i18n";
|
import { fmtRelative, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate, openBeside } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import type { Session } from "../types";
|
import type { Session } from "../types";
|
||||||
|
|
||||||
@@ -82,6 +82,9 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
|
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button role="menuitem" className="hide-mobile" onClick={() => (setMenu(false), openBeside(s.id))}>
|
||||||
|
<Icon name="external" /> {t("sidebar.openBeside")}
|
||||||
|
</button>
|
||||||
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
|
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
|
||||||
<Icon name="folder" /> {t("sidebar.moveTo")}
|
<Icon name="folder" /> {t("sidebar.moveTo")}
|
||||||
</button>
|
</button>
|
||||||
@@ -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 t = useT();
|
||||||
const sessions = useStore((s) => s.sessions);
|
const sessions = useStore((s) => s.sessions);
|
||||||
const busy = useStore((s) => s.busy);
|
const busy = useStore((s) => s.busy);
|
||||||
@@ -210,7 +213,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
|
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
|
||||||
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
|
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
|
||||||
{hits.map((h) => (
|
{hits.map((h) => (
|
||||||
<button key={h.id} className={`session-item ${h.id === activeId ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
<button key={h.id} className={`session-item ${activeIds.includes(h.id) ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
||||||
<div className="title">
|
<div className="title">
|
||||||
<span>{h.title || t("session.untitled")}</span>
|
<span>{h.title || t("session.untitled")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +245,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
</button>
|
</button>
|
||||||
{!collapsed.has(g.id) &&
|
{!collapsed.has(g.id) &&
|
||||||
g.items.map((s) => (
|
g.items.map((s) => (
|
||||||
<SessionRow key={s.id} s={s} active={s.id === activeId} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { get, post } from "../api";
|
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 { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import type { Plan } from "./Budget";
|
import { Bar, type Plan } from "./Budget";
|
||||||
|
|
||||||
type Level = "ok" | "warn" | "err" | "off";
|
type Level = "ok" | "warn" | "err" | "off";
|
||||||
|
|
||||||
@@ -21,12 +21,25 @@ interface McpEntry {
|
|||||||
tools?: number;
|
tools?: number;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
interface Meter {
|
||||||
|
key: string;
|
||||||
|
provider: string;
|
||||||
|
kind: "total" | "provider" | "images" | "5h" | "week" | "month";
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
ratio: number;
|
||||||
|
}
|
||||||
interface Overview {
|
interface Overview {
|
||||||
version: string;
|
version: string;
|
||||||
providers: Record<string, ProviderState>;
|
providers: Record<string, ProviderState>;
|
||||||
mcp: Record<string, McpEntry>;
|
mcp: Record<string, McpEntry>;
|
||||||
|
/** 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;
|
const POLL_MS = 30_000;
|
||||||
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
|
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
|
||||||
const RECONNECT_GRACE_MS = 10_000;
|
const RECONNECT_GRACE_MS = 10_000;
|
||||||
@@ -197,6 +210,22 @@ export function StatusLeds() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!!data?.budgets?.length && (
|
||||||
|
<div className="status-section">
|
||||||
|
<div className="status-head">
|
||||||
|
<Led level={combine(data.budgets.map(meterLevel))} /> {t("status.budgets")}
|
||||||
|
</div>
|
||||||
|
{data.budgets.map((m) => (
|
||||||
|
<div key={m.key} className="status-row status-meter" title={`${fmtCost(m.used, data.currency ?? "USD")} / ${fmtCost(m.limit, data.currency ?? "USD")}`}>
|
||||||
|
<Led level={meterLevel(m)} />
|
||||||
|
<span className="grow">{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)}`}</span>
|
||||||
|
<Bar share={m.ratio} warning={m.ratio >= 0.8} exceeded={m.ratio >= 1} />
|
||||||
|
<span className="hint pct">{fmtPercent(m.ratio)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{admin && (
|
{admin && (
|
||||||
<div className="status-foot">
|
<div className="status-foot">
|
||||||
<button className="small ghost" onClick={() => go("providers")}>
|
<button className="small ghost" onClick={() => go("providers")}>
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
|||||||
|
|
||||||
type Patch = { path: string[]; value: unknown };
|
type Patch = { path: string[]; value: unknown };
|
||||||
|
|
||||||
interface ToolInfo {
|
export interface ToolInfo {
|
||||||
name: string;
|
name: string;
|
||||||
read_only: boolean;
|
read_only: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tool checkboxes: nothing checked = all tools. */
|
/** Tool checkboxes: nothing checked = all tools. */
|
||||||
function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
|
export function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
|
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
|
||||||
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
|
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
|
||||||
@@ -211,7 +211,9 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
|||||||
|
|
||||||
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
|
const { data: all, reload } = useFetch<AgentInfo[]>("/agents");
|
||||||
|
// Personal modes are managed under "My modes".
|
||||||
|
const agents = all?.filter((a) => a.source !== "personal");
|
||||||
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||||||
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
||||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { get, put } from "../../api";
|
||||||
|
import { confirmDialog, toast } from "../../dialogs";
|
||||||
|
import { useFetch } from "../../hooks";
|
||||||
|
import { useT } from "../../i18n";
|
||||||
|
import { Icon } from "../Icon";
|
||||||
|
import { ModelSelect } from "../ModelSelect";
|
||||||
|
import { ToolPicker, type ToolInfo } from "./AgentsEditor";
|
||||||
|
|
||||||
|
interface Mode {
|
||||||
|
description?: string | null;
|
||||||
|
prompt?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
tools?: string[] | null;
|
||||||
|
read_only?: boolean | null;
|
||||||
|
style?: "coding" | "chat" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modes a user creates for themselves; they appear in New session and the mode selector. */
|
||||||
|
export function PersonalModes() {
|
||||||
|
const t = useT();
|
||||||
|
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||||||
|
const [modes, setModes] = useState<Record<string, Mode>>();
|
||||||
|
const [open, setOpen] = useState<string>();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
get<Record<string, Mode>>("/account/modes")
|
||||||
|
.then(setModes)
|
||||||
|
.catch((e) => toast(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = async (next: Record<string, Mode>) => {
|
||||||
|
try {
|
||||||
|
setModes(await put<Record<string, Mode>>("/account/modes", next));
|
||||||
|
toast(t("settings.saved"), "success");
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
toast(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
const n = name.trim();
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||||
|
if (modes?.[n]) return toast(t("modes.exists", { name: n }));
|
||||||
|
if (await save({ ...modes, [n]: { style: "chat", description: "", prompt: "" } })) {
|
||||||
|
setName("");
|
||||||
|
setOpen(n);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (n: string) => {
|
||||||
|
if (!(await confirmDialog({ message: t("agents.deleteConfirm", { name: n }), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
|
const next = { ...modes };
|
||||||
|
delete next[n];
|
||||||
|
void save(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!modes) return <div className="empty">{t("common.loading")}</div>;
|
||||||
|
const names = Object.keys(modes).sort();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="notice">{t("modes.intro")}</div>
|
||||||
|
{names.map((n) => (
|
||||||
|
<ModeRow key={n + JSON.stringify(modes[n])} name={n} mode={modes[n]} tools={tools} open={open === n} onToggle={() => setOpen(open === n ? undefined : n)} onSave={(m) => save({ ...modes, [n]: m })} onRemove={() => remove(n)} />
|
||||||
|
))}
|
||||||
|
{names.length === 0 && <div className="empty">{t("modes.none")}</div>}
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("agents.addMode")}</h3>
|
||||||
|
<div className="row">
|
||||||
|
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && void add()} />
|
||||||
|
<button className="primary" onClick={() => void add()} disabled={!name.trim()}>
|
||||||
|
+ {t("agents.addMode")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeRow({ name, mode, tools, open, onToggle, onSave, onRemove }: { name: string; mode: Mode; tools: ToolInfo[]; open: boolean; onToggle: () => void; onSave: (m: Mode) => Promise<boolean>; 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 (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
{name}
|
||||||
|
<span className="chip">{form.style === "chat" ? t("agents.styleChatShort") : t("agents.styleCodingShort")}</span>
|
||||||
|
{form.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="small ghost danger" onClick={onRemove} title={t("common.delete")} aria-label={t("common.delete")}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
<button className="small" onClick={onToggle}>
|
||||||
|
{open ? t("common.collapse") : t("common.edit")}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
{!open && mode.description && <div className="hint">{mode.description}</div>}
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="form">
|
||||||
|
<label>{t("agents.description")}</label>
|
||||||
|
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
|
<label>{t("agents.style")}</label>
|
||||||
|
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
|
||||||
|
<option value="coding">{t("agents.styleCoding")}</option>
|
||||||
|
<option value="chat">{t("agents.styleChat")}</option>
|
||||||
|
</select>
|
||||||
|
<label>{t("agents.model")}</label>
|
||||||
|
<ModelSelect value={form.model} onChange={(v) => setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} />
|
||||||
|
<label>{t("agents.readOnlyLabel")}</label>
|
||||||
|
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||||
|
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
|
||||||
|
<span className="hint">{t("agents.readOnlyHint")}</span>
|
||||||
|
</label>
|
||||||
|
<label>{t("agents.tools")}</label>
|
||||||
|
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
|
||||||
|
<label>{t("agents.prompt")}</label>
|
||||||
|
<textarea rows={8} value={form.prompt} placeholder={t("modes.promptPlaceholder")} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ marginTop: 12 }}>
|
||||||
|
<button className="primary" onClick={submit}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -215,6 +215,7 @@ export const de: Messages = {
|
|||||||
"sidebar.week": "Letzte 7 Tage",
|
"sidebar.week": "Letzte 7 Tage",
|
||||||
"sidebar.older": "Älter",
|
"sidebar.older": "Älter",
|
||||||
"status.title": "Status",
|
"status.title": "Status",
|
||||||
|
"status.budgets": "Budgets",
|
||||||
"status.server": "Server",
|
"status.server": "Server",
|
||||||
"status.models": "Modelle",
|
"status.models": "Modelle",
|
||||||
"status.mcp": "MCP",
|
"status.mcp": "MCP",
|
||||||
@@ -454,6 +455,13 @@ export const de: Messages = {
|
|||||||
"shareIn.newChat": "Neuen Chat starten",
|
"shareIn.newChat": "Neuen Chat starten",
|
||||||
"shareIn.appendTo": "oder an eine letzte Session anhängen",
|
"shareIn.appendTo": "oder an eine letzte Session anhängen",
|
||||||
"common.back": "Zurück",
|
"common.back": "Zurück",
|
||||||
|
"settings.tab.modes": "Meine Modi",
|
||||||
|
"modes.intro": "Deine eigenen Modi: ein Prompt, eine Art (Programmieren oder Unterhaltung), Werkzeuge und optional ein Modell. Sie erscheinen in „Neue Sitzung“ und in der Modus-Auswahl deiner Chats – nur für dich.",
|
||||||
|
"modes.none": "Du hast noch keine eigenen Modi.",
|
||||||
|
"modes.exists": "Ein Modus namens {name} existiert schon.",
|
||||||
|
"modes.promptPlaceholder": "z. B. Du bist ein geduldiger Englischlehrer. Korrigiere meine Sätze und erkläre kurz, warum.",
|
||||||
|
"agents.styleChatShort": "Unterhaltung",
|
||||||
|
"agents.styleCodingShort": "Programmieren",
|
||||||
"settings.tab.templates": "Vorlagen",
|
"settings.tab.templates": "Vorlagen",
|
||||||
"templates.title": "Prompt-Vorlagen",
|
"templates.title": "Prompt-Vorlagen",
|
||||||
"templates.filter": "Vorlagen durchsuchen…",
|
"templates.filter": "Vorlagen durchsuchen…",
|
||||||
@@ -486,6 +494,8 @@ export const de: Messages = {
|
|||||||
"sidebar.organise": "Session organisieren",
|
"sidebar.organise": "Session organisieren",
|
||||||
"sidebar.pin": "Oben anpinnen",
|
"sidebar.pin": "Oben anpinnen",
|
||||||
"sidebar.unpin": "Nicht mehr anpinnen",
|
"sidebar.unpin": "Nicht mehr anpinnen",
|
||||||
|
"sidebar.openBeside": "Daneben öffnen",
|
||||||
|
"chat.closePane": "Spalte schließen",
|
||||||
"sidebar.moveTo": "In Ordner verschieben…",
|
"sidebar.moveTo": "In Ordner verschieben…",
|
||||||
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
||||||
"sidebar.archiveOne": "Archivieren",
|
"sidebar.archiveOne": "Archivieren",
|
||||||
@@ -846,6 +856,7 @@ export const de: Messages = {
|
|||||||
"agents.modePrimary": "Modus",
|
"agents.modePrimary": "Modus",
|
||||||
"agents.modeSubagent": "Subagent",
|
"agents.modeSubagent": "Subagent",
|
||||||
"agents.source.builtin": "eingebaut",
|
"agents.source.builtin": "eingebaut",
|
||||||
|
"agents.source.personal": "persönlich",
|
||||||
"agents.source.config": "Config",
|
"agents.source.config": "Config",
|
||||||
"agents.source.file": "Datei",
|
"agents.source.file": "Datei",
|
||||||
"agents.model": "Modell",
|
"agents.model": "Modell",
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export const en = {
|
|||||||
"sidebar.week": "Last 7 days",
|
"sidebar.week": "Last 7 days",
|
||||||
"sidebar.older": "Older",
|
"sidebar.older": "Older",
|
||||||
"status.title": "Status",
|
"status.title": "Status",
|
||||||
|
"status.budgets": "Budgets",
|
||||||
"status.server": "Server",
|
"status.server": "Server",
|
||||||
"status.models": "Models",
|
"status.models": "Models",
|
||||||
"status.mcp": "MCP",
|
"status.mcp": "MCP",
|
||||||
@@ -453,6 +454,13 @@ export const en = {
|
|||||||
"shareIn.newChat": "Start a new chat",
|
"shareIn.newChat": "Start a new chat",
|
||||||
"shareIn.appendTo": "or add to a recent session",
|
"shareIn.appendTo": "or add to a recent session",
|
||||||
"common.back": "Back",
|
"common.back": "Back",
|
||||||
|
"settings.tab.modes": "My modes",
|
||||||
|
"modes.intro": "Your own modes: a prompt, a kind (coding or conversation), tools and an optional model. They appear in New session and in the mode selector of your chats – only for you.",
|
||||||
|
"modes.none": "You have no modes of your own yet.",
|
||||||
|
"modes.exists": "A mode named {name} already exists.",
|
||||||
|
"modes.promptPlaceholder": "e.g. You are a patient German teacher. Correct my sentences and explain briefly why.",
|
||||||
|
"agents.styleChatShort": "conversation",
|
||||||
|
"agents.styleCodingShort": "coding",
|
||||||
"settings.tab.templates": "Templates",
|
"settings.tab.templates": "Templates",
|
||||||
"templates.title": "Prompt templates",
|
"templates.title": "Prompt templates",
|
||||||
"templates.filter": "Search templates…",
|
"templates.filter": "Search templates…",
|
||||||
@@ -485,6 +493,8 @@ export const en = {
|
|||||||
"sidebar.organise": "Organise session",
|
"sidebar.organise": "Organise session",
|
||||||
"sidebar.pin": "Pin to top",
|
"sidebar.pin": "Pin to top",
|
||||||
"sidebar.unpin": "Unpin",
|
"sidebar.unpin": "Unpin",
|
||||||
|
"sidebar.openBeside": "Open beside",
|
||||||
|
"chat.closePane": "Close this column",
|
||||||
"sidebar.moveTo": "Move to folder…",
|
"sidebar.moveTo": "Move to folder…",
|
||||||
"sidebar.removeFromFolder": "Remove from folder",
|
"sidebar.removeFromFolder": "Remove from folder",
|
||||||
"sidebar.archiveOne": "Archive",
|
"sidebar.archiveOne": "Archive",
|
||||||
@@ -845,6 +855,7 @@ export const en = {
|
|||||||
"agents.modePrimary": "mode",
|
"agents.modePrimary": "mode",
|
||||||
"agents.modeSubagent": "subagent",
|
"agents.modeSubagent": "subagent",
|
||||||
"agents.source.builtin": "built-in",
|
"agents.source.builtin": "built-in",
|
||||||
|
"agents.source.personal": "personal",
|
||||||
"agents.source.config": "config",
|
"agents.source.config": "config",
|
||||||
"agents.source.file": "file",
|
"agents.source.file": "file",
|
||||||
"agents.model": "Model",
|
"agents.model": "Model",
|
||||||
|
|||||||
@@ -15,3 +15,23 @@ export function useRoute(): string[] {
|
|||||||
export function navigate(path: string) {
|
export function navigate(path: string) {
|
||||||
location.hash = path.startsWith("/") ? path : `/${path}`;
|
location.hash = path.startsWith("/") ? path : `/${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Chats shown side by side: #/s/<id>+<id>+<id> (at most 3). */
|
||||||
|
export const MAX_PANES = 3;
|
||||||
|
|
||||||
|
export function openChats(): string[] {
|
||||||
|
const m = location.hash.match(/^#\/s\/([^/]+)/);
|
||||||
|
return m ? m[1].split("+").filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a chat as another column (replaces the last one when all columns are used). */
|
||||||
|
export function openBeside(id: string) {
|
||||||
|
const ids = openChats().filter((x) => x !== id);
|
||||||
|
if (ids.length >= MAX_PANES) ids.pop();
|
||||||
|
navigate(`/s/${[...ids, id].join("+")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePane(id: string) {
|
||||||
|
const ids = openChats().filter((x) => x !== id);
|
||||||
|
navigate(ids.length ? `/s/${ids.join("+")}` : "/");
|
||||||
|
}
|
||||||
@@ -3725,3 +3725,48 @@ span.chat-tab > button {
|
|||||||
.compacting-card.auto {
|
.compacting-card.auto {
|
||||||
border-color: color-mix(in srgb, var(--accent) 60%, var(--bg3));
|
border-color: color-mix(in srgb, var(--accent) 60%, var(--bg3));
|
||||||
}
|
}
|
||||||
|
.status-meter .budget-bar {
|
||||||
|
width: 70px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.status-meter .pct {
|
||||||
|
width: 38px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- split view (chats side by side) ------------------------------------------- */
|
||||||
|
.split-view {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.split-pane {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.split-pane + .split-pane {
|
||||||
|
border-left: 1px solid var(--bg3);
|
||||||
|
}
|
||||||
|
.split-pane .topbar {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.split-pane .topbar .grow {
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
.split-pane .topbar h1 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Narrow columns: the context chip moves out (the cost chip opens the usage overlay). */
|
||||||
|
.split-pane .topbar span.chip.hide-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.split-pane + .split-pane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -155,7 +155,7 @@ export interface AgentInfo {
|
|||||||
style?: "coding" | "chat";
|
style?: "coding" | "chat";
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tools?: string[] | null;
|
tools?: string[] | null;
|
||||||
source: "builtin" | "config" | "file";
|
source: "builtin" | "config" | "file" | "personal";
|
||||||
max_parallel?: number | null;
|
max_parallel?: number | null;
|
||||||
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
|
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
|
||||||
/** Source file of Markdown agents. */
|
/** Source file of Markdown agents. */
|
||||||
|
|||||||
@@ -3,6 +3,24 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00005": {
|
||||||
|
en: ["Agents commit with the git name and e-mail from your settings – also in repositories with their own identity"],
|
||||||
|
de: ["Agents committen mit Git-Name und -E-Mail aus deinen Einstellungen – auch in Repos mit eigener Identität"],
|
||||||
|
},
|
||||||
|
"202611-00004": {
|
||||||
|
en: [
|
||||||
|
"Chats side by side: “Open beside” in a chat's menu in the sidebar",
|
||||||
|
"Settings → My modes: your own modes",
|
||||||
|
"Budget warnings as push notifications, budgets in the status overlay",
|
||||||
|
"File manager tabs are the same on every device",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
"Chats nebeneinander: „Daneben öffnen“ im Menü eines Chats in der Seitenleiste",
|
||||||
|
"Einstellungen → Meine Modi: eigene Modi",
|
||||||
|
"Budget-Warnungen als Push, Budgets im Status-Overlay",
|
||||||
|
"Dateimanager-Tabs sind auf jedem Gerät gleich",
|
||||||
|
],
|
||||||
|
},
|
||||||
"202611-00003": {
|
"202611-00003": {
|
||||||
en: [
|
en: [
|
||||||
"Terminals and file managers as tabs in a chat – terminals keep running, also for another device",
|
"Terminals and file managers as tabs in a chat – terminals keep running, also for another device",
|
||||||
|
|||||||
Reference in new issue
Block a user