Compare commits

..
10 Commits
Author SHA1 Message Date
Richard ZinkandClaude Opus 5.5 872ebb94dd deploy.sh: image check no longer fails at random (grep -q + pipefail)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:44:51 +01:00
Richard Zink b302fa0b17 Release v202611-00012 2026-11-10 08:24:40 +01:00
Richard ZinkandClaude Opus 5.5 bd490efda5 Approval mode of automations in the list; Telegram switch per chat
- Automations: the approval mode is a selection in the list; PATCH /jobs/{id}
  writes permission_mode into the job file's frontmatter (rest of the file
  unchanged, invalid modes rejected). "Allow everything" lets nightly runs work
  without waiting for approvals via Telegram until the timeout.
- Session.telegram_quiet: approvals and questions of a chat (and its subagents)
  are not sent to Telegram; the chat open in Telegram always reports. Toggle in
  the chat's ⋯ menu when a bot is paired. New chats from the web UI and
  automation runs start quiet; chats started from Telegram and existing chats
  keep reporting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:24:34 +01:00
Richard Zink 9339e2db41 Release v202611-00011 2026-11-09 22:16:00 +01:00
Richard ZinkandClaude Opus 5.5 d7f89ee257 Fix stale PWA service worker after updates; collapsible task list; short screens
Installed apps checked their service worker at /sw-<old build>.js. That file no
longer exists after an update and the static fallback answered with index.html,
so the update check failed and the old worker kept serving the previous app
shell. The old frontend crashed on the new image references (base64Size of a
missing `data`), leaving a blank page on phones. Old service worker URLs now get
the clean-up worker (/sw.js), which unregisters and reloads; missing scripts and
styles answer 404 instead of HTML.

- Task list above the composer folds to one line (progress, current task),
  remembered per device.
- max-height: 560px layout (square phones such as the Clicks Communicator,
  landscape): tighter top bar, composer and new-chat wizard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 22:15:45 +01:00
Richard Zink b1fa54a7b6 Release v202611-00010 2026-11-09 20:21:42 +01:00
Richard ZinkandClaude Opus 5.5 e8e455b779 Store chat images separately; JPEG for large PNGs; gzip/brotli responses
Images of messages (pasted images, screenshots, generated images) were base64
inside the message JSON, so opening a chat downloaded all of them at once
(66 MB for a chat with 120 screenshots). They now live in a `blobs` table,
addressed by SHA-256; messages keep a reference (blob, size). The UI loads
them from /api/blobs/<hash> lazily with an immutable cache header, the runner
fills the bytes in before model requests, JSON exports stay self-contained.

- Store::add_message moves inline images out; large opaque PNGs become JPEG
  (q85) when smaller. Older chats are converted once at start-up in the
  background, unused images are removed (also after deleting a chat) and the
  database is vacuumed when that frees 32 MB or more.
- Responses are compressed with gzip/brotli (not images, SSE streams or
  binary downloads).

Example (copy of production): chat load 66 MB -> 1.5 MB (370 KB brotli),
database 90 MB -> 27 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:21:24 +01:00
Richard Zink 6d656a355d Release v202611-00009 2026-11-09 19:46:54 +01:00
Richard ZinkandClaude Opus 5.5 ca55404f99 Sandbox: mount /lib64 in the bubblewrap start-up check
On x86_64 the dynamic loader lives in /lib64, so the probe failed with
"execvp /bin/true: No such file or directory" and shell commands of non-admin
users were disabled on amd64 hosts. The sandbox itself already mounted it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:46:16 +01:00
Richard ZinkandClaude Opus 5.5 0241f6031b Deploy script for remote installations; move-server: --exclude, quoted .env values
scripts/deploy.sh rolls a published image out to an installation set up with
move-server.sh (DEPLOY_TARGET in .env): checks the image for the target's
architecture, waits until no agent or terminal runs (--force skips), pulls and
recreates, runs the smoke test and goes back to the previous version on failure.

move-server.sh: --exclude leaves paths out of the workspace copy; values in .env
may be quoted (WORKSPACE='/home/...').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:46:16 +01:00
37 changed files with 993 additions and 47 deletions

No files matched your search

+51
View File
@@ -3,6 +3,57 @@
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-00012] – 2026-11-10
### Added
- **Approval mode of automations in the list:** *Automations* shows the approval mode of each
automation as a selection; the change is written into the job file and applies from the next
run. "Allow everything" lets nightly runs work without waiting for approvals (they used to
wait for an answer via Telegram until the timeout).
- **Telegram per chat:** *⋯ → Send questions to Telegram* (shown with a paired bot) decides
whether approvals and questions of that chat and its subagents are sent to Telegram. New chats
from the web UI and automation runs start with it switched off (they stay in the web UI);
chats started from Telegram and existing chats keep reporting. The chat currently open in
Telegram always reports.
## [v202611-00011] – 2026-11-09
### Added
- **Collapsible task list:** the model's task list above the composer folds to one line
(progress and current task) with a click on its heading; the choice is kept per device.
- **Square and short screens** (e.g. Clicks Communicator, phones in landscape): tighter top bar,
composer and new-chat wizard, so the chat keeps most of the height.
### Fixed
- **Blank page on phones after the update:** installed apps (PWA) checked their service worker
at its old URL, got the web page instead of a script and therefore kept the previous app
version forever. With the new image format of v202611-00010 that old version crashed after
showing the chat for a moment. Old service worker URLs now get the clean-up worker, which
removes the stale worker and reloads the current version. Missing scripts/styles answer
404 instead of the web page.
## [v202611-00010] – 2026-11-09
### Changed
- **Chats with many images load much faster:** images (screenshots, generated images, pasted
images) are stored separately instead of inside the messages and load on demand when they
scroll into view, cached by the browser. Large PNGs without transparency are stored as JPEG.
Older chats are converted once on the first start; the database usually shrinks a lot.
Example: a chat with 120 screenshots loads 1.5 MB instead of 66 MB.
- **Compressed responses:** the API and the web UI are sent with gzip/brotli (not images,
downloads or live streams).
## [v202611-00009] – 2026-11-09
### Fixed
- **Sandbox on x86_64:** the start-up check of bubblewrap did not mount `/lib64`, so it failed
on amd64 hosts and shell commands of non-admin users were disabled.
## [v202611-00008] – 2026-11-09 ## [v202611-00008] – 2026-11-09
### Added ### Added
Generated
+67
View File
@@ -52,6 +52,21 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "alloc-no-stdlib"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -153,6 +168,18 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "async-compression"
version = "0.4.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.92" version = "0.1.92"
@@ -363,6 +390,27 @@ dependencies = [
"hybrid-array", "hybrid-array",
] ]
[[package]]
name = "brotli"
version = "9.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "6.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.13.1" version = "1.13.1"
@@ -682,6 +730,24 @@ dependencies = [
"static_assertions", "static_assertions",
] ]
[[package]]
name = "compression-codecs"
version = "0.4.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.10.2" version = "0.10.2"
@@ -4035,6 +4101,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [ dependencies = [
"async-compression",
"bitflags 2.13.2", "bitflags 2.13.2",
"bytes", "bytes",
"futures-core", "futures-core",
+1 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00008" src="https://img.shields.io/badge/version-v202611--00008-d69e2e?style=flat-square"> <img alt="Version v202611-00012" src="https://img.shields.io/badge/version-v202611--00012-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">
+2 -2
View File
@@ -421,7 +421,7 @@ impl App {
_ => None, _ => None,
}; };
if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) { if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) {
self.images.push(ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) }); self.images.push(ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
} }
} }
} }
@@ -840,7 +840,7 @@ impl App {
for (cmd, args) in tries { for (cmd, args) in tries {
if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() { if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() {
if out.status.success() && out.stdout.starts_with(b"\x89PNG") { if out.status.success() && out.stdout.starts_with(b"\x89PNG") {
self.images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) }); self.images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
self.status = format!("image {} attached", self.images.len()); self.status = format!("image {} attached", self.images.len());
return; return;
} }
+1 -1
View File
@@ -408,7 +408,7 @@ pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
return Ok(Output { return Ok(Output {
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()), content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
is_error: false, is_error: false,
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }], images: vec![ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
diff: None, diff: None,
}); });
} }
+20 -2
View File
@@ -10,11 +10,25 @@ pub enum Role {
Assistant, Assistant,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ImageData { pub struct ImageData {
pub media_type: String, pub media_type: String,
/// Base64-encoded bytes. /// Base64-encoded bytes. Empty when the image is stored separately (`blob`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub data: String, pub data: String,
/// Server only: SHA-256 of the bytes in the image store; the UI loads
/// /api/blobs/<blob> and the server fills `data` in before a model request.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
/// Size in bytes of a stored image (shown in the UI without fetching it).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
impl ImageData {
pub fn new(media_type: impl Into<String>, data: String) -> Self {
Self { media_type: media_type.into(), data, blob: None, size: None }
}
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -169,6 +183,10 @@ pub struct Session {
/// Instructions the user set for this chat; added to the system prompt of every turn. /// Instructions the user set for this chat; added to the system prompt of every turn.
#[serde(default, skip_serializing_if = "String::is_empty")] #[serde(default, skip_serializing_if = "String::is_empty")]
pub instructions: String, pub instructions: String,
/// Approvals and questions of this chat (and its subagents) are not sent to Telegram,
/// unless it is the chat currently open in Telegram.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub telegram_quiet: bool,
} }
impl Session { impl Session {
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Roll out a published CodeCraft image to a remote installation that was set up with
# scripts/move-server.sh (docker-compose.yml + .env in one folder on the target).
#
# scripts/release.sh # build and publish a new version (amd64 + arm64)
# scripts/deploy.sh # roll the newest tag out to the target
#
# Steps:
# 1. check that the image exists in the registry for the target's architecture,
# 2. wait until no agent or terminal runs on the target (unless --force),
# 3. set CODECRAFT_VERSION in the target's .env, docker compose pull && up -d
# (or start.sh on hosts without Compose),
# 4. run scripts/smoke-test.sh on the target; on failure the previous version is
# started again.
#
# Usage: scripts/deploy.sh [vYYYYMM-NNNNN] [--target user@host:/dir] [--force]
# [--wait MINUTES] [--dry-run]
# vYYYYMM-NNNNN version to deploy (default: the newest vYYYYMM-NNNNN git tag)
# --target installation folder on the target (default: DEPLOY_TARGET from .env)
# --force restart even if agents or terminals are running (they end)
# --wait N how long to wait for an idle server before giving up (default 15)
# --dry-run show the steps only
#
# Environment:
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
# CONTAINER container name on the target (default codecraft)
# PORT port of CodeCraft on the target, for the smoke test (default: PORT
# from the target's .env, else 8787)
set -euo pipefail
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
CONTAINER="${CONTAINER:-codecraft}"
tag="" target="" force=0 wait_min=15 dry=0
while (($#)); do
case "$1" in
--target) target="$2"; shift ;;
--force) force=1 ;;
--wait) wait_min="$2"; shift ;;
--dry-run) dry=1 ;;
-h|--help) sed -n '2,29p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;;
*) tag="$1" ;;
esac
shift
done
cd "$(git rev-parse --show-toplevel)"
step() { printf '\n==> %s\n' "$*"; }
fail() { echo "error: $*" >&2; exit 1; }
q() { printf '%q' "$1"; }
env_get() { [[ -f .env ]] && sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
target="${target:-$(env_get DEPLOY_TARGET || true)}"
[[ "$target" == *:/* ]] || fail "no target – pass --target user@host:/dir or set DEPLOY_TARGET in .env"
host="${target%%:*}"
dir="${target#*:}"; dir="${dir%/}"
remote() { ssh -o BatchMode=yes "$host" "$1"; }
run_remote() { if (( dry )); then echo "[dry-run] on $host: $1"; else remote "$1"; fi; }
if [[ -z "$tag" ]]; then
git fetch --tags --quiet origin || true
tag=$(git tag -l 'v[0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9]' | sort | tail -1)
[[ -n "$tag" ]] || fail "no release tag found"
fi
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
step "Checking $host:$dir and the image $REGISTRY:$tag"
remote "test -f $(q "$dir/.env") && test -f $(q "$dir/docker-compose.yml")" \
|| fail "$dir on $host has no .env/docker-compose.yml – set it up with scripts/move-server.sh"
current=$(remote "sed -n 's/^CODECRAFT_VERSION=//p' $(q "$dir/.env") | tail -1")
case "$(remote 'uname -m')" in
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
esac
if [[ -n "$arch" ]]; then
# Captured first: `inspect | grep -q` fails under pipefail when grep exits early (SIGPIPE).
manifests=$(docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null || true)
grep -q "Platform:.*linux/$arch" <<<"$manifests" \
|| fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first"
fi
echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})"
[[ "$current" == "$tag" ]] && echo "note: $tag is already configured – the container is recreated anyway"
# Busy = the container runs more than the server process (agents' shells, terminals, ...).
busy() { (( $(remote "docker top $(q "$CONTAINER") -o pid 2>/dev/null | tail -n +2 | wc -l") > 1 )); }
if (( ! force )) && remote "docker inspect -f '{{.State.Running}}' $(q "$CONTAINER") 2>/dev/null" | grep -q true; then
step "Waiting until no agent or terminal runs (up to $wait_min min, --force skips this)"
deadline=$(( $(date +%s) + wait_min * 60 ))
while busy; do
(( $(date +%s) < deadline )) || fail "still busy after $wait_min min – try later or use --force"
sleep 15
done
echo "idle"
fi
step "Updating to $tag"
run_remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$tag/' .env \
&& (grep -q '^CODECRAFT_VERSION=' .env || echo 'CODECRAFT_VERSION=$tag' >> .env) \
&& docker pull -q $(q "$REGISTRY:$tag") >/dev/null \
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
step "Smoke test"
port="${PORT:-$(remote "sed -n 's/^PORT=//p' $(q "$dir/.env") | tail -1")}"; port="${port:-8787}"
if (( dry )); then
echo "[dry-run] smoke test on $host: http://127.0.0.1:$port ${tag#v}"
else
ssh -o BatchMode=yes "$host" "cat > $(q "$dir/smoke-test.sh")" <scripts/smoke-test.sh
if ! remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${tag#v}"; then
if [[ -n "$current" && "$current" != "$tag" ]]; then
echo "smoke test failed – going back to $current" >&2
remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$current/' .env \
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
fi
fail "deploy of $tag failed"
fi
fi
step "Done"
echo "$host runs CodeCraft $tag."
+10 -5
View File
@@ -37,12 +37,15 @@
# --source-container C container to stop here (default codecraft) # --source-container C container to stop here (default codecraft)
# --source-volume V data volume here (default codecraft-data) # --source-volume V data volume here (default codecraft-data)
# --source-workspace D project folder here (default: WORKSPACE of .env) # --source-workspace D project folder here (default: WORKSPACE of .env)
# --exclude PATTERN leave this out of the workspace copy (rsync pattern, relative to the
# workspace, e.g. /old-project/ or /app/target/); repeatable
# --no-stop do not stop the source (only for tests – the copy may be inconsistent) # --no-stop do not stop the source (only for tests – the copy may be inconsistent)
# --dry-run show the steps only # --dry-run show the steps only
set -euo pipefail set -euo pipefail
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft" target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0 src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
excludes=()
while (($#)); do while (($#)); do
case "$1" in case "$1" in
--workspace) ws_target="$2"; shift ;; --workspace) ws_target="$2"; shift ;;
@@ -56,9 +59,10 @@ while (($#)); do
--source-container) src_container="$2"; shift ;; --source-container) src_container="$2"; shift ;;
--source-volume) src_volume="$2"; shift ;; --source-volume) src_volume="$2"; shift ;;
--source-workspace) ws_source="$2"; shift ;; --source-workspace) ws_source="$2"; shift ;;
--exclude) excludes+=(--exclude "$2"); shift ;;
--no-stop) stop=0 ;; --no-stop) stop=0 ;;
--dry-run) dry=1 ;; --dry-run) dry=1 ;;
-h|--help) sed -n '2,36p' "$0"; exit 0 ;; -h|--help) sed -n '2,38p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;; -*) echo "unknown option $1" >&2; exit 2 ;;
*) target="$1" ;; *) target="$1" ;;
esac esac
@@ -68,7 +72,8 @@ done
cd "$(git rev-parse --show-toplevel)" cd "$(git rev-parse --show-toplevel)"
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; } [[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
env_get() { sed -n "s/^$1=//p" .env | tail -1; } # Value of a .env variable, without surrounding quotes.
env_get() { sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
host="${target%%:*}" host="${target%%:*}"
dir="${target#*:}" dir="${target#*:}"
@@ -93,9 +98,9 @@ remote() {
} }
q() { printf '%q' "$1"; } q() { printf '%q' "$1"; }
sync_workspace() { sync_workspace() {
if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/" if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
} }
put_file() { # put_file <remote path> < content put_file() { # put_file <remote path> < content
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
+1 -1
View File
@@ -38,7 +38,7 @@ tokio-tungstenite = "0.29"
tokio-util = "0.7" tokio-util = "0.7"
toml = "1" toml = "1"
toml_edit = "0.25" toml_edit = "0.25"
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] } tower-http = { version = "0.7", features = ["fs", "cors", "trace", "compression-gzip", "compression-br"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4", "v7"] } uuid = { version = "1", features = ["v4", "v7"] }
+86 -3
View File
@@ -80,6 +80,7 @@ pub fn router(app: AppState) -> Router {
.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}/tabs", get(session_tabs).put(put_file_tabs))
.route("/sessions/{id}/export", get(export_session)) .route("/sessions/{id}/export", get(export_session))
.route("/blobs/{hash}", get(get_blob))
.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))
.route("/sessions/{id}/abort", post(abort)) .route("/sessions/{id}/abort", post(abort))
@@ -171,8 +172,7 @@ pub fn router(app: AppState) -> Router {
.find(|p| p.join("index.html").exists()) .find(|p| p.join("index.html").exists())
.unwrap_or_else(|| PathBuf::from("./web/dist")) .unwrap_or_else(|| PathBuf::from("./web/dist"))
}); });
let index = web_dir.join("index.html"); let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(missing_static_file(web_dir.clone()));
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
Router::new() Router::new()
.nest("/api", api) .nest("/api", api)
@@ -181,9 +181,54 @@ pub fn router(app: AppState) -> Router {
.route("/cli/install.sh", get(cli_install_script)) .route("/cli/install.sh", get(cli_install_script))
.route("/cli/download/{target}", get(cli_download)) .route("/cli/download/{target}", get(cli_download))
.fallback_service(static_files) .fallback_service(static_files)
.layer(compression())
.with_state(app) .with_state(app)
} }
/// Paths that are not a file of the web build. Usually a client-side route → index.html,
/// except for:
/// - `/sw-<build>.js` of an earlier build: installed apps check their service worker at
/// that URL for updates. Answering with index.html (not a script) makes the update
/// fail, so the old worker kept serving the old app shell forever – which broke when
/// the API changed. They get the kill switch /sw.js instead: it removes the old worker
/// and reloads, and the page then registers the current worker.
/// - other missing scripts/styles (old chunks): 404 instead of HTML.
fn missing_static_file(web_dir: PathBuf) -> axum::routing::MethodRouter {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
axum::routing::any(move |uri: axum::http::Uri| {
let web_dir = web_dir.clone();
async move {
let path = uri.path();
let name = path.trim_start_matches('/');
if name.starts_with("sw-") && name.ends_with(".js") && !name.contains('/') {
return match tokio::fs::read(web_dir.join("sw.js")).await {
Ok(js) => ([(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache, no-store")], js).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
};
}
if [".js", ".mjs", ".css", ".map"].iter().any(|ext| path.ends_with(ext)) {
return StatusCode::NOT_FOUND.into_response();
}
match tokio::fs::read(web_dir.join("index.html")).await {
Ok(html) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], html).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
})
}
/// gzip/brotli for JSON, HTML, JS and CSS. Images and SSE streams are left alone (default
/// predicate), and so are binary downloads (already compressed or large).
fn compression() -> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate + Clone> {
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
let predicate = DefaultPredicate::new()
.and(NotForContentType::const_new("application/octet-stream"))
.and(NotForContentType::const_new("application/zip"))
.and(NotForContentType::const_new("application/gzip"));
tower_http::compression::CompressionLayer::new().compress_when(predicate)
}
fn cli_dir() -> PathBuf { fn cli_dir() -> PathBuf {
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli")) std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
} }
@@ -575,6 +620,8 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(), instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
@@ -744,7 +791,8 @@ struct ExportQ {
/// Download a session as Markdown (default) or JSON. /// Download a session as Markdown (default) or JSON.
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> { async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
let s = readable(&app, &user, &id)?; let s = readable(&app, &user, &id)?;
let messages = app.store.messages(&id)?; // JSON exports are self-contained: images inline instead of store references.
let messages = if q.format == "json" { app.store.messages_for_model(&id)? } else { app.store.messages(&id)? };
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string(); let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base }; let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
let (body, mime, ext) = if q.format == "json" { let (body, mime, ext) = if q.format == "json" {
@@ -785,6 +833,8 @@ struct UpdateSession {
folder: Option<String>, folder: Option<String>,
/// Instructions for this chat ("" clears them). /// Instructions for this chat ("" clears them).
instructions: Option<String>, instructions: Option<String>,
/// Approvals and questions of this chat are not sent to Telegram.
telegram_quiet: Option<bool>,
} }
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult { async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
@@ -833,6 +883,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(i) = body.instructions { if let Some(i) = body.instructions {
s.instructions = i.trim().to_string(); s.instructions = i.trim().to_string();
} }
if let Some(q) = body.telegram_quiet {
s.telegram_quiet = q;
}
})?; })?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
} }
@@ -879,9 +932,33 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
crate::shells::kill_session(&app, &id); crate::shells::kill_session(&app, &id);
app.terminals.kill_session(&id); app.terminals.kill_session(&id);
app.store.delete_session(&id)?; app.store.delete_session(&id)?;
// Images only this session used.
let a = app.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = a.store.gc_blobs() {
tracing::warn!("image cleanup: {e:#}");
}
});
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
/// so it never changes and the browser may keep it.
async fn get_blob(Extension(_user): Extension<User>, State(app): State<AppState>, Path(hash): Path<String>) -> Result<axum::response::Response, ApiError> {
if !crate::blobs::is_hash(&hash) {
return Err(not_found("image"));
}
let (mime, bytes) = app.store.blob(&hash)?.ok_or_else(|| not_found("image"))?;
Ok((
[
(axum::http::header::CONTENT_TYPE, mime),
(axum::http::header::CACHE_CONTROL, "private, max-age=31536000, immutable".to_string()),
],
bytes,
)
.into_response())
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct PromptBody { struct PromptBody {
text: String, text: String,
@@ -1584,10 +1661,16 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
#[derive(Deserialize)] #[derive(Deserialize)]
struct UpdateJob { struct UpdateJob {
enabled: Option<bool>, enabled: Option<bool>,
/// Written into the job file's frontmatter.
permission_mode: Option<String>,
} }
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult { async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
let job = own_job(&app, &user, &id)?; let job = own_job(&app, &user, &id)?;
if let Some(m) = b.permission_mode.as_deref() {
crate::jobs::set_permission_mode(std::path::Path::new(&job.path), m).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "job.permission_mode", &job.path, m);
}
if let Some(e) = b.enabled { if let Some(e) = b.enabled {
app.store.job_set_enabled(&job.id, e)?; app.store.job_set_enabled(&job.id, e)?;
} }
+23
View File
@@ -131,9 +131,32 @@ impl App {
}); });
crate::auth::bootstrap(&app)?; crate::auth::bootstrap(&app)?;
app.clone().start_mcp(); app.clone().start_mcp();
app.clone().start_image_migration();
Ok(app) Ok(app)
} }
/// Move inline images of older chats to the image store, drop unused images and
/// shrink the database file (in the background; chats load meanwhile as before).
fn start_image_migration(self: Arc<Self>) {
tokio::task::spawn_blocking(move || {
match self.store.migrate_inline_images() {
Ok(0) => {}
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
}
match self.store.gc_blobs() {
Ok(0) => {}
Ok(n) => tracing::info!("removed {n} unused images"),
Err(e) => tracing::warn!("image cleanup: {e:#}"),
}
match self.store.vacuum_if_worthwhile() {
Ok(true) => tracing::info!("database compacted"),
Ok(false) => {}
Err(e) => tracing::warn!("database compaction: {e:#}"),
}
});
}
fn start_mcp(self: Arc<Self>) { fn start_mcp(self: Arc<Self>) {
let servers = self.config().mcp.clone(); let servers = self.config().mcp.clone();
let mcp = self.mcp.clone(); let mcp = self.mcp.clone();
+134
View File
@@ -0,0 +1,134 @@
//! Images of chat messages are stored in the `blobs` table instead of as base64 inside the
//! message JSON. A chat then loads without its images; the UI fetches them on demand from
//! /api/blobs/<hash> (cached by the browser), and the runner puts the bytes back in before
//! a model request. Large PNGs without transparency are stored as JPEG.
use crate::types::{ImageData, Message, Part};
use base64::Engine;
use sha2::{Digest, Sha256};
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
/// PNGs above this size are re-encoded as JPEG when that is smaller and loses no transparency.
const RECOMPRESS_ABOVE: usize = 100 * 1024;
const JPEG_QUALITY: u8 = 85;
/// An image to put into the store.
pub struct Blob {
pub hash: String,
pub media_type: String,
pub bytes: Vec<u8>,
}
/// Call `f` for every image of a message (pasted images and tool results).
pub fn for_each_image(m: &mut Message, mut f: impl FnMut(&mut ImageData)) {
for part in &mut m.parts {
match part {
Part::Image { image } => f(image),
Part::ToolResult { images, .. } => images.iter_mut().for_each(&mut f),
_ => {}
}
}
}
/// Move the inline images of `m` out: each becomes a reference (`blob`, `size`, no
/// `data`). Returns the blobs to store. Images that already are references stay as they are.
pub fn externalize(m: &mut Message) -> Vec<Blob> {
let mut out = Vec::new();
for_each_image(m, |img| {
if img.data.is_empty() {
return;
}
let Ok(raw) = B64.decode(img.data.as_bytes()) else { return };
let (media_type, bytes) = compact(&img.media_type, raw);
let hash = hash(&bytes);
img.media_type = media_type.clone();
img.data.clear();
img.blob = Some(hash.clone());
img.size = Some(bytes.len() as u64);
out.push(Blob { hash, media_type, bytes });
});
out
}
/// Whether `s` looks like a blob hash (64 lowercase hex digits).
pub fn is_hash(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn hash(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// Re-encode a large opaque PNG as JPEG if that is smaller; anything else is kept.
fn compact(media_type: &str, raw: Vec<u8>) -> (String, Vec<u8>) {
if media_type != "image/png" || raw.len() <= RECOMPRESS_ABOVE {
return (media_type.to_string(), raw);
}
let Ok(img) = image::load_from_memory_with_format(&raw, image::ImageFormat::Png) else {
return (media_type.to_string(), raw);
};
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p.0[3] != 255) {
return (media_type.to_string(), raw);
}
let mut jpeg = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, JPEG_QUALITY);
if img.to_rgb8().write_with_encoder(enc).is_err() || jpeg.len() >= raw.len() {
return (media_type.to_string(), raw);
}
("image/jpeg".to_string(), jpeg)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Role;
fn png(w: u32, h: u32, alpha: u8) -> Vec<u8> {
// Noise, so the PNG is large and JPEG wins.
let img = image::RgbaImage::from_fn(w, h, |x, y| {
let v = (x.wrapping_mul(7919) ^ y.wrapping_mul(104_729)).wrapping_mul(2_654_435_761) >> 24;
image::Rgba([v as u8, (v >> 1) as u8, (v >> 2) as u8, alpha])
});
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
buf
}
fn message(img: ImageData) -> Message {
let mut m = Message { id: "m1".into(), role: Role::User, parts: vec![], model: None, usage: None, created_at: 0 };
m.parts.push(Part::ToolResult { call_id: "c".into(), name: "browser".into(), content: String::new(), images: vec![img], is_error: false, meta: None });
m
}
#[test]
fn large_opaque_png_becomes_a_jpeg_reference() {
let raw = png(400, 400, 255);
let mut m = message(ImageData::new("image/png", B64.encode(&raw)));
let blobs = externalize(&mut m);
assert_eq!(blobs.len(), 1);
assert_eq!(blobs[0].media_type, "image/jpeg");
assert!(blobs[0].bytes.len() < raw.len());
let Part::ToolResult { images, .. } = &m.parts[0] else { panic!() };
assert!(images[0].data.is_empty());
assert_eq!(images[0].blob.as_deref(), Some(blobs[0].hash.as_str()));
assert_eq!(images[0].size, Some(blobs[0].bytes.len() as u64));
assert!(is_hash(&blobs[0].hash));
// Already a reference: nothing to do.
assert!(externalize(&mut m).is_empty());
}
#[test]
fn transparent_and_small_pngs_stay_png() {
let mut m = message(ImageData::new("image/png", B64.encode(png(400, 400, 128))));
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
let mut m = message(ImageData::new("image/png", B64.encode(png(8, 8, 255))));
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
}
#[test]
fn serialized_reference_has_no_data() {
let img = ImageData { media_type: "image/jpeg".into(), data: String::new(), blob: Some("ab".into()), size: Some(3) };
assert_eq!(serde_json::to_value(&img).unwrap(), serde_json::json!({"media_type": "image/jpeg", "blob": "ab", "size": 3}));
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ async fn render_page(pdf: &str, page: u32) -> Result<ImageData> {
let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle); let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle);
let mut buf = Vec::new(); let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?; img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
Ok(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) }) Ok(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
} }
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> { fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
+1 -1
View File
@@ -258,7 +258,7 @@ fn preview_for_model(path: &Path) -> Option<ImageData> {
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle); let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut buf = Vec::new(); let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?; img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) }) Some(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
} }
pub struct GenerateImage; pub struct GenerateImage;
+51
View File
@@ -165,6 +165,8 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(), instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
}; };
let session = app.save_session(session)?; let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?; app.store.job_started(&job.id, &session.id, now)?;
@@ -284,6 +286,49 @@ pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
} }
} }
/// Set `permission_mode` in the frontmatter of a job file (replaces the line or adds it
/// before the closing `---`); the rest of the file stays as it is.
pub fn set_permission_mode(path: &Path, mode: &str) -> Result<JobSpec> {
if !crate::permission::MODES.contains(&mode) {
bail!("invalid permission_mode '{mode}'");
}
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let updated = set_frontmatter_key(&text, "permission_mode", mode).ok_or_else(|| anyhow!("{} has no frontmatter", path.display()))?;
std::fs::write(path, &updated)?;
match parse(path) {
Ok(spec) => Ok(spec),
Err(e) => {
let _ = std::fs::write(path, &text);
Err(e)
}
}
}
fn set_frontmatter_key(text: &str, key: &str, value: &str) -> Option<String> {
let bom = if text.starts_with('\u{feff}') { "\u{feff}" } else { "" };
let t = &text[bom.len()..];
let rest = t.strip_prefix("---")?;
let end = rest.find("\n---")?;
let (head, tail) = (&rest[..end], &rest[end..]);
let prefix = format!("{key}:");
let mut found = false;
let mut lines: Vec<String> = head
.split('\n')
.map(|l| {
if !found && l.trim_start().starts_with(&prefix) {
found = true;
format!("{key}: {value}")
} else {
l.to_string()
}
})
.collect();
if !found {
lines.push(format!("{key}: {value}"));
}
Some(format!("{bom}---{}{tail}", lines.join("\n")))
}
/// Free file name for a new job in `<project>/.codecraft/jobs/`. /// Free file name for a new job in `<project>/.codecraft/jobs/`.
pub fn new_job_path(project: &Path, name: &str) -> PathBuf { pub fn new_job_path(project: &Path, name: &str) -> PathBuf {
let dir = project.join(".codecraft/jobs"); let dir = project.join(".codecraft/jobs");
@@ -446,6 +491,12 @@ mod tests {
let s = parse(&p).unwrap(); let s = parse(&p).unwrap();
assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto")); assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto"));
assert_eq!(job_cwd(&p, &s), dir); assert_eq!(job_cwd(&p, &s), dir);
// Added when missing, replaced when present; comments and the prompt stay.
assert_eq!(set_permission_mode(&p, "bypass").unwrap().permission_mode, "bypass");
std::fs::write(&p, "---\nname: N\npermission_mode: auto # comment\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n").unwrap();
assert_eq!(set_permission_mode(&p, "accept_edits").unwrap().permission_mode, "accept_edits");
assert_eq!(std::fs::read_to_string(&p).unwrap(), "---\nname: N\npermission_mode: accept_edits\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n");
assert!(set_permission_mode(&p, "yolo").is_err());
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
} }
} }
+1
View File
@@ -4,6 +4,7 @@ mod api;
mod app; mod app;
mod auth; mod auth;
mod backup; mod backup;
mod blobs;
mod cli_api; mod cli_api;
mod budget; mod budget;
mod config; mod config;
+2
View File
@@ -212,6 +212,8 @@ impl McpServer {
match c["type"].as_str().unwrap_or_default() { match c["type"].as_str().unwrap_or_default() {
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()), "text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
"image" => out.images.push(ImageData { "image" => out.images.push(ImageData {
blob: None,
size: None,
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(), media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
data: c["data"].as_str().unwrap_or_default().into(), data: c["data"].as_str().unwrap_or_default().into(),
}), }),
+4 -3
View File
@@ -291,7 +291,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten()); let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
let tools = available_tools(&app, &agent, owner.as_ref()); let tools = available_tools(&app, &agent, owner.as_ref());
let history = active_history(app.store.messages(session_id)?); let history = active_history(app.store.messages_for_model(session_id)?);
let req = ChatRequest { let req = ChatRequest {
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions), system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
messages: history, messages: history,
@@ -705,6 +705,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(), instructions: String::new(),
telegram_quiet: false,
}; };
let child = app.save_session(child)?; let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -1126,7 +1127,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
model.effort = Some("medium".into()); model.effort = Some("medium".into());
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) }); app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
let result = async { let result = async {
let history = active_history(app.store.messages(&session.id)?); let history = active_history(app.store.messages_for_model(&session.id)?);
// The user's request: the last user message with text that is not feedback/tool output. // The user's request: the last user message with text that is not feedback/tool output.
let req_idx = history let req_idx = history
.iter() .iter()
@@ -1251,7 +1252,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken,
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> { async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
let session = app.session(session_id)?; let session = app.session(session_id)?;
let history = active_history(app.store.messages(session_id)?); let history = active_history(app.store.messages_for_model(session_id)?);
if history.is_empty() { if history.is_empty() {
return Ok(()); return Ok(());
} }
+2
View File
@@ -37,6 +37,8 @@ pub async fn probe() -> Result<(), String> {
} }
let res = match Command::new("bwrap") let res = match Command::new("bwrap")
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"]) .args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
// The dynamic loader lives in /lib64 on x86_64 (/lib on arm64).
.args(["--ro-bind-try", "/lib64", "/lib64", "--ro-bind-try", "/lib32", "/lib32"])
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"]) .args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
.env_clear() .env_clear()
.output() .output()
+129 -2
View File
@@ -1,6 +1,6 @@
//! SQLite persistence for sessions and messages. //! SQLite persistence for sessions and messages.
use crate::types::{Message, Part, Session, now_ms}; use crate::types::{ImageData, Message, Part, Session, now_ms};
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use anyhow::Result; use anyhow::Result;
@@ -90,6 +90,13 @@ impl Store {
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at); CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
-- Images of messages, referenced by hash from the message JSON (see blobs.rs).
CREATE TABLE IF NOT EXISTS blobs (
hash TEXT PRIMARY KEY,
media_type TEXT NOT NULL,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS queue ( CREATE TABLE IF NOT EXISTS queue (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -274,12 +281,21 @@ impl Store {
} }
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> { pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
// Images go to the blob table (encoded before taking the lock).
let mut m = m.clone();
let blobs = crate::blobs::externalize(&mut m);
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
for b in &blobs {
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![b.hash, b.media_type, b.bytes, now_ms()],
)?;
}
conn.execute( conn.execute(
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2, "INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3) (SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
ON CONFLICT(id) DO UPDATE SET data = excluded.data", ON CONFLICT(id) DO UPDATE SET data = excluded.data",
params![m.id, session_id, serde_json::to_string(m)?], params![m.id, session_id, serde_json::to_string(&m)?],
)?; )?;
conn.execute( conn.execute(
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1", "UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
@@ -300,6 +316,103 @@ impl Store {
Ok(out) Ok(out)
} }
/// Messages with the bytes of their images filled in – for model requests and exports.
pub fn messages_for_model(&self, session_id: &str) -> Result<Vec<Message>> {
let mut out = self.messages(session_id)?;
let conn = self.conn.lock().unwrap();
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for m in &mut out {
crate::blobs::for_each_image(m, |img| fill_image(&conn, &mut cache, img));
}
Ok(out)
}
/// Fill in the bytes of one image reference (no-op for inline images).
pub fn resolve_image(&self, img: &ImageData) -> ImageData {
let mut img = img.clone();
let conn = self.conn.lock().unwrap();
fill_image(&conn, &mut std::collections::HashMap::new(), &mut img);
img
}
/// One stored image: (media type, bytes).
pub fn blob(&self, hash: &str) -> Result<Option<(String, Vec<u8>)>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT media_type, data FROM blobs WHERE hash = ?1", [hash], |r| Ok((r.get(0)?, r.get(1)?)))
.optional()?)
}
/// Move inline images of older messages to the blob table (once, after the update
/// that introduced it). Does not touch `updated_at` of the sessions.
pub fn migrate_inline_images(&self) -> Result<usize> {
let ids: Vec<String> = {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id FROM messages WHERE data LIKE '%\"media_type\"%\"data\":\"%'")?;
let rows = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
rows
};
let mut moved = 0;
for id in ids {
let data: Option<String> = {
let conn = self.conn.lock().unwrap();
conn.query_row("SELECT data FROM messages WHERE id = ?1", [&id], |r| r.get(0)).optional()?
};
let Some(data) = data else { continue };
let Ok(mut m) = serde_json::from_str::<Message>(&data) else { continue };
let blobs = crate::blobs::externalize(&mut m);
if blobs.is_empty() {
continue;
}
let conn = self.conn.lock().unwrap();
for b in &blobs {
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![b.hash, b.media_type, b.bytes, now_ms()],
)?;
}
conn.execute("UPDATE messages SET data = ?2 WHERE id = ?1", params![id, serde_json::to_string(&m)?])?;
moved += blobs.len();
}
Ok(moved)
}
/// Delete images no message refers to any more (deleted or truncated sessions).
pub fn gc_blobs(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let mut used = std::collections::HashSet::new();
let mut stmt = conn.prepare("SELECT data FROM messages WHERE data LIKE '%\"blob\":\"%'")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let data: String = row.get(0)?;
for (i, _) in data.match_indices("\"blob\":\"") {
if let Some(h) = data.get(i + 8..i + 72) {
used.insert(h.to_string());
}
}
}
drop(rows);
drop(stmt);
let all: Vec<String> = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
let mut removed = 0;
for h in all.iter().filter(|h| !used.contains(*h)) {
removed += conn.execute("DELETE FROM blobs WHERE hash = ?1", [h])?;
}
Ok(removed)
}
/// Give the space of deleted data back to the file system when it is worth it.
pub fn vacuum_if_worthwhile(&self) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let free: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
let page: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
if free * page < 32 * 1024 * 1024 {
return Ok(false);
}
conn.execute_batch("VACUUM")?;
Ok(true)
}
/// Remove all messages after (and including) the given one – used for "edit & resend". /// Remove all messages after (and including) the given one – used for "edit & resend".
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> { pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
@@ -312,6 +425,20 @@ impl Store {
} }
} }
/// Put the bytes of a stored image into `img.data`.
fn fill_image(conn: &Connection, cache: &mut std::collections::HashMap<String, String>, img: &mut ImageData) {
use base64::Engine;
let Some(hash) = img.blob.clone().filter(|_| img.data.is_empty()) else { return };
if !cache.contains_key(&hash) {
let Ok(bytes) = conn.query_row("SELECT data FROM blobs WHERE hash = ?1", [&hash], |r| r.get::<_, Vec<u8>>(0)) else {
tracing::warn!("image {hash} is missing in the store");
return;
};
cache.insert(hash.clone(), base64::engine::general_purpose::STANDARD.encode(bytes));
}
img.data = cache[&hash].clone();
}
/// One model request, as recorded for statistics. /// One model request, as recorded for statistics.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct UsageRecord { pub struct UsageRecord {
+17 -4
View File
@@ -354,13 +354,13 @@ async fn handle_update(app: &Arc<App>, user_id: &str, bot: &Bot, u: &Value) -> R
let mut notes = Vec::new(); let mut notes = Vec::new();
if let Some(photo) = m["photo"].as_array().and_then(|p| p.last()) { if let Some(photo) = m["photo"].as_array().and_then(|p| p.last()) {
let (bytes, _) = bot.download(photo["file_id"].as_str().unwrap_or("")).await?; let (bytes, _) = bot.download(photo["file_id"].as_str().unwrap_or("")).await?;
images.push(ImageData { media_type: "image/jpeg".into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) }); images.push(ImageData { blob: None, size: None, media_type: "image/jpeg".into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
} }
if let Some(doc) = m.get("document") { if let Some(doc) = m.get("document") {
let mime = doc["mime_type"].as_str().unwrap_or(""); let mime = doc["mime_type"].as_str().unwrap_or("");
let (bytes, path) = bot.download(doc["file_id"].as_str().unwrap_or("")).await?; let (bytes, path) = bot.download(doc["file_id"].as_str().unwrap_or("")).await?;
if mime.starts_with("image/") && bytes.len() < 10 * 1024 * 1024 { if mime.starts_with("image/") && bytes.len() < 10 * 1024 * 1024 {
images.push(ImageData { media_type: mime.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) }); images.push(ImageData { blob: None, size: None, media_type: mime.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
} else { } else {
let name = doc["file_name"].as_str().map(String::from).unwrap_or_else(|| path.rsplit('/').next().unwrap_or("file").to_string()); let name = doc["file_name"].as_str().map(String::from).unwrap_or_else(|| path.rsplit('/').next().unwrap_or("file").to_string());
let rel = save_upload(&session, &name, &bytes)?; let rel = save_upload(&session, &name, &bytes)?;
@@ -538,6 +538,8 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(), instructions: String::new(),
// Started from Telegram: keeps reporting there after switching to another chat.
telegram_quiet: false,
})?; })?;
update(app, &u.id, |st| st.session = Some(s.id.clone())); update(app, &u.id, |st| st.session = Some(s.id.clone()));
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?; bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
@@ -869,6 +871,17 @@ async fn listen(app: Arc<App>) {
let Some(chat) = st.chat_id else { continue }; let Some(chat) = st.chat_id else { continue };
let de = german(&app, &owner); let de = german(&app, &owner);
let is_current = st.session.as_deref() == Some(session_id.as_str()); let is_current = st.session.as_deref() == Some(session_id.as_str());
// Chats with "not to Telegram": their approvals and questions stay in the web UI
// (subagents follow their top-level chat; the chat open in Telegram always reports).
if !is_current && matches!(ev, Event::PermissionRequest { .. } | Event::QuestionRequest { .. }) {
let root = app.root_session_id(&session_id);
let quiet = if st.session.as_deref() == Some(root.as_str()) {
false
} else if root == session_id { session.telegram_quiet } else { app.store.get_session(&root).ok().flatten().is_some_and(|r| r.telegram_quiet) };
if quiet {
continue;
}
}
if let Err(e) = on_event(&app, &bot, chat, de, &session, is_current, &ev, &mut progress).await { if let Err(e) = on_event(&app, &bot, chat, de, &session, is_current, &ev, &mut progress).await {
tracing::debug!("telegram send: {e:#}"); tracing::debug!("telegram send: {e:#}");
} }
@@ -876,7 +889,7 @@ async fn listen(app: Arc<App>) {
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn on_event(_app: &App, bot: &Bot, chat: i64, de: bool, session: &Session, is_current: bool, ev: &Event, progress: &mut HashMap<String, Progress>) -> Result<()> { async fn on_event(app: &App, bot: &Bot, chat: i64, de: bool, session: &Session, is_current: bool, ev: &Event, progress: &mut HashMap<String, Progress>) -> Result<()> {
let sid = session.id.clone(); let sid = session.id.clone();
match ev { match ev {
// Approvals and questions of every own session: the agent waits for them. // Approvals and questions of every own session: the agent waits for them.
@@ -947,7 +960,7 @@ async fn on_event(_app: &App, bot: &Bot, chat: i64, de: bool, session: &Session,
for part in &message.parts { for part in &message.parts {
if let Part::ToolResult { images, name, .. } = part { if let Part::ToolResult { images, name, .. } = part {
for img in images { for img in images {
let _ = bot.send_photo(chat, img, Some(name)).await; let _ = bot.send_photo(chat, &app.store.resolve_image(img), Some(name)).await;
} }
} }
} }
+1 -1
View File
@@ -186,7 +186,7 @@ impl Tool for OpenScad {
continue; continue;
} }
if let Ok(bytes) = std::fs::read(&out) { if let Ok(bytes) = std::fs::read(&out) {
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }); images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
files.push(out); files.push(out);
} }
} }
+2
View File
@@ -27,6 +27,8 @@ pub(crate) fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
let mut buf = Vec::new(); let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?; img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
Ok(ImageData { Ok(ImageData {
blob: None,
size: None,
media_type: "image/png".into(), media_type: "image/png".into(),
data: base64::engine::general_purpose::STANDARD.encode(&buf), data: base64::engine::general_purpose::STANDARD.encode(&buf),
}) })
+2
View File
@@ -107,6 +107,8 @@ impl Tool for Read {
return Ok(ToolOutput { return Ok(ToolOutput {
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()), content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
images: vec![ImageData { images: vec![ImageData {
blob: None,
size: None,
media_type: mt.into(), media_type: mt.into(),
data: base64::engine::general_purpose::STANDARD.encode(&bytes), data: base64::engine::general_purpose::STANDARD.encode(&bytes),
}], }],
+44 -10
View File
@@ -1,15 +1,15 @@
import { takePrefill } from "../share"; import { takePrefill } from "../share";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure"; import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api"; import { del, get, patch, post } from "../api";
import { useModels } from "../hooks"; import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { dismissError, loadSession, loadSessions, useStore } from "../store"; import { dismissError, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types"; import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity"; import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode } from "../prefs"; import { useReasoningMode, useTodosCollapsed } from "../prefs";
import { Composer } from "./Composer"; import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog"; import { ShareDialog } from "./ShareDialog";
import { RenameDialog } from "./RenameDialog"; import { RenameDialog } from "./RenameDialog";
@@ -152,6 +152,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const isAdmin = useStore((s) => s.me?.role === "admin"); const isAdmin = useStore((s) => s.me?.role === "admin");
const tabs = useChatTabs(sessionId, session?.cwd ?? ""); const tabs = useChatTabs(sessionId, session?.cwd ?? "");
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
// The Telegram switch in the ⋯ menu only matters with a paired bot.
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
const [editingInstructions, setEditingInstructions] = useState(false); const [editingInstructions, setEditingInstructions] = useState(false);
const models = useModels(); const models = useModels();
const [loadError, setLoadError] = useState<string>(); const [loadError, setLoadError] = useState<string>();
@@ -352,6 +354,20 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
> >
<Icon name="edit" /> {t("instructions.title")} <Icon name="edit" /> {t("instructions.title")}
</a> </a>
{telegramPaired && !session.parent_id && (
<a
href="#"
role="menuitemcheckbox"
aria-checked={!session.telegram_quiet}
title={t("chat.telegramHint")}
onClick={(e) => {
e.preventDefault();
void update({ telegram_quiet: !session.telegram_quiet });
}}
>
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
</a>
)}
<a href={`/api/sessions/${sessionId}/export?format=md`} download> <a href={`/api/sessions/${sessionId}/export?format=md`} download>
<Icon name="download" /> {t("chat.exportMd")} <Icon name="download" /> {t("chat.exportMd")}
</a> </a>
@@ -471,8 +487,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{imgs.length > 0 && ( {imgs.length > 0 && (
<div className="user-files"> <div className="user-files">
{imgs.map((p, i) => ( {imgs.map((p, i) => (
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}> <FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" /> <img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
</FileFigure> </FileFigure>
))} ))}
</div> </div>
@@ -503,10 +519,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} /> <ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)} )}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && ( {openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<div className="todos"> <TodoPanel todos={todos} />
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
<TodoList todos={todos} />
</div>
)} )}
{permissions.map((p) => ( {permissions.map((p) => (
<PermissionCard key={p.id} req={p} currentId={sessionId} /> <PermissionCard key={p.id} req={p} currentId={sessionId} />
@@ -633,3 +646,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
</div> </div>
); );
} }
/** Tasks of the model above the composer; folds to one line (current task, progress). */
function TodoPanel({ todos }: { todos: Todo[] }) {
const t = useT();
const [collapsed, setCollapsed] = useTodosCollapsed();
const done = todos.filter((x) => x.status === "completed").length;
const current = todos.find((x) => x.status === "in_progress");
return (
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
<span>{t("tool.todos")}</span>
<span className="todos-count">
{done}/{todos.length}
</span>
{collapsed && current && <span className="todos-current">{current.content}</span>}
</button>
{!collapsed && <TodoList todos={todos} />}
</div>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { TemplatePicker } from "./TemplatePicker"; import { TemplatePicker } from "./TemplatePicker";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { formatSize } from "./FileFigure"; import { formatSize, imageSrc } from "./FileFigure";
import { uploadFile } from "../api"; import { uploadFile } from "../api";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch, useModels } from "../hooks"; import { useAgents, useFetch, useModels } from "../hooks";
@@ -257,7 +257,7 @@ export function Composer({
)} )}
{images.map((img, i) => ( {images.map((img, i) => (
<div className="att" key={i}> <div className="att" key={i}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" /> <img src={imageSrc(img)} alt="" />
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}> <button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" /> <Icon name="close" />
</button> </button>
+11
View File
@@ -1,5 +1,6 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import type { ImageData } from "../types";
/** Human-readable file size. */ /** Human-readable file size. */
export function formatSize(n: number) { export function formatSize(n: number) {
@@ -32,6 +33,16 @@ export function basename(p: string) {
} }
/** Bytes of a base64 payload. */ /** Bytes of a base64 payload. */
/** URL of an image of a message: inline (data URL) or from the image store. */
export function imageSrc(img: ImageData) {
return img.data ? `data:${img.media_type};base64,${img.data}` : `/api/blobs/${img.blob}`;
}
/** Size in bytes of an image of a message. */
export function imageSize(img: ImageData) {
return img.data ? base64Size(img.data) : img.size;
}
export function base64Size(b64: string) { export function base64Size(b64: string) {
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
return Math.floor((b64.length * 3) / 4) - pad; return Math.floor((b64.length * 3) / 4) - pad;
+16 -1
View File
@@ -107,7 +107,22 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
</span> </span>
<span>{t("jobs.mode")}</span> <span>{t("jobs.mode")}</span>
<span> <span>
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min {j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · max {j.spec?.timeout_minutes} min
</span>
<span>{t("jobs.permissionMode")}</span>
<span>
<select
value={j.spec?.permission_mode ?? "auto"}
disabled={busy === j.id}
title={t("jobs.permissionModeHint")}
aria-label={t("jobs.permissionMode")}
onChange={(e) => act(j.id, () => patch(`/jobs/${j.id}`, { permission_mode: e.target.value }))}
>
<option value="default">{t("permissionMode.default")}</option>
<option value="auto">{t("permissionMode.auto")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
</span> </span>
</div> </div>
)} )}
+2 -2
View File
@@ -5,7 +5,7 @@ import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n"; import { useT, type Key } from "../i18n";
import type { ImageData } from "../types"; import type { ImageData } from "../types";
import { readImage } from "./Composer"; import { readImage } from "./Composer";
import { formatSize } from "./FileFigure"; import { formatSize, imageSrc } from "./FileFigure";
import { ModelSelect } from "./ModelSelect"; import { ModelSelect } from "./ModelSelect";
import { takePrefill } from "../share"; import { takePrefill } from "../share";
@@ -478,7 +478,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<div className="attachments"> <div className="attachments">
{images.map((img, i) => ( {images.map((img, i) => (
<div className="att" key={`i${i}`}> <div className="att" key={`i${i}`}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" /> <img src={imageSrc(img)} alt="" />
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}> <button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" /> <Icon name="close" />
</button> </button>
+2 -1
View File
@@ -3,6 +3,7 @@ import { del, post } from "../api";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { useStore } from "../store"; import { useStore } from "../store";
import type { QueueItem } from "../types"; import type { QueueItem } from "../types";
import { imageSrc } from "./FileFigure";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { confirmDialog, promptDialog, toast } from "../dialogs";
const EMPTY: QueueItem[] = []; const EMPTY: QueueItem[] = [];
@@ -30,7 +31,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
{it.source === "background" && <span className="chip">{t("tool.background")}</span>} {it.source === "background" && <span className="chip">{t("tool.background")}</span>}
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span> <span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
{images.map((p, i) => ( {images.map((p, i) => (
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" /> <img key={i} src={imageSrc(p.image)} alt="" />
))} ))}
{it.source !== "background" && ( {it.source !== "background" && (
<button <button
+3 -3
View File
@@ -1,5 +1,5 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { base64Size, FileFigure } from "./FileFigure"; import { FileFigure, imageSize, imageSrc } from "./FileFigure";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { useStore } from "../store"; import { useStore } from "../store";
@@ -166,8 +166,8 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
</div> </div>
) : ( ) : (
result?.images?.map((img, i) => ( result?.images?.map((img, i) => (
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}> <FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" /> <img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
</FileFigure> </FileFigure>
)) ))
)} )}
+4
View File
@@ -593,6 +593,10 @@ export const de: Messages = {
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert", "chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
"chat.compactingManual": "Unterhaltung wird komprimiert", "chat.compactingManual": "Unterhaltung wird komprimiert",
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter", "chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
"chat.telegram": "Fragen an Telegram senden",
"chat.telegramHint": "Freigaben und Rückfragen dieses Chats auch per Telegram schicken (der gerade in Telegram geöffnete Chat meldet sich immer)",
"jobs.permissionMode": "Freigabemodus",
"jobs.permissionModeHint": "Gilt ab dem nächsten Lauf (wird in die Datei geschrieben). „Alles erlauben“: der Lauf fragt nie nach und wartet nicht auf Freigaben.",
"instructions.title": "Anweisungen für diesen Chat", "instructions.title": "Anweisungen für diesen Chat",
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.", "instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.", "instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
+4
View File
@@ -592,6 +592,10 @@ export const en = {
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically", "chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
"chat.compactingManual": "Compacting the conversation", "chat.compactingManual": "Compacting the conversation",
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards", "chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
"chat.telegram": "Send questions to Telegram",
"chat.telegramHint": "Also send approvals and questions of this chat via Telegram (the chat currently open in Telegram always reports)",
"jobs.permissionMode": "Approval mode",
"jobs.permissionModeHint": "Applies from the next run (written to the file). “Allow everything”: the run never asks and never waits for approval.",
"instructions.title": "Instructions for this chat", "instructions.title": "Instructions for this chat",
"instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.", "instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.",
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.", "instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
+18
View File
@@ -25,3 +25,21 @@ export function useReasoningMode(): ReasoningMode {
getReasoningMode, getReasoningMode,
); );
} }
const TODOS_KEY = "codecraft.todosCollapsed";
/** Task list above the composer folded to a one-line summary (per device). */
export function useTodosCollapsed(): [boolean, (v: boolean) => void] {
const v = useSyncExternalStore(
(l) => {
listeners.add(l);
return () => listeners.delete(l);
},
() => localStorage.getItem(TODOS_KEY) === "1",
);
const set = (c: boolean) => {
localStorage.setItem(TODOS_KEY, c ? "1" : "0");
listeners.forEach((l) => l());
};
return [v, set];
}
+139
View File
@@ -721,6 +721,39 @@ button.session-group:hover {
margin: 8px 0; margin: 8px 0;
font-size: 13px; font-size: 13px;
} }
.todos-head {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 0;
background: none;
border: none;
color: var(--muted);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.todos-head:hover {
color: var(--fg1);
}
.todos-count {
font-variant-numeric: tabular-nums;
}
.todos-current {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--accent);
font-weight: 600;
}
.todos.collapsed {
padding-block: 6px;
}
.todos ul { .todos ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -3127,6 +3160,11 @@ button.danger-solid {
.job-grid > span:nth-child(odd) { .job-grid > span:nth-child(odd) {
color: var(--fg3); color: var(--fg3);
} }
.job-grid select {
max-width: 100%;
font-size: 13px;
padding: 2px 6px;
}
.job-prompt { .job-prompt {
margin-top: 10px; margin-top: 10px;
} }
@@ -3773,3 +3811,104 @@ span.chat-tab > button {
.instructions-modal { .instructions-modal {
width: min(640px, calc(100vw - 32px)); width: min(640px, calc(100vw - 32px));
} }
/* --- short screens ----------------------------------------------------------
* Square phones (e.g. Clicks Communicator, ~393x400 CSS px with a hardware
* keyboard) and phones in landscape: height is the scarce dimension, so bars,
* the composer and the new-chat wizard get tighter and the chat keeps the room. */
@media (max-height: 560px) {
.topbar {
min-height: 40px;
padding: 4px 10px;
padding-top: max(4px, env(safe-area-inset-top));
gap: 4px;
}
.topbar button.ghost {
padding: 4px 6px;
}
.chat {
padding: 10px 12px 14px;
}
.msg {
margin-bottom: 10px;
}
.composer-wrap {
padding: 6px 10px;
padding-bottom: max(6px, env(safe-area-inset-bottom));
}
.composer textarea {
min-height: 38px;
max-height: 30vh;
padding: 8px 10px;
}
/* One placeholder line instead of two (the textarea grows with its content). */
.composer textarea::placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.composer-bar {
margin-top: 4px;
gap: 4px;
}
.composer-bar select {
max-width: 150px;
padding: 2px 4px;
}
.composer-actions button {
padding: 4px 10px;
}
.todos {
margin: 4px 0;
padding: 6px 10px;
}
.page {
padding: 12px;
}
.wiz-title {
font-size: 19px;
margin: 0 0 8px;
}
.wiz-sub {
display: none;
}
.wiz-panel {
padding: 12px;
}
.wiz-step {
margin-bottom: 16px;
}
.wiz-step h3 {
font-size: 16px;
margin-bottom: 8px;
}
.wiz-cards,
.wiz-cards.five {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.wiz-card {
padding: 8px 10px;
gap: 8px;
min-height: 44px;
font-size: 14px;
}
.wiz-card.active {
padding: 7px 9px;
}
.wiz-card-text {
padding-right: 14px;
}
.wiz-card-desc {
display: none;
}
.wiz-icon {
width: 20px;
height: 20px;
}
.wiz-num {
width: 24px;
height: 24px;
font-size: 12px;
}
}
+8 -1
View File
@@ -2,7 +2,12 @@
export interface ImageData { export interface ImageData {
media_type: string; media_type: string;
data: string; /** Base64 bytes; absent when the server stores the image separately (blob). */
data?: string;
/** SHA-256 of a stored image: GET /api/blobs/<blob>. */
blob?: string;
/** Size in bytes of a stored image. */
size?: number;
} }
export type Part = export type Part =
@@ -69,6 +74,8 @@ export interface Session {
folder?: string | null; folder?: string | null;
/** Instructions for this chat (added to the system prompt). */ /** Instructions for this chat (added to the system prompt). */
instructions?: string; instructions?: string;
/** Approvals and questions of this chat are not sent to Telegram. */
telegram_quiet?: boolean;
} }
export interface QuestionOption { export interface QuestionOption {
+12
View File
@@ -3,6 +3,18 @@
* 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-00012": {
en: ["Automations: set the approval mode right in the list – “Allow everything” runs unattended without asking", "⋯ → Send questions to Telegram: per chat, off for new chats"],
de: ["Automatisierungen: Freigabemodus direkt in der Liste wählen – „Alles erlauben“ läuft nachts ohne Rückfrage", "⋯ → Fragen an Telegram senden: pro Chat, bei neuen Chats aus"],
},
"202611-00011": {
en: ["The task list of the model can be folded (click on \"Tasks\")", "Better layout on square and short screens (e.g. Clicks Communicator, landscape)"],
de: ["Die Aufgabenliste des Modells lässt sich zuklappen (Klick auf „Aufgaben“)", "Besseres Layout auf quadratischen und niedrigen Displays (z. B. Clicks Communicator, Querformat)"],
},
"202611-00010": {
en: ["Chats with many images (screenshots, generated images) open much faster – images load when they scroll into view"],
de: ["Chats mit vielen Bildern (Screenshots, generierte Bilder) öffnen viel schneller – Bilder laden erst beim Hinscrollen"],
},
"202611-00008": { "202611-00008": {
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"], en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"], de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],