Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9339e2db41 | ||
|
|
d7f89ee257 | ||
|
|
b1fa54a7b6 | ||
|
|
e8e455b779 | ||
|
|
6d656a355d | ||
|
|
ca55404f99 | ||
|
|
0241f6031b | ||
|
|
4244b9370c |
No files matched your search
+38
-1
@@ -3,7 +3,44 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [Unreleased]
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
Generated
+67
@@ -52,6 +52,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -153,6 +168,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -363,6 +390,27 @@ dependencies = [
|
||||
"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]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
@@ -682,6 +730,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
@@ -4035,6 +4101,7 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.2",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00007" src="https://img.shields.io/badge/version-v202611--00007-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00011" src="https://img.shields.io/badge/version-v202611--00011-d69e2e?style=flat-square">
|
||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
|
||||
+2
-2
@@ -421,7 +421,7 @@ impl App {
|
||||
_ => None,
|
||||
};
|
||||
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 {
|
||||
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") {
|
||||
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());
|
||||
return;
|
||||
}
|
||||
|
||||
+1
-1
@@ -408,7 +408,7 @@ pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
|
||||
return Ok(Output {
|
||||
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
+16
-2
@@ -10,11 +10,25 @@ pub enum Role {
|
||||
Assistant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ImageData {
|
||||
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,
|
||||
/// 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)]
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/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
|
||||
docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null | grep -q "Platform:.*linux/$arch" \
|
||||
|| 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
@@ -37,12 +37,15 @@
|
||||
# --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)
|
||||
# --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)
|
||||
# --dry-run show the steps only
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
excludes=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--workspace) ws_target="$2"; shift ;;
|
||||
@@ -56,9 +59,10 @@ while (($#)); do
|
||||
--source-container) src_container="$2"; shift ;;
|
||||
--source-volume) src_volume="$2"; shift ;;
|
||||
--source-workspace) ws_source="$2"; shift ;;
|
||||
--exclude) excludes+=(--exclude "$2"); shift ;;
|
||||
--no-stop) stop=0 ;;
|
||||
--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 ;;
|
||||
*) target="$1" ;;
|
||||
esac
|
||||
@@ -68,7 +72,8 @@ done
|
||||
|
||||
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; }
|
||||
# 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%%:*}"
|
||||
dir="${target#*:}"
|
||||
@@ -93,9 +98,9 @@ remote() {
|
||||
}
|
||||
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
|
||||
if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
|
||||
if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
|
||||
else rsync -a --numeric-ids "${excludes[@]}" -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
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ tokio-tungstenite = "0.29"
|
||||
tokio-util = "0.7"
|
||||
toml = "1"
|
||||
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-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1", features = ["v4", "v7"] }
|
||||
|
||||
+73
-3
@@ -80,6 +80,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.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("/blobs/{hash}", get(get_blob))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
.route("/sessions/{id}/abort", post(abort))
|
||||
@@ -171,8 +172,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.find(|p| p.join("index.html").exists())
|
||||
.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(tower_http::services::ServeFile::new(index));
|
||||
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(missing_static_file(web_dir.clone()));
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api)
|
||||
@@ -181,9 +181,54 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/cli/install.sh", get(cli_install_script))
|
||||
.route("/cli/download/{target}", get(cli_download))
|
||||
.fallback_service(static_files)
|
||||
.layer(compression())
|
||||
.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 {
|
||||
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
|
||||
}
|
||||
@@ -744,7 +789,8 @@ struct ExportQ {
|
||||
/// 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> {
|
||||
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 = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
|
||||
let (body, mime, ext) = if q.format == "json" {
|
||||
@@ -879,9 +925,33 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
crate::shells::kill_session(&app, &id);
|
||||
app.terminals.kill_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})))
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
struct PromptBody {
|
||||
text: String,
|
||||
|
||||
@@ -131,9 +131,32 @@ impl App {
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
app.clone().start_image_migration();
|
||||
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>) {
|
||||
let servers = self.config().mcp.clone();
|
||||
let mcp = self.mcp.clone();
|
||||
|
||||
@@ -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
@@ -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 mut buf = Vec::new();
|
||||
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> {
|
||||
|
||||
@@ -258,7 +258,7 @@ fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
let mut buf = Vec::new();
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,7 @@ mod api;
|
||||
mod app;
|
||||
mod auth;
|
||||
mod backup;
|
||||
mod blobs;
|
||||
mod cli_api;
|
||||
mod budget;
|
||||
mod config;
|
||||
|
||||
@@ -212,6 +212,8 @@ impl McpServer {
|
||||
match c["type"].as_str().unwrap_or_default() {
|
||||
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
|
||||
"image" => out.images.push(ImageData {
|
||||
blob: None,
|
||||
size: None,
|
||||
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
|
||||
data: c["data"].as_str().unwrap_or_default().into(),
|
||||
}),
|
||||
|
||||
@@ -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 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 {
|
||||
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
|
||||
messages: history,
|
||||
@@ -1126,7 +1126,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
model.effort = Some("medium".into());
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
|
||||
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.
|
||||
let req_idx = history
|
||||
.iter()
|
||||
@@ -1251,7 +1251,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<()> {
|
||||
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() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ pub async fn probe() -> Result<(), String> {
|
||||
}
|
||||
let res = match Command::new("bwrap")
|
||||
.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"])
|
||||
.env_clear()
|
||||
.output()
|
||||
|
||||
+129
-2
@@ -1,6 +1,6 @@
|
||||
//! 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_json::{Value, json};
|
||||
use anyhow::Result;
|
||||
@@ -90,6 +90,13 @@ impl Store {
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
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<()> {
|
||||
// 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();
|
||||
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(
|
||||
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
|
||||
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
|
||||
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(
|
||||
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
|
||||
@@ -300,6 +316,103 @@ impl Store {
|
||||
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".
|
||||
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UsageRecord {
|
||||
|
||||
@@ -354,13 +354,13 @@ async fn handle_update(app: &Arc<App>, user_id: &str, bot: &Bot, u: &Value) -> R
|
||||
let mut notes = Vec::new();
|
||||
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?;
|
||||
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") {
|
||||
let mime = doc["mime_type"].as_str().unwrap_or("");
|
||||
let (bytes, path) = bot.download(doc["file_id"].as_str().unwrap_or("")).await?;
|
||||
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 {
|
||||
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)?;
|
||||
@@ -876,7 +876,7 @@ async fn listen(app: Arc<App>) {
|
||||
}
|
||||
|
||||
#[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();
|
||||
match ev {
|
||||
// Approvals and questions of every own session: the agent waits for them.
|
||||
@@ -947,7 +947,7 @@ async fn on_event(_app: &App, bot: &Bot, chat: i64, de: bool, session: &Session,
|
||||
for part in &message.parts {
|
||||
if let Part::ToolResult { images, name, .. } = part {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ impl Tool for OpenScad {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub(crate) fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
|
||||
Ok(ImageData {
|
||||
blob: None,
|
||||
size: None,
|
||||
media_type: "image/png".into(),
|
||||
data: base64::engine::general_purpose::STANDARD.encode(&buf),
|
||||
})
|
||||
|
||||
@@ -107,6 +107,8 @@ impl Tool for Read {
|
||||
return Ok(ToolOutput {
|
||||
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
|
||||
images: vec![ImageData {
|
||||
blob: None,
|
||||
size: None,
|
||||
media_type: mt.into(),
|
||||
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}],
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { takePrefill } from "../share";
|
||||
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 { del, get, patch, post } from "../api";
|
||||
import { useModels } from "../hooks";
|
||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
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 { useReasoningMode } from "../prefs";
|
||||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||
import { Composer } from "./Composer";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import { RenameDialog } from "./RenameDialog";
|
||||
@@ -471,8 +471,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{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)}>
|
||||
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
<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={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
@@ -503,10 +503,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} />
|
||||
)}
|
||||
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
||||
<div className="todos">
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||
<TodoList todos={todos} />
|
||||
</div>
|
||||
<TodoPanel todos={todos} />
|
||||
)}
|
||||
{permissions.map((p) => (
|
||||
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
||||
@@ -633,3 +630,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TemplatePicker } from "./TemplatePicker";
|
||||
import { Icon } from "./Icon";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { formatSize, imageSrc } from "./FileFigure";
|
||||
import { uploadFile } from "../api";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAgents, useFetch, useModels } from "../hooks";
|
||||
@@ -257,7 +257,7 @@ export function Composer({
|
||||
)}
|
||||
{images.map((img, 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")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { ImageData } from "../types";
|
||||
|
||||
/** Human-readable file size. */
|
||||
export function formatSize(n: number) {
|
||||
@@ -32,6 +33,16 @@ export function basename(p: string) {
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
||||
return Math.floor((b64.length * 3) / 4) - pad;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAgents, useFetch } from "../hooks";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import type { ImageData } from "../types";
|
||||
import { readImage } from "./Composer";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { formatSize, imageSrc } from "./FileFigure";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { takePrefill } from "../share";
|
||||
|
||||
@@ -478,7 +478,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
<div className="attachments">
|
||||
{images.map((img, 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")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { del, post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore } from "../store";
|
||||
import type { QueueItem } from "../types";
|
||||
import { imageSrc } from "./FileFigure";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
|
||||
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>}
|
||||
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
|
||||
{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" && (
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { base64Size, FileFigure } from "./FileFigure";
|
||||
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -166,8 +166,8 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
</div>
|
||||
) : (
|
||||
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)}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
|
||||
<img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -25,3 +25,21 @@ export function useReasoningMode(): ReasoningMode {
|
||||
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];
|
||||
}
|
||||
@@ -721,6 +721,39 @@ button.session-group:hover {
|
||||
margin: 8px 0;
|
||||
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 {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -3773,3 +3806,104 @@ span.chat-tab > button {
|
||||
.instructions-modal {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -2,7 +2,12 @@
|
||||
|
||||
export interface ImageData {
|
||||
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 =
|
||||
|
||||
+9
-1
@@ -3,7 +3,15 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
unreleased: {
|
||||
"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": {
|
||||
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
|
||||
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user