Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b302fa0b17 | ||
|
|
bd490efda5 | ||
|
|
9339e2db41 | ||
|
|
d7f89ee257 | ||
|
|
b1fa54a7b6 | ||
|
|
e8e455b779 | ||
|
|
6d656a355d | ||
|
|
ca55404f99 | ||
|
|
0241f6031b | ||
|
|
4244b9370c | ||
|
|
dae76a5636 | ||
|
|
ff8ff585a2 |
No files matched your search
@@ -3,6 +3,74 @@
|
|||||||
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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Instructions per chat:** *⋯ → Instructions for this chat* (Telegram: `/prompt`) sets rules
|
||||||
|
that apply to every message of that chat on top of the mode – language, tone, the part of the
|
||||||
|
project to work on. A chip in the header shows when a chat has them; forks keep them.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- OpenAI-compatible providers: tool results no longer carry a `name` field. Gateways that
|
||||||
|
translate to Anthropic (opencode) rejected the request with "anthropic-messages cannot
|
||||||
|
preserve named conversation participants".
|
||||||
|
- Telegram: the token field is no longer filled in by the browser's password manager, the bot
|
||||||
|
token is found in whatever is pasted (also the whole BotFather message), and a wrong format
|
||||||
|
gets a clear message instead of "HTTP 404".
|
||||||
|
|
||||||
## [v202611-00007] – 2026-11-09
|
## [v202611-00007] – 2026-11-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+67
@@ -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",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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-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
@@ -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
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
|
|||||||
Part::Image { image } => parts.push(json!({
|
Part::Image { image } => parts.push(json!({
|
||||||
"type": "image_url", "image_url": {"url": data_url(image)}
|
"type": "image_url", "image_url": {"url": data_url(image)}
|
||||||
})),
|
})),
|
||||||
Part::ToolResult { call_id, name, content, images, is_error, .. } => {
|
Part::ToolResult { call_id, content, images, is_error, .. } => {
|
||||||
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
|
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
|
||||||
|
// No "name": it is not part of the tool message in the Chat
|
||||||
|
// Completions API, and gateways that translate to Anthropic
|
||||||
|
// (opencode) reject named participants.
|
||||||
out.push(json!({
|
out.push(json!({
|
||||||
"role": "tool", "tool_call_id": call_id, "name": name,
|
"role": "tool", "tool_call_id": call_id,
|
||||||
"content": if text.is_empty() { "(no output)".into() } else { text }
|
"content": if text.is_empty() { "(no output)".into() } else { text }
|
||||||
}));
|
}));
|
||||||
tool_images.extend(images.iter().cloned());
|
tool_images.extend(images.iter().cloned());
|
||||||
@@ -328,6 +331,22 @@ mod tests {
|
|||||||
(text, reasoning)
|
(text, reasoning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_messages_have_no_name() {
|
||||||
|
use crate::types::{Message, Part, Role};
|
||||||
|
let call = Part::ToolCall { id: "c1".into(), name: "bash".into(), input: serde_json::json!({"command": "ls"}), extra: None };
|
||||||
|
let result = Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "ok".into(), images: vec![], is_error: false, meta: None };
|
||||||
|
let msgs = vec![
|
||||||
|
Message::new(Role::User, vec![Part::Text { text: "hi".into() }]),
|
||||||
|
Message::new(Role::Assistant, vec![call]),
|
||||||
|
Message::new(Role::User, vec![result]),
|
||||||
|
];
|
||||||
|
let wire = super::to_wire_messages("sys", &msgs);
|
||||||
|
let tool = wire.iter().find(|m| m["role"] == "tool").expect("tool message");
|
||||||
|
assert_eq!(tool["tool_call_id"], "c1");
|
||||||
|
assert!(wire.iter().all(|m| m.get("name").is_none()), "no message may carry a name: {wire:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn splits_think_tags_across_chunks() {
|
fn splits_think_tags_across_chunks() {
|
||||||
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
|
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
|
||||||
|
|||||||
+23
-2
@@ -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)]
|
||||||
@@ -166,6 +180,13 @@ pub struct Session {
|
|||||||
/// User-defined group in the session list.
|
/// User-defined group in the session list.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub folder: Option<String>,
|
pub folder: Option<String>,
|
||||||
|
/// Instructions the user set for this chat; added to the system prompt of every turn.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
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 {
|
||||||
|
|||||||
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-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
@@ -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"] }
|
||||||
|
|||||||
+96
-4
@@ -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"))
|
||||||
}
|
}
|
||||||
@@ -574,6 +619,9 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
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)))
|
||||||
@@ -743,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" {
|
||||||
@@ -782,12 +831,19 @@ struct UpdateSession {
|
|||||||
archived: Option<bool>,
|
archived: Option<bool>,
|
||||||
/// "" removes the folder.
|
/// "" removes the folder.
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
|
/// Instructions for this chat ("" clears them).
|
||||||
|
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 {
|
||||||
writable(&app, &user, &id)?;
|
writable(&app, &user, &id)?;
|
||||||
// Organising the list must not move the session to "today".
|
// Organising the list must not move the session to "today".
|
||||||
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none();
|
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none() && body.instructions.is_none();
|
||||||
|
if body.instructions.as_ref().is_some_and(|i| i.len() > 20_000) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "instructions too long (max 20,000 characters)".into()));
|
||||||
|
}
|
||||||
if let Some(m) = &body.model {
|
if let Some(m) = &body.model {
|
||||||
app.registry().resolve(m)?;
|
app.registry().resolve(m)?;
|
||||||
}
|
}
|
||||||
@@ -824,6 +880,12 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if let Some(p) = body.permission_mode {
|
if let Some(p) = body.permission_mode {
|
||||||
s.permission_mode = p;
|
s.permission_mode = p;
|
||||||
}
|
}
|
||||||
|
if let Some(i) = body.instructions {
|
||||||
|
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)))
|
||||||
}
|
}
|
||||||
@@ -870,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,
|
||||||
@@ -1575,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)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
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)?;
|
||||||
@@ -283,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");
|
||||||
@@ -445,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -216,6 +216,17 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
|
|||||||
|
|
||||||
/// The plain chat mode: no coding instructions, project files or memory – only the
|
/// The plain chat mode: no coding instructions, project files or memory – only the
|
||||||
/// date, the model and what the user keeps in their personal memory.
|
/// date, the model and what the user keeps in their personal memory.
|
||||||
|
/// Add the instructions the user set for this chat (they come last, so they win).
|
||||||
|
pub fn with_chat_instructions(mut system: String, instructions: &str) -> String {
|
||||||
|
if !instructions.trim().is_empty() {
|
||||||
|
system.push_str(&format!(
|
||||||
|
"\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n",
|
||||||
|
instructions.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
system
|
||||||
|
}
|
||||||
|
|
||||||
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
|
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
|
||||||
let mut s = agent.prompt.clone();
|
let mut s = agent.prompt.clone();
|
||||||
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
|
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
|
||||||
|
|||||||
@@ -291,9 +291,9 @@ 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::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()),
|
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,
|
||||||
tools: tools.iter().map(|t| t.def()).collect(),
|
tools: tools.iter().map(|t| t.def()).collect(),
|
||||||
max_tokens: model.max_output.unwrap_or(16_000),
|
max_tokens: model.max_output.unwrap_or(16_000),
|
||||||
@@ -704,6 +704,8 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
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 }]);
|
||||||
@@ -1125,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()
|
||||||
@@ -1250,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(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
+40
-6
@@ -329,6 +329,7 @@ async fn handle_update(app: &Arc<App>, user_id: &str, bot: &Bot, u: &Value) -> R
|
|||||||
return new_session(app, &u, bot, chat, &format!("{folder} {}", cur.agent), de).await;
|
return new_session(app, &u, bot, chat, &format!("{folder} {}", cur.agent), de).await;
|
||||||
}
|
}
|
||||||
"permissions" => return set_permissions(app, &u, bot, chat, arg, de).await,
|
"permissions" => return set_permissions(app, &u, bot, chat, arg, de).await,
|
||||||
|
"prompt" => return chat_instructions(app, &u, bot, chat, arg, de).await,
|
||||||
"effort" => return set_effort(app, &u, bot, chat, arg, de).await,
|
"effort" => return set_effort(app, &u, bot, chat, arg, de).await,
|
||||||
"costs" => return costs(app, &u, bot, chat, de).await,
|
"costs" => return costs(app, &u, bot, chat, de).await,
|
||||||
"memory" => return memory(app, &u, bot, chat, de).await,
|
"memory" => return memory(app, &u, bot, chat, de).await,
|
||||||
@@ -353,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)?;
|
||||||
@@ -401,13 +402,13 @@ fn help(de: bool) -> String {
|
|||||||
if de {
|
if de {
|
||||||
"<b>CodeCraft</b> – Nachrichten gehen an die aktuelle Sitzung (Fotos und Dateien auch).\n\n\
|
"<b>CodeCraft</b> – Nachrichten gehen an die aktuelle Sitzung (Fotos und Dateien auch).\n\n\
|
||||||
/sessions – letzte Sitzungen\n/session <Nr|Name> – Sitzung wechseln\n/new [Ordner] [Modus] – neue Sitzung (ohne Ordner: Chat)\n\
|
/sessions – letzte Sitzungen\n/session <Nr|Name> – Sitzung wechseln\n/new [Ordner] [Modus] – neue Sitzung (ohne Ordner: Chat)\n\
|
||||||
/clear – neue Sitzung im gleichen Ordner\n/mode [Name] – Modus\n/model [Modell] – Modell\n/permissions [Modus] – Freigaben\n/effort [Stufe] – Denktiefe\n\
|
/clear – neue Sitzung im gleichen Ordner\n/mode [Name] – Modus\n/model [Modell] – Modell\n/permissions [Modus] – Freigaben\n/prompt [Text|-] – Anweisungen für diesen Chat\n/effort [Stufe] – Denktiefe\n\
|
||||||
/stop – Runde abbrechen\n/status – aktuelle Sitzung\n/costs – Kosten und Budgets\n/memory – Gedächtnis\n/jobs – Automationen\n/compact – Unterhaltung komprimieren"
|
/stop – Runde abbrechen\n/status – aktuelle Sitzung\n/costs – Kosten und Budgets\n/memory – Gedächtnis\n/jobs – Automationen\n/compact – Unterhaltung komprimieren"
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
"<b>CodeCraft</b> – messages go to the current session (photos and files too).\n\n\
|
"<b>CodeCraft</b> – messages go to the current session (photos and files too).\n\n\
|
||||||
/sessions – recent sessions\n/session <n|name> – switch session\n/new [folder] [mode] – new session (no folder: chat)\n\
|
/sessions – recent sessions\n/session <n|name> – switch session\n/new [folder] [mode] – new session (no folder: chat)\n\
|
||||||
/clear – new session in the same folder\n/mode [name] – mode\n/model [model] – model\n/permissions [mode] – approvals\n/effort [level] – reasoning effort\n\
|
/clear – new session in the same folder\n/mode [name] – mode\n/model [model] – model\n/permissions [mode] – approvals\n/prompt [text|-] – instructions for this chat\n/effort [level] – reasoning effort\n\
|
||||||
/stop – stop the turn\n/status – current session\n/costs – costs and budgets\n/memory – memory\n/jobs – automations\n/compact – compact the conversation"
|
/stop – stop the turn\n/status – current session\n/costs – costs and budgets\n/memory – memory\n/jobs – automations\n/compact – compact the conversation"
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -536,6 +537,9 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
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?;
|
||||||
@@ -620,6 +624,25 @@ async fn status_message(app: &App, u: &User, bot: &Bot, chat: i64, de: bool) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show, set ("/prompt text") or clear ("/prompt -") the instructions of the current chat.
|
||||||
|
async fn chat_instructions(app: &App, u: &User, bot: &Bot, chat: i64, arg: &str, de: bool) -> Result<()> {
|
||||||
|
let s = current(app, u, de)?;
|
||||||
|
if arg.is_empty() {
|
||||||
|
let text = if s.instructions.is_empty() {
|
||||||
|
tr(de, "Keine Anweisungen für diesen Chat. Setzen mit /prompt <Text>.", "No instructions for this chat. Set them with /prompt <text>.").to_string()
|
||||||
|
} else {
|
||||||
|
format!("<b>{}</b>\n{}\n\n<i>{}</i>", tr(de, "Anweisungen für diesen Chat", "Instructions for this chat"), esc(&s.instructions), tr(de, "Löschen mit /prompt -", "Clear with /prompt -"))
|
||||||
|
};
|
||||||
|
bot.send(chat, &text, None).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let new = if arg == "-" { String::new() } else { arg.to_string() };
|
||||||
|
app.update_session(&s.id, |x| x.instructions = new.clone())?;
|
||||||
|
let text = if new.is_empty() { tr(de, "Anweisungen gelöscht.", "Instructions cleared.") } else { tr(de, "Anweisungen gesetzt – sie gelten ab der nächsten Nachricht.", "Instructions set – they apply from the next message.") };
|
||||||
|
bot.send(chat, text, None).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
const PERMISSION_MODES: &[&str] = &["auto", "default", "accept_edits", "bypass"];
|
const PERMISSION_MODES: &[&str] = &["auto", "default", "accept_edits", "bypass"];
|
||||||
const EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
const EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||||
|
|
||||||
@@ -848,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:#}");
|
||||||
}
|
}
|
||||||
@@ -855,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.
|
||||||
@@ -926,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
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";
|
||||||
|
import { InstructionsDialog } from "./InstructionsDialog";
|
||||||
import { RegenerateButton } from "./Regenerate";
|
import { RegenerateButton } from "./Regenerate";
|
||||||
import { WorktreeChip } from "./WorktreeChip";
|
import { WorktreeChip } from "./WorktreeChip";
|
||||||
import { QueueList } from "./QueueList";
|
import { QueueList } from "./QueueList";
|
||||||
@@ -151,6 +152,9 @@ 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 models = useModels();
|
const models = useModels();
|
||||||
const [loadError, setLoadError] = useState<string>();
|
const [loadError, setLoadError] = useState<string>();
|
||||||
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
||||||
@@ -307,6 +311,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<ShellsChip sessionId={sessionId} busy={busy} />
|
<ShellsChip sessionId={sessionId} busy={busy} />
|
||||||
{isAdmin && <BudgetChip />}
|
{isAdmin && <BudgetChip />}
|
||||||
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
||||||
|
{!!session.instructions && (
|
||||||
|
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
|
||||||
|
<Icon name="edit" /> {t("instructions.chip")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
@@ -335,6 +344,30 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<Icon name="more" />
|
<Icon name="more" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="menu-pop-body">
|
<div className="menu-pop-body">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||||
|
setEditingInstructions(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="edit" /> {t("instructions.title")}
|
||||||
|
</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>
|
||||||
@@ -454,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>
|
||||||
@@ -486,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} />
|
||||||
@@ -527,6 +557,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
|
||||||
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||||
@@ -615,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { patch } from "../api";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
|
/** Instructions for one chat: added to the system prompt of every turn. */
|
||||||
|
export function InstructionsDialog({ sessionId, value, onClose }: { sessionId: string; value: string; onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [text, setText] = useState(value);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const save = async (next: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await patch(`/sessions/${sessionId}`, { instructions: next });
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal instructions-modal" role="dialog" aria-label={t("instructions.title")} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{t("instructions.title")}</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 8 }}>
|
||||||
|
{t("instructions.hint")}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
rows={10}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
value={text}
|
||||||
|
placeholder={t("instructions.placeholder")}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => (e.ctrlKey || e.metaKey) && e.key === "Enter" && !busy && void save(text)}
|
||||||
|
/>
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
<div className="row" style={{ marginTop: 10, justifyContent: "flex-end", gap: 6 }}>
|
||||||
|
{value && (
|
||||||
|
<button className="danger" onClick={() => void save("")} disabled={busy}>
|
||||||
|
{t("instructions.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="spacer" />
|
||||||
|
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||||
|
<button className="primary" onClick={() => void save(text)} disabled={busy || text.trim() === value.trim()}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -593,6 +593,15 @@ 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.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.clear": "Entfernen",
|
||||||
|
"instructions.chip": "Anweisungen",
|
||||||
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||||
"chat.context": "Kontext",
|
"chat.context": "Kontext",
|
||||||
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
||||||
|
|||||||
@@ -592,6 +592,15 @@ 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.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.clear": "Remove",
|
||||||
|
"instructions.chip": "Instructions",
|
||||||
"chat.compacted": "Conversation compacted – summary",
|
"chat.compacted": "Conversation compacted – summary",
|
||||||
"chat.context": "Context",
|
"chat.context": "Context",
|
||||||
"chat.contextHint": "{tokens} tokens in the last request",
|
"chat.contextHint": "{tokens} tokens in the last request",
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -3770,3 +3808,107 @@ span.chat-tab > button {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -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 =
|
||||||
@@ -67,6 +72,10 @@ export interface Session {
|
|||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
folder?: string | null;
|
folder?: string | null;
|
||||||
|
/** Instructions for this chat (added to the system prompt). */
|
||||||
|
instructions?: string;
|
||||||
|
/** Approvals and questions of this chat are not sent to Telegram. */
|
||||||
|
telegram_quiet?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionOption {
|
export interface QuestionOption {
|
||||||
|
|||||||
@@ -3,6 +3,22 @@
|
|||||||
* 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": {
|
||||||
|
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
|
||||||
|
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],
|
||||||
|
},
|
||||||
"202611-00007": {
|
"202611-00007": {
|
||||||
en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"],
|
en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"],
|
||||||
de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"],
|
de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"],
|
||||||
|
|||||||
Reference in new issue
Block a user