Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b302fa0b17 | ||
|
|
bd490efda5 | ||
|
|
9339e2db41 | ||
|
|
d7f89ee257 | ||
|
|
b1fa54a7b6 | ||
|
|
e8e455b779 | ||
|
|
6d656a355d | ||
|
|
ca55404f99 | ||
|
|
0241f6031b | ||
|
|
4244b9370c | ||
|
|
dae76a5636 | ||
|
|
ff8ff585a2 | ||
|
|
434aa84d40 | ||
|
|
0a368e493d | ||
|
|
011756ca3d | ||
|
|
b7c8741a7d | ||
|
|
bf99292f43 | ||
|
|
36de035dd8 | ||
|
|
b59c5b8890 | ||
|
|
eef17eb591 | ||
|
|
6b1d7f91b8 | ||
|
|
0f4bfa0ab0 | ||
|
|
85b68d98ef | ||
|
|
d152ad836d |
No files matched your search
+116
@@ -3,6 +3,122 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
- **Telegram bot as a chat interface:** every user can connect their own bot (*Settings →
|
||||
Account → Telegram*: token from @BotFather, then `/start <code>` pairs it with their Telegram
|
||||
account – other chats are ignored). Messages, photos and files go to the current session; the
|
||||
agent's steps show live, the answer comes formatted, images from tools come as photos, and
|
||||
approvals and questions have buttons. Commands like the terminal client: `/sessions`,
|
||||
`/session`, `/new`, `/clear`, `/mode`, `/model`, `/permissions`, `/effort`, `/stop`, `/status`,
|
||||
`/costs`, `/memory`, `/jobs`, `/compact`, `/help`. The server polls Telegram – no public
|
||||
webhook needed.
|
||||
|
||||
## [v202611-00006] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- The image contains the Docker CLI (static client). With the host's `/var/run/docker.sock`
|
||||
mounted (and its group), admins' agents and terminals can use the host's Docker;
|
||||
`scripts/move-server.sh --docker-sock` sets this up on a new host.
|
||||
- `scripts/move-server.sh --tools-dir DIR`: agent home, users' homes and `/tmp` on a separate disk.
|
||||
|
||||
## [v202611-00005] – 2026-11-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Commits by agents, terminals and the git panel always use the name and e-mail from
|
||||
*Settings → Account → Git* – also in repositories that have their own `user.name`/`user.email`
|
||||
(agents had set those as a workaround while no identity was configured). Agents are told not
|
||||
to change the git identity.
|
||||
|
||||
## [v202611-00004] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Chats side by side:** *Open beside* in a chat's sidebar menu shows up to three chats as
|
||||
columns, each with its own tabs and composer (desktop).
|
||||
- **My modes:** every user can create personal modes under *Settings → My modes* (kind, tools,
|
||||
read-only, prompt, model); they appear only for that user.
|
||||
- **Budget warnings** as push notifications for admins (warning share and 100 %) for the total,
|
||||
provider and image budgets and the opencode Go windows; the status overlay shows all budgets.
|
||||
- `scripts/move-server.sh` moves an installation to another Docker host with bind mounts
|
||||
(e.g. Unraid), `scripts/smoke-test.sh` checks a running instance.
|
||||
|
||||
### Changed
|
||||
|
||||
- File manager tabs of a chat are stored on the server, so they are the same on every device.
|
||||
|
||||
## [v202611-00003] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+68
@@ -52,6 +52,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -153,6 +168,18 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -363,6 +390,27 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "9.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
@@ -682,6 +730,24 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
@@ -3040,6 +3106,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -4034,6 +4101,7 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.2",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
|
||||
+10
@@ -67,6 +67,14 @@ RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
|
||||
&& mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \
|
||||
&& cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64
|
||||
|
||||
# --- Docker CLI (static client; the daemon is the host's via the mounted socket) --
|
||||
FROM toolchain AS dockercli
|
||||
ARG TARGETARCH
|
||||
ARG DOCKER_VERSION=29.9.3
|
||||
RUN case "$TARGETARCH" in amd64) arch=x86_64 ;; arm64) arch=aarch64 ;; *) echo "unsupported $TARGETARCH" >&2; exit 1 ;; esac \
|
||||
&& curl -fsSL "https://download.docker.com/linux/static/stable/$arch/docker-$DOCKER_VERSION.tgz" | tar -xz -C /tmp docker/docker \
|
||||
&& install -m 0755 /tmp/docker/docker /docker
|
||||
|
||||
# --- runtime ------------------------------------------------------------------
|
||||
# Node is included so agents can work on JS projects and run npx-based MCP servers.
|
||||
FROM node:22-bookworm-slim
|
||||
@@ -85,6 +93,8 @@ RUN npm install -g typescript@5 typescript-language-server pyright && npm cache
|
||||
COPY --from=server /codecraft /usr/local/bin/codecraft
|
||||
COPY --from=web /web/dist /app/web
|
||||
COPY --from=cli /out /app/cli
|
||||
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
||||
COPY --from=dockercli /docker /usr/local/bin/docker
|
||||
COPY server/ghidra /app/ghidra
|
||||
COPY server/resources /app/resources
|
||||
# The agent's home lives on the data volume: tools it installs in user space
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00003" src="https://img.shields.io/badge/version-v202611--00003-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-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="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">
|
||||
@@ -97,6 +97,17 @@ volume `codecraft-data`; recreating the container keeps it. Your projects are mo
|
||||
WebSocket support) and set `auth.public_url` – installing the app and push notifications
|
||||
require HTTPS.
|
||||
|
||||
**Moving to another host** (for example Unraid with bind mounts instead of the Docker volume):
|
||||
`scripts/move-server.sh root@newhost:/mnt/user/appdata/codecraft --workspace /mnt/user/projects`
|
||||
copies the workspace, stops CodeCraft, streams the data volume to `<dir>/data`, writes a
|
||||
`docker-compose.yml` (and a `start.sh` for hosts without Compose) with bind mounts, starts the
|
||||
published image there and runs `scripts/smoke-test.sh`. The old container is only stopped, so
|
||||
you can roll back. `--help` lists the options (owner, port, bind address, dry run).
|
||||
`--tools-dir /mnt/disks/ssd/codecraft` puts everything agents install or download – the agent
|
||||
home (`/data/home`: toolchains, pip/npm/cargo packages, caches), the users' homes and `/tmp` – on
|
||||
a separate disk; the data folder keeps only the database, configuration and backups. Step by step,
|
||||
with rollback and troubleshooting: [docs/MOVING.md](docs/MOVING.md).
|
||||
|
||||
## How it works
|
||||
|
||||
CodeCraft runs on your server, next to your projects. You talk to it from a browser, the
|
||||
@@ -154,6 +165,9 @@ output limit, reasoning effort, extra request options and prices per million tok
|
||||
model's fallback.
|
||||
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
|
||||
process one at a time; waiting time is shown and never counted as model latency.
|
||||
- **Budget warnings:** admins get a push notification when the total, a provider's or the image
|
||||
budget, or an opencode Go window reaches the warning share (default 80 %) and when it is used
|
||||
up; the status overlay shows all budgets with bars.
|
||||
- **Prices:** *Fetch prices* fills in prices for OpenRouter and opencode (Zen and Go), including
|
||||
long-context tiers and peak-hour factors, which are applied per request.
|
||||
- **Status:** every five minutes each provider's model list is fetched (no tokens used), so
|
||||
@@ -199,6 +213,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t
|
||||
|
||||
## Integrations
|
||||
|
||||
- **Telegram:** connect your own bot (*Settings → Account → Telegram*) and use CodeCraft from
|
||||
Telegram – sessions, modes, models, approvals with buttons, photos and files; `/help` in the
|
||||
bot lists the commands.
|
||||
- **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client.
|
||||
- **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
|
||||
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
|
||||
|
||||
+2
-2
@@ -421,7 +421,7 @@ impl App {
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) {
|
||||
self.images.push(ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||
self.images.push(ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -840,7 +840,7 @@ impl App {
|
||||
for (cmd, args) in tries {
|
||||
if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() {
|
||||
if out.status.success() && out.stdout.starts_with(b"\x89PNG") {
|
||||
self.images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
|
||||
self.images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
|
||||
self.status = format!("image {} attached", self.images.len());
|
||||
return;
|
||||
}
|
||||
|
||||
+1
-1
@@ -408,7 +408,7 @@ pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
|
||||
return Ok(Output {
|
||||
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
|
||||
is_error: false,
|
||||
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
|
||||
images: vec![ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
|
||||
diff: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent w
|
||||
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
|
||||
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
|
||||
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
||||
- Git identity and credentials come from the user's settings: never set user.name, user.email or credential helpers yourself (no `git config user.*`, no `-c user.*`).
|
||||
- Reference code as path:line.
|
||||
- Answer in the language the user writes in."#;
|
||||
|
||||
|
||||
@@ -47,10 +47,13 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
|
||||
Part::Image { image } => parts.push(json!({
|
||||
"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() };
|
||||
// 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!({
|
||||
"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 }
|
||||
}));
|
||||
tool_images.extend(images.iter().cloned());
|
||||
@@ -328,6 +331,22 @@ mod tests {
|
||||
(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]
|
||||
fn splits_think_tags_across_chunks() {
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ImageData {
|
||||
pub media_type: String,
|
||||
/// Base64-encoded bytes.
|
||||
/// Base64-encoded bytes. Empty when the image is stored separately (`blob`).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub data: String,
|
||||
/// Server only: SHA-256 of the bytes in the image store; the UI loads
|
||||
/// /api/blobs/<blob> and the server fills `data` in before a model request.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blob: Option<String>,
|
||||
/// Size in bytes of a stored image (shown in the UI without fetching it).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
impl ImageData {
|
||||
pub fn new(media_type: impl Into<String>, data: String) -> Self {
|
||||
Self { media_type: media_type.into(), data, blob: None, size: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -166,6 +180,13 @@ pub struct Session {
|
||||
/// User-defined group in the session list.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Moving CodeCraft to another host (Unraid)
|
||||
|
||||
This guide moves a running installation – Docker volume `codecraft-data` on the current host –
|
||||
to another Docker host that keeps its data in **bind mounts**, for example Unraid. The script
|
||||
`scripts/move-server.sh` does the work; this page explains what goes where, what to prepare and
|
||||
how to check and roll back.
|
||||
|
||||
## What lives where
|
||||
|
||||
| Part | Contents | Today (Docker volume) | On the new host |
|
||||
|---|---|---|---|
|
||||
| **Data** | database `codecraft.db`, `config.toml` (incl. API keys), daily backups | `codecraft-data:/data` | `<dir>/data` → `/data` |
|
||||
| **Agent installs** | the agent's home `/data/home`: toolchains (Rust, …), pip/pipx/npm/cargo packages, downloaded programs (`~/opt`), caches | inside the volume | `<tools-dir>/home` → `/data/home` |
|
||||
| **Users' homes** | homes of sandboxed users `/data/users/<name>/home` (their installs, git settings) | inside the volume | `<tools-dir>/users` → `/data/users` |
|
||||
| **Temporary files** | `/tmp` of agents, builds, downloads | container layer | `<tools-dir>/tmp` → `/tmp` |
|
||||
| **Workspace** | your projects | `WORKSPACE` from `.env` | `--workspace` path → `/workspace` |
|
||||
| **Image** | the program | registry image | registry image |
|
||||
|
||||
The image never changes: the container runs without root (no `apt`, no `sudo`), so agents can
|
||||
only install into their home directory. With `--tools-dir` everything agents install or
|
||||
download lands on a separate disk (e.g. an SSD); the data folder keeps only the database,
|
||||
configuration and backups. If the tools disk is lost, agents simply install what they need
|
||||
again – nothing important is on it. (Backups contain the database and configuration, not the
|
||||
agent home.)
|
||||
|
||||
Sizes on 2026-11-09 for orientation: agent home 2.2 GB (of which downloaded programs 1.3 GB,
|
||||
Rust toolchain 0.8 GB), database/config/backups about 1.2 GB.
|
||||
|
||||
## Before you start
|
||||
|
||||
On the **new host** (Unraid):
|
||||
|
||||
- **SSH with a key** from the current host, without a password prompt. Check from the current
|
||||
host: `ssh root@unraid true` (no output, no question = fine).
|
||||
- **docker**, **rsync** and **tar** available (all three are on Unraid by default).
|
||||
- **Docker Compose** is optional: without it the script starts the container with the generated
|
||||
`start.sh` (Unraid: the "Docker Compose Manager" plugin adds Compose).
|
||||
- The **tools disk** is mounted, e.g. an SSD via Unassigned Devices under `/mnt/disks/ssd`.
|
||||
- **Registry access**: the image comes from `git.familie-zink.org/zink/codecraft`. If the pull is
|
||||
refused, run `docker login git.familie-zink.org` on Unraid once.
|
||||
|
||||
Decide:
|
||||
|
||||
| Question | Option | Example |
|
||||
|---|---|---|
|
||||
| Folder for data, compose file and scripts | target `root@host:/dir` | `/mnt/user/appdata/codecraft` |
|
||||
| Folder for agent installs and `/tmp` | `--tools-dir` | `/mnt/disks/ssd/codecraft` |
|
||||
| Your projects | `--workspace` | `/mnt/user/projects` |
|
||||
| Owner of files (the container runs as this user) | `--uid`, `--gid` | Unraid default `99`/`100` (nobody:users); today `1000`/`1000` |
|
||||
| Docker of the new host for agents and terminals | `--docker-sock` | bind mount of `/var/run/docker.sock` plus its group (see below) |
|
||||
| Port and address | `--port`, `--bind` | `8787`; `0.0.0.0` if the reverse proxy runs on another machine or in another Docker network, `127.0.0.1` if it runs on the same host with host networking |
|
||||
|
||||
## Moving
|
||||
|
||||
On the **current host**, in the repository folder (where `.env` is):
|
||||
|
||||
```sh
|
||||
# 1. Look at the steps first (changes nothing)
|
||||
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
|
||||
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock --dry-run
|
||||
|
||||
# 2. Move (same command without --dry-run)
|
||||
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
|
||||
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock
|
||||
```
|
||||
|
||||
What happens:
|
||||
|
||||
1. **Checks**: docker/tar/rsync on the target; the target data folder (and `home/`, `users/` in
|
||||
the tools folder) must be empty – the script never overwrites.
|
||||
2. **Workspace, first pass** with rsync while CodeCraft keeps running (the long part).
|
||||
3. **Stops CodeCraft here.** Running agents and terminals end. The container is only stopped,
|
||||
not removed.
|
||||
4. **Data volume** is streamed with tar over SSH into `<dir>/data`; with `--tools-dir` the agent
|
||||
home and the users' homes are then moved to the tools folder.
|
||||
5. **Workspace, second pass**: only what changed since step 2.
|
||||
6. **Owner** of data, tools and workspace set to `--uid:--gid` (needs root on the target).
|
||||
7. **Writes** `<dir>/docker-compose.yml`, `<dir>/.env` (your `.env` with the new paths, port and
|
||||
owner), `<dir>/start.sh` (the same as `docker run`, for hosts without Compose) and
|
||||
`<dir>/docker/seccomp-sandbox.json`.
|
||||
8. **Starts** the image `CODECRAFT_VERSION` from `.env` (Compose, else `start.sh`).
|
||||
9. **Smoke test** on the target (`scripts/smoke-test.sh`): health, version, web UI, terminal
|
||||
client download.
|
||||
|
||||
Afterwards:
|
||||
|
||||
- **Reverse proxy**: point the proxy host for `code.familie-zink.org` (Nginx Proxy Manager) to the
|
||||
new host and port; keep WebSocket support on.
|
||||
- **Check in the web UI**: sign in, open a chat, check the status LEDs (providers, MCP servers),
|
||||
let an agent run `git status` in a project (credentials, identity), open a terminal tab.
|
||||
Models or MCP servers on the old host's LAN address or `localhost` need new addresses.
|
||||
- **Clean up** on the old host when everything works: `docker rm codecraft`, later
|
||||
`docker volume rm codecraft-data`.
|
||||
|
||||
## Docker socket (`--docker-sock`)
|
||||
|
||||
With `--docker-sock` the container gets the new host's Docker: `/var/run/docker.sock` is bind
|
||||
mounted and the container joins the socket's group (the script reads its group id on the
|
||||
target). This is **root-equivalent access to the host**: whoever can start containers can mount
|
||||
any host folder.
|
||||
|
||||
- Admins' agents and terminals (they run without sandbox) can use it.
|
||||
- Sandboxed users do not see the socket (the sandbox does not mount `/var/run`).
|
||||
- `docker` and `docker-compose` commands always need an approval, except in *bypass* mode.
|
||||
- The image contains the Docker CLI (static client); the daemon is the host's.
|
||||
|
||||
The current host has the same setup in its local `docker-compose.override.yml` (group 997).
|
||||
|
||||
## Rollback
|
||||
|
||||
Nothing is deleted on the old host. On Unraid stop the new container (`docker stop codecraft`),
|
||||
on the old host start the old one (`docker start codecraft`) and point the reverse proxy back.
|
||||
Changes made on the new host in the meantime (chats, settings) are not in the old installation.
|
||||
|
||||
## Running it on the new host
|
||||
|
||||
In `<dir>` on Unraid:
|
||||
|
||||
| Task | With Compose | Without Compose |
|
||||
|---|---|---|
|
||||
| Update | set `CODECRAFT_VERSION` in `.env`, then `docker compose pull && docker compose up -d` | set `CODECRAFT_VERSION` in `.env`, `docker pull …:<version>`, then `./start.sh` |
|
||||
| Restart | `docker compose restart` | `docker restart codecraft` |
|
||||
| Logs | `docker compose logs -f` | `docker logs -f codecraft` |
|
||||
| Check | `bash smoke-test.sh http://127.0.0.1:8787 <version>` | same |
|
||||
|
||||
Releases are published from the development machine with `scripts/release.sh`; the new host
|
||||
only pulls them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`data on the target is not empty`**: an earlier attempt left files. Move `<dir>/data` (and
|
||||
`home/`, `users/` in the tools folder) away and run the script again.
|
||||
- **Image pull refused**: `docker login git.familie-zink.org` on the target, then
|
||||
`cd <dir> && docker compose up -d` (or `./start.sh`).
|
||||
- **Smoke test fails**: `docker logs codecraft` on the target. The old installation is stopped but
|
||||
intact – `docker start codecraft` on the old host brings it back.
|
||||
- **`could not chown`**: the SSH user is not root; set the owner by hand
|
||||
(`chown -R 99:100 <dir>/data <tools-dir> <workspace>`).
|
||||
- **Permission denied inside the container**: data, tools and workspace must belong to the
|
||||
`UID`/`GID` in `<dir>/.env`.
|
||||
- **Port already in use**: change `PORT` in `<dir>/.env` and start again.
|
||||
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."
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
# Move this CodeCraft installation to another Docker host (e.g. Unraid) that keeps its
|
||||
# data in bind mounts instead of the Docker volume "codecraft-data".
|
||||
#
|
||||
# Run it on the CURRENT host, in the repository folder (where .env lives):
|
||||
#
|
||||
# scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||
# --workspace /mnt/user/projects --uid 99 --gid 100 --bind 0.0.0.0
|
||||
#
|
||||
# What it does:
|
||||
# 1. copies the workspace while CodeCraft keeps running (rsync, first pass),
|
||||
# 2. stops the CodeCraft container here (it is kept for a rollback),
|
||||
# 3. streams the data volume (database, config, backups, agent home) to <dir>/data,
|
||||
# 4. syncs the workspace again (only what changed),
|
||||
# 5. writes <dir>/docker-compose.yml, <dir>/.env and <dir>/start.sh (docker run, for hosts
|
||||
# without Compose) with bind mounts, and starts the published image there,
|
||||
# 6. runs scripts/smoke-test.sh on the new host.
|
||||
# Afterwards point the reverse proxy to the new host. To roll back: start the old container
|
||||
# here again (docker compose start) – nothing is deleted on this host.
|
||||
#
|
||||
# Target: user@host:/dir (SSH; rsync and docker on the target), or local:/dir to try it on
|
||||
# this host (use --name/--port so it does not clash with the running installation).
|
||||
#
|
||||
# Options:
|
||||
# --workspace DIR project folder on the target (default <dir>/workspace)
|
||||
# --tools-dir DIR separate disk for what agents install and download: the agent home
|
||||
# (/data/home), the users' homes (/data/users) and /tmp are bind
|
||||
# mounts from DIR/home, DIR/users and DIR/tmp (default: inside <dir>/data)
|
||||
# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env)
|
||||
# --docker-sock give the container the target's Docker (bind mount of
|
||||
# /var/run/docker.sock plus its group). Root-equivalent access to the
|
||||
# target host for admins' agents and terminals; sandboxed users do not
|
||||
# see it, and docker commands still need approval
|
||||
# --port N host port on the target (default: PORT of .env)
|
||||
# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1)
|
||||
# --name NAME container name on the target (default codecraft)
|
||||
# --source-container C container to stop here (default codecraft)
|
||||
# --source-volume V data volume here (default codecraft-data)
|
||||
# --source-workspace D project folder here (default: WORKSPACE of .env)
|
||||
# --exclude PATTERN leave this out of the workspace copy (rsync pattern, relative to the
|
||||
# workspace, e.g. /old-project/ or /app/target/); repeatable
|
||||
# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
|
||||
# --dry-run show the steps only
|
||||
set -euo pipefail
|
||||
|
||||
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
|
||||
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
|
||||
excludes=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--workspace) ws_target="$2"; shift ;;
|
||||
--tools-dir) tools_dir="$2"; shift ;;
|
||||
--docker-sock) docker_sock=1 ;;
|
||||
--uid) uid="$2"; shift ;;
|
||||
--gid) gid="$2"; shift ;;
|
||||
--port) port="$2"; shift ;;
|
||||
--bind) bind="$2"; shift ;;
|
||||
--name) name="$2"; shift ;;
|
||||
--source-container) src_container="$2"; shift ;;
|
||||
--source-volume) src_volume="$2"; shift ;;
|
||||
--source-workspace) ws_source="$2"; shift ;;
|
||||
--exclude) excludes+=(--exclude "$2"); shift ;;
|
||||
--no-stop) stop=0 ;;
|
||||
--dry-run) dry=1 ;;
|
||||
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option $1" >&2; exit 2 ;;
|
||||
*) target="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[[ "$target" == *:/* ]] || { echo "usage: $0 user@host:/dir | local:/dir [options] (see --help)" >&2; exit 2; }
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
|
||||
# Value of a .env variable, without surrounding quotes.
|
||||
env_get() { sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
|
||||
|
||||
host="${target%%:*}"
|
||||
dir="${target#*:}"
|
||||
dir="${dir%/}"
|
||||
ws_target="${ws_target:-$dir/workspace}"
|
||||
uid="${uid:-$(env_get UID)}"; uid="${uid:-1000}"
|
||||
gid="${gid:-$(env_get GID)}"; gid="${gid:-1000}"
|
||||
port="${port:-$(env_get PORT)}"; port="${port:-8787}"
|
||||
bind="${bind:-$(env_get BIND_ADDR)}"; bind="${bind:-127.0.0.1}"
|
||||
version="$(env_get CODECRAFT_VERSION)"; version="${version:-latest}"
|
||||
ws_source="${ws_source:-$(env_get WORKSPACE)}"; ws_source="${ws_source:-./workspace}"
|
||||
image="$(docker inspect "$src_container" --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
image_repo="${image%:*}"
|
||||
[[ -n "$image_repo" && "$image_repo" != codecraft ]] || image_repo="git.familie-zink.org/zink/codecraft"
|
||||
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
fail() { echo "error: $*" >&2; exit 1; }
|
||||
# Run a shell command on the target.
|
||||
remote() {
|
||||
if (( dry )); then echo "[dry-run] on $host: $1"; return 0; fi
|
||||
if [[ "$host" == local ]]; then bash -c "$1"; else ssh -o BatchMode=yes "$host" "$1"; fi
|
||||
}
|
||||
q() { printf '%q' "$1"; }
|
||||
sync_workspace() {
|
||||
if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
|
||||
if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
|
||||
else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
|
||||
}
|
||||
put_file() { # put_file <remote path> < content
|
||||
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
|
||||
if [[ "$host" == local ]]; then cat >"$1"; else ssh -o BatchMode=yes "$host" "cat > $(q "$1")"; fi
|
||||
}
|
||||
|
||||
step "Checking source and target"
|
||||
docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
|
||||
[[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
|
||||
echo "source: container $src_container, volume $src_volume, workspace $ws_source"
|
||||
tools_dir="${tools_dir%/}"
|
||||
echo "target: $host:$dir (data in $dir/data, workspace $ws_target${tools_dir:+, agent installs and /tmp in $tools_dir}), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version"
|
||||
remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync"
|
||||
remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \
|
||||
|| fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)"
|
||||
if [[ -n "$tools_dir" ]]; then
|
||||
remote "mkdir -p $(q "$tools_dir") && test ! -e $(q "$tools_dir/home") && test ! -e $(q "$tools_dir/users")" \
|
||||
|| fail "$tools_dir already has home/ or users/ – refusing to overwrite (move them away first)"
|
||||
fi
|
||||
|
||||
step "Copying the workspace (first pass, CodeCraft keeps running)"
|
||||
sync_workspace
|
||||
|
||||
if (( stop )); then
|
||||
step "Stopping $src_container here (running agents and terminals end)"
|
||||
(( dry )) && echo "[dry-run] docker stop $src_container" || docker stop "$src_container" >/dev/null
|
||||
fi
|
||||
|
||||
step "Copying the data volume"
|
||||
if (( dry )); then
|
||||
echo "[dry-run] tar of volume $src_volume -> $host:$dir/data"
|
||||
else
|
||||
docker run --rm --entrypoint tar --user 0:0 -v "$src_volume:/data:ro" "${image:-$image_repo:$version}" -C /data -cf - . \
|
||||
| remote "tar -C $(q "$dir/data") -xpf -"
|
||||
fi
|
||||
|
||||
if [[ -n "$tools_dir" ]]; then
|
||||
step "Moving the agent home and the users' homes to $tools_dir"
|
||||
remote "mkdir -p $(q "$tools_dir/tmp") $(q "$dir/data/home") $(q "$dir/data/users") \
|
||||
&& mv $(q "$dir/data/home") $(q "$tools_dir/home") && mv $(q "$dir/data/users") $(q "$tools_dir/users") \
|
||||
&& mkdir -p $(q "$dir/data/home") $(q "$dir/data/users") && chmod 1777 $(q "$tools_dir/tmp")"
|
||||
fi
|
||||
|
||||
step "Copying the workspace (changes since the first pass)"
|
||||
sync_workspace
|
||||
remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") ${tools_dir:+$(q "$tools_dir/home") $(q "$tools_dir/users")} 2>/dev/null || echo 'note: could not chown (not root) – check file owners'"
|
||||
# Bind mounts for the tools disk and the Docker socket (compose lines and docker run flags).
|
||||
tool_mounts="" tool_flags="" sock_compose="" sock_flags=""
|
||||
if (( docker_sock )); then
|
||||
if (( dry )); then sock_gid=0; else
|
||||
sock_gid=$(remote "stat -c %g /var/run/docker.sock") || fail "no /var/run/docker.sock on the target"
|
||||
fi
|
||||
sock_compose=$(printf ' - /var/run/docker.sock:/var/run/docker.sock\n group_add:\n - "%s"\n' "$sock_gid")
|
||||
sock_flags="-v /var/run/docker.sock:/var/run/docker.sock --group-add $sock_gid "
|
||||
fi
|
||||
if [[ -n "$tools_dir" ]]; then
|
||||
tool_mounts=$(printf ' - %s/home:/data/home\n - %s/users:/data/users\n - %s/tmp:/tmp\n' "$tools_dir" "$tools_dir" "$tools_dir")
|
||||
tool_flags="-v $tools_dir/home:/data/home -v $tools_dir/users:/data/users -v $tools_dir/tmp:/tmp "
|
||||
fi
|
||||
|
||||
step "Writing docker-compose.yml, .env and start.sh"
|
||||
put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json
|
||||
grep -vE '^(COMPOSE_FILE|WORKSPACE|UID|GID|PORT|BIND_ADDR)=' .env | {
|
||||
cat
|
||||
printf '\n# Written by scripts/move-server.sh\nWORKSPACE=%s\nUID=%s\nGID=%s\nPORT=%s\nBIND_ADDR=%s\n' "$ws_target" "$uid" "$gid" "$port" "$bind"
|
||||
} | put_file "$dir/.env"
|
||||
put_file "$dir/docker-compose.yml" <<EOF
|
||||
# CodeCraft with bind mounts (written by scripts/move-server.sh).
|
||||
# Update: set CODECRAFT_VERSION in .env, then docker compose pull && docker compose up -d
|
||||
services:
|
||||
codecraft:
|
||||
image: $image_repo:\${CODECRAFT_VERSION:-latest}
|
||||
container_name: $name
|
||||
restart: unless-stopped
|
||||
user: "\${UID}:\${GID}"
|
||||
ports:
|
||||
- "\${BIND_ADDR}:\${PORT}:8787"
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
- $dir/data:/data
|
||||
- \${WORKSPACE}:/workspace
|
||||
$tool_mounts
|
||||
$sock_compose
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
shm_size: "1gb"
|
||||
security_opt:
|
||||
- seccomp=$dir/docker/seccomp-sandbox.json
|
||||
- apparmor=unconfined
|
||||
- systempaths=unconfined
|
||||
EOF
|
||||
put_file "$dir/start.sh" <<EOF
|
||||
#!/bin/sh
|
||||
# Same as docker-compose.yml, for hosts without Docker Compose.
|
||||
set -e
|
||||
cd "\$(dirname "\$0")"
|
||||
# Read values instead of sourcing .env: UID is read-only in bash.
|
||||
val() { sed -n "s/^\$1=//p" .env | tail -1; }
|
||||
docker rm -f $name >/dev/null 2>&1 || true
|
||||
exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
|
||||
-p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
|
||||
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" $tool_flags$sock_flags\\
|
||||
--add-host host.docker.internal:host-gateway --shm-size 1g \\
|
||||
--security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
|
||||
--security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
|
||||
$image_repo:\$(val CODECRAFT_VERSION)
|
||||
EOF
|
||||
remote "chmod +x $(q "$dir/start.sh")"
|
||||
|
||||
step "Starting CodeCraft on the target"
|
||||
remote "cd $(q "$dir") && docker pull -q $image_repo:$version >/dev/null && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" \
|
||||
|| fail "start failed – if the image pull was refused, run 'docker login ${image_repo%%/*}' on the target and start again with: cd $dir && docker compose up -d (or ./start.sh)"
|
||||
|
||||
step "Smoke test on the target"
|
||||
put_file "$dir/smoke-test.sh" <scripts/smoke-test.sh
|
||||
remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${version#v}" || fail "smoke test failed – the old installation here is stopped but intact (docker start $src_container)"
|
||||
|
||||
step "Done"
|
||||
cat <<EOF
|
||||
CodeCraft runs on $host ($bind:$port). Next:
|
||||
- point the reverse proxy (e.g. Nginx Proxy Manager) for your domain to the new host and port,
|
||||
- check the agents' tools in a chat (git credentials, MCP servers, LAN addresses),
|
||||
- when everything works, remove the old container here: docker rm $src_container
|
||||
(the volume $src_volume stays until you delete it: docker volume rm $src_volume).
|
||||
Rollback: on the target 'docker stop $name', here 'docker start $src_container'.
|
||||
EOF
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Quick check of a running CodeCraft: health, version, web UI, terminal client download.
|
||||
#
|
||||
# Usage: scripts/smoke-test.sh [URL] [EXPECTED_VERSION]
|
||||
# URL base URL (default http://127.0.0.1:8787)
|
||||
# EXPECTED_VERSION e.g. 202611-00003 (without "v"); checked when given
|
||||
#
|
||||
# Exit code 0 when everything answers, 1 otherwise.
|
||||
set -uo pipefail
|
||||
|
||||
url="${1:-http://127.0.0.1:8787}"
|
||||
url="${url%/}"
|
||||
want="${2:-}"
|
||||
want="${want#v}"
|
||||
fail=0
|
||||
check() { if [[ "$2" == ok ]]; then printf ' ok %s\n' "$1"; else printf ' FAIL %s (%s)\n' "$1" "$2"; fail=1; fi; }
|
||||
|
||||
echo "smoke test: $url"
|
||||
health=""
|
||||
for _ in $(seq 1 60); do
|
||||
health=$(curl -fsS -m 5 "$url/api/health" 2>/dev/null) && break
|
||||
sleep 2
|
||||
done
|
||||
[[ -n "$health" ]] && check "health" ok || check "health" "no answer after 2 minutes"
|
||||
if [[ -n "$health" ]]; then
|
||||
version=$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' <<<"$health")
|
||||
if [[ -n "$want" && "$version" != "$want" ]]; then check "version $version" "expected $want"; else check "version ${version:-?}" ok; fi
|
||||
grep -q '"ok":true' <<<"$health" && check "server reports ok" ok || check "server reports ok" "$health"
|
||||
fi
|
||||
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/")
|
||||
[[ "$code" == 200 ]] && check "web UI" ok || check "web UI" "HTTP $code"
|
||||
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/cli/install.sh")
|
||||
[[ "$code" == 200 ]] && check "terminal client install script" ok || check "terminal client install script" "HTTP $code"
|
||||
code=$(curl -s -o /dev/null -m 60 -w '%{http_code}' "$url/cli/download/linux-x86_64")
|
||||
[[ "$code" == 200 ]] && check "terminal client download" ok || check "terminal client download" "HTTP $code"
|
||||
exit $fail
|
||||
+2
-2
@@ -28,7 +28,7 @@ shell-words = "1"
|
||||
libc = "0.2"
|
||||
ignore = "0.4"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form", "multipart"] }
|
||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -38,7 +38,7 @@ tokio-tungstenite = "0.29"
|
||||
tokio-util = "0.7"
|
||||
toml = "1"
|
||||
toml_edit = "0.25"
|
||||
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
|
||||
tower-http = { version = "0.7", features = ["fs", "cors", "trace", "compression-gzip", "compression-br"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1", features = ["v4", "v7"] }
|
||||
|
||||
+234
-27
@@ -15,7 +15,7 @@ use axum::middleware;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -78,7 +78,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/title", post(suggest_title))
|
||||
.route("/sessions/{id}/regenerate", post(regenerate))
|
||||
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
|
||||
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
||||
.route("/sessions/{id}/export", get(export_session))
|
||||
.route("/blobs/{hash}", get(get_blob))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
.route("/sessions/{id}/abort", post(abort))
|
||||
@@ -136,6 +138,8 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
||||
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
||||
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
||||
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
|
||||
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
|
||||
.route("/templates", get(crate::cli_api::get_templates))
|
||||
.route("/templates/{scope}", put(crate::cli_api::put_templates))
|
||||
.route("/cli/config", get(crate::cli_api::config))
|
||||
@@ -168,8 +172,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.find(|p| p.join("index.html").exists())
|
||||
.unwrap_or_else(|| PathBuf::from("./web/dist"))
|
||||
});
|
||||
let index = web_dir.join("index.html");
|
||||
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
|
||||
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(missing_static_file(web_dir.clone()));
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api)
|
||||
@@ -178,9 +181,54 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/cli/install.sh", get(cli_install_script))
|
||||
.route("/cli/download/{target}", get(cli_download))
|
||||
.fallback_service(static_files)
|
||||
.layer(compression())
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
/// Paths that are not a file of the web build. Usually a client-side route → index.html,
|
||||
/// except for:
|
||||
/// - `/sw-<build>.js` of an earlier build: installed apps check their service worker at
|
||||
/// that URL for updates. Answering with index.html (not a script) makes the update
|
||||
/// fail, so the old worker kept serving the old app shell forever – which broke when
|
||||
/// the API changed. They get the kill switch /sw.js instead: it removes the old worker
|
||||
/// and reloads, and the page then registers the current worker.
|
||||
/// - other missing scripts/styles (old chunks): 404 instead of HTML.
|
||||
fn missing_static_file(web_dir: PathBuf) -> axum::routing::MethodRouter {
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
axum::routing::any(move |uri: axum::http::Uri| {
|
||||
let web_dir = web_dir.clone();
|
||||
async move {
|
||||
let path = uri.path();
|
||||
let name = path.trim_start_matches('/');
|
||||
if name.starts_with("sw-") && name.ends_with(".js") && !name.contains('/') {
|
||||
return match tokio::fs::read(web_dir.join("sw.js")).await {
|
||||
Ok(js) => ([(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache, no-store")], js).into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
}
|
||||
if [".js", ".mjs", ".css", ".map"].iter().any(|ext| path.ends_with(ext)) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
match tokio::fs::read(web_dir.join("index.html")).await {
|
||||
Ok(html) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], html).into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// gzip/brotli for JSON, HTML, JS and CSS. Images and SSE streams are left alone (default
|
||||
/// predicate), and so are binary downloads (already compressed or large).
|
||||
fn compression() -> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate + Clone> {
|
||||
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
|
||||
let predicate = DefaultPredicate::new()
|
||||
.and(NotForContentType::const_new("application/octet-stream"))
|
||||
.and(NotForContentType::const_new("application/zip"))
|
||||
.and(NotForContentType::const_new("application/gzip"));
|
||||
tower_http::compression::CompressionLayer::new().compress_when(predicate)
|
||||
}
|
||||
|
||||
fn cli_dir() -> PathBuf {
|
||||
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
|
||||
}
|
||||
@@ -322,13 +370,81 @@ fn scope(app: &App, user: &User, cwd: Option<String>) -> (PathBuf, PathBuf) {
|
||||
|
||||
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||
Json(json!(prompts::agents(&app, &root, &cwd)))
|
||||
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id))))
|
||||
}
|
||||
|
||||
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
||||
/// Built-in tools for the mode editor (name, read-only, first line of the description).
|
||||
async fn agent_tools(Extension(user): Extension<User>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
/// Telegram bot of the user: bot name, pairing state, errors (the token stays hidden).
|
||||
async fn get_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let s = crate::telegram::load(&app, &user.id);
|
||||
let (running, error) = crate::telegram::status(&user.id);
|
||||
Ok(Json(json!({
|
||||
"configured": !s.token.is_empty(),
|
||||
"bot": s.bot,
|
||||
"paired": s.chat_id.is_some(),
|
||||
"pair_code": if s.chat_id.is_none() { s.pair_code } else { String::new() },
|
||||
"running": running,
|
||||
"error": error,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TelegramBody {
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Connect a bot: check the token, start polling and create a pairing code.
|
||||
async fn put_telegram(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<TelegramBody>) -> ApiResult {
|
||||
let token = crate::telegram::extract_token(&b.token)
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "that is not a bot token – it looks like 123456789:AAH… (paste only the token from @BotFather)".into()))?;
|
||||
let bot = crate::telegram::check_token(&token).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("token rejected by Telegram: {e:#}")))?;
|
||||
let s = crate::telegram::Settings { token, bot, pair_code: crate::telegram::new_pair_code(), ..Default::default() };
|
||||
app.store.set_user_setting(&user.id, "telegram", &s)?;
|
||||
crate::telegram::restart(&app, &user.id);
|
||||
app.audit(Some(&user), "telegram.connect", &s.bot, "");
|
||||
get_telegram(Extension(user), State(app)).await
|
||||
}
|
||||
|
||||
async fn delete_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
crate::telegram::stop(&user.id);
|
||||
app.store.set_user_setting(&user.id, "telegram", &crate::telegram::Settings::default())?;
|
||||
app.audit(Some(&user), "telegram.disconnect", "", "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// The user's personal modes (name -> settings).
|
||||
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
|
||||
}
|
||||
|
||||
/// Replace the user's personal modes. Names of built-in, admin or project modes are
|
||||
/// taken; tools and read-only work like in admin modes (the user's sandbox still applies).
|
||||
async fn put_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>, Json(modes): Json<std::collections::BTreeMap<String, crate::config::AgentConfig>>) -> ApiResult {
|
||||
if modes.len() > 30 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "too many personal modes (max 30)".into()));
|
||||
}
|
||||
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||
let taken: Vec<String> = prompts::agents(&app, &root, &root).into_iter().map(|a| a.name).collect();
|
||||
let mut clean = std::collections::BTreeMap::new();
|
||||
for (name, mut m) in modes {
|
||||
if name.is_empty() || name.len() > 40 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid mode name '{name}' (letters, digits, - and _)")));
|
||||
}
|
||||
if taken.contains(&name) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("the name '{name}' is already used by another mode")));
|
||||
}
|
||||
if m.prompt.as_deref().is_some_and(|p| p.len() > 20_000) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "prompt too long (max 20,000 characters)".into()));
|
||||
}
|
||||
m.mode = Some("primary".into());
|
||||
m.max_parallel = None;
|
||||
clean.insert(name, m);
|
||||
}
|
||||
app.store.set_user_setting(&user.id, "modes", &clean)?;
|
||||
Ok(Json(json!(clean)))
|
||||
}
|
||||
|
||||
/// Built-in tools for the mode editors (name, read-only, first line of the description).
|
||||
async fn agent_tools() -> ApiResult {
|
||||
let list: Vec<Value> = crate::tools::builtin_tools()
|
||||
.iter()
|
||||
.map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()}))
|
||||
@@ -336,6 +452,7 @@ async fn agent_tools(Extension(user): Extension<User>) -> ApiResult {
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
||||
async fn delete_agent(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>, Query(q): Query<CwdQuery>) -> ApiResult {
|
||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||
let agent = prompts::agents(&app, &root, &cwd).into_iter().find(|a| a.name == name).ok_or_else(|| not_found("agent"))?;
|
||||
@@ -502,6 +619,9 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
pinned: false,
|
||||
archived: false,
|
||||
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)?;
|
||||
Ok(Json(json!(s)))
|
||||
@@ -671,7 +791,8 @@ struct ExportQ {
|
||||
/// Download a session as Markdown (default) or JSON.
|
||||
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
|
||||
let s = readable(&app, &user, &id)?;
|
||||
let messages = app.store.messages(&id)?;
|
||||
// JSON exports are self-contained: images inline instead of store references.
|
||||
let messages = if q.format == "json" { app.store.messages_for_model(&id)? } else { app.store.messages(&id)? };
|
||||
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
|
||||
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
|
||||
let (body, mime, ext) = if q.format == "json" {
|
||||
@@ -710,12 +831,19 @@ struct UpdateSession {
|
||||
archived: Option<bool>,
|
||||
/// "" removes the folder.
|
||||
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 {
|
||||
writable(&app, &user, &id)?;
|
||||
// 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 {
|
||||
app.registry().resolve(m)?;
|
||||
}
|
||||
@@ -752,10 +880,50 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
if let Some(p) = body.permission_mode {
|
||||
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)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct FileTab {
|
||||
id: String,
|
||||
dir: String,
|
||||
}
|
||||
|
||||
fn file_tabs_key(session_id: &str) -> String {
|
||||
format!("file_tabs:{session_id}")
|
||||
}
|
||||
|
||||
/// Tabs of a chat for this user: its terminals and file managers (kept on the server,
|
||||
/// so they are the same on every device).
|
||||
async fn session_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let files: Vec<FileTab> = app.store.user_setting(&user.id, &file_tabs_key(&id))?;
|
||||
Ok(Json(json!({"terminals": app.terminals.for_session(&id, &user.id), "files": files})))
|
||||
}
|
||||
|
||||
async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(tabs): Json<Vec<FileTab>>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
if tabs.len() > 20 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "too many file manager tabs".into()));
|
||||
}
|
||||
let roots = app.user_roots(Some(&user.id));
|
||||
for t in &tabs {
|
||||
let p = crate::tools::normalize(&PathBuf::from(&t.dir));
|
||||
if t.id.len() > 64 || !roots.iter().any(|r| p.starts_with(r)) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "folder is outside your folders".into()));
|
||||
}
|
||||
}
|
||||
app.store.set_user_setting(&user.id, &file_tabs_key(&id), &tabs)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
// Notify before deleting: visibility is checked against the stored session.
|
||||
@@ -764,9 +932,33 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
crate::shells::kill_session(&app, &id);
|
||||
app.terminals.kill_session(&id);
|
||||
app.store.delete_session(&id)?;
|
||||
// Images only this session used.
|
||||
let a = app.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e) = a.store.gc_blobs() {
|
||||
tracing::warn!("image cleanup: {e:#}");
|
||||
}
|
||||
});
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
|
||||
/// so it never changes and the browser may keep it.
|
||||
async fn get_blob(Extension(_user): Extension<User>, State(app): State<AppState>, Path(hash): Path<String>) -> Result<axum::response::Response, ApiError> {
|
||||
if !crate::blobs::is_hash(&hash) {
|
||||
return Err(not_found("image"));
|
||||
}
|
||||
let (mime, bytes) = app.store.blob(&hash)?.ok_or_else(|| not_found("image"))?;
|
||||
Ok((
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, mime),
|
||||
(axum::http::header::CACHE_CONTROL, "private, max-age=31536000, immutable".to_string()),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PromptBody {
|
||||
text: String,
|
||||
@@ -791,41 +983,48 @@ fn set_turn_effort(app: &App, id: &str, effort: Option<&str>) -> Result<(), ApiE
|
||||
|
||||
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
||||
let session = writable(&app, &user, &id)?;
|
||||
let text = body.text.trim().to_string();
|
||||
send_prompt(&app, session, body.text, body.images, body.effort.as_deref()).map(Json)
|
||||
}
|
||||
|
||||
/// Handle a user message for a session (web UI and Telegram): built-in commands,
|
||||
/// slash command expansion, then start or queue the turn.
|
||||
pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text: String, images: Vec<ImageData>, effort: Option<&str>) -> Result<Value, ApiError> {
|
||||
let id = session.id.clone();
|
||||
let text = text.trim().to_string();
|
||||
// Built-in commands.
|
||||
if text == "/compact" {
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
tokio::spawn(do_compact(app.clone(), id.clone()));
|
||||
return Ok(Json(json!({"ok": true, "command": "compact"})));
|
||||
return Ok(json!({"ok": true, "command": "compact"}));
|
||||
}
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let mut prompt_text = text.clone();
|
||||
if text.starts_with('/') {
|
||||
if let Some((expanded, agent, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) {
|
||||
if let Some((expanded, agent, clean_room)) = prompts::expand_command(app, &root, &cwd, &text) {
|
||||
prompt_text = expanded;
|
||||
if agent.is_some() || clean_room.is_some() {
|
||||
let mut s = session.clone();
|
||||
if let Some(a) = agent {
|
||||
s.agent = a;
|
||||
}
|
||||
if clean_room.is_some() {
|
||||
s.clean_room = clean_room;
|
||||
}
|
||||
app.save_session(s)?;
|
||||
app.update_session(&id, |s| {
|
||||
if let Some(a) = agent {
|
||||
s.agent = a;
|
||||
}
|
||||
if clean_room.is_some() {
|
||||
s.clean_room = clean_room;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
set_turn_effort(&app, &id, body.effort.as_deref())?;
|
||||
set_turn_effort(app, &id, effort)?;
|
||||
let mut parts = vec![Part::Text { text: prompt_text }];
|
||||
for image in body.images {
|
||||
for image in images {
|
||||
parts.push(Part::Image { image });
|
||||
}
|
||||
match runner::submit(app.clone(), id, parts, "user")? {
|
||||
runner::Submitted::Started => Ok(Json(json!({"ok": true}))),
|
||||
runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))),
|
||||
runner::Submitted::Started => Ok(json!({"ok": true})),
|
||||
runner::Submitted::Queued(item) => Ok(json!({"ok": true, "queued": item})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -849,7 +1048,7 @@ async fn flush_queue(Extension(user): Extension<User>, State(app): State<AppStat
|
||||
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
|
||||
}
|
||||
|
||||
async fn do_compact(app: AppState, id: String) {
|
||||
pub(crate) async fn do_compact(app: AppState, id: String) {
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
|
||||
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
|
||||
@@ -1225,7 +1424,9 @@ async fn status_overview(Extension(user): Extension<User>, State(app): State<App
|
||||
(name, v)
|
||||
})
|
||||
.collect();
|
||||
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp}))
|
||||
// Budgets and plan windows (admins: all; plan windows are shown per provider anyway).
|
||||
let budgets = if admin { crate::budget::meters(&app).unwrap_or_default() } else { vec![] };
|
||||
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp, "budgets": budgets, "currency": cfg.server.currency}))
|
||||
}
|
||||
|
||||
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
|
||||
@@ -1460,10 +1661,16 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateJob {
|
||||
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 {
|
||||
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 {
|
||||
app.store.job_set_enabled(&job.id, e)?;
|
||||
}
|
||||
|
||||
@@ -131,9 +131,32 @@ impl App {
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
app.clone().start_image_migration();
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// Move inline images of older chats to the image store, drop unused images and
|
||||
/// shrink the database file (in the background; chats load meanwhile as before).
|
||||
fn start_image_migration(self: Arc<Self>) {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
match self.store.migrate_inline_images() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
||||
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
||||
}
|
||||
match self.store.gc_blobs() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("removed {n} unused images"),
|
||||
Err(e) => tracing::warn!("image cleanup: {e:#}"),
|
||||
}
|
||||
match self.store.vacuum_if_worthwhile() {
|
||||
Ok(true) => tracing::info!("database compacted"),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("database compaction: {e:#}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn start_mcp(self: Arc<Self>) {
|
||||
let servers = self.config().mcp.clone();
|
||||
let mcp = self.mcp.clone();
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Images of chat messages are stored in the `blobs` table instead of as base64 inside the
|
||||
//! message JSON. A chat then loads without its images; the UI fetches them on demand from
|
||||
//! /api/blobs/<hash> (cached by the browser), and the runner puts the bytes back in before
|
||||
//! a model request. Large PNGs without transparency are stored as JPEG.
|
||||
|
||||
use crate::types::{ImageData, Message, Part};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
/// PNGs above this size are re-encoded as JPEG when that is smaller and loses no transparency.
|
||||
const RECOMPRESS_ABOVE: usize = 100 * 1024;
|
||||
const JPEG_QUALITY: u8 = 85;
|
||||
|
||||
/// An image to put into the store.
|
||||
pub struct Blob {
|
||||
pub hash: String,
|
||||
pub media_type: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Call `f` for every image of a message (pasted images and tool results).
|
||||
pub fn for_each_image(m: &mut Message, mut f: impl FnMut(&mut ImageData)) {
|
||||
for part in &mut m.parts {
|
||||
match part {
|
||||
Part::Image { image } => f(image),
|
||||
Part::ToolResult { images, .. } => images.iter_mut().for_each(&mut f),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the inline images of `m` out: each becomes a reference (`blob`, `size`, no
|
||||
/// `data`). Returns the blobs to store. Images that already are references stay as they are.
|
||||
pub fn externalize(m: &mut Message) -> Vec<Blob> {
|
||||
let mut out = Vec::new();
|
||||
for_each_image(m, |img| {
|
||||
if img.data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(raw) = B64.decode(img.data.as_bytes()) else { return };
|
||||
let (media_type, bytes) = compact(&img.media_type, raw);
|
||||
let hash = hash(&bytes);
|
||||
img.media_type = media_type.clone();
|
||||
img.data.clear();
|
||||
img.blob = Some(hash.clone());
|
||||
img.size = Some(bytes.len() as u64);
|
||||
out.push(Blob { hash, media_type, bytes });
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `s` looks like a blob hash (64 lowercase hex digits).
|
||||
pub fn is_hash(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Re-encode a large opaque PNG as JPEG if that is smaller; anything else is kept.
|
||||
fn compact(media_type: &str, raw: Vec<u8>) -> (String, Vec<u8>) {
|
||||
if media_type != "image/png" || raw.len() <= RECOMPRESS_ABOVE {
|
||||
return (media_type.to_string(), raw);
|
||||
}
|
||||
let Ok(img) = image::load_from_memory_with_format(&raw, image::ImageFormat::Png) else {
|
||||
return (media_type.to_string(), raw);
|
||||
};
|
||||
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p.0[3] != 255) {
|
||||
return (media_type.to_string(), raw);
|
||||
}
|
||||
let mut jpeg = Vec::new();
|
||||
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, JPEG_QUALITY);
|
||||
if img.to_rgb8().write_with_encoder(enc).is_err() || jpeg.len() >= raw.len() {
|
||||
return (media_type.to_string(), raw);
|
||||
}
|
||||
("image/jpeg".to_string(), jpeg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::Role;
|
||||
|
||||
fn png(w: u32, h: u32, alpha: u8) -> Vec<u8> {
|
||||
// Noise, so the PNG is large and JPEG wins.
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
let v = (x.wrapping_mul(7919) ^ y.wrapping_mul(104_729)).wrapping_mul(2_654_435_761) >> 24;
|
||||
image::Rgba([v as u8, (v >> 1) as u8, (v >> 2) as u8, alpha])
|
||||
});
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
fn message(img: ImageData) -> Message {
|
||||
let mut m = Message { id: "m1".into(), role: Role::User, parts: vec![], model: None, usage: None, created_at: 0 };
|
||||
m.parts.push(Part::ToolResult { call_id: "c".into(), name: "browser".into(), content: String::new(), images: vec![img], is_error: false, meta: None });
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_opaque_png_becomes_a_jpeg_reference() {
|
||||
let raw = png(400, 400, 255);
|
||||
let mut m = message(ImageData::new("image/png", B64.encode(&raw)));
|
||||
let blobs = externalize(&mut m);
|
||||
assert_eq!(blobs.len(), 1);
|
||||
assert_eq!(blobs[0].media_type, "image/jpeg");
|
||||
assert!(blobs[0].bytes.len() < raw.len());
|
||||
let Part::ToolResult { images, .. } = &m.parts[0] else { panic!() };
|
||||
assert!(images[0].data.is_empty());
|
||||
assert_eq!(images[0].blob.as_deref(), Some(blobs[0].hash.as_str()));
|
||||
assert_eq!(images[0].size, Some(blobs[0].bytes.len() as u64));
|
||||
assert!(is_hash(&blobs[0].hash));
|
||||
// Already a reference: nothing to do.
|
||||
assert!(externalize(&mut m).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_and_small_pngs_stay_png() {
|
||||
let mut m = message(ImageData::new("image/png", B64.encode(png(400, 400, 128))));
|
||||
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
|
||||
let mut m = message(ImageData::new("image/png", B64.encode(png(8, 8, 255))));
|
||||
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_reference_has_no_data() {
|
||||
let img = ImageData { media_type: "image/jpeg".into(), data: String::new(), blob: Some("ab".into()), size: Some(3) };
|
||||
assert_eq!(serde_json::to_value(&img).unwrap(), serde_json::json!({"media_type": "image/jpeg", "blob": "ab", "size": 3}));
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,124 @@ pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// One budget gauge: what is used of a limit (monthly budgets, Go plan windows).
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Meter {
|
||||
/// Stable id for alert state, e.g. "provider:anthropic" or "plan:opencode-go:5h".
|
||||
pub key: String,
|
||||
pub provider: String,
|
||||
/// "total", "provider", "images" or the plan window ("5h", "week", "month").
|
||||
pub kind: String,
|
||||
pub used: f64,
|
||||
pub limit: f64,
|
||||
pub ratio: f64,
|
||||
}
|
||||
|
||||
/// All configured budgets and plan windows with their current use (no remote calls).
|
||||
pub fn meters(app: &App) -> Result<Vec<Meter>> {
|
||||
let cfg = app.config();
|
||||
let spent = app.store.cost_by_provider(month_start())?;
|
||||
let mut out = Vec::new();
|
||||
let mut push = |key: String, provider: &str, kind: &str, used: f64, limit: f64| {
|
||||
if limit > 0.0 {
|
||||
out.push(Meter { key, provider: provider.into(), kind: kind.into(), used, limit, ratio: used / limit });
|
||||
}
|
||||
};
|
||||
push("total".into(), "total", "total", spent.values().sum(), cfg.server.monthly_budget);
|
||||
for (name, p) in cfg.providers.iter().filter(|(_, p)| p.enabled) {
|
||||
push(format!("provider:{name}"), name, "provider", spent.get(name).copied().unwrap_or(0.0), p.monthly_budget.unwrap_or(0.0));
|
||||
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
|
||||
let plan = plan_usage(app, name, limit)?;
|
||||
for w in plan["windows"].as_array().into_iter().flatten() {
|
||||
let window = w["window"].as_str().unwrap_or("");
|
||||
push(format!("plan:{name}:{window}"), name, window, w["used"].as_f64().unwrap_or(0.0), w["limit"].as_f64().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.images.enabled {
|
||||
push("images".into(), "fal", "images", spent.get("fal").copied().unwrap_or(0.0), cfg.images.monthly_budget.unwrap_or(0.0));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const ALERT_STATE: &str = "budget_alert_state";
|
||||
/// Alert state is global; it is kept under a pseudo user id.
|
||||
const SYSTEM: &str = "_system";
|
||||
|
||||
fn alert_text(m: &Meter, level: u8, de: bool) -> String {
|
||||
let pct = (m.ratio * 100.0).round() as i64;
|
||||
let what = match (m.kind.as_str(), de) {
|
||||
("total", true) => "Gesamtbudget des Monats".to_string(),
|
||||
("total", false) => "Monthly total budget".to_string(),
|
||||
("provider", true) => format!("Monatsbudget {}", m.provider),
|
||||
("provider", false) => format!("Monthly budget of {}", m.provider),
|
||||
("images", true) => "Bildbudget des Monats".to_string(),
|
||||
("images", false) => "Monthly image budget".to_string(),
|
||||
("5h", true) => format!("{}: 5-Stunden-Limit", m.provider),
|
||||
("5h", false) => format!("{}: 5-hour limit", m.provider),
|
||||
("week", true) => format!("{}: Wochenlimit", m.provider),
|
||||
("week", false) => format!("{}: weekly limit", m.provider),
|
||||
(_, true) => format!("{}: Monatslimit des Plans", m.provider),
|
||||
(_, false) => format!("{}: monthly plan limit", m.provider),
|
||||
};
|
||||
match (level, de) {
|
||||
(2, true) => format!("{what} ausgeschöpft ({pct} %)"),
|
||||
(2, false) => format!("{what} used up ({pct} %)"),
|
||||
(_, true) => format!("{what} zu {pct} % verbraucht"),
|
||||
(_, false) => format!("{what} {pct} % used"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push admins when a budget or plan window crosses the warning share or 100 %. Each
|
||||
/// level is sent once; dropping below (new month, rolling window) re-arms it.
|
||||
pub async fn check_alerts(app: &App) -> Result<()> {
|
||||
let warn = app.config().server.budget_warning;
|
||||
let mut state: std::collections::BTreeMap<String, u8> = app.store.user_setting(SYSTEM, ALERT_STATE)?;
|
||||
let mut alerts = Vec::new();
|
||||
let meters = meters(app)?;
|
||||
for m in &meters {
|
||||
let level = if m.ratio >= 1.0 { 2 } else if warn > 0.0 && m.ratio >= warn { 1 } else { 0 };
|
||||
let before = state.get(&m.key).copied().unwrap_or(0);
|
||||
if level > before {
|
||||
alerts.push((m.clone(), level));
|
||||
}
|
||||
state.insert(m.key.clone(), level);
|
||||
}
|
||||
state.retain(|k, _| meters.iter().any(|m| &m.key == k));
|
||||
app.store.set_user_setting(SYSTEM, ALERT_STATE, &state)?;
|
||||
if alerts.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for user in app.store.users()?.into_iter().filter(|u| u.is_admin() && !u.disabled) {
|
||||
let prefs: Value = app.store.user_setting(&user.id, "prefs").unwrap_or_default();
|
||||
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
|
||||
let body = alerts.iter().map(|(m, l)| alert_text(m, *l, de)).collect::<Vec<_>>().join("\n");
|
||||
let used_up = alerts.iter().any(|(_, l)| *l == 2);
|
||||
let title = match (used_up, de) {
|
||||
(true, true) => "CodeCraft – Budget ausgeschöpft",
|
||||
(true, false) => "CodeCraft – budget used up",
|
||||
(false, true) => "CodeCraft – Budget fast verbraucht",
|
||||
(false, false) => "CodeCraft – budget nearly used",
|
||||
};
|
||||
let payload = json!({"kind": "budget", "title": title, "body": body, "tag": "budget", "url": "/#/stats"});
|
||||
let (sent, _) = crate::push::send_to_user(app, &user.id, &payload).await;
|
||||
tracing::info!(user = %user.username, sent, alerts = alerts.len(), "budget alert");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn spawn_alerts(app: std::sync::Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(120)).await;
|
||||
loop {
|
||||
if let Err(e) = check_alerts(&app).await {
|
||||
tracing::warn!("budget alerts: {e:#}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(600)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn overview(app: &App) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let spent = app.store.cost_by_provider(month_start())?;
|
||||
@@ -384,6 +502,14 @@ mod tests {
|
||||
assert_eq!(mc.price_tiers[0].above_context, 200000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_texts() {
|
||||
let m = super::Meter { key: "plan:go:5h".into(), provider: "opencode-go".into(), kind: "5h".into(), used: 1.7, limit: 2.0, ratio: 0.85 };
|
||||
assert_eq!(super::alert_text(&m, 1, true), "opencode-go: 5-Stunden-Limit zu 85 % verbraucht");
|
||||
let t = super::Meter { key: "total".into(), provider: "total".into(), kind: "total".into(), used: 10.0, limit: 10.0, ratio: 1.0 };
|
||||
assert_eq!(super::alert_text(&t, 2, false), "Monthly total budget used up (100 %)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_entry_flags() {
|
||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||
|
||||
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
|
||||
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let cfg = app.config();
|
||||
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||
let agents: Vec<Value> = crate::prompts::agents(&app, &root, &root)
|
||||
let agents: Vec<Value> = crate::prompts::agents_for(&app, &root, &root, Some(&user.id))
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let mut v = json!(a);
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ async fn render_page(pdf: &str, page: u32) -> Result<ImageData> {
|
||||
let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle);
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
|
||||
Ok(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||
Ok(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||
}
|
||||
|
||||
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
|
||||
|
||||
+7
-2
@@ -44,6 +44,8 @@ tokio::task_local! {
|
||||
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
||||
/// The user's git config (credentials, identity) for unsandboxed runs.
|
||||
static GITCONFIG: Option<std::path::PathBuf>;
|
||||
/// The user's commit identity (overrides a repository's own user.name/email).
|
||||
static GITENV: Vec<(String, String)>;
|
||||
}
|
||||
|
||||
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||
@@ -63,6 +65,7 @@ async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
}
|
||||
c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default());
|
||||
c
|
||||
}
|
||||
};
|
||||
@@ -171,7 +174,8 @@ fn parse_status(raw: &str) -> Value {
|
||||
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
||||
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
||||
let cfg = crate::gitcreds::global_config(&app, &user);
|
||||
SANDBOX.scope(spec, GITCONFIG.scope(cfg, handle_inner(user, app, b))).await
|
||||
let env = crate::gitcreds::identity_env(&app, &user.id);
|
||||
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
|
||||
}
|
||||
|
||||
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
||||
@@ -388,8 +392,9 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
|
||||
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
||||
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
||||
let cfg = crate::gitcreds::global_config(app, user);
|
||||
let env = crate::gitcreds::identity_env(app, &user.id);
|
||||
let s = session.clone();
|
||||
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
|
||||
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
|
||||
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
||||
session.cwd = cwd.to_string();
|
||||
}
|
||||
|
||||
@@ -35,6 +35,24 @@ pub struct GitSettings {
|
||||
pub credentials: Vec<GitCredential>,
|
||||
}
|
||||
|
||||
/// The user's commit identity as environment variables. They take precedence over a
|
||||
/// repository's own `user.name`/`user.email`, so commits made by agents, terminals and
|
||||
/// the git panel always carry the identity from the settings.
|
||||
pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
|
||||
let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default();
|
||||
let mut env = Vec::new();
|
||||
let (name, email) = (s.name.trim(), s.email.trim());
|
||||
if !name.is_empty() {
|
||||
env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string()));
|
||||
env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string()));
|
||||
}
|
||||
if !email.is_empty() {
|
||||
env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string()));
|
||||
env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string()));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
||||
app.data_dir().join("users").join(&user.username).join("home")
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
|
||||
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||
Some(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||
}
|
||||
|
||||
pub struct GenerateImage;
|
||||
|
||||
@@ -164,6 +164,9 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
||||
pinned: false,
|
||||
archived: false,
|
||||
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)?;
|
||||
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/`.
|
||||
pub fn new_job_path(project: &Path, name: &str) -> PathBuf {
|
||||
let dir = project.join(".codecraft/jobs");
|
||||
@@ -445,6 +491,12 @@ mod tests {
|
||||
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!(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod api;
|
||||
mod app;
|
||||
mod auth;
|
||||
mod backup;
|
||||
mod blobs;
|
||||
mod cli_api;
|
||||
mod budget;
|
||||
mod config;
|
||||
@@ -30,6 +31,7 @@ mod runner;
|
||||
mod sandbox;
|
||||
mod shells;
|
||||
mod store;
|
||||
mod telegram;
|
||||
mod terminal;
|
||||
mod tools;
|
||||
mod types;
|
||||
@@ -72,6 +74,8 @@ async fn main() -> Result<()> {
|
||||
update::spawn_checker(app.clone());
|
||||
health::spawn_checker(app.clone());
|
||||
digest::spawn(app.clone());
|
||||
budget::spawn_alerts(app.clone());
|
||||
telegram::spawn(app.clone());
|
||||
backup::spawn(app.clone());
|
||||
let router = api::router(app);
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
|
||||
@@ -212,6 +212,8 @@ impl McpServer {
|
||||
match c["type"].as_str().unwrap_or_default() {
|
||||
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
|
||||
"image" => out.images.push(ImageData {
|
||||
blob: None,
|
||||
size: None,
|
||||
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
|
||||
data: c["data"].as_str().unwrap_or_default().into(),
|
||||
}),
|
||||
|
||||
+45
-2
@@ -147,8 +147,40 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
map.into_values().collect()
|
||||
}
|
||||
|
||||
pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent {
|
||||
agents(app, root, cwd)
|
||||
/// Personal modes a user created for themselves (user setting "modes").
|
||||
pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap<String, crate::config::AgentConfig> {
|
||||
app.store.user_setting(user_id, "modes").unwrap_or_default()
|
||||
}
|
||||
|
||||
/// All agents plus the user's personal modes. Personal modes never replace built-in,
|
||||
/// admin or project ones of the same name.
|
||||
pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> Vec<Agent> {
|
||||
let mut list = agents(app, root, cwd);
|
||||
let Some(uid) = user_id else { return list };
|
||||
for (name, a) in personal_modes(app, uid) {
|
||||
if list.iter().any(|x| x.name == name) {
|
||||
continue;
|
||||
}
|
||||
list.push(Agent {
|
||||
name,
|
||||
description: a.description.unwrap_or_default(),
|
||||
mode: "primary".into(),
|
||||
prompt: a.prompt.unwrap_or_default(),
|
||||
tools: a.tools,
|
||||
read_only: a.read_only.unwrap_or(false),
|
||||
style: a.style.filter(|s| s == "chat").unwrap_or_else(|| "coding".into()),
|
||||
model: a.model.filter(|m| !m.is_empty()),
|
||||
source: "personal".into(),
|
||||
max_parallel: None,
|
||||
refine: a.refine,
|
||||
path: None,
|
||||
});
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
pub fn find_agent_for(app: &App, root: &Path, cwd: &Path, name: &str, user_id: Option<&str>) -> Agent {
|
||||
agents_for(app, root, cwd, user_id)
|
||||
.into_iter()
|
||||
.find(|a| a.name == name)
|
||||
.unwrap_or_else(|| builtin_agents(true).remove(0))
|
||||
@@ -184,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
|
||||
/// 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 {
|
||||
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")));
|
||||
|
||||
@@ -268,7 +268,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let roots = app.user_roots(session.owner_id.as_deref());
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let mut agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
|
||||
let mut agent = prompts::find_agent_for(&app, &root, &cwd, &session.agent, session.owner_id.as_deref());
|
||||
let clean_room = session.clean_room(app.config().craft.clean_room);
|
||||
// The craft prompt follows the session's clean room / deep switch.
|
||||
if agent.name == "craft" && agent.source == "builtin" {
|
||||
@@ -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 tools = available_tools(&app, &agent, owner.as_ref());
|
||||
let history = active_history(app.store.messages(session_id)?);
|
||||
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||
let req = ChatRequest {
|
||||
system: prompts::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,
|
||||
tools: tools.iter().map(|t| t.def()).collect(),
|
||||
max_tokens: model.max_output.unwrap_or(16_000),
|
||||
@@ -667,7 +667,7 @@ pub async fn run_subagent(app: Arc<App>, ctx: &ToolCtx, agent: &str, description
|
||||
fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> {
|
||||
let parent = app.session(&ctx.session_id)?;
|
||||
let root = ctx.root();
|
||||
let parent_agent = prompts::find_agent(app, &root, &ctx.cwd, &parent.agent);
|
||||
let parent_agent = prompts::find_agent_for(app, &root, &ctx.cwd, &parent.agent, parent.owner_id.as_deref());
|
||||
let agents = prompts::agents(app, &root, &ctx.cwd);
|
||||
let mut agent = agents
|
||||
.iter()
|
||||
@@ -704,6 +704,8 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
||||
pinned: false,
|
||||
archived: false,
|
||||
folder: None,
|
||||
instructions: String::new(),
|
||||
telegram_quiet: false,
|
||||
};
|
||||
let child = app.save_session(child)?;
|
||||
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());
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
|
||||
let result = async {
|
||||
let history = active_history(app.store.messages(&session.id)?);
|
||||
let history = active_history(app.store.messages_for_model(&session.id)?);
|
||||
// The user's request: the last user message with text that is not feedback/tool output.
|
||||
let req_idx = history
|
||||
.iter()
|
||||
@@ -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<()> {
|
||||
let session = app.session(session_id)?;
|
||||
let history = active_history(app.store.messages(session_id)?);
|
||||
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||
if history.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
+27
-1
@@ -18,6 +18,8 @@ pub struct SandboxSpec {
|
||||
pub roots: Vec<PathBuf>,
|
||||
/// Host directory mounted as the user's home.
|
||||
pub home: PathBuf,
|
||||
/// Extra environment (the user's git identity).
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl SandboxSpec {
|
||||
@@ -35,6 +37,8 @@ pub async fn probe() -> Result<(), String> {
|
||||
}
|
||||
let res = match Command::new("bwrap")
|
||||
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
|
||||
// The dynamic loader lives in /lib64 on x86_64 (/lib on arm64).
|
||||
.args(["--ro-bind-try", "/lib64", "/lib64", "--ro-bind-try", "/lib32", "/lib32"])
|
||||
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
|
||||
.env_clear()
|
||||
.output()
|
||||
@@ -69,7 +73,7 @@ pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
|
||||
}
|
||||
let home = app.data_dir().join("users").join(&user.username).join("home");
|
||||
let _ = std::fs::create_dir_all(&home);
|
||||
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
|
||||
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home, env: crate::gitcreds::identity_env(app, &user.id) })
|
||||
}
|
||||
|
||||
/// Build a command that runs `program args` inside the sandbox in `cwd`.
|
||||
@@ -148,9 +152,31 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
|
||||
] {
|
||||
push(&[&"--setenv", &k, &v]);
|
||||
}
|
||||
for (k, v) in &spec.env {
|
||||
push(&[&"--setenv", k, v]);
|
||||
}
|
||||
push(&[&"--chdir", &cwd, &program]);
|
||||
for a in args {
|
||||
push(&[a]);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn git_identity_reaches_the_sandbox() {
|
||||
let spec = SandboxSpec {
|
||||
username: "ann".into(),
|
||||
roots: vec![],
|
||||
home: PathBuf::from("/tmp/ann"),
|
||||
env: vec![("GIT_AUTHOR_NAME".into(), "Ann Example".into())],
|
||||
};
|
||||
let args: Vec<String> = bwrap_args(&spec, Path::new("/tmp"), &[], true, "bash", &[]).into_iter().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
let i = args.iter().position(|a| a == "GIT_AUTHOR_NAME").expect("identity set");
|
||||
assert_eq!(args[i - 1], "--setenv");
|
||||
assert_eq!(args[i + 1], "Ann Example");
|
||||
}
|
||||
}
|
||||
+129
-2
@@ -1,6 +1,6 @@
|
||||
//! SQLite persistence for sessions and messages.
|
||||
|
||||
use crate::types::{Message, Part, Session, now_ms};
|
||||
use crate::types::{ImageData, Message, Part, Session, now_ms};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use anyhow::Result;
|
||||
@@ -90,6 +90,13 @@ impl Store {
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
|
||||
-- Images of messages, referenced by hash from the message JSON (see blobs.rs).
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
hash TEXT PRIMARY KEY,
|
||||
media_type TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
@@ -274,12 +281,21 @@ impl Store {
|
||||
}
|
||||
|
||||
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
|
||||
// Images go to the blob table (encoded before taking the lock).
|
||||
let mut m = m.clone();
|
||||
let blobs = crate::blobs::externalize(&mut m);
|
||||
let conn = self.conn.lock().unwrap();
|
||||
for b in &blobs {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![b.hash, b.media_type, b.bytes, now_ms()],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
|
||||
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
|
||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data",
|
||||
params![m.id, session_id, serde_json::to_string(m)?],
|
||||
params![m.id, session_id, serde_json::to_string(&m)?],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
|
||||
@@ -300,6 +316,103 @@ impl Store {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Messages with the bytes of their images filled in – for model requests and exports.
|
||||
pub fn messages_for_model(&self, session_id: &str) -> Result<Vec<Message>> {
|
||||
let mut out = self.messages(session_id)?;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
for m in &mut out {
|
||||
crate::blobs::for_each_image(m, |img| fill_image(&conn, &mut cache, img));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fill in the bytes of one image reference (no-op for inline images).
|
||||
pub fn resolve_image(&self, img: &ImageData) -> ImageData {
|
||||
let mut img = img.clone();
|
||||
let conn = self.conn.lock().unwrap();
|
||||
fill_image(&conn, &mut std::collections::HashMap::new(), &mut img);
|
||||
img
|
||||
}
|
||||
|
||||
/// One stored image: (media type, bytes).
|
||||
pub fn blob(&self, hash: &str) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row("SELECT media_type, data FROM blobs WHERE hash = ?1", [hash], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Move inline images of older messages to the blob table (once, after the update
|
||||
/// that introduced it). Does not touch `updated_at` of the sessions.
|
||||
pub fn migrate_inline_images(&self) -> Result<usize> {
|
||||
let ids: Vec<String> = {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM messages WHERE data LIKE '%\"media_type\"%\"data\":\"%'")?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
rows
|
||||
};
|
||||
let mut moved = 0;
|
||||
for id in ids {
|
||||
let data: Option<String> = {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row("SELECT data FROM messages WHERE id = ?1", [&id], |r| r.get(0)).optional()?
|
||||
};
|
||||
let Some(data) = data else { continue };
|
||||
let Ok(mut m) = serde_json::from_str::<Message>(&data) else { continue };
|
||||
let blobs = crate::blobs::externalize(&mut m);
|
||||
if blobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
for b in &blobs {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![b.hash, b.media_type, b.bytes, now_ms()],
|
||||
)?;
|
||||
}
|
||||
conn.execute("UPDATE messages SET data = ?2 WHERE id = ?1", params![id, serde_json::to_string(&m)?])?;
|
||||
moved += blobs.len();
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Delete images no message refers to any more (deleted or truncated sessions).
|
||||
pub fn gc_blobs(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
let mut stmt = conn.prepare("SELECT data FROM messages WHERE data LIKE '%\"blob\":\"%'")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let data: String = row.get(0)?;
|
||||
for (i, _) in data.match_indices("\"blob\":\"") {
|
||||
if let Some(h) = data.get(i + 8..i + 72) {
|
||||
used.insert(h.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
let all: Vec<String> = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
|
||||
let mut removed = 0;
|
||||
for h in all.iter().filter(|h| !used.contains(*h)) {
|
||||
removed += conn.execute("DELETE FROM blobs WHERE hash = ?1", [h])?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Give the space of deleted data back to the file system when it is worth it.
|
||||
pub fn vacuum_if_worthwhile(&self) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let free: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
|
||||
let page: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
|
||||
if free * page < 32 * 1024 * 1024 {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.execute_batch("VACUUM")?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Remove all messages after (and including) the given one – used for "edit & resend".
|
||||
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -312,6 +425,20 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the bytes of a stored image into `img.data`.
|
||||
fn fill_image(conn: &Connection, cache: &mut std::collections::HashMap<String, String>, img: &mut ImageData) {
|
||||
use base64::Engine;
|
||||
let Some(hash) = img.blob.clone().filter(|_| img.data.is_empty()) else { return };
|
||||
if !cache.contains_key(&hash) {
|
||||
let Ok(bytes) = conn.query_row("SELECT data FROM blobs WHERE hash = ?1", [&hash], |r| r.get::<_, Vec<u8>>(0)) else {
|
||||
tracing::warn!("image {hash} is missing in the store");
|
||||
return;
|
||||
};
|
||||
cache.insert(hash.clone(), base64::engine::general_purpose::STANDARD.encode(bytes));
|
||||
}
|
||||
img.data = cache[&hash].clone();
|
||||
}
|
||||
|
||||
/// One model request, as recorded for statistics.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UsageRecord {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -157,7 +157,12 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
|
||||
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
|
||||
}
|
||||
None => {
|
||||
let c = pty_process::Command::new(shell[0]).args(&shell[1..]).current_dir(cwd).env("TERM", "xterm-256color").env("CODECRAFT", "1");
|
||||
let c = pty_process::Command::new(shell[0])
|
||||
.args(&shell[1..])
|
||||
.current_dir(cwd)
|
||||
.env("TERM", "xterm-256color")
|
||||
.env("CODECRAFT", "1")
|
||||
.envs(crate::gitcreds::identity_env(app, &user.id));
|
||||
match crate::gitcreds::global_config(app, user) {
|
||||
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
||||
None => c,
|
||||
|
||||
@@ -150,8 +150,11 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
||||
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||
// The session owner's git credentials and identity.
|
||||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
|
||||
if let Some(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
if let Some(u) = owner {
|
||||
if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
}
|
||||
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ impl Tool for OpenScad {
|
||||
continue;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(&out) {
|
||||
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
|
||||
images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
|
||||
files.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub(crate) fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
|
||||
Ok(ImageData {
|
||||
blob: None,
|
||||
size: None,
|
||||
media_type: "image/png".into(),
|
||||
data: base64::engine::general_purpose::STANDARD.encode(&buf),
|
||||
})
|
||||
|
||||
@@ -107,6 +107,8 @@ impl Tool for Read {
|
||||
return Ok(ToolOutput {
|
||||
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
|
||||
images: vec![ImageData {
|
||||
blob: None,
|
||||
size: None,
|
||||
media_type: mt.into(),
|
||||
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}],
|
||||
|
||||
+14
-3
@@ -1,7 +1,7 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
||||
import { useT } from "./i18n";
|
||||
import { navigate, useRoute } from "./router";
|
||||
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
|
||||
import { applyEvent, loadSessions, resync, setState } from "./store";
|
||||
import type { Info } from "./types";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
@@ -80,7 +80,18 @@ export function App() {
|
||||
|
||||
const [section, arg] = route;
|
||||
let content;
|
||||
if (section === "s" && arg) content = <ChatView key={arg} sessionId={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
const chatIds = section === "s" && arg ? arg.split("+").filter(Boolean).slice(0, MAX_PANES) : [];
|
||||
if (chatIds.length === 1) content = <ChatView key={chatIds[0]} sessionId={chatIds[0]} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (chatIds.length > 1)
|
||||
content = (
|
||||
<div className="split-view">
|
||||
{chatIds.map((id) => (
|
||||
<div key={id} className="split-pane">
|
||||
<ChatView sessionId={id} onMenu={() => setMenuOpen(true)} onClosePane={() => closePane(id)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||
@@ -102,7 +113,7 @@ export function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
||||
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
|
||||
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
|
||||
<main className="main">{content}</main>
|
||||
<WhatsNew version={info.version} />
|
||||
<Palette />
|
||||
|
||||
@@ -44,7 +44,7 @@ interface BudgetData {
|
||||
providers: BudgetRow[];
|
||||
}
|
||||
|
||||
function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
|
||||
export function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
|
||||
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
|
||||
return (
|
||||
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { del, get, post } from "../api";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { del, get, post, put } from "../api";
|
||||
import { confirmDialog, toast } from "../dialogs";
|
||||
import { useT } from "../i18n";
|
||||
import { Icon } from "./Icon";
|
||||
@@ -11,31 +11,24 @@ const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files })));
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`;
|
||||
const filesKey = (sessionId: string) => `codecraft.chatFiles.${sessionId}`;
|
||||
|
||||
/** A file manager tab: lives in this browser (it has no server state). */
|
||||
/** A file manager tab (stored per user and chat on the server). */
|
||||
interface FileTab {
|
||||
id: string;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
const loadFileTabs = (sessionId: string): FileTab[] => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(filesKey(sessionId)) ?? "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tabs of a chat: the chat itself, its terminals and file managers. Terminals live on
|
||||
* the server until closed, so they show up again after a reload or on another device
|
||||
* (the list is refreshed periodically); file manager tabs are kept per browser.
|
||||
* Tabs of a chat: the chat itself, its terminals and file managers. Both are kept on the
|
||||
* server, so they show up again after a reload or on another device (the list is
|
||||
* refreshed periodically); which tab is active is per browser tab.
|
||||
*/
|
||||
export function useChatTabs(sessionId: string, cwd: string) {
|
||||
const t = useT();
|
||||
const [terms, setTerms] = useState<TermInfo[]>([]);
|
||||
const [files, setFilesState] = useState<FileTab[]>(() => loadFileTabs(sessionId));
|
||||
const [files, setFilesState] = useState<FileTab[]>([]);
|
||||
// Local file tab changes not yet saved: polling must not undo them.
|
||||
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [tab, setTabState] = useState<string>(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
||||
|
||||
const setTab = useCallback(
|
||||
@@ -46,10 +39,14 @@ export function useChatTabs(sessionId: string, cwd: string) {
|
||||
[sessionId],
|
||||
);
|
||||
const setFiles = useCallback(
|
||||
(update: (f: FileTab[]) => FileTab[]) =>
|
||||
(update: (f: FileTab[]) => FileTab[], delay = 0) =>
|
||||
setFilesState((cur) => {
|
||||
const next = update(cur);
|
||||
localStorage.setItem(filesKey(sessionId), JSON.stringify(next));
|
||||
clearTimeout(pending.current);
|
||||
pending.current = setTimeout(() => {
|
||||
pending.current = undefined;
|
||||
void put(`/sessions/${sessionId}/tabs`, next).catch((e) => toast(e));
|
||||
}, delay);
|
||||
return next;
|
||||
}),
|
||||
[sessionId],
|
||||
@@ -57,15 +54,18 @@ export function useChatTabs(sessionId: string, cwd: string) {
|
||||
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
get<TermInfo[]>(`/terminals?session=${encodeURIComponent(sessionId)}`)
|
||||
.then(setTerms)
|
||||
get<{ terminals: TermInfo[]; files: FileTab[] }>(`/sessions/${sessionId}/tabs`)
|
||||
.then((r) => {
|
||||
setTerms(r.terminals);
|
||||
if (!pending.current) setFilesState(r.files);
|
||||
})
|
||||
.catch(() => undefined),
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
||||
setFilesState(loadFileTabs(sessionId));
|
||||
setFilesState([]);
|
||||
setTerms([]);
|
||||
void reload();
|
||||
const id = setInterval(reload, POLL_MS);
|
||||
@@ -110,7 +110,8 @@ export function useChatTabs(sessionId: string, cwd: string) {
|
||||
if (tab === id) setTab("chat");
|
||||
};
|
||||
|
||||
const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x))), [setFiles]);
|
||||
// Browsing saves the folder after a short pause, not on every click.
|
||||
const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x)), 1000), [setFiles]);
|
||||
|
||||
return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged };
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { takePrefill } from "../share";
|
||||
import { Icon } from "./Icon";
|
||||
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
|
||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { del, get, patch, post } from "../api";
|
||||
import { useModels } from "../hooks";
|
||||
import { useFetch, useModels } from "../hooks";
|
||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
||||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode } from "../prefs";
|
||||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||
import { Composer } from "./Composer";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import { RenameDialog } from "./RenameDialog";
|
||||
import { InstructionsDialog } from "./InstructionsDialog";
|
||||
import { RegenerateButton } from "./Regenerate";
|
||||
import { WorktreeChip } from "./WorktreeChip";
|
||||
import { QueueList } from "./QueueList";
|
||||
@@ -136,7 +137,7 @@ function RefineFeedback({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
|
||||
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
||||
const t = useT();
|
||||
const session = useStore((s) => s.sessionById[sessionId]);
|
||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||
@@ -151,6 +152,9 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
||||
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 [loadError, setLoadError] = useState<string>();
|
||||
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
||||
@@ -307,6 +311,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<ShellsChip sessionId={sessionId} busy={busy} />
|
||||
{isAdmin && <BudgetChip />}
|
||||
{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 && (
|
||||
<>
|
||||
@@ -335,6 +344,30 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<Icon name="more" />
|
||||
</summary>
|
||||
<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>
|
||||
<Icon name="download" /> {t("chat.exportMd")}
|
||||
</a>
|
||||
@@ -368,6 +401,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onClosePane && (
|
||||
<button className="small ghost" onClick={onClosePane} title={t("chat.closePane")} aria-label={t("chat.closePane")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ChatTabBar state={tabs} />
|
||||
<ChatTabPanes state={tabs} />
|
||||
@@ -449,8 +487,8 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
|
||||
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
@@ -481,10 +519,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
||||
)}
|
||||
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
||||
<div className="todos">
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||
<TodoList todos={todos} />
|
||||
</div>
|
||||
<TodoPanel todos={todos} />
|
||||
)}
|
||||
{permissions.map((p) => (
|
||||
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
||||
@@ -522,6 +557,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
|
||||
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||
@@ -610,3 +646,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tasks of the model above the composer; folds to one line (current task, progress). */
|
||||
function TodoPanel({ todos }: { todos: Todo[] }) {
|
||||
const t = useT();
|
||||
const [collapsed, setCollapsed] = useTodosCollapsed();
|
||||
const done = todos.filter((x) => x.status === "completed").length;
|
||||
const current = todos.find((x) => x.status === "in_progress");
|
||||
return (
|
||||
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
|
||||
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
|
||||
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
|
||||
<span>{t("tool.todos")}</span>
|
||||
<span className="todos-count">
|
||||
{done}/{todos.length}
|
||||
</span>
|
||||
{collapsed && current && <span className="todos-current">{current.content}</span>}
|
||||
</button>
|
||||
{!collapsed && <TodoList todos={todos} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TemplatePicker } from "./TemplatePicker";
|
||||
import { Icon } from "./Icon";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { formatSize, imageSrc } from "./FileFigure";
|
||||
import { uploadFile } from "../api";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAgents, useFetch, useModels } from "../hooks";
|
||||
@@ -257,7 +257,7 @@ export function Composer({
|
||||
)}
|
||||
{images.map((img, i) => (
|
||||
<div className="att" key={i}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<img src={imageSrc(img)} alt="" />
|
||||
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { ImageData } from "../types";
|
||||
|
||||
/** Human-readable file size. */
|
||||
export function formatSize(n: number) {
|
||||
@@ -32,6 +33,16 @@ export function basename(p: string) {
|
||||
}
|
||||
|
||||
/** Bytes of a base64 payload. */
|
||||
/** URL of an image of a message: inline (data URL) or from the image store. */
|
||||
export function imageSrc(img: ImageData) {
|
||||
return img.data ? `data:${img.media_type};base64,${img.data}` : `/api/blobs/${img.blob}`;
|
||||
}
|
||||
|
||||
/** Size in bytes of an image of a message. */
|
||||
export function imageSize(img: ImageData) {
|
||||
return img.data ? base64Size(img.data) : img.size;
|
||||
}
|
||||
|
||||
export function base64Size(b64: string) {
|
||||
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
||||
return Math.floor((b64.length * 3) / 4) - pad;
|
||||
|
||||
@@ -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>{t("jobs.mode")}</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAgents, useFetch } from "../hooks";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import type { ImageData } from "../types";
|
||||
import { readImage } from "./Composer";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { formatSize, imageSrc } from "./FileFigure";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { takePrefill } from "../share";
|
||||
|
||||
@@ -478,7 +478,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
<div className="attachments">
|
||||
{images.map((img, i) => (
|
||||
<div className="att" key={`i${i}`}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<img src={imageSrc(img)} alt="" />
|
||||
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
|
||||
@@ -28,7 +28,7 @@ function recent() {
|
||||
/** Move to the next/previous session relative to the open one. */
|
||||
function step(dir: 1 | -1) {
|
||||
const list = recent();
|
||||
const cur = location.hash.match(/^#\/s\/([^/]+)/)?.[1];
|
||||
const cur = location.hash.match(/^#\/s\/([^/+]+)/)?.[1];
|
||||
const i = list.findIndex((s) => s.id === cur);
|
||||
const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)];
|
||||
if (next) navigate(`/s/${next.id}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { del, post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore } from "../store";
|
||||
import type { QueueItem } from "../types";
|
||||
import { imageSrc } from "./FileFigure";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
|
||||
const EMPTY: QueueItem[] = [];
|
||||
@@ -30,7 +31,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
|
||||
{it.source === "background" && <span className="chip">{t("tool.background")}</span>}
|
||||
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
|
||||
{images.map((p, i) => (
|
||||
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
<img key={i} src={imageSrc(p.image)} alt="" />
|
||||
))}
|
||||
{it.source !== "background" && (
|
||||
<button
|
||||
|
||||
@@ -12,6 +12,7 @@ import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { Account } from "./settings/Account";
|
||||
import { Templates } from "./settings/Templates";
|
||||
import { PersonalModes } from "./settings/PersonalModes";
|
||||
import { AgentsEditor } from "./settings/AgentsEditor";
|
||||
import { Folders } from "./settings/Folders";
|
||||
import { Users } from "./settings/Users";
|
||||
@@ -1039,8 +1040,8 @@ function Raw({ reload }: { reload: () => void }) {
|
||||
|
||||
// --- page -------------------------------------------------------------------
|
||||
|
||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "account", "raw"] as const;
|
||||
const USER_TABS = ["general", "templates", "account"] as const;
|
||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "modes", "account", "raw"] as const;
|
||||
const USER_TABS = ["general", "templates", "modes", "account"] as const;
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
general: "gear",
|
||||
providers: "brain",
|
||||
@@ -1052,6 +1053,7 @@ const TAB_ICONS: Record<string, string> = {
|
||||
tools: "toolbox",
|
||||
audit: "scroll",
|
||||
templates: "template",
|
||||
modes: "sparkle",
|
||||
account: "user",
|
||||
raw: "file-text",
|
||||
};
|
||||
@@ -1088,6 +1090,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
||||
{!isAdmin && active === "general" && <InterfaceSettings />}
|
||||
{active === "account" && <Account />}
|
||||
{active === "templates" && <Templates />}
|
||||
{active === "modes" && <PersonalModes />}
|
||||
{cfg && active === "users" && <Users />}
|
||||
{cfg && active === "folders" && <Folders />}
|
||||
{cfg && active === "audit" && <Audit />}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { get, patch, post } from "../api";
|
||||
import { promptDialog, toast } from "../dialogs";
|
||||
import { fmtRelative, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { navigate, openBeside } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { Session } from "../types";
|
||||
|
||||
@@ -82,6 +82,9 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
||||
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
|
||||
</button>
|
||||
)}
|
||||
<button role="menuitem" className="hide-mobile" onClick={() => (setMenu(false), openBeside(s.id))}>
|
||||
<Icon name="external" /> {t("sidebar.openBeside")}
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
|
||||
<Icon name="folder" /> {t("sidebar.moveTo")}
|
||||
</button>
|
||||
@@ -99,7 +102,7 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
|
||||
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
|
||||
const t = useT();
|
||||
const sessions = useStore((s) => s.sessions);
|
||||
const busy = useStore((s) => s.busy);
|
||||
@@ -210,7 +213,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
|
||||
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
|
||||
{hits.map((h) => (
|
||||
<button key={h.id} className={`session-item ${h.id === activeId ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
||||
<button key={h.id} className={`session-item ${activeIds.includes(h.id) ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
||||
<div className="title">
|
||||
<span>{h.title || t("session.untitled")}</span>
|
||||
</div>
|
||||
@@ -242,7 +245,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
</button>
|
||||
{!collapsed.has(g.id) &&
|
||||
g.items.map((s) => (
|
||||
<SessionRow key={s.id} s={s} active={s.id === activeId} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
||||
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { get, post } from "../api";
|
||||
import { fmtPercent, useT, type Key } from "../i18n";
|
||||
import { fmtCost, fmtPercent, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { Icon } from "./Icon";
|
||||
import type { Plan } from "./Budget";
|
||||
import { Bar, type Plan } from "./Budget";
|
||||
|
||||
type Level = "ok" | "warn" | "err" | "off";
|
||||
|
||||
@@ -21,12 +21,25 @@ interface McpEntry {
|
||||
tools?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
interface Meter {
|
||||
key: string;
|
||||
provider: string;
|
||||
kind: "total" | "provider" | "images" | "5h" | "week" | "month";
|
||||
used: number;
|
||||
limit: number;
|
||||
ratio: number;
|
||||
}
|
||||
interface Overview {
|
||||
version: string;
|
||||
providers: Record<string, ProviderState>;
|
||||
mcp: Record<string, McpEntry>;
|
||||
/** Budgets and plan windows (admins only). */
|
||||
budgets?: Meter[];
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
const meterLevel = (m: Meter): Level => (m.ratio >= 1 ? "err" : m.ratio >= 0.8 ? "warn" : "ok");
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
|
||||
const RECONNECT_GRACE_MS = 10_000;
|
||||
@@ -197,6 +210,22 @@ export function StatusLeds() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!data?.budgets?.length && (
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={combine(data.budgets.map(meterLevel))} /> {t("status.budgets")}
|
||||
</div>
|
||||
{data.budgets.map((m) => (
|
||||
<div key={m.key} className="status-row status-meter" title={`${fmtCost(m.used, data.currency ?? "USD")} / ${fmtCost(m.limit, data.currency ?? "USD")}`}>
|
||||
<Led level={meterLevel(m)} />
|
||||
<span className="grow">{m.kind === "total" ? t("stats.total") : m.kind === "images" ? t("nav.images") : m.kind === "provider" ? m.provider : `${m.provider} · ${t(`budget.window.${m.kind}` as Key)}`}</span>
|
||||
<Bar share={m.ratio} warning={m.ratio >= 0.8} exceeded={m.ratio >= 1} />
|
||||
<span className="hint pct">{fmtPercent(m.ratio)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{admin && (
|
||||
<div className="status-foot">
|
||||
<button className="small ghost" onClick={() => go("providers")}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { base64Size, FileFigure } from "./FileFigure";
|
||||
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -166,8 +166,8 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
</div>
|
||||
) : (
|
||||
result?.images?.map((img, i) => (
|
||||
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
|
||||
<img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
|
||||
import { ApiTokens } from "./ApiTokens";
|
||||
import { CliInstall } from "./CliInstall";
|
||||
import { Memory } from "./Memory";
|
||||
import { Telegram } from "./Telegram";
|
||||
|
||||
export function Account() {
|
||||
const t = useT();
|
||||
@@ -51,6 +52,7 @@ export function Account() {
|
||||
<Memory />
|
||||
<GitAccount />
|
||||
<CliInstall />
|
||||
<Telegram />
|
||||
<ApiTokens />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,14 +9,14 @@ import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||
|
||||
type Patch = { path: string[]; value: unknown };
|
||||
|
||||
interface ToolInfo {
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
read_only: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Tool checkboxes: nothing checked = all tools. */
|
||||
function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
|
||||
export function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
|
||||
const t = useT();
|
||||
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
|
||||
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
|
||||
@@ -211,7 +211,9 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
||||
|
||||
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
||||
const t = useT();
|
||||
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
|
||||
const { data: all, reload } = useFetch<AgentInfo[]>("/agents");
|
||||
// Personal modes are managed under "My modes".
|
||||
const agents = all?.filter((a) => a.source !== "personal");
|
||||
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||||
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, put } from "../../api";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import { Icon } from "../Icon";
|
||||
import { ModelSelect } from "../ModelSelect";
|
||||
import { ToolPicker, type ToolInfo } from "./AgentsEditor";
|
||||
|
||||
interface Mode {
|
||||
description?: string | null;
|
||||
prompt?: string | null;
|
||||
model?: string | null;
|
||||
tools?: string[] | null;
|
||||
read_only?: boolean | null;
|
||||
style?: "coding" | "chat" | null;
|
||||
}
|
||||
|
||||
/** Modes a user creates for themselves; they appear in New session and the mode selector. */
|
||||
export function PersonalModes() {
|
||||
const t = useT();
|
||||
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||||
const [modes, setModes] = useState<Record<string, Mode>>();
|
||||
const [open, setOpen] = useState<string>();
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
get<Record<string, Mode>>("/account/modes")
|
||||
.then(setModes)
|
||||
.catch((e) => toast(e));
|
||||
}, []);
|
||||
|
||||
const save = async (next: Record<string, Mode>) => {
|
||||
try {
|
||||
setModes(await put<Record<string, Mode>>("/account/modes", next));
|
||||
toast(t("settings.saved"), "success");
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const add = async () => {
|
||||
const n = name.trim();
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||
if (modes?.[n]) return toast(t("modes.exists", { name: n }));
|
||||
if (await save({ ...modes, [n]: { style: "chat", description: "", prompt: "" } })) {
|
||||
setName("");
|
||||
setOpen(n);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (n: string) => {
|
||||
if (!(await confirmDialog({ message: t("agents.deleteConfirm", { name: n }), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||
const next = { ...modes };
|
||||
delete next[n];
|
||||
void save(next);
|
||||
};
|
||||
|
||||
if (!modes) return <div className="empty">{t("common.loading")}</div>;
|
||||
const names = Object.keys(modes).sort();
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("modes.intro")}</div>
|
||||
{names.map((n) => (
|
||||
<ModeRow key={n + JSON.stringify(modes[n])} name={n} mode={modes[n]} tools={tools} open={open === n} onToggle={() => setOpen(open === n ? undefined : n)} onSave={(m) => save({ ...modes, [n]: m })} onRemove={() => remove(n)} />
|
||||
))}
|
||||
{names.length === 0 && <div className="empty">{t("modes.none")}</div>}
|
||||
<div className="panel">
|
||||
<h3>{t("agents.addMode")}</h3>
|
||||
<div className="row">
|
||||
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && void add()} />
|
||||
<button className="primary" onClick={() => void add()} disabled={!name.trim()}>
|
||||
+ {t("agents.addMode")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeRow({ name, mode, tools, open, onToggle, onSave, onRemove }: { name: string; mode: Mode; tools: ToolInfo[]; open: boolean; onToggle: () => void; onSave: (m: Mode) => Promise<boolean>; onRemove: () => void }) {
|
||||
const t = useT();
|
||||
const [form, setForm] = useState({
|
||||
description: mode.description ?? "",
|
||||
prompt: mode.prompt ?? "",
|
||||
model: mode.model ?? "",
|
||||
tools: mode.tools ?? [],
|
||||
read_only: !!mode.read_only,
|
||||
style: (mode.style ?? "coding") as "coding" | "chat",
|
||||
});
|
||||
const submit = () =>
|
||||
void onSave({
|
||||
description: form.description || null,
|
||||
prompt: form.prompt || null,
|
||||
model: form.model || null,
|
||||
tools: form.tools.length ? form.tools : null,
|
||||
read_only: form.read_only || null,
|
||||
style: form.style === "chat" ? "chat" : null,
|
||||
});
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{name}
|
||||
<span className="chip">{form.style === "chat" ? t("agents.styleChatShort") : t("agents.styleCodingShort")}</span>
|
||||
{form.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||
<span className="spacer" />
|
||||
<button className="small ghost danger" onClick={onRemove} title={t("common.delete")} aria-label={t("common.delete")}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
<button className="small" onClick={onToggle}>
|
||||
{open ? t("common.collapse") : t("common.edit")}
|
||||
</button>
|
||||
</h3>
|
||||
{!open && mode.description && <div className="hint">{mode.description}</div>}
|
||||
{open && (
|
||||
<>
|
||||
<div className="form">
|
||||
<label>{t("agents.description")}</label>
|
||||
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<label>{t("agents.style")}</label>
|
||||
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
|
||||
<option value="coding">{t("agents.styleCoding")}</option>
|
||||
<option value="chat">{t("agents.styleChat")}</option>
|
||||
</select>
|
||||
<label>{t("agents.model")}</label>
|
||||
<ModelSelect value={form.model} onChange={(v) => setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} />
|
||||
<label>{t("agents.readOnlyLabel")}</label>
|
||||
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
|
||||
<span className="hint">{t("agents.readOnlyHint")}</span>
|
||||
</label>
|
||||
<label>{t("agents.tools")}</label>
|
||||
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
|
||||
<label>{t("agents.prompt")}</label>
|
||||
<textarea rows={8} value={form.prompt} placeholder={t("modes.promptPlaceholder")} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="primary" onClick={submit}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { del, put } from "../../api";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
interface Status {
|
||||
configured: boolean;
|
||||
bot: string;
|
||||
paired: boolean;
|
||||
pair_code: string;
|
||||
running: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** Connect a personal Telegram bot as a chat interface. */
|
||||
export function Telegram() {
|
||||
const t = useT();
|
||||
const { data, reload } = useFetch<Status>("/account/telegram");
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const connect = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await put("/account/telegram", { token: token.trim() });
|
||||
setToken("");
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const disconnect = async () => {
|
||||
if (!(await confirmDialog({ message: t("telegram.disconnectConfirm"), danger: true, confirmLabel: t("telegram.disconnect") }))) return;
|
||||
await del("/account/telegram").catch((e) => toast(e));
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<Icon name="share" /> {t("telegram.title")}
|
||||
<span className="spacer" />
|
||||
{data?.configured && (
|
||||
<button className="small ghost danger" onClick={disconnect}>
|
||||
{t("telegram.disconnect")}
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
{t("telegram.intro")}
|
||||
</div>
|
||||
{data && !data.configured && (
|
||||
<>
|
||||
<ol className="cli-steps">
|
||||
<li>{t("telegram.step1")}</li>
|
||||
<li>{t("telegram.step2")}</li>
|
||||
</ol>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
name="telegram-bot-token"
|
||||
value={token}
|
||||
placeholder="123456789:AAH…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="mono"
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button className="primary" onClick={() => void connect()} disabled={busy || !token.trim()}>
|
||||
{busy ? t("telegram.checking") : t("telegram.connect")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{data?.configured && (
|
||||
<>
|
||||
<div className="row" style={{ gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<span className="chip">@{data.bot}</span>
|
||||
<span className={`chip ${data.running && !data.error ? "ok" : "warn"}`}>{data.running && !data.error ? t("telegram.running") : t("telegram.stopped")}</span>
|
||||
{data.paired && <span className="chip ok">{t("telegram.paired")}</span>}
|
||||
</div>
|
||||
{!data.paired && data.pair_code && (
|
||||
<div className="notice" style={{ marginTop: 10 }}>
|
||||
{t("telegram.pairHint", { bot: data.bot })}{" "}
|
||||
<a href={`https://t.me/${data.bot}?start=${data.pair_code}`} target="_blank" rel="noreferrer">
|
||||
t.me/{data.bot}
|
||||
</a>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<code className="token-value">/start {data.pair_code}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.error && <div className="notice err" style={{ marginTop: 10 }}>{data.error}</div>}
|
||||
{data.paired && <div className="hint" style={{ marginTop: 10 }}>{t("telegram.commands")}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -215,6 +215,7 @@ export const de: Messages = {
|
||||
"sidebar.week": "Letzte 7 Tage",
|
||||
"sidebar.older": "Älter",
|
||||
"status.title": "Status",
|
||||
"status.budgets": "Budgets",
|
||||
"status.server": "Server",
|
||||
"status.models": "Modelle",
|
||||
"status.mcp": "MCP",
|
||||
@@ -404,6 +405,19 @@ export const de: Messages = {
|
||||
"tokens.title": "API-Tokens",
|
||||
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
|
||||
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
|
||||
"telegram.title": "Telegram",
|
||||
"telegram.intro": "CodeCraft über Telegram mit deinem eigenen Bot nutzen: Nachrichten gehen an deine aktuelle Sitzung, Befehle wechseln Sitzungen, Modi und Modelle, Freigaben und Rückfragen kommen mit Knöpfen. Nur dein Telegram-Konto kann den Bot nutzen.",
|
||||
"telegram.step1": "In Telegram @BotFather öffnen, /newbot senden und den Schritten folgen.",
|
||||
"telegram.step2": "Das Token von BotFather hier einfügen.",
|
||||
"telegram.connect": "Verbinden",
|
||||
"telegram.checking": "Prüfe…",
|
||||
"telegram.running": "läuft",
|
||||
"telegram.stopped": "läuft nicht",
|
||||
"telegram.paired": "gekoppelt",
|
||||
"telegram.pairHint": "Letzter Schritt: deinen Bot öffnen und den Code senden (oder den Link antippen) – danach bedient er nur noch dein Telegram-Konto:",
|
||||
"telegram.commands": "In Telegram: /help zeigt die Befehle – /sessions, /new, /mode, /model, /stop, /status, /costs …",
|
||||
"telegram.disconnect": "Trennen",
|
||||
"telegram.disconnectConfirm": "Telegram-Bot trennen? Er antwortet dann nicht mehr; mit seinem Token kannst du ihn wieder verbinden.",
|
||||
"tokens.copy": "Kopieren",
|
||||
"tokens.copied": "Kopiert",
|
||||
"tokens.name": "Name",
|
||||
@@ -454,6 +468,13 @@ export const de: Messages = {
|
||||
"shareIn.newChat": "Neuen Chat starten",
|
||||
"shareIn.appendTo": "oder an eine letzte Session anhängen",
|
||||
"common.back": "Zurück",
|
||||
"settings.tab.modes": "Meine Modi",
|
||||
"modes.intro": "Deine eigenen Modi: ein Prompt, eine Art (Programmieren oder Unterhaltung), Werkzeuge und optional ein Modell. Sie erscheinen in „Neue Sitzung“ und in der Modus-Auswahl deiner Chats – nur für dich.",
|
||||
"modes.none": "Du hast noch keine eigenen Modi.",
|
||||
"modes.exists": "Ein Modus namens {name} existiert schon.",
|
||||
"modes.promptPlaceholder": "z. B. Du bist ein geduldiger Englischlehrer. Korrigiere meine Sätze und erkläre kurz, warum.",
|
||||
"agents.styleChatShort": "Unterhaltung",
|
||||
"agents.styleCodingShort": "Programmieren",
|
||||
"settings.tab.templates": "Vorlagen",
|
||||
"templates.title": "Prompt-Vorlagen",
|
||||
"templates.filter": "Vorlagen durchsuchen…",
|
||||
@@ -486,6 +507,8 @@ export const de: Messages = {
|
||||
"sidebar.organise": "Session organisieren",
|
||||
"sidebar.pin": "Oben anpinnen",
|
||||
"sidebar.unpin": "Nicht mehr anpinnen",
|
||||
"sidebar.openBeside": "Daneben öffnen",
|
||||
"chat.closePane": "Spalte schließen",
|
||||
"sidebar.moveTo": "In Ordner verschieben…",
|
||||
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
||||
"sidebar.archiveOne": "Archivieren",
|
||||
@@ -570,6 +593,15 @@ export const de: Messages = {
|
||||
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
||||
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
||||
"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.context": "Kontext",
|
||||
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
||||
@@ -846,6 +878,7 @@ export const de: Messages = {
|
||||
"agents.modePrimary": "Modus",
|
||||
"agents.modeSubagent": "Subagent",
|
||||
"agents.source.builtin": "eingebaut",
|
||||
"agents.source.personal": "persönlich",
|
||||
"agents.source.config": "Config",
|
||||
"agents.source.file": "Datei",
|
||||
"agents.model": "Modell",
|
||||
|
||||
@@ -214,6 +214,7 @@ export const en = {
|
||||
"sidebar.week": "Last 7 days",
|
||||
"sidebar.older": "Older",
|
||||
"status.title": "Status",
|
||||
"status.budgets": "Budgets",
|
||||
"status.server": "Server",
|
||||
"status.models": "Models",
|
||||
"status.mcp": "MCP",
|
||||
@@ -403,6 +404,19 @@ export const en = {
|
||||
"tokens.title": "API tokens",
|
||||
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
|
||||
"tokens.copyNow": "Copy the token now – it is shown only once:",
|
||||
"telegram.title": "Telegram",
|
||||
"telegram.intro": "Use CodeCraft from Telegram with your own bot: messages go to your current session, commands switch sessions, modes and models, approvals and questions come with buttons. Only your Telegram account can use the bot.",
|
||||
"telegram.step1": "In Telegram, open @BotFather, send /newbot and follow the steps.",
|
||||
"telegram.step2": "Paste the token BotFather gives you here.",
|
||||
"telegram.connect": "Connect",
|
||||
"telegram.checking": "Checking…",
|
||||
"telegram.running": "running",
|
||||
"telegram.stopped": "not running",
|
||||
"telegram.paired": "paired",
|
||||
"telegram.pairHint": "Last step: open your bot and send the code (or tap the link) – from then on only your Telegram account is served:",
|
||||
"telegram.commands": "In Telegram: /help lists the commands – /sessions, /new, /mode, /model, /stop, /status, /costs …",
|
||||
"telegram.disconnect": "Disconnect",
|
||||
"telegram.disconnectConfirm": "Disconnect the Telegram bot? It stops answering; you can connect it again with its token.",
|
||||
"tokens.copy": "Copy",
|
||||
"tokens.copied": "Copied",
|
||||
"tokens.name": "Name",
|
||||
@@ -453,6 +467,13 @@ export const en = {
|
||||
"shareIn.newChat": "Start a new chat",
|
||||
"shareIn.appendTo": "or add to a recent session",
|
||||
"common.back": "Back",
|
||||
"settings.tab.modes": "My modes",
|
||||
"modes.intro": "Your own modes: a prompt, a kind (coding or conversation), tools and an optional model. They appear in New session and in the mode selector of your chats – only for you.",
|
||||
"modes.none": "You have no modes of your own yet.",
|
||||
"modes.exists": "A mode named {name} already exists.",
|
||||
"modes.promptPlaceholder": "e.g. You are a patient German teacher. Correct my sentences and explain briefly why.",
|
||||
"agents.styleChatShort": "conversation",
|
||||
"agents.styleCodingShort": "coding",
|
||||
"settings.tab.templates": "Templates",
|
||||
"templates.title": "Prompt templates",
|
||||
"templates.filter": "Search templates…",
|
||||
@@ -485,6 +506,8 @@ export const en = {
|
||||
"sidebar.organise": "Organise session",
|
||||
"sidebar.pin": "Pin to top",
|
||||
"sidebar.unpin": "Unpin",
|
||||
"sidebar.openBeside": "Open beside",
|
||||
"chat.closePane": "Close this column",
|
||||
"sidebar.moveTo": "Move to folder…",
|
||||
"sidebar.removeFromFolder": "Remove from folder",
|
||||
"sidebar.archiveOne": "Archive",
|
||||
@@ -569,6 +592,15 @@ export const en = {
|
||||
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
||||
"chat.compactingManual": "Compacting the conversation",
|
||||
"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.context": "Context",
|
||||
"chat.contextHint": "{tokens} tokens in the last request",
|
||||
@@ -845,6 +877,7 @@ export const en = {
|
||||
"agents.modePrimary": "mode",
|
||||
"agents.modeSubagent": "subagent",
|
||||
"agents.source.builtin": "built-in",
|
||||
"agents.source.personal": "personal",
|
||||
"agents.source.config": "config",
|
||||
"agents.source.file": "file",
|
||||
"agents.model": "Model",
|
||||
|
||||
@@ -25,3 +25,21 @@ export function useReasoningMode(): ReasoningMode {
|
||||
getReasoningMode,
|
||||
);
|
||||
}
|
||||
|
||||
const TODOS_KEY = "codecraft.todosCollapsed";
|
||||
|
||||
/** Task list above the composer folded to a one-line summary (per device). */
|
||||
export function useTodosCollapsed(): [boolean, (v: boolean) => void] {
|
||||
const v = useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => localStorage.getItem(TODOS_KEY) === "1",
|
||||
);
|
||||
const set = (c: boolean) => {
|
||||
localStorage.setItem(TODOS_KEY, c ? "1" : "0");
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
return [v, set];
|
||||
}
|
||||
@@ -15,3 +15,23 @@ export function useRoute(): string[] {
|
||||
export function navigate(path: string) {
|
||||
location.hash = path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
/** Chats shown side by side: #/s/<id>+<id>+<id> (at most 3). */
|
||||
export const MAX_PANES = 3;
|
||||
|
||||
export function openChats(): string[] {
|
||||
const m = location.hash.match(/^#\/s\/([^/]+)/);
|
||||
return m ? m[1].split("+").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
/** Add a chat as another column (replaces the last one when all columns are used). */
|
||||
export function openBeside(id: string) {
|
||||
const ids = openChats().filter((x) => x !== id);
|
||||
if (ids.length >= MAX_PANES) ids.pop();
|
||||
navigate(`/s/${[...ids, id].join("+")}`);
|
||||
}
|
||||
|
||||
export function closePane(id: string) {
|
||||
const ids = openChats().filter((x) => x !== id);
|
||||
navigate(ids.length ? `/s/${ids.join("+")}` : "/");
|
||||
}
|
||||
@@ -721,6 +721,39 @@ button.session-group:hover {
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.todos-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.todos-head:hover {
|
||||
color: var(--fg1);
|
||||
}
|
||||
.todos-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.todos-current {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.todos.collapsed {
|
||||
padding-block: 6px;
|
||||
}
|
||||
.todos ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -3127,6 +3160,11 @@ button.danger-solid {
|
||||
.job-grid > span:nth-child(odd) {
|
||||
color: var(--fg3);
|
||||
}
|
||||
.job-grid select {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.job-prompt {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -3725,3 +3763,152 @@ span.chat-tab > button {
|
||||
.compacting-card.auto {
|
||||
border-color: color-mix(in srgb, var(--accent) 60%, var(--bg3));
|
||||
}
|
||||
.status-meter .budget-bar {
|
||||
width: 70px;
|
||||
flex: none;
|
||||
}
|
||||
.status-meter .pct {
|
||||
width: 38px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- split view (chats side by side) ------------------------------------------- */
|
||||
.split-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.split-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.split-pane + .split-pane {
|
||||
border-left: 1px solid var(--bg3);
|
||||
}
|
||||
.split-pane .topbar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.split-pane .topbar .grow {
|
||||
min-width: 90px;
|
||||
}
|
||||
.split-pane .topbar h1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Narrow columns: the context chip moves out (the cost chip opens the usage overlay). */
|
||||
.split-pane .topbar span.chip.hide-mobile {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.split-pane + .split-pane {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -2,7 +2,12 @@
|
||||
|
||||
export interface ImageData {
|
||||
media_type: string;
|
||||
data: string;
|
||||
/** Base64 bytes; absent when the server stores the image separately (blob). */
|
||||
data?: string;
|
||||
/** SHA-256 of a stored image: GET /api/blobs/<blob>. */
|
||||
blob?: string;
|
||||
/** Size in bytes of a stored image. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export type Part =
|
||||
@@ -67,6 +72,10 @@ export interface Session {
|
||||
pinned?: boolean;
|
||||
archived?: boolean;
|
||||
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 {
|
||||
@@ -155,7 +164,7 @@ export interface AgentInfo {
|
||||
style?: "coding" | "chat";
|
||||
model?: string | null;
|
||||
tools?: string[] | null;
|
||||
source: "builtin" | "config" | "file";
|
||||
source: "builtin" | "config" | "file" | "personal";
|
||||
max_parallel?: number | null;
|
||||
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
|
||||
/** Source file of Markdown agents. */
|
||||
|
||||
@@ -3,6 +3,48 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
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": {
|
||||
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"],
|
||||
},
|
||||
"202611-00006": {
|
||||
en: ["Docker CLI in the image – with the host's Docker socket mounted, admins' agents and terminals can use Docker"],
|
||||
de: ["Docker-CLI im Image – mit eingehängtem Docker-Socket können Admin-Agents und -Terminals Docker nutzen"],
|
||||
},
|
||||
"202611-00005": {
|
||||
en: ["Agents commit with the git name and e-mail from your settings – also in repositories with their own identity"],
|
||||
de: ["Agents committen mit Git-Name und -E-Mail aus deinen Einstellungen – auch in Repos mit eigener Identität"],
|
||||
},
|
||||
"202611-00004": {
|
||||
en: [
|
||||
"Chats side by side: “Open beside” in a chat's menu in the sidebar",
|
||||
"Settings → My modes: your own modes",
|
||||
"Budget warnings as push notifications, budgets in the status overlay",
|
||||
"File manager tabs are the same on every device",
|
||||
],
|
||||
de: [
|
||||
"Chats nebeneinander: „Daneben öffnen“ im Menü eines Chats in der Seitenleiste",
|
||||
"Einstellungen → Meine Modi: eigene Modi",
|
||||
"Budget-Warnungen als Push, Budgets im Status-Overlay",
|
||||
"Dateimanager-Tabs sind auf jedem Gerät gleich",
|
||||
],
|
||||
},
|
||||
"202611-00003": {
|
||||
en: [
|
||||
"Terminals and file managers as tabs in a chat – terminals keep running, also for another device",
|
||||
|
||||
Reference in new issue
Block a user