Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e9eda28b5 | ||
|
|
bd896794fd | ||
|
|
ca14ba3338 | ||
|
|
97e66d2118 | ||
|
|
b3a298fb7c | ||
|
|
61219dbca2 | ||
|
|
9fd92d5b39 | ||
|
|
b99f71526a | ||
|
|
90d65ec654 | ||
|
|
7298102e93 | ||
|
|
538aac9122 | ||
|
|
05c215f685 | ||
|
|
872ebb94dd | ||
|
|
b302fa0b17 | ||
|
|
bd490efda5 | ||
|
|
9339e2db41 | ||
|
|
d7f89ee257 | ||
|
|
b1fa54a7b6 | ||
|
|
e8e455b779 | ||
|
|
6d656a355d | ||
|
|
ca55404f99 | ||
|
|
0241f6031b | ||
|
|
4244b9370c | ||
|
|
dae76a5636 | ||
|
|
ff8ff585a2 | ||
|
|
434aa84d40 | ||
|
|
0a368e493d | ||
|
|
011756ca3d | ||
|
|
b7c8741a7d | ||
|
|
bf99292f43 | ||
|
|
36de035dd8 | ||
|
|
b59c5b8890 | ||
|
|
eef17eb591 | ||
|
|
6b1d7f91b8 | ||
|
|
0f4bfa0ab0 | ||
|
|
85b68d98ef | ||
|
|
d152ad836d | ||
|
|
81ee084ff2 | ||
|
|
c850586a81 | ||
|
|
242b80605c | ||
|
|
c9f553dc60 |
No files matched your search
+233
@@ -3,6 +3,239 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [v202611-00018] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Enforced reviews:** when a review is configured for a mode, every todo the agent marks
|
||||
completed is reviewed; with findings the todo is reopened and the agent has to fix them and
|
||||
complete it again (up to the configured rounds per todo). Ending the turn with open todos sends
|
||||
the agent back to work as long as it makes progress – for unattended runs, e.g. overnight
|
||||
(such modes may take up to 2,000 steps per turn). The chat shows passed reviews and these
|
||||
reminders; *Settings → Modes* can switch it off per mode (then only one review at the end of a
|
||||
turn, as before).
|
||||
- **Max input per model:** providers that limit the input below the context window (e.g. 272k of
|
||||
400k) are taken into account; fetched from the model catalogue, editable in the provider settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Context overflow instead of compaction** (e.g. DeepSeek V4.1 Flash with 384k max output):
|
||||
requests asked for the model's full max output, and providers count input + output against the
|
||||
window, so long chats failed before automatic compaction kicked in. The output budget is now what
|
||||
the window leaves, the chat is compacted when no reasonable room for an answer is left, and a
|
||||
"context too long" error compacts and retries once instead of stopping. A turn that hits the
|
||||
step limit now says so.
|
||||
|
||||
## [v202611-00017] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Markdown in your messages:** your messages are shown formatted like the model's answers
|
||||
(headings, lists, tables, code blocks, quotes, bold/italic); single line breaks stay as typed.
|
||||
The eye button next to the text field shows a preview of the formatted message before sending.
|
||||
|
||||
## [v202611-00016] – 2026-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Phone: page stayed shifted after the keyboard closed** – the top bar (menu) was out of
|
||||
reach. On Android the keyboard now shrinks the page instead of shifting it
|
||||
(`interactive-widget=resizes-content`; the chat stays at its latest message); browsers that
|
||||
still shift the page (iOS Safari) get it scrolled back once the keyboard closes.
|
||||
|
||||
## [v202611-00015] – 2026-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Generated images missing on other devices:** when an agent moved or renamed a generated
|
||||
image afterwards (e.g. sorted it into subfolders), the gallery pointed to the old file – only
|
||||
the device that had it in its browser cache still showed it. Now:
|
||||
- generated images are also kept in the image store and shown from there when the file is gone
|
||||
(existing images get their copy on the first start);
|
||||
- their entries follow moves and renames: after an agent's `mv`/`rename`, when moved with the
|
||||
file manager, and on the next view (found by file name, or by content when renamed);
|
||||
- in the chat, an image that cannot be found falls back to the preview the model saw.
|
||||
|
||||
## [v202611-00014] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **New folder in the folder picker:** creating an automation or a chat, *New folder* creates a
|
||||
subfolder of the shown folder and selects it.
|
||||
|
||||
## [v202611-00013] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Automation editor:** *New automation* asks for name, project and a starting point (blank
|
||||
example structure or a template), creates `.codecraft/jobs/<name>.md` and opens an editor:
|
||||
the instructions in a Markdown editor, schedule and settings next to them, and an assistant
|
||||
chat in which a model works out the instructions with you and edits the file (reloaded after
|
||||
each answer). New automations start switched off.
|
||||
- **Graphical schedule:** every few minutes, hourly, daily, on weekdays, monthly or a cron
|
||||
expression, with the next runs in server time.
|
||||
- **Chat list:** folders and projects can be dragged into any order or moved and pinned to the
|
||||
top via their menu; the order is kept per user on all devices. A new message moves the chat
|
||||
to the top of its folder or project, no longer the whole project.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Automation files hold only the instructions;** name, schedule, mode, model, approval mode
|
||||
and time limit are settings of the automation. Existing job files are taken over on the first
|
||||
start (settings into the database, the frontmatter removed from the file). Registering a
|
||||
plain Markdown file creates a switched-off automation to be scheduled in the editor.
|
||||
|
||||
## [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
|
||||
|
||||
- The Docker image ships the terminal client again (static Linux x86_64 and aarch64 binaries):
|
||||
`curl -fsSL https://<your-server>/cli/install.sh | sh`, download links in the settings and
|
||||
`codecraft update` work without building from source.
|
||||
- File manager: *Open chat here* in a folder's menu starts a chat in that folder.
|
||||
- **Tabs in a chat:** open terminals and file managers next to the chat (topbar buttons or
|
||||
"+" in the tab strip), as many as needed. Terminals of a chat keep running on the server until
|
||||
they are closed, so they are there again after a reload or on another device (output is
|
||||
replayed); they end when the chat is deleted or the server restarts.
|
||||
- **Chat mode:** *New session → Just chat* (and mode `chat`) for plain conversation – a lean
|
||||
prompt with date and personal memory, web search and fetch only, no project or files.
|
||||
- **Own modes:** *Settings → Agents & commands → New mode* with kind (coding or conversation),
|
||||
read-only switch, tool checkboxes, prompt, model and review loop; custom modes show up in
|
||||
*New session* and the mode selector.
|
||||
- The chat shows when the conversation is being compacted – automatically because the context
|
||||
is nearly full (with tokens and share of the limit) or on request – and labels the summary.
|
||||
|
||||
### Changed
|
||||
|
||||
- The mode selector in the composer is a dropdown.
|
||||
|
||||
### Removed
|
||||
|
||||
- *Settings → Config file* no longer offers to move `${NAME}` values into the configuration (a
|
||||
one-time migration); `${NAME}` references keep working.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Session updates from background tasks (title, token usage, phase, todos) and from the UI
|
||||
no longer overwrite each other. Often the first answer's token usage was lost, so automatic
|
||||
compaction and the context display started one turn late.
|
||||
|
||||
## [v202611-00002] – 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",
|
||||
|
||||
+28
-2
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Multi-arch: the web UI and the server are built once on the build
|
||||
# Multi-arch: the web UI, the server and the terminal client are built once on the build
|
||||
# machine (cross-compiled with cargo-zigbuild); only the runtime stage runs
|
||||
# per target platform.
|
||||
|
||||
@@ -19,7 +19,7 @@ ARG ZIG_VERSION=0.17.1
|
||||
RUN curl -fsSL https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz | tar -xJ -C /opt \
|
||||
&& ln -s /opt/zig-*/zig /usr/local/bin/zig \
|
||||
&& cargo install --locked cargo-zigbuild \
|
||||
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
|
||||
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-musl
|
||||
WORKDIR /src
|
||||
|
||||
# --- server (for the target platform, glibc of Debian bookworm) ----------------
|
||||
@@ -52,6 +52,29 @@ RUN touch core/src/lib.rs server/src/main.rs \
|
||||
&& cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \
|
||||
&& cp "target/$(cat /target)/release/codecraft" /codecraft
|
||||
|
||||
# --- terminal client (static Linux binaries for x86_64 and aarch64) -----------
|
||||
# Served by the server for download (/cli/install.sh, codecraft update).
|
||||
FROM toolchain AS cli
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY core ./core
|
||||
COPY server/Cargo.toml ./server/
|
||||
COPY server/resources ./server/resources
|
||||
COPY cli ./cli
|
||||
ARG CODECRAFT_VERSION=""
|
||||
ENV CODECRAFT_VERSION=${CODECRAFT_VERSION}
|
||||
RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
|
||||
&& cargo zigbuild --release -p codecraft-cli --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \
|
||||
&& 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
|
||||
@@ -69,6 +92,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
|
||||
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-00002" src="https://img.shields.io/badge/version-v202611--00002-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00018" src="https://img.shields.io/badge/version-v202611--00018-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">
|
||||
@@ -82,8 +82,7 @@ docker compose -f docker-compose.release.yml logs codecraft # one-time setup c
|
||||
Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a
|
||||
provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys,
|
||||
tokens and everything else are set in the UI and stored in the configuration file
|
||||
(`${NAME}` references to environment variables still work, and *Settings → Config file* moves
|
||||
their values into the file).
|
||||
(`${NAME}` references to environment variables still work).
|
||||
|
||||
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
|
||||
Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with
|
||||
@@ -98,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
|
||||
@@ -155,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
|
||||
@@ -174,15 +187,14 @@ Anthropic, OpenAI, Gemini, OpenRouter, opencode or a local model server (Ollama,
|
||||
llama.cpp, vLLM – detected automatically); both can be mixed.
|
||||
|
||||
```sh
|
||||
cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli
|
||||
curl -fsSL https://<your-codecraft-server>/cli/install.sh | sh # Linux x86_64 / aarch64
|
||||
codecraft login # server URL, user name, password → creates an API token
|
||||
codecraft # start in the current folder; /help lists the commands
|
||||
```
|
||||
|
||||
There are no prebuilt binaries yet, so the client is installed from source (needs a Rust
|
||||
toolchain). A server that has binaries in `CODECRAFT_CLI_DIR` (`codecraft-linux-x86_64`,
|
||||
`codecraft-linux-aarch64`) also offers `curl -fsSL https://<your-server>/cli/install.sh | sh`
|
||||
and `codecraft update`.
|
||||
The Docker image ships static binaries for Linux x86_64 and aarch64; the server offers them
|
||||
for download (*Settings → Account*) and `codecraft update` fetches new versions from it. Other
|
||||
platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli`.
|
||||
|
||||
## Automations
|
||||
|
||||
@@ -190,17 +202,24 @@ and `codecraft update`.
|
||||
<img src="docs/images/automations.png" alt="Creating an automation from a template: schedule chips, cron expression, mode, permission mode, model and the task for the agent" width="100%">
|
||||
</p>
|
||||
|
||||
An automation is a Markdown file with a cron schedule in its front matter
|
||||
(`.codecraft/jobs/<name>.md`). Each run is a normal chat session, so you can read and continue
|
||||
it; if it needs a permission, you get a push notification. Ask an agent in any chat to set one
|
||||
up, start from a template (dependency check, security review of recent commits, test suite
|
||||
health, daily summary, TODO report, documentation freshness) or write the file yourself.
|
||||
An automation is an agent that starts on a schedule. Its instructions are a Markdown file
|
||||
(`.codecraft/jobs/<name>.md`, only the prompt); name, schedule, mode, model and limits are set
|
||||
in the UI, with a graphical schedule editor (every few minutes, hourly, daily, on weekdays,
|
||||
monthly or a cron expression). *New automation* creates the file – blank with an example
|
||||
structure or from a template (dependency check, security review of recent commits, test suite
|
||||
health, daily summary, TODO report, documentation freshness) – and opens an editor with an
|
||||
assistant chat in which a model works out the instructions with you and writes the file. You
|
||||
can also ask an agent in any chat to set one up. Each run is a normal chat session, so you can
|
||||
read and continue it.
|
||||
|
||||
**Webhooks** start an automation from outside, e.g. on every push: the request is checked with a
|
||||
shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed to the agent.
|
||||
|
||||
## 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
|
||||
@@ -336,7 +355,7 @@ web/ React + Vite PWA
|
||||
src/i18n English (reference) and German
|
||||
```
|
||||
|
||||
The Docker build cross-compiles the server with
|
||||
The Docker build cross-compiles the server and the terminal client with
|
||||
[cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild), so a multi-arch image builds
|
||||
quickly on either architecture:
|
||||
`docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/codecraft:<version> --push .`
|
||||
@@ -354,7 +373,7 @@ Translations: copy `web/src/i18n/en.ts`, translate the values and register the l
|
||||
|
||||
Early, but in daily use. See the [changelog](CHANGELOG.md). Planned next:
|
||||
|
||||
- Prebuilt terminal client binaries (Linux, macOS, Windows)
|
||||
- Terminal client builds for macOS and Windows
|
||||
- MCP OAuth, resources and prompts
|
||||
- The OpenAI Responses API
|
||||
|
||||
|
||||
+2
-2
@@ -508,7 +508,7 @@ impl Agent {
|
||||
let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
||||
for p in &m.parts {
|
||||
let line = match p {
|
||||
Part::Text { text } | Part::Summary { text } => text.clone(),
|
||||
Part::Text { text } | Part::Summary { text, .. } => text.clone(),
|
||||
Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::<String>()),
|
||||
Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::<String>()),
|
||||
_ => continue,
|
||||
@@ -524,7 +524,7 @@ impl Agent {
|
||||
if summary.trim().is_empty() {
|
||||
return Err(anyhow!("compaction produced no summary"));
|
||||
}
|
||||
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary }]));
|
||||
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary, auto: false, tokens: None }]));
|
||||
self.session.context_tokens = 0;
|
||||
self.read_files.lock().unwrap().clear();
|
||||
let _ = self.session.save();
|
||||
|
||||
+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,
|
||||
});
|
||||
}
|
||||
|
||||
+1
-2
@@ -10,8 +10,7 @@
|
||||
#
|
||||
# Keys and tokens are entered in the web UI (Settings) and stored here, readable
|
||||
# for admins and included in backups. Any string value may also reference an
|
||||
# environment variable as ${NAME}; Settings → Config file moves such values
|
||||
# into this file.
|
||||
# environment variable as ${NAME}.
|
||||
# =============================================================================
|
||||
|
||||
[server]
|
||||
|
||||
@@ -64,6 +64,9 @@ pub struct ModelConfig {
|
||||
pub context: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_output: Option<u64>,
|
||||
/// Input limit when the provider sets one below the context window (e.g. 272k of 400k).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_input: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub reasoning: bool,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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."#;
|
||||
|
||||
@@ -34,6 +35,9 @@ Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pa
|
||||
pub const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
|
||||
pub const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine.";
|
||||
|
||||
/// Plain conversation (mode "chat"): no project, no coding workflow.
|
||||
pub const CHAT_PROMPT: &str = "You are a helpful, knowledgeable assistant having a conversation with the user. Answer directly and naturally in the user's language; keep answers as short as the question allows and go into depth when asked. Use Markdown only where it helps (lists, code, tables). You can search the web (web_search) and read pages (web_fetch) for current or specific facts – say where information comes from. You have no access to files or a shell in this mode; if the user wants something built or changed in a project, suggest switching to build mode.";
|
||||
|
||||
pub const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
|
||||
pub const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn to_wire_messages(messages: &[Message]) -> Vec<Value> {
|
||||
Part::Text { text } if !text.is_empty() => {
|
||||
content.push(json!({"type": "text", "text": text}))
|
||||
}
|
||||
Part::Summary { text } => content.push(json!({
|
||||
Part::Summary { text, .. } => content.push(json!({
|
||||
"type": "text",
|
||||
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
||||
})),
|
||||
|
||||
@@ -40,17 +40,20 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
|
||||
Part::Text { text } if !text.is_empty() => {
|
||||
parts.push(json!({"type": "text", "text": text}))
|
||||
}
|
||||
Part::Summary { text } => parts.push(json!({
|
||||
Part::Summary { text, .. } => parts.push(json!({
|
||||
"type": "text",
|
||||
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
||||
})),
|
||||
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."]);
|
||||
|
||||
+34
-3
@@ -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)]
|
||||
@@ -57,6 +71,12 @@ pub enum Part {
|
||||
/// Marks the start of a compacted history; the text is the summary.
|
||||
Summary {
|
||||
text: String,
|
||||
/// Started automatically because the context was nearly full.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
auto: bool,
|
||||
/// Context size (tokens) when it was compacted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tokens: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -160,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 {
|
||||
@@ -213,6 +240,9 @@ pub enum Event {
|
||||
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
|
||||
/// Messages waiting for a busy session (full list).
|
||||
QueueUpdated { session_id: String, items: Value },
|
||||
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
|
||||
/// a nearly full context (tokens of limit).
|
||||
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
|
||||
}
|
||||
|
||||
impl Event {
|
||||
@@ -234,7 +264,8 @@ impl Event {
|
||||
| Error { session_id, .. }
|
||||
| Waiting { session_id, .. }
|
||||
| QueueUpdated { session_id, .. }
|
||||
| StepStarted { session_id, .. } => Some(session_id),
|
||||
| StepStarted { session_id, .. }
|
||||
| Compaction { session_id, .. } => Some(session_id),
|
||||
McpStatus { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
+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
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# Roll out a published CodeCraft image to a remote installation that was set up with
|
||||
# scripts/move-server.sh (docker-compose.yml + .env in one folder on the target).
|
||||
#
|
||||
# scripts/release.sh # build and publish a new version (amd64 + arm64)
|
||||
# scripts/deploy.sh # roll the newest tag out to the target
|
||||
#
|
||||
# Steps:
|
||||
# 1. check that the image exists in the registry for the target's architecture,
|
||||
# 2. wait until no agent or terminal runs on the target (unless --force),
|
||||
# 3. set CODECRAFT_VERSION in the target's .env, docker compose pull && up -d
|
||||
# (or start.sh on hosts without Compose),
|
||||
# 4. run scripts/smoke-test.sh on the target; on failure the previous version is
|
||||
# started again.
|
||||
#
|
||||
# Usage: scripts/deploy.sh [vYYYYMM-NNNNN] [--target user@host:/dir] [--force]
|
||||
# [--wait MINUTES] [--dry-run]
|
||||
# vYYYYMM-NNNNN version to deploy (default: the newest vYYYYMM-NNNNN git tag)
|
||||
# --target installation folder on the target (default: DEPLOY_TARGET from .env)
|
||||
# --force restart even if agents or terminals are running (they end)
|
||||
# --wait N how long to wait for an idle server before giving up (default 15)
|
||||
# --dry-run show the steps only
|
||||
#
|
||||
# Environment:
|
||||
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
|
||||
# CONTAINER container name on the target (default codecraft)
|
||||
# PORT port of CodeCraft on the target, for the smoke test (default: PORT
|
||||
# from the target's .env, else 8787)
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
|
||||
CONTAINER="${CONTAINER:-codecraft}"
|
||||
|
||||
tag="" target="" force=0 wait_min=15 dry=0
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--target) target="$2"; shift ;;
|
||||
--force) force=1 ;;
|
||||
--wait) wait_min="$2"; shift ;;
|
||||
--dry-run) dry=1 ;;
|
||||
-h|--help) sed -n '2,29p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option $1" >&2; exit 2 ;;
|
||||
*) tag="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
fail() { echo "error: $*" >&2; exit 1; }
|
||||
q() { printf '%q' "$1"; }
|
||||
env_get() { [[ -f .env ]] && sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
|
||||
|
||||
target="${target:-$(env_get DEPLOY_TARGET || true)}"
|
||||
[[ "$target" == *:/* ]] || fail "no target – pass --target user@host:/dir or set DEPLOY_TARGET in .env"
|
||||
host="${target%%:*}"
|
||||
dir="${target#*:}"; dir="${dir%/}"
|
||||
remote() { ssh -o BatchMode=yes "$host" "$1"; }
|
||||
run_remote() { if (( dry )); then echo "[dry-run] on $host: $1"; else remote "$1"; fi; }
|
||||
|
||||
if [[ -z "$tag" ]]; then
|
||||
git fetch --tags --quiet origin || true
|
||||
tag=$(git tag -l 'v[0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9]' | sort | tail -1)
|
||||
[[ -n "$tag" ]] || fail "no release tag found"
|
||||
fi
|
||||
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
|
||||
|
||||
step "Checking $host:$dir and the image $REGISTRY:$tag"
|
||||
remote "test -f $(q "$dir/.env") && test -f $(q "$dir/docker-compose.yml")" \
|
||||
|| fail "$dir on $host has no .env/docker-compose.yml – set it up with scripts/move-server.sh"
|
||||
current=$(remote "sed -n 's/^CODECRAFT_VERSION=//p' $(q "$dir/.env") | tail -1")
|
||||
case "$(remote 'uname -m')" in
|
||||
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
|
||||
esac
|
||||
if [[ -n "$arch" ]]; then
|
||||
# Captured first: `inspect | grep -q` fails under pipefail when grep exits early (SIGPIPE).
|
||||
manifests=$(docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null || true)
|
||||
grep -q "Platform:.*linux/$arch" <<<"$manifests" \
|
||||
|| fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first"
|
||||
fi
|
||||
echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})"
|
||||
[[ "$current" == "$tag" ]] && echo "note: $tag is already configured – the container is recreated anyway"
|
||||
|
||||
# Busy = the container runs more than the server process (agents' shells, terminals, ...).
|
||||
busy() { (( $(remote "docker top $(q "$CONTAINER") -o pid 2>/dev/null | tail -n +2 | wc -l") > 1 )); }
|
||||
if (( ! force )) && remote "docker inspect -f '{{.State.Running}}' $(q "$CONTAINER") 2>/dev/null" | grep -q true; then
|
||||
step "Waiting until no agent or terminal runs (up to $wait_min min, --force skips this)"
|
||||
deadline=$(( $(date +%s) + wait_min * 60 ))
|
||||
while busy; do
|
||||
(( $(date +%s) < deadline )) || fail "still busy after $wait_min min – try later or use --force"
|
||||
sleep 15
|
||||
done
|
||||
echo "idle"
|
||||
fi
|
||||
|
||||
step "Updating to $tag"
|
||||
run_remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$tag/' .env \
|
||||
&& (grep -q '^CODECRAFT_VERSION=' .env || echo 'CODECRAFT_VERSION=$tag' >> .env) \
|
||||
&& docker pull -q $(q "$REGISTRY:$tag") >/dev/null \
|
||||
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
|
||||
|
||||
step "Smoke test"
|
||||
port="${PORT:-$(remote "sed -n 's/^PORT=//p' $(q "$dir/.env") | tail -1")}"; port="${port:-8787}"
|
||||
if (( dry )); then
|
||||
echo "[dry-run] smoke test on $host: http://127.0.0.1:$port ${tag#v}"
|
||||
else
|
||||
ssh -o BatchMode=yes "$host" "cat > $(q "$dir/smoke-test.sh")" <scripts/smoke-test.sh
|
||||
if ! remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${tag#v}"; then
|
||||
if [[ -n "$current" && "$current" != "$tag" ]]; then
|
||||
echo "smoke test failed – going back to $current" >&2
|
||||
remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$current/' .env \
|
||||
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
|
||||
fi
|
||||
fail "deploy of $tag failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
step "Done"
|
||||
echo "$host runs CodeCraft $tag."
|
||||
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"] }
|
||||
|
||||
+470
-94
@@ -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;
|
||||
@@ -66,10 +66,10 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/folders/{id}/access", put(set_folder_access))
|
||||
.route("/config", get(get_config).patch(patch_config))
|
||||
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
||||
.route("/config/inline-env", get(inline_env_preview).post(inline_env_apply))
|
||||
.route("/providers/reload", post(reload_providers))
|
||||
.route("/models", get(models))
|
||||
.route("/agents", get(agents))
|
||||
.route("/agents/tools", get(agent_tools))
|
||||
.route("/agents/{name}", delete(delete_agent))
|
||||
.route("/commands", get(commands))
|
||||
.route("/sessions", get(list_sessions).post(create_session))
|
||||
@@ -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))
|
||||
@@ -135,7 +137,10 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/images/{id}/file", get(image_file))
|
||||
.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/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
|
||||
.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))
|
||||
@@ -146,7 +151,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/account/tokens/{id}", delete(auth::delete_api_token))
|
||||
.route("/jobs", get(list_jobs).post(register_job))
|
||||
.route("/jobs/create", post(create_job))
|
||||
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
|
||||
.route("/jobs/schedule", get(schedule_preview))
|
||||
.route("/jobs/{id}", get(get_job).patch(update_job).delete(delete_job))
|
||||
.route("/jobs/{id}/assistant", post(job_assistant))
|
||||
.route("/jobs/{id}/run", post(run_job))
|
||||
.route("/jobs/{id}/webhook", post(job_webhook_settings))
|
||||
.route("/terminals", get(list_terminals).post(open_terminal))
|
||||
@@ -168,8 +175,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 +184,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"))
|
||||
}
|
||||
@@ -290,24 +341,6 @@ struct RawBody {
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// Which ${VAR} references the config still has (and whether they are set).
|
||||
async fn inline_env_preview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
let (_, set, unset) = config::inline_env(&app.config_text()?)?;
|
||||
Ok(Json(json!({"set": set, "unset": unset})))
|
||||
}
|
||||
|
||||
/// Move the values of set environment variables into the config file.
|
||||
async fn inline_env_apply(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
let (text, set, unset) = config::inline_env(&app.config_text()?)?;
|
||||
if !set.is_empty() {
|
||||
app.save_config(&text).await?;
|
||||
app.audit(Some(&user), "config.inline_env", set.join(", "), "");
|
||||
}
|
||||
Ok(Json(json!({"set": set, "unset": unset})))
|
||||
}
|
||||
|
||||
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
app.save_config(&body.text).await?;
|
||||
@@ -340,7 +373,86 @@ 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))))
|
||||
}
|
||||
|
||||
/// 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()}))
|
||||
.collect();
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
||||
@@ -510,6 +622,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)))
|
||||
@@ -679,7 +794,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" {
|
||||
@@ -718,50 +834,97 @@ 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 {
|
||||
let mut s = writable(&app, &user, &id)?;
|
||||
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();
|
||||
if let Some(t) = body.title {
|
||||
s.title = t;
|
||||
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)?;
|
||||
s.model = m;
|
||||
if let Some(m) = &body.model {
|
||||
app.registry().resolve(m)?;
|
||||
}
|
||||
if let Some(a) = body.agent {
|
||||
s.agent = a;
|
||||
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
}
|
||||
if let Some(c) = body.clean_room {
|
||||
s.clean_room = Some(c);
|
||||
// Atomic: a running turn updates the same session (usage, title, todos) meanwhile.
|
||||
let s = app.modify_session(&id, !organise_only, |s| {
|
||||
if let Some(t) = body.title {
|
||||
s.title = t;
|
||||
}
|
||||
if let Some(m) = body.model {
|
||||
s.model = m;
|
||||
}
|
||||
if let Some(a) = body.agent {
|
||||
s.agent = a;
|
||||
}
|
||||
if let Some(c) = body.clean_room {
|
||||
s.clean_room = Some(c);
|
||||
}
|
||||
if let Some(p) = body.pinned {
|
||||
s.pinned = p;
|
||||
}
|
||||
if let Some(a) = body.archived {
|
||||
s.archived = a;
|
||||
if a {
|
||||
s.pinned = false;
|
||||
}
|
||||
}
|
||||
if let Some(f) = body.folder {
|
||||
let f = f.trim().chars().take(60).collect::<String>();
|
||||
s.folder = (!f.is_empty()).then_some(f);
|
||||
}
|
||||
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()));
|
||||
}
|
||||
if let Some(p) = body.pinned {
|
||||
s.pinned = p;
|
||||
}
|
||||
if let Some(a) = body.archived {
|
||||
s.archived = a;
|
||||
if a {
|
||||
s.pinned = false;
|
||||
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()));
|
||||
}
|
||||
}
|
||||
if let Some(f) = body.folder {
|
||||
let f = f.trim().chars().take(60).collect::<String>();
|
||||
s.folder = (!f.is_empty()).then_some(f);
|
||||
}
|
||||
if organise_only {
|
||||
app.store.save_session(&s)?;
|
||||
app.emit(Event::SessionUpdated { session: s.clone() });
|
||||
return Ok(Json(json!(s)));
|
||||
}
|
||||
if let Some(p) = body.permission_mode {
|
||||
if !crate::permission::MODES.contains(&p.as_str()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
}
|
||||
s.permission_mode = p;
|
||||
}
|
||||
Ok(Json(json!(app.save_session(s)?)))
|
||||
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 {
|
||||
@@ -770,10 +933,35 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
||||
runner::abort(&app, &id);
|
||||
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,
|
||||
@@ -798,41 +986,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})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,11 +1051,11 @@ 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 });
|
||||
if let Err(e) = runner::compact(app.clone(), &id, cancel).await {
|
||||
if let Err(e) = runner::compact(app.clone(), &id, cancel, None).await {
|
||||
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
|
||||
}
|
||||
app.with_runtime(&id, |r| r.cancel = None);
|
||||
@@ -1232,7 +1427,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> {
|
||||
@@ -1366,13 +1563,26 @@ fn own_image(app: &App, user: &User, id: &str) -> Result<crate::store::ImageReco
|
||||
|
||||
async fn image_file(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> Result<Response, ApiError> {
|
||||
let img = own_image(&app, &user, &id)?;
|
||||
let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?;
|
||||
let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) {
|
||||
let ctype_of = |p: &str| match std::path::Path::new(p).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
Ok(([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400")], bytes).into_response())
|
||||
let respond = |ctype: String, bytes: Vec<u8>| ([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400".to_string())], bytes).into_response();
|
||||
if let Ok(bytes) = tokio::fs::read(&img.path).await {
|
||||
return Ok(respond(ctype_of(&img.path).into(), bytes));
|
||||
}
|
||||
// The file was moved or renamed (agents tidy up folders): with a copy in the image store,
|
||||
// answer from there and look for the file in the background; otherwise look now.
|
||||
if let Some((mime, bytes)) = img.blob.as_deref().map(|h| app.store.blob(h)).transpose()?.flatten() {
|
||||
let a = app.clone();
|
||||
tokio::task::spawn_blocking(move || crate::images::relocate(&a, &img));
|
||||
return Ok(respond(mime, bytes));
|
||||
}
|
||||
let a = app.clone();
|
||||
let found = tokio::task::spawn_blocking(move || crate::images::relocate(&a, &img)).await.ok().flatten().ok_or_else(|| not_found("image file"))?;
|
||||
let bytes = tokio::fs::read(&found).await.map_err(|_| not_found("image file"))?;
|
||||
Ok(respond(ctype_of(&found.display().to_string()).into(), bytes))
|
||||
}
|
||||
|
||||
async fn delete_image(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
@@ -1427,15 +1637,24 @@ async fn list_jobs(Extension(user): Extension<User>, State(app): State<AppState>
|
||||
Ok(Json(json!(jobs)))
|
||||
}
|
||||
|
||||
async fn get_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterJob {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Register an existing Markdown file (older files: settings from their frontmatter).
|
||||
async fn register_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<RegisterJob>) -> ApiResult {
|
||||
let p = crate::files::checked_path(&app, &user, &b.path, true)?;
|
||||
crate::jobs::parse(&p).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
if !p.is_file() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("{} is not a file", p.display())));
|
||||
}
|
||||
let job = app.store.job_register(&user.id, &p.display().to_string())?;
|
||||
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.audit(Some(&user), "job.register", &job.path, "");
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
@@ -1444,22 +1663,38 @@ async fn register_job(Extension(user): Extension<User>, State(app): State<AppSta
|
||||
struct CreateJob {
|
||||
/// Project folder; the file goes to <cwd>/.codecraft/jobs/<name>.md.
|
||||
cwd: String,
|
||||
#[serde(flatten)]
|
||||
job: crate::jobs::NewJob,
|
||||
name: String,
|
||||
/// Initial content of the instructions file (example or template).
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
schedule: Option<String>,
|
||||
#[serde(default)]
|
||||
agent: Option<String>,
|
||||
#[serde(default)]
|
||||
permission_mode: Option<String>,
|
||||
/// Default false: the user switches it on once prompt and schedule are ready.
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
async fn create_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<CreateJob>) -> ApiResult {
|
||||
let dir = crate::files::checked_path(&app, &user, &b.cwd, true)?;
|
||||
if b.job.name.trim().is_empty() || b.job.prompt.trim().is_empty() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "name and prompt are required".into()));
|
||||
if b.prompt.trim().is_empty() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "the instructions are empty".into()));
|
||||
}
|
||||
if !crate::permission::MODES.contains(&b.job.permission_mode.as_str()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
let mut settings = crate::jobs::JobSettings::new(&b.name);
|
||||
if let Some(x) = b.schedule {
|
||||
settings.schedule = x;
|
||||
}
|
||||
if let Some(x) = b.agent {
|
||||
settings.agent = x;
|
||||
}
|
||||
if let Some(x) = b.permission_mode {
|
||||
settings.permission_mode = x;
|
||||
}
|
||||
// Inside the checked folder: the file name is a slug of the name.
|
||||
let path = crate::jobs::new_job_path(&dir, &b.job.name);
|
||||
crate::jobs::write_file(&path, &b.job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
let job = app.store.job_register(&user.id, &path.display().to_string())?;
|
||||
let path = crate::jobs::new_job_path(&dir, &b.name);
|
||||
let job = crate::jobs::create(&app.store, &user.id, &path, settings, &b.prompt, b.enabled).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.audit(Some(&user), "job.create", &job.path, "");
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
@@ -1467,10 +1702,46 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateJob {
|
||||
enabled: Option<bool>,
|
||||
name: Option<String>,
|
||||
schedule: Option<String>,
|
||||
agent: Option<String>,
|
||||
/// "" = the server's default model.
|
||||
model: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
timeout_minutes: Option<u64>,
|
||||
}
|
||||
|
||||
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some();
|
||||
if changes {
|
||||
let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
|
||||
if let Some(x) = b.name {
|
||||
st.name = x;
|
||||
}
|
||||
if let Some(x) = b.schedule {
|
||||
st.schedule = x;
|
||||
}
|
||||
if let Some(x) = b.agent {
|
||||
st.agent = x;
|
||||
}
|
||||
if let Some(x) = b.model {
|
||||
if !x.is_empty() {
|
||||
app.registry().resolve(&x)?;
|
||||
}
|
||||
st.model = Some(x);
|
||||
}
|
||||
if let Some(x) = b.permission_mode {
|
||||
st.permission_mode = x;
|
||||
}
|
||||
if let Some(x) = b.timeout_minutes {
|
||||
st.timeout_minutes = x;
|
||||
}
|
||||
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.store.job_set_settings(&job.id, &st)?;
|
||||
app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
|
||||
}
|
||||
if let Some(e) = b.enabled {
|
||||
app.store.job_set_enabled(&job.id, e)?;
|
||||
}
|
||||
@@ -1478,6 +1749,96 @@ async fn update_job(Extension(user): Extension<User>, State(app): State<AppState
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
|
||||
/// Chat in which the user works out the instructions with a model (created once, reused
|
||||
/// while it exists). The model edits the job file directly.
|
||||
async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
let mut st = job.settings.clone().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "automation without settings".into()))?;
|
||||
if let Some(sid) = st.assistant_session.as_deref() {
|
||||
if app.store.get_session(sid)?.is_some_and(|s| s.owner_id.as_deref() == Some(user.id.as_str())) {
|
||||
return Ok(Json(json!({"session_id": sid})));
|
||||
}
|
||||
}
|
||||
let path = std::path::Path::new(&job.path);
|
||||
let cwd = crate::jobs::cwd_of(&job.path, st.cwd.as_deref());
|
||||
let cfg = app.config();
|
||||
let model = st
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
|
||||
.or_else(|| app.registry().first_model())
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
|
||||
let now = now_ms();
|
||||
let instructions = format!(
|
||||
"You help the user write the instructions of the automation \"{name}\". They live in the Markdown file {file} \
|
||||
and are executed unattended by an agent (mode {agent}) on a schedule the user sets in the UI. That run starts a \
|
||||
fresh session and sees only this file – no conversation, nobody to ask.\n\
|
||||
- Work on that file directly with your edit tools and keep it up to date after every round; the user sees it next to this chat.\n\
|
||||
- Find out what the automation should achieve, then shape the file into clear, self-contained instructions: \
|
||||
goal, steps, expected result (e.g. a short report), limits (what must not be done).\n\
|
||||
- Write only the instructions: no frontmatter, no schedule or settings in the file.\n\
|
||||
- Do not carry out the automation's task yourself and do not create other files. Use the language the user writes in.",
|
||||
name = st.name,
|
||||
file = path.display(),
|
||||
agent = st.agent,
|
||||
);
|
||||
let s = Session {
|
||||
id: new_id(),
|
||||
title: format!("{} – {}", st.name, if crate::telegram::german(&app, &user.id) { "Anweisungen" } else { "instructions" }),
|
||||
cwd: cwd.display().to_string(),
|
||||
model,
|
||||
agent: "build".into(),
|
||||
permission_mode: "auto".into(),
|
||||
parent_id: None,
|
||||
owner_id: Some(user.id.clone()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
todos: Value::Null,
|
||||
last_usage: None,
|
||||
clean_room: None,
|
||||
phase: None,
|
||||
worktree: None,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
folder: None,
|
||||
instructions,
|
||||
telegram_quiet: true,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
st.assistant_session = Some(s.id.clone());
|
||||
app.store.job_set_settings(&job.id, &st)?;
|
||||
Ok(Json(json!({"session_id": s.id})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScheduleQ {
|
||||
expr: String,
|
||||
}
|
||||
|
||||
/// Check a cron expression and list its next runs (for the schedule editor).
|
||||
async fn schedule_preview(Query(q): Query<ScheduleQ>) -> ApiResult {
|
||||
let expr = q.expr.trim();
|
||||
if let Err(e) = crate::jobs::check_schedule(expr) {
|
||||
return Ok(Json(json!({"ok": false, "error": format!("{e:#}")})));
|
||||
}
|
||||
let mut next = Vec::new();
|
||||
let mut t = now_ms();
|
||||
for _ in 0..5 {
|
||||
match crate::jobs::next_run(expr, t) {
|
||||
Some(n) => {
|
||||
next.push(n);
|
||||
t = n + 1000;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
// Wall-clock times of the server: cron works in its time zone, the browser may be elsewhere.
|
||||
let local: Vec<String> = next.iter().filter_map(|n| chrono::DateTime::from_timestamp_millis(*n)).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%dT%H:%M").to_string()).collect();
|
||||
let tz = std::env::var("TZ").ok().filter(|z| !z.is_empty()).unwrap_or_else(|| chrono::Local::now().format("UTC%:z").to_string());
|
||||
Ok(Json(json!({"ok": true, "next": next, "next_local": local, "tz": tz})))
|
||||
}
|
||||
|
||||
async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
app.store.job_delete(&job.id)?;
|
||||
@@ -1563,20 +1924,35 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
|
||||
|
||||
// --- web terminal ------------------------------------------------------------------
|
||||
|
||||
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!(app.terminals.list(&user.id))))
|
||||
#[derive(Deserialize)]
|
||||
struct TerminalQuery {
|
||||
session: Option<String>,
|
||||
}
|
||||
|
||||
/// All terminals of the user, or the tabs of one chat (?session=…).
|
||||
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<TerminalQuery>) -> ApiResult {
|
||||
Ok(Json(json!(match q.session {
|
||||
Some(s) => app.terminals.for_session(&s, &user.id),
|
||||
None => app.terminals.list(&user.id),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenTerminal {
|
||||
cwd: Option<String>,
|
||||
/// Open as a tab of this chat (in its folder unless `cwd` is given).
|
||||
session_id: Option<String>,
|
||||
cols: Option<u16>,
|
||||
rows: Option<u16>,
|
||||
}
|
||||
|
||||
async fn open_terminal(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<OpenTerminal>) -> ApiResult {
|
||||
let cwd = validate_cwd(&app, &user, b.cwd)?;
|
||||
let term = crate::terminal::spawn(&app, &user, &cwd, b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
let session = match &b.session_id {
|
||||
Some(id) => Some(writable(&app, &user, id)?),
|
||||
None => None,
|
||||
};
|
||||
let cwd = validate_cwd(&app, &user, b.cwd.or_else(|| session.as_ref().map(|s| s.cwd.clone())))?;
|
||||
let term = crate::terminal::spawn(&app, &user, &cwd, session.map(|s| s.id), b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.audit(Some(&user), "terminal.open", cwd.display().to_string(), &crate::auth::client_ip(&headers));
|
||||
Ok(Json(term.info()))
|
||||
}
|
||||
|
||||
+51
-3
@@ -46,6 +46,8 @@ pub struct Runtime {
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
/// Serializes read-modify-write of sessions (see `update_session`).
|
||||
session_write: Mutex<()>,
|
||||
config_path: PathBuf,
|
||||
config: RwLock<Arc<Config>>,
|
||||
registry: RwLock<Arc<Registry>>,
|
||||
@@ -101,6 +103,7 @@ impl App {
|
||||
let registry = Registry::build(&cfg).await;
|
||||
let (events, _) = broadcast::channel(4096);
|
||||
let app = Arc::new(Self {
|
||||
session_write: Mutex::new(()),
|
||||
config_path,
|
||||
config: RwLock::new(Arc::new(cfg)),
|
||||
registry: RwLock::new(Arc::new(registry)),
|
||||
@@ -128,9 +131,37 @@ 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 crate::images::backfill_copies(&self) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
||||
Err(e) => tracing::warn!("copies of generated images: {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();
|
||||
@@ -326,10 +357,27 @@ impl App {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Load, change and save a session atomically: background tasks (title, usage, phase,
|
||||
/// todos) change different fields at the same time and must not undo each other.
|
||||
pub fn update_session(&self, id: &str, f: impl FnOnce(&mut Session)) -> Result<Session> {
|
||||
self.modify_session(id, true, f)
|
||||
}
|
||||
|
||||
/// Like `update_session`; `touch = false` keeps `updated_at` (organising the list).
|
||||
pub fn modify_session(&self, id: &str, touch: bool, f: impl FnOnce(&mut Session)) -> Result<Session> {
|
||||
let _guard = self.session_write.lock().unwrap();
|
||||
let mut s = self.session(id)?;
|
||||
f(&mut s);
|
||||
if touch {
|
||||
return self.save_session(s);
|
||||
}
|
||||
self.store.save_session(&s)?;
|
||||
self.emit(Event::SessionUpdated { session: s.clone() });
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> {
|
||||
let mut s = self.session(session_id)?;
|
||||
s.todos = todos;
|
||||
self.save_session(s)?;
|
||||
self.update_session(session_id, |s| s.todos = todos)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 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))
|
||||
}
|
||||
|
||||
/// Address of `bytes` in the image store (SHA-256, hex).
|
||||
pub 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())?;
|
||||
@@ -291,6 +409,7 @@ fn opencode_model(m: &Value) -> Value {
|
||||
"id": m["id"],
|
||||
"context": m["limit"]["context"],
|
||||
"max_output": m["limit"]["output"],
|
||||
"max_input": m["limit"]["input"],
|
||||
"vision": images,
|
||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||
"input_price": input["price"],
|
||||
@@ -384,6 +503,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);
|
||||
|
||||
+18
-1
@@ -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);
|
||||
@@ -231,6 +231,23 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
|
||||
Ok(Json(b))
|
||||
}
|
||||
|
||||
/// Order and pinning of the groups (folders, projects) in the chat list – per user, so
|
||||
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
|
||||
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let v: Value = app.store.user_setting(&user.id, "sidebar")?;
|
||||
Ok(Json(if v.is_null() { json!({"order": [], "pinned": []}) } else { v }))
|
||||
}
|
||||
|
||||
pub async fn put_sidebar(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
|
||||
let ids = |k: &str| b[k].as_array().is_some_and(|a| a.iter().all(|x| x.as_str().is_some_and(|s| s.len() <= 1000)));
|
||||
if !ids("order") || !ids("pinned") || b.to_string().len() > 100_000 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid chat list layout".into()));
|
||||
}
|
||||
let v = json!({"order": b["order"], "pinned": b["pinned"]});
|
||||
app.store.set_user_setting(&user.id, "sidebar", &v)?;
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
/// Personal memory (applies to all projects, web and CLI).
|
||||
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let m: String = app.store.user_setting(&user.id, "memory")?;
|
||||
|
||||
+10
-71
@@ -385,6 +385,13 @@ pub struct AgentConfig {
|
||||
pub tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub prompt: Option<String>,
|
||||
/// Only read-only tools (like plan mode).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub read_only: Option<bool>,
|
||||
/// "coding" (default: project context and coding instructions) or "chat" (plain
|
||||
/// conversation with only this prompt, like the built-in chat mode).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
/// Max. instances of this subagent running at the same time.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_parallel: Option<usize>,
|
||||
@@ -406,6 +413,9 @@ pub struct RefineConfig {
|
||||
/// Review criteria; empty = the mode's defaults.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub criteria: Option<String>,
|
||||
/// Also review every todo the agent marks completed (reopened when findings come back).
|
||||
#[serde(default = "yes")]
|
||||
pub after_todos: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -470,57 +480,6 @@ pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
|
||||
Ok((cfg, text))
|
||||
}
|
||||
|
||||
/// Replace `${NAME}` references in all string values with the environment's values,
|
||||
/// so the settings live in the config only. Comments and formatting stay; references
|
||||
/// to unset variables are kept. Returns (new text, replaced names, unset names).
|
||||
pub fn inline_env(text: &str) -> Result<(String, Vec<String>, Vec<String>)> {
|
||||
use std::collections::BTreeSet;
|
||||
let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?;
|
||||
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
|
||||
let (mut done, mut missing) = (BTreeSet::new(), BTreeSet::new());
|
||||
let mut fix = |s: &str| -> Option<String> {
|
||||
if !re.is_match(s) {
|
||||
return None;
|
||||
}
|
||||
let out = re.replace_all(s, |c: ®ex::Captures| match std::env::var(&c[1]).ok().filter(|v| !v.is_empty()) {
|
||||
Some(v) => {
|
||||
done.insert(c[1].to_string());
|
||||
v
|
||||
}
|
||||
None => {
|
||||
missing.insert(c[1].to_string());
|
||||
c[0].to_string()
|
||||
}
|
||||
});
|
||||
(out != s).then(|| out.into_owned())
|
||||
};
|
||||
fn walk_value(v: &mut toml_edit::Value, fix: &mut dyn FnMut(&str) -> Option<String>) {
|
||||
match v {
|
||||
toml_edit::Value::String(f) => {
|
||||
if let Some(new) = fix(f.value()) {
|
||||
let decor = f.decor().clone();
|
||||
let mut nv = toml_edit::Formatted::new(new);
|
||||
*nv.decor_mut() = decor;
|
||||
*f = nv;
|
||||
}
|
||||
}
|
||||
toml_edit::Value::Array(a) => a.iter_mut().for_each(|x| walk_value(x, fix)),
|
||||
toml_edit::Value::InlineTable(t) => t.iter_mut().for_each(|(_, x)| walk_value(x, fix)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn walk_item(i: &mut toml_edit::Item, fix: &mut dyn FnMut(&str) -> Option<String>) {
|
||||
match i {
|
||||
toml_edit::Item::Value(v) => walk_value(v, fix),
|
||||
toml_edit::Item::Table(t) => t.iter_mut().for_each(|(_, x)| walk_item(x, fix)),
|
||||
toml_edit::Item::ArrayOfTables(a) => a.iter_mut().for_each(|t| t.iter_mut().for_each(|(_, x)| walk_item(x, fix))),
|
||||
toml_edit::Item::None => {}
|
||||
}
|
||||
}
|
||||
walk_item(doc.as_item_mut(), &mut fix);
|
||||
Ok((doc.to_string(), done.into_iter().collect(), missing.into_iter().collect()))
|
||||
}
|
||||
|
||||
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConfigPatch {
|
||||
@@ -667,26 +626,6 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod inline_env_tests {
|
||||
#[test]
|
||||
fn replaces_set_variables_and_keeps_comments() {
|
||||
// SAFETY: test-only, the variable names are unique to this test.
|
||||
unsafe {
|
||||
std::env::set_var("CC_TEST_INLINE_KEY", "sk-123");
|
||||
std::env::remove_var("CC_TEST_INLINE_UNSET");
|
||||
}
|
||||
let text = "# keys: use ${NAME}\n[providers.a]\napi_key = \"${CC_TEST_INLINE_KEY}\" # stored\nheaders = { Authorization = \"Bearer ${CC_TEST_INLINE_KEY}\" }\nother = \"${CC_TEST_INLINE_UNSET}\"\n";
|
||||
let (out, done, missing) = super::inline_env(text).unwrap();
|
||||
assert!(out.contains("api_key = \"sk-123\" # stored"), "{out}");
|
||||
assert!(out.contains("Bearer sk-123"));
|
||||
assert!(out.contains("# keys: use ${NAME}"), "comments untouched");
|
||||
assert!(out.contains("${CC_TEST_INLINE_UNSET}"));
|
||||
assert_eq!(done, vec!["CC_TEST_INLINE_KEY"]);
|
||||
assert_eq!(missing, vec!["CC_TEST_INLINE_UNSET"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+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> {
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn markdown(s: &Session, messages: &[Message]) -> String {
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())),
|
||||
Part::Summary { text } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
|
||||
Part::Summary { text, .. } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
|
||||
Part::Reasoning { text, .. } if !text.trim().is_empty() => {
|
||||
out.push_str(&format!("<details><summary>Thinking</summary>\n\n{}\n\n</details>\n\n", text.trim()))
|
||||
}
|
||||
|
||||
@@ -353,6 +353,10 @@ pub async fn move_or_copy(Extension(user): Extension<User>, State(app): State<Ap
|
||||
}
|
||||
let (f, t) = (from.clone(), to.clone());
|
||||
tokio::task::spawn_blocking(move || if b.copy { copy_recursive(&f, &t) } else { std::fs::rename(&f, &t) }).await??;
|
||||
if !b.copy {
|
||||
// Generated images in the moved file or folder keep their entries.
|
||||
app.store.move_image_paths(&from.display().to_string(), &to.display().to_string())?;
|
||||
}
|
||||
Ok(Json(json!({"ok": true, "path": to})))
|
||||
}
|
||||
|
||||
|
||||
+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")
|
||||
}
|
||||
|
||||
+127
-1
@@ -213,6 +213,8 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
|
||||
let path = req.dir.join(name);
|
||||
tokio::fs::write(&path, &bytes).await?;
|
||||
// A copy in the image store keeps it viewable when the file is moved or deleted.
|
||||
let blob = app.store.put_blob(&ctype, &bytes).map_err(|e| tracing::warn!("image copy: {e:#}")).ok();
|
||||
let rec = ImageRecord {
|
||||
id: new_id(),
|
||||
user_id: req.user_id.clone(),
|
||||
@@ -223,6 +225,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
width: img["width"].as_i64().unwrap_or(0),
|
||||
height: img["height"].as_i64().unwrap_or(0),
|
||||
created_at: now_ms(),
|
||||
blob,
|
||||
};
|
||||
app.store.add_image(&rec)?;
|
||||
records.push(rec);
|
||||
@@ -252,13 +255,105 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// A generated image whose file is gone: the same file name below its original folder
|
||||
/// (or the folder above it), e.g. after an agent sorted the images into subfolders.
|
||||
/// Only inside `roots`; looks at a few thousand entries at most.
|
||||
pub fn find_moved(path: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
|
||||
let name = path.file_name()?;
|
||||
search_near(path, roots, |p, _| p.file_name() == Some(name))
|
||||
}
|
||||
|
||||
/// Like `find_moved`, but by content: an image file with the same bytes (renamed).
|
||||
pub fn find_by_content(path: &Path, roots: &[PathBuf], hash: &str, size: u64) -> Option<PathBuf> {
|
||||
search_near(path, roots, |p, len| {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
len == size && matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg" | "webp")) && std::fs::read(p).is_ok_and(|b| crate::blobs::hash(&b) == hash)
|
||||
})
|
||||
}
|
||||
|
||||
/// Files below the folder of `path` and the folder above it (3 levels, hidden folders
|
||||
/// skipped, inside `roots`) for which `matches(path, size)` holds.
|
||||
fn search_near(path: &Path, roots: &[PathBuf], matches: impl Fn(&Path, u64) -> bool) -> Option<PathBuf> {
|
||||
let dir = path.parent()?;
|
||||
let mut budget = 5000usize;
|
||||
for base in [Some(dir), dir.parent()].into_iter().flatten() {
|
||||
if !roots.iter().any(|r| base.starts_with(r)) {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![(base.to_path_buf(), 0usize)];
|
||||
while let Some((d, depth)) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&d) else { continue };
|
||||
for e in entries.flatten() {
|
||||
if budget == 0 {
|
||||
return None;
|
||||
}
|
||||
budget -= 1;
|
||||
let p = e.path();
|
||||
let Ok(ft) = e.file_type() else { continue };
|
||||
if ft.is_file() && p != path && matches(&p, e.metadata().map(|m| m.len()).unwrap_or(0)) {
|
||||
return Some(p);
|
||||
}
|
||||
let hidden = p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.') || n == "node_modules");
|
||||
if ft.is_dir() && depth < 3 && !hidden {
|
||||
stack.push((p, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where the file of a generated image is now: by name, then (with a copy in the image
|
||||
/// store) by content. Corrects the stored path when found.
|
||||
pub fn relocate(app: &App, rec: &ImageRecord) -> Option<PathBuf> {
|
||||
let path = Path::new(&rec.path);
|
||||
let roots = app.user_roots(rec.user_id.as_deref());
|
||||
let found = find_moved(path, &roots).or_else(|| {
|
||||
let hash = rec.blob.as_deref()?;
|
||||
let (_, bytes) = app.store.blob(hash).ok()??;
|
||||
find_by_content(path, &roots, hash, bytes.len() as u64)
|
||||
})?;
|
||||
let _ = app.store.set_image_path(&rec.id, &found.display().to_string());
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// After an agent moved or renamed files: find the generated images of `owner` from the
|
||||
/// last two weeks whose files are gone and correct their paths. Returns how many.
|
||||
pub fn relocate_missing(app: &App, owner: Option<&str>) -> usize {
|
||||
let since = now_ms() - 14 * 86_400_000;
|
||||
let Ok(list) = app.store.images(owner, 500) else { return 0 };
|
||||
list.iter().filter(|r| r.created_at >= since && !Path::new(&r.path).exists()).filter(|r| relocate(app, r).is_some()).count()
|
||||
}
|
||||
|
||||
/// Copies in the image store for generated images from before they were kept (once per
|
||||
/// image, files up to 25 MB): later moves and renames are then found by content.
|
||||
pub fn backfill_copies(app: &App) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for (id, path) in app.store.images_without_copy()? {
|
||||
let Ok(meta) = std::fs::metadata(&path) else { continue };
|
||||
if !meta.is_file() || meta.len() > 25 * 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||||
let mime = match Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
let hash = app.store.put_blob(mime, &bytes)?;
|
||||
app.store.set_image_blob(&id, &hash)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Downscaled copy for the model's context.
|
||||
fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
let img = image::open(path).ok()?;
|
||||
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;
|
||||
@@ -363,6 +458,37 @@ impl Tool for GenerateImage {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_moved_files_and_keeps_their_copies() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-img-moved-{}", std::process::id()));
|
||||
let art = dir.join("art");
|
||||
std::fs::create_dir_all(art.join("older")).unwrap();
|
||||
std::fs::write(art.join("older/20261110-a.png"), b"x").unwrap();
|
||||
// Moved into a subfolder: found; renamed or outside the roots: not.
|
||||
assert_eq!(find_moved(&art.join("20261110-a.png"), std::slice::from_ref(&dir)), Some(art.join("older/20261110-a.png")));
|
||||
assert_eq!(find_moved(&art.join("20261110-b.png"), std::slice::from_ref(&dir)), None);
|
||||
assert_eq!(find_moved(&art.join("20261110-a.png"), &[dir.join("elsewhere")]), None);
|
||||
// Renamed: found by content.
|
||||
std::fs::write(art.join("older/level-5.png"), b"renamed bytes").unwrap();
|
||||
let h = crate::blobs::hash(b"renamed bytes");
|
||||
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &h, 13), Some(art.join("older/level-5.png")));
|
||||
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &crate::blobs::hash(b"other"), 13), None);
|
||||
// The image store keeps the copy of a generated image.
|
||||
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
|
||||
let hash = store.put_blob("image/png", b"png bytes").unwrap();
|
||||
store
|
||||
.add_image(&ImageRecord { id: "i".into(), user_id: None, session_id: None, prompt: "p".into(), model: "m".into(), path: "/gone.png".into(), width: 1, height: 1, created_at: 0, blob: Some(hash.clone()) })
|
||||
.unwrap();
|
||||
assert_eq!(store.gc_blobs().unwrap(), 0);
|
||||
// Moved with the file manager: the entry follows (file or folder, not look-alikes).
|
||||
assert_eq!(store.move_image_paths("/gone.png", "/art/new.png").unwrap(), 1);
|
||||
assert_eq!(store.move_image_paths("/art", "/bilder/").unwrap(), 1);
|
||||
assert_eq!(store.move_image_paths("/bild", "/x").unwrap(), 0);
|
||||
assert_eq!(store.image("i").unwrap().unwrap().path, "/bilder/new.png");
|
||||
assert_eq!(store.blob(&hash).unwrap().unwrap().1, b"png bytes");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn m(id: &str) -> ImageModel {
|
||||
with_defaults(ImageModel { id: id.into(), ..Default::default() })
|
||||
}
|
||||
|
||||
+237
-68
@@ -1,20 +1,23 @@
|
||||
//! Scheduled agents ("automations"): a Markdown file with a cron schedule in
|
||||
//! its frontmatter starts an agent session at the given times.
|
||||
//! Scheduled agents ("automations"). The instructions are a Markdown file (only the
|
||||
//! prompt, e.g. `<project>/.codecraft/jobs/<name>.md`); name, schedule, mode, model and
|
||||
//! limits are settings of the automation in the database, edited in the UI.
|
||||
//!
|
||||
//! Older job files carried the settings as frontmatter:
|
||||
//!
|
||||
//! ```markdown
|
||||
//! ---
|
||||
//! name: Nightly dependency check
|
||||
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
|
||||
//! agent: build # mode (default build)
|
||||
//! model: provider/model # optional, default: server default model
|
||||
//! permission_mode: auto # default auto; "bypass" runs without asking
|
||||
//! timeout_minutes: 60 # optional, default 60
|
||||
//! agent: build
|
||||
//! permission_mode: auto
|
||||
//! timeout_minutes: 60
|
||||
//! ---
|
||||
//! Check for outdated dependencies and open a summary …
|
||||
//! ```
|
||||
//!
|
||||
//! The file is re-read on every run, so edits apply to the next run. Each run
|
||||
//! is a normal chat session owned by the job's owner.
|
||||
//! They are adopted once (settings into the database, the frontmatter removed from the
|
||||
//! file). The file is re-read on every run, so edits apply to the next run. Each run is
|
||||
//! a normal chat session owned by the job's owner.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||
@@ -40,6 +43,70 @@ pub struct Job {
|
||||
pub created_at: i64,
|
||||
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
|
||||
pub webhook: bool,
|
||||
/// None: not adopted yet (settings still in the file's frontmatter).
|
||||
#[serde(skip)]
|
||||
pub settings: Option<JobSettings>,
|
||||
}
|
||||
|
||||
/// Settings of an automation (database; the file holds only the prompt).
|
||||
#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)]
|
||||
pub struct JobSettings {
|
||||
pub name: String,
|
||||
/// 5-field cron in the server's time zone.
|
||||
pub schedule: String,
|
||||
#[serde(default = "default_agent")]
|
||||
pub agent: String,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default = "default_mode")]
|
||||
pub permission_mode: String,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_minutes: u64,
|
||||
/// Working directory (absolute or relative to the file); default: the project of the file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
/// Chat in which the user works out the prompt with a model.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub assistant_session: Option<String>,
|
||||
}
|
||||
|
||||
impl JobSettings {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.trim().to_string(),
|
||||
schedule: "0 3 * * *".into(),
|
||||
agent: default_agent(),
|
||||
model: None,
|
||||
permission_mode: default_mode(),
|
||||
timeout_minutes: default_timeout(),
|
||||
cwd: None,
|
||||
assistant_session: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.name = self.name.trim().chars().take(120).collect();
|
||||
if self.name.is_empty() {
|
||||
bail!("the automation needs a name");
|
||||
}
|
||||
self.schedule = self.schedule.trim().to_string();
|
||||
check_schedule(&self.schedule)?;
|
||||
if !crate::permission::MODES.contains(&self.permission_mode.as_str()) {
|
||||
bail!("invalid permission_mode '{}'", self.permission_mode);
|
||||
}
|
||||
self.model = self.model.take().map(|m| m.trim().to_string()).filter(|m| !m.is_empty());
|
||||
self.timeout_minutes = self.timeout_minutes.clamp(1, 24 * 60);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Valid 5-field cron expression?
|
||||
pub fn check_schedule(schedule: &str) -> Result<()> {
|
||||
if schedule.split_whitespace().count() != 5 {
|
||||
bail!("invalid schedule '{schedule}': expected 5 fields (minute hour day month weekday)");
|
||||
}
|
||||
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parsed job file.
|
||||
@@ -52,15 +119,89 @@ pub struct JobSpec {
|
||||
pub permission_mode: String,
|
||||
pub timeout_minutes: u64,
|
||||
pub cwd: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assistant_session: Option<String>,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
impl JobSpec {
|
||||
fn from_settings(s: &JobSettings, prompt: String) -> Self {
|
||||
Self {
|
||||
name: s.name.clone(),
|
||||
schedule: s.schedule.clone(),
|
||||
agent: s.agent.clone(),
|
||||
model: s.model.clone(),
|
||||
permission_mode: s.permission_mode.clone(),
|
||||
timeout_minutes: s.timeout_minutes,
|
||||
cwd: s.cwd.clone(),
|
||||
assistant_session: s.assistant_session.clone(),
|
||||
prompt,
|
||||
}
|
||||
}
|
||||
|
||||
fn settings(&self) -> JobSettings {
|
||||
JobSettings {
|
||||
name: self.name.clone(),
|
||||
schedule: self.schedule.clone(),
|
||||
agent: self.agent.clone(),
|
||||
model: self.model.clone(),
|
||||
permission_mode: self.permission_mode.clone(),
|
||||
timeout_minutes: self.timeout_minutes,
|
||||
cwd: self.cwd.clone(),
|
||||
assistant_session: self.assistant_session.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings and prompt of a job: settings from the database, the prompt from its file
|
||||
/// (a leftover frontmatter is ignored); not yet adopted jobs from the frontmatter.
|
||||
pub fn spec(job: &Job) -> Result<JobSpec> {
|
||||
let path = Path::new(&job.path);
|
||||
let Some(settings) = &job.settings else { return parse(path) };
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let (_, body) = crate::prompts::frontmatter(&text);
|
||||
if body.trim().is_empty() {
|
||||
bail!("{}: the instructions are empty", path.display());
|
||||
}
|
||||
Ok(JobSpec::from_settings(settings, body.trim().to_string()))
|
||||
}
|
||||
|
||||
/// Move the frontmatter of an older job file into the database and remove it from the
|
||||
/// file. Files without frontmatter get default settings (named after the file) and are
|
||||
/// switched off until the user sets a schedule. Returns the updated job.
|
||||
pub fn adopt(store: &crate::store::Store, job: Job) -> Result<Job> {
|
||||
if job.settings.is_some() {
|
||||
return Ok(job);
|
||||
}
|
||||
let path = Path::new(&job.path);
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let (meta, body) = crate::prompts::frontmatter(&text);
|
||||
let settings = if meta.is_empty() {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("automation").replace(['-', '_'], " ");
|
||||
store.job_set_enabled(&job.id, false)?;
|
||||
JobSettings::new(&stem)
|
||||
} else {
|
||||
let spec = parse(path)?;
|
||||
// Prompt only from now on.
|
||||
std::fs::write(path, format!("{}\n", body.trim()))?;
|
||||
spec.settings()
|
||||
};
|
||||
store.job_set_settings(&job.id, &settings)?;
|
||||
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
|
||||
}
|
||||
|
||||
/// Settings from the frontmatter of an older job file.
|
||||
pub fn parse(path: &Path) -> Result<JobSpec> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let (meta, body) = crate::prompts::frontmatter(&text);
|
||||
let unquote = |s: &String| s.trim().trim_matches('"').trim_matches('\'').to_string();
|
||||
// Values may carry a trailing comment (`auto # default`).
|
||||
let unquote = |s: &String| {
|
||||
let v = s.trim();
|
||||
let v = if v.starts_with('"') || v.starts_with('\'') { v } else { v.split(" #").next().unwrap_or(v).trim() };
|
||||
v.trim_matches('"').trim_matches('\'').to_string()
|
||||
};
|
||||
let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"<cron>\"`", path.display()))?;
|
||||
croner::Cron::from_str(&schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
|
||||
check_schedule(&schedule)?;
|
||||
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
|
||||
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
|
||||
bail!("invalid permission_mode '{permission_mode}'");
|
||||
@@ -76,6 +217,7 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
|
||||
permission_mode,
|
||||
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
|
||||
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
|
||||
assistant_session: None,
|
||||
prompt: body.trim().to_string(),
|
||||
})
|
||||
}
|
||||
@@ -89,7 +231,13 @@ pub fn next_run(schedule: &str, after_ms: i64) -> Option<i64> {
|
||||
|
||||
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
|
||||
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
|
||||
if let Some(c) = &spec.cwd {
|
||||
cwd_of(&path.display().to_string(), spec.cwd.as_deref())
|
||||
}
|
||||
|
||||
/// Working directory for a job file and its optional `cwd` setting.
|
||||
pub fn cwd_of(file: &str, cwd: Option<&str>) -> PathBuf {
|
||||
let path = Path::new(file);
|
||||
if let Some(c) = cwd {
|
||||
let p = PathBuf::from(c);
|
||||
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
|
||||
}
|
||||
@@ -106,13 +254,19 @@ fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
|
||||
/// Job with its parsed file and next run, for the UI and the tool.
|
||||
pub fn describe(job: &Job) -> Value {
|
||||
let mut v = json!(job);
|
||||
match parse(Path::new(&job.path)) {
|
||||
match spec(job) {
|
||||
Ok(spec) => {
|
||||
v["spec"] = json!(spec);
|
||||
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000);
|
||||
v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None });
|
||||
}
|
||||
Err(e) => v["error"] = json!(format!("{e:#}")),
|
||||
Err(e) => {
|
||||
v["error"] = json!(format!("{e:#}"));
|
||||
// The settings stay editable when the file is missing or empty.
|
||||
if let Some(st) = &job.settings {
|
||||
v["spec"] = json!(JobSpec::from_settings(st, String::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -125,7 +279,7 @@ pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
||||
/// Start a run; `payload` (webhook body) is appended to the instructions.
|
||||
pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
|
||||
let path = PathBuf::from(&job.path);
|
||||
let spec = match parse(&path) {
|
||||
let spec = match spec(job) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
|
||||
@@ -164,6 +318,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)?;
|
||||
@@ -201,12 +358,20 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
||||
/// Background scheduler: checks every 30 s which jobs are due.
|
||||
pub fn spawn_scheduler(app: Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
// Older job files: settings from the frontmatter into the database.
|
||||
for job in app.store.jobs(None).unwrap_or_default().into_iter().filter(|j| j.settings.is_none()) {
|
||||
let path = job.path.clone();
|
||||
match adopt(&app.store, job) {
|
||||
Ok(_) => tracing::info!("automation {path}: settings moved from the file into the database"),
|
||||
Err(e) => tracing::warn!("automation {path}: {e:#}"),
|
||||
}
|
||||
}
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
let Ok(jobs) = app.store.jobs(None) else { continue };
|
||||
let now = now_ms();
|
||||
for job in jobs.into_iter().filter(|j| j.enabled) {
|
||||
let Ok(spec) = parse(Path::new(&job.path)) else { continue };
|
||||
let Ok(spec) = spec(&job) else { continue };
|
||||
// Due if a scheduled time passed since the last run (or since creation).
|
||||
let base = job.last_run.unwrap_or(job.created_at);
|
||||
let Some(next) = next_run(&spec.schedule, base) else { continue };
|
||||
@@ -233,22 +398,6 @@ pub fn spawn_scheduler(app: Arc<App>) {
|
||||
/// Tool: create, list, run and delete automations.
|
||||
pub struct Schedule;
|
||||
|
||||
/// Fields of a job file written by the agent tool or the "from template" dialog.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct NewJob {
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
#[serde(default = "default_agent")]
|
||||
pub agent: String,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default = "default_mode")]
|
||||
pub permission_mode: String,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_minutes: u64,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
fn default_agent() -> String {
|
||||
"build".into()
|
||||
}
|
||||
@@ -259,28 +408,20 @@ fn default_timeout() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
/// Write (and validate) a job file.
|
||||
pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
|
||||
croner::Cron::from_str(&j.schedule).map_err(|e| anyhow!("invalid schedule '{}': {e}", j.schedule))?;
|
||||
let q = |s: &str| format!("\"{}\"", s.replace('"', "'").replace('\n', " "));
|
||||
let plain = |s: &str| s.chars().filter(|c| c.is_alphanumeric() || "-_/.:".contains(*c)).collect::<String>();
|
||||
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(&j.name), q(&j.schedule), plain(&j.agent));
|
||||
if let Some(m) = j.model.as_deref().filter(|m| !m.is_empty()) {
|
||||
fm.push_str(&format!("model: {}\n", plain(m)));
|
||||
/// Write the instructions file, register it and store the settings.
|
||||
pub fn create(store: &crate::store::Store, owner: &str, path: &Path, mut settings: JobSettings, prompt: &str, enabled: bool) -> Result<Job> {
|
||||
settings.validate()?;
|
||||
if path.exists() {
|
||||
bail!("{} already exists", path.display());
|
||||
}
|
||||
fm.push_str(&format!("permission_mode: {}\n", plain(&j.permission_mode)));
|
||||
fm.push_str(&format!("timeout_minutes: {}\n---\n", j.timeout_minutes.clamp(1, 24 * 60)));
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, format!("{fm}{}\n", j.prompt.trim()))?;
|
||||
match parse(path) {
|
||||
Ok(spec) => Ok(spec),
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(path);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
std::fs::write(path, format!("{}\n", prompt.trim()))?;
|
||||
let job = store.job_register(owner, &path.display().to_string())?;
|
||||
store.job_set_settings(&job.id, &settings)?;
|
||||
store.job_set_enabled(&job.id, enabled)?;
|
||||
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
|
||||
}
|
||||
|
||||
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
|
||||
@@ -302,10 +443,11 @@ impl Tool for Schedule {
|
||||
"schedule"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Manage automations: agents that run on a cron schedule from a Markdown job file \
|
||||
(.codecraft/jobs/<name>.md in the working directory). Actions: \
|
||||
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the job file and registers it; \
|
||||
`register` (path) registers an existing job file; `list`; `run` (id) starts a run now; \
|
||||
"Manage automations: agents that run on a cron schedule. The instructions are a Markdown file \
|
||||
(.codecraft/jobs/<name>.md in the working directory, only the prompt); name, schedule, mode, model and \
|
||||
limits are settings of the automation. Actions: \
|
||||
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the file and registers it; \
|
||||
`register` (path) registers an existing Markdown file (switched off until a schedule is set in the UI); `list`; `run` (id) starts a run now; \
|
||||
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
|
||||
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
|
||||
The prompt must be self-contained: the run starts a fresh session without this conversation."
|
||||
@@ -348,19 +490,15 @@ impl Tool for Schedule {
|
||||
let prompt = arg_str(&input, "prompt")?;
|
||||
let rel = format!(".codecraft/jobs/{}.md", slug(name));
|
||||
let path = ctx.resolve(&rel)?;
|
||||
write_file(
|
||||
&path,
|
||||
&NewJob {
|
||||
name: name.into(),
|
||||
schedule: schedule.into(),
|
||||
agent: input["agent"].as_str().unwrap_or("build").into(),
|
||||
model: input["model"].as_str().map(String::from),
|
||||
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
|
||||
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
|
||||
prompt: prompt.into(),
|
||||
},
|
||||
)?;
|
||||
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
|
||||
let settings = JobSettings {
|
||||
schedule: schedule.into(),
|
||||
agent: input["agent"].as_str().unwrap_or("build").into(),
|
||||
model: input["model"].as_str().map(String::from),
|
||||
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
|
||||
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
|
||||
..JobSettings::new(name)
|
||||
};
|
||||
let job = create(&ctx.app.store, &owner, &path, settings, prompt, true)?;
|
||||
let d = describe(&job);
|
||||
Ok(ToolOutput::text(format!(
|
||||
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
|
||||
@@ -372,8 +510,10 @@ impl Tool for Schedule {
|
||||
}
|
||||
"register" => {
|
||||
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||
parse(&path)?;
|
||||
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
|
||||
if !path.is_file() {
|
||||
bail!("{} is not a file", path.display());
|
||||
}
|
||||
let job = adopt(&ctx.app.store, ctx.app.store.job_register(&owner, &path.display().to_string())?)?;
|
||||
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
|
||||
}
|
||||
"list" => {
|
||||
@@ -447,6 +587,35 @@ mod tests {
|
||||
assert_eq!(job_cwd(&p, &s), dir);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopts_frontmatter_and_creates_prompt_only_files() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-job-adopt-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
|
||||
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
|
||||
// Older file: settings move into the database, the file keeps only the prompt.
|
||||
let p = dir.join(".codecraft/jobs/old.md");
|
||||
std::fs::write(&p, "---\nname: \"Old\"\nschedule: \"0 4 * * *\"\npermission_mode: bypass # comment\n---\nDo old things.\n").unwrap();
|
||||
let job = adopt(&store, store.job_register("u", &p.display().to_string()).unwrap()).unwrap();
|
||||
let st = job.settings.clone().unwrap();
|
||||
assert_eq!((st.name.as_str(), st.schedule.as_str(), st.permission_mode.as_str()), ("Old", "0 4 * * *", "bypass"));
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "Do old things.\n");
|
||||
assert_eq!(spec(&job).unwrap().prompt, "Do old things.");
|
||||
assert!(job.enabled);
|
||||
// Plain Markdown: default settings, switched off.
|
||||
let q = dir.join(".codecraft/jobs/weekly-report.md");
|
||||
std::fs::write(&q, "Write a report.\n").unwrap();
|
||||
let job = adopt(&store, store.job_register("u", &q.display().to_string()).unwrap()).unwrap();
|
||||
assert_eq!(job.settings.as_ref().unwrap().name, "weekly report");
|
||||
assert!(!job.enabled);
|
||||
// New automation.
|
||||
let r = dir.join(".codecraft/jobs/new.md");
|
||||
assert!(create(&store, "u", &r, JobSettings { schedule: "nope".into(), ..JobSettings::new("N") }, "x", false).is_err());
|
||||
let job = create(&store, "u", &r, JobSettings::new("New"), "# New\n\nGoal", false).unwrap();
|
||||
assert_eq!(spec(&job).unwrap().prompt, "# New\n\nGoal");
|
||||
assert!(check_schedule("0 3 * *").is_err() && check_schedule("*/15 8-18 * * 1-5").is_ok());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// --- incoming webhooks -------------------------------------------------------------
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
+75
-10
@@ -23,6 +23,8 @@ pub struct Agent {
|
||||
/// Allowed tools; None = all.
|
||||
pub tools: Option<Vec<String>>,
|
||||
pub read_only: bool,
|
||||
/// "coding" or "chat" (plain conversation prompt).
|
||||
pub style: String,
|
||||
pub model: Option<String>,
|
||||
/// "builtin", "config" or "file".
|
||||
pub source: String,
|
||||
@@ -41,11 +43,12 @@ pub use codecraft_core::prompts::*;
|
||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||
let craft = craft_prompt(clean_room);
|
||||
vec![
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -113,11 +116,12 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
|
||||
prompt: body,
|
||||
tools,
|
||||
read_only: false,
|
||||
read_only: meta.get("read_only").is_some_and(|v| v == "true"),
|
||||
style: meta.get("style").filter(|v| *v == "chat").cloned().unwrap_or_else(|| "coding".into()),
|
||||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||
source: "file".into(),
|
||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }),
|
||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
|
||||
path: Some(p.display().to_string()),
|
||||
});
|
||||
}
|
||||
@@ -131,7 +135,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
|
||||
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
|
||||
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
|
||||
read_only: base.as_ref().is_some_and(|b| b.read_only),
|
||||
read_only: a.read_only.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.read_only)),
|
||||
style: a.style.clone().filter(|s| s == "chat" || s == "coding").or(base.as_ref().map(|b| b.style.clone())).unwrap_or_else(|| "coding".into()),
|
||||
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
|
||||
source: "config".into(),
|
||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||
@@ -142,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))
|
||||
@@ -177,7 +214,35 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
|
||||
out
|
||||
}
|
||||
|
||||
/// 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")));
|
||||
if let Some(u) = owner {
|
||||
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
|
||||
if !memory.trim().is_empty() {
|
||||
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
|
||||
if agent.style == "chat" {
|
||||
return chat_prompt(app, agent, model, owner);
|
||||
}
|
||||
let mut s = String::from(BASE_PROMPT);
|
||||
s.push_str("\n\n");
|
||||
s.push_str(&agent.prompt);
|
||||
|
||||
+315
-43
@@ -18,6 +18,8 @@ use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const MAX_STEPS: usize = 250;
|
||||
/// Modes with enforced reviews work through their todo list (e.g. overnight).
|
||||
const MAX_STEPS_ENFORCED: usize = 2_000;
|
||||
const MAX_SUBAGENT_DEPTH: usize = 3;
|
||||
|
||||
/// What happened to a prompt.
|
||||
@@ -260,7 +262,15 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
let mut stop_hooks = 0;
|
||||
let mut refine_done = 0u32;
|
||||
let turn_start = now_ms();
|
||||
for _ in 0..MAX_STEPS {
|
||||
// Compacted for the current step (no second compaction before a request went through).
|
||||
let mut compacted_now = false;
|
||||
// Mandatory reviews of completed todos: rounds per todo, and where the last one ended.
|
||||
let mut todo_reviews: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
let mut review_since = turn_start;
|
||||
// Reminders about open todos at the end of a turn (see MAX_IDLE_CONTINUES).
|
||||
let mut last_reminder: Option<Value> = None;
|
||||
let mut idle_continues = 0u32;
|
||||
for step_no in 0..=MAX_STEPS_ENFORCED {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
@@ -268,47 +278,72 @@ 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" {
|
||||
agent.prompt = prompts::craft_prompt(clean_room);
|
||||
}
|
||||
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
|
||||
let step_limit = if enforced_review(&agent) > 0 { MAX_STEPS_ENFORCED } else { MAX_STEPS };
|
||||
if step_no >= step_limit {
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("Stopped after {step_limit} steps (limit of a turn) – send a message to continue.") });
|
||||
break;
|
||||
}
|
||||
let (provider, mut model) = app.registry().resolve(&model_spec)?;
|
||||
if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) {
|
||||
model.effort = Some(effort);
|
||||
}
|
||||
|
||||
// Auto-compaction when the last request used most of the context window.
|
||||
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
|
||||
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||
// Providers count input + max_tokens against the context window: the answer needs
|
||||
// room too. Estimated input = last request (input + its answer) + what came since.
|
||||
let est_input = estimate_input(session.last_usage.as_ref(), &history);
|
||||
let want_output = model.max_output.unwrap_or(16_000);
|
||||
if let Some(ctx_len) = model.context {
|
||||
let threshold = app.config().server.auto_compact_threshold;
|
||||
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
|
||||
compact(app.clone(), session_id, cancel.clone()).await?;
|
||||
// Input limit: the context window, or the provider's lower input limit.
|
||||
let input_limit = model.max_input.map_or(ctx_len, |m| m.min(ctx_len));
|
||||
let room = ctx_len.saturating_sub(est_input);
|
||||
// Most of the input limit used, or no room left for a reasonable answer.
|
||||
if threshold > 0.0 && (est_input as f64 > input_limit as f64 * threshold || room < want_output.min(MIN_ANSWER_ROOM)) && !compacted_now {
|
||||
compacted_now = true;
|
||||
compact(app.clone(), session_id, cancel.clone(), Some((est_input, input_limit))).await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let max_tokens = answer_budget(want_output, model.context, est_input);
|
||||
|
||||
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 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),
|
||||
max_tokens,
|
||||
reasoning: true,
|
||||
session_id: Some(session_id.to_string()),
|
||||
model: model.clone(),
|
||||
};
|
||||
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
|
||||
Ok(step) => step,
|
||||
Ok(step) => {
|
||||
compacted_now = false;
|
||||
step
|
||||
}
|
||||
// The estimate was off and the provider says the input is too long: compact
|
||||
// once and try again instead of stopping.
|
||||
Err(e) if is_context_overflow(&e) && !compacted_now && !cancel.is_cancelled() => {
|
||||
compacted_now = true;
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("context window full ({}) – compacting the conversation and continuing", short_error(&e)) });
|
||||
compact(app.clone(), session_id, cancel.clone(), Some((est_input, model.context.unwrap_or(0)))).await?;
|
||||
continue;
|
||||
}
|
||||
// Host down or budget used up: switch to the model's fallback for this step.
|
||||
Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => {
|
||||
let fb = model.fallback.clone().unwrap_or_default();
|
||||
let (fb_provider, fb_model) = app.registry().resolve(&fb)?;
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("{model_spec} is not available ({}) – using the fallback model {fb}", short_error(&e)) });
|
||||
let req = ChatRequest { model: fb_model.clone(), max_tokens: fb_model.max_output.unwrap_or(16_000), ..req };
|
||||
let req = ChatRequest { model: fb_model.clone(), max_tokens: answer_budget(fb_model.max_output.unwrap_or(16_000), fb_model.context, est_input), ..req };
|
||||
stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
@@ -353,12 +388,37 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
}
|
||||
}
|
||||
let normal_end = matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "");
|
||||
// Enforced reviews also mean: work until the todo list is done. Ending the turn with
|
||||
// open todos sends the agent back (as long as it makes progress).
|
||||
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() && enforced_review(&agent) > 0 {
|
||||
let todos_now = app.session(session_id)?.todos;
|
||||
let open = open_todos(&todos_now);
|
||||
if !open.is_empty() {
|
||||
let progressed = last_reminder.as_ref() != Some(&todos_now);
|
||||
idle_continues = if progressed { 0 } else { idle_continues + 1 };
|
||||
if idle_continues < MAX_IDLE_CONTINUES {
|
||||
last_reminder = Some(todos_now);
|
||||
save_user_parts(
|
||||
&app,
|
||||
session_id,
|
||||
vec![Part::Text {
|
||||
text: format!(
|
||||
"<todo-reminder open=\"{}\">\n{}\n</todo-reminder>\nThese todos are still open. Continue working until all of them are completed (each completed todo is reviewed). Only if you are truly blocked and need the user, say clearly what you need and stop.",
|
||||
open.len(),
|
||||
open.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
|
||||
),
|
||||
}],
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() {
|
||||
// Refinement loop: a reviewer checks the turn; findings send the agent back to work.
|
||||
let rounds = agent.refine.as_ref().map(|r| r.rounds).unwrap_or(0);
|
||||
if refine_done < rounds {
|
||||
refine_done += 1;
|
||||
match review_turn(&app, &session, &agent, &model_spec, turn_start, refine_done, rounds, &cancel).await {
|
||||
match review_turn(&app, &session, &agent, &model_spec, turn_start, None, refine_done, rounds, &cancel).await {
|
||||
Ok(Some(feedback)) => {
|
||||
save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?;
|
||||
continue;
|
||||
@@ -412,6 +472,40 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await);
|
||||
}
|
||||
}
|
||||
// Mode with a review: every todo marked completed is reviewed; with findings it is
|
||||
// reopened and the agent has to fix them and complete it again (max. `rounds` per todo).
|
||||
// The verdict goes into the same message as the tool results.
|
||||
let rounds = enforced_review(&agent);
|
||||
if rounds > 0 && depth == 0 && !cancel.is_cancelled() && calls.iter().any(|(_, n, _)| n == "todo_write") {
|
||||
let todos_now = app.session(session_id)?.todos;
|
||||
let due: Vec<String> = newly_completed(&session.todos, &todos_now)
|
||||
.into_iter()
|
||||
.filter(|t| todo_reviews.get(t).copied().unwrap_or(0) < rounds)
|
||||
.collect();
|
||||
if !due.is_empty() {
|
||||
for t in &due {
|
||||
*todo_reviews.entry(t.clone()).or_default() += 1;
|
||||
}
|
||||
let round = due.iter().filter_map(|t| todo_reviews.get(t)).copied().max().unwrap_or(1);
|
||||
let fresh = app.session(session_id)?;
|
||||
let reviewed = review_turn(&app, &fresh, &agent, &model_spec, review_since, Some(&due), round, rounds, &cancel).await;
|
||||
review_since = now_ms();
|
||||
match reviewed {
|
||||
Ok(Some(feedback)) => {
|
||||
app.set_todos(session_id, reopen_todos(&todos_now, &due))?;
|
||||
results.push(Part::Text { text: feedback });
|
||||
}
|
||||
Ok(None) => {
|
||||
let open = open_todos(&todos_now);
|
||||
let next = if open.is_empty() { "All todos are done – check the whole request once more, then finish." } else { "Continue with the next open todo." };
|
||||
results.push(Part::Text {
|
||||
text: format!("<refine-approved reviewer=\"{}\">\n{}\n</refine-approved>\nReview passed. {next}", review_model(&agent, &model_spec), due.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")),
|
||||
});
|
||||
}
|
||||
Err(e) => tracing::warn!("todo review of {session_id} failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deliver messages the user queued while we were working (with attachments).
|
||||
results.extend(take_queue(&app, session_id));
|
||||
save_user_parts(&app, session_id, results)?;
|
||||
@@ -419,6 +513,55 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
Ok(final_text)
|
||||
}
|
||||
|
||||
/// Review rounds when the mode enforces reviews of completed todos (0 = not enforced).
|
||||
fn enforced_review(agent: &prompts::Agent) -> u32 {
|
||||
agent.refine.as_ref().filter(|r| r.after_todos).map(|r| r.rounds).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The model that reviews for this mode.
|
||||
fn review_model(agent: &prompts::Agent, model_spec: &str) -> String {
|
||||
agent.refine.as_ref().and_then(|r| r.model.clone()).filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string())
|
||||
}
|
||||
|
||||
/// Contents of the todos that are not completed yet.
|
||||
fn open_todos(todos: &Value) -> Vec<String> {
|
||||
todos.as_array().into_iter().flatten().filter(|t| t["status"] != "completed").filter_map(|t| t["content"].as_str()).map(String::from).collect()
|
||||
}
|
||||
|
||||
/// Times in a row the agent may end its turn with open todos without making progress.
|
||||
const MAX_IDLE_CONTINUES: u32 = 10;
|
||||
|
||||
/// Contents of the todos that are completed in `after` but were not in `before`.
|
||||
fn newly_completed(before: &Value, after: &Value) -> Vec<String> {
|
||||
let done_before: std::collections::HashSet<&str> = before
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|t| t["status"] == "completed")
|
||||
.filter_map(|t| t["content"].as_str())
|
||||
.collect();
|
||||
after
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|t| t["status"] == "completed")
|
||||
.filter_map(|t| t["content"].as_str())
|
||||
.filter(|c| !done_before.contains(c))
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The todo list with the given items open again (in progress).
|
||||
fn reopen_todos(todos: &Value, items: &[String]) -> Value {
|
||||
let mut out = todos.clone();
|
||||
for t in out.as_array_mut().into_iter().flatten() {
|
||||
if t["content"].as_str().is_some_and(|c| items.iter().any(|i| i == c)) {
|
||||
t["status"] = Value::from("in_progress");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tool_result(call_id: &str, name: &str, content: String, is_error: bool, meta: Option<Value>) -> Part {
|
||||
Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta }
|
||||
}
|
||||
@@ -563,7 +706,15 @@ async fn execute_call(
|
||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||
run_task(app, &ctx, input, depth).await
|
||||
} else {
|
||||
tool.run(input, &ctx).await
|
||||
let moves = name == "bash" && input["command"].as_str().is_some_and(moves_files);
|
||||
let out = tool.run(input, &ctx).await;
|
||||
if moves {
|
||||
// Generated images the command moved or renamed keep their entries.
|
||||
let a = app.clone();
|
||||
let owner = session.owner_id.clone();
|
||||
tokio::task::spawn_blocking(move || crate::images::relocate_missing(&a, owner.as_deref()));
|
||||
}
|
||||
out
|
||||
};
|
||||
// Diagnostics: report new errors of the changed file right away.
|
||||
let res = match res {
|
||||
@@ -609,6 +760,11 @@ async fn execute_call(
|
||||
}
|
||||
}
|
||||
|
||||
/// A shell command that may move or rename files (`mv`, `git mv`, `rename`).
|
||||
fn moves_files(cmd: &str) -> bool {
|
||||
cmd.split(|c: char| c.is_whitespace() || ";&|()`$".contains(c)).any(|w| w == "mv" || w == "rename")
|
||||
}
|
||||
|
||||
async fn run_task(app: &Arc<App>, ctx: &ToolCtx, input: Value, depth: usize) -> Result<tools::ToolOutput> {
|
||||
let prompt = tools::arg_str(&input, "prompt")?.to_string();
|
||||
let description = input["description"].as_str().unwrap_or("subagent").to_string();
|
||||
@@ -667,7 +823,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 +860,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 }]);
|
||||
@@ -746,7 +904,7 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
|
||||
for m in &req.messages {
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(),
|
||||
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => chars += text.len(),
|
||||
Part::ToolCall { input, .. } => chars += input.to_string().len(),
|
||||
Part::ToolResult { content, images: imgs, .. } => {
|
||||
chars += content.len();
|
||||
@@ -764,6 +922,47 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
|
||||
|
||||
/// Errors after which the fallback model takes over: the host is unreachable
|
||||
/// (connection, timeout, gateway errors) or the provider's budget is used up.
|
||||
/// Room an answer gets at least before the conversation is compacted.
|
||||
const MIN_ANSWER_ROOM: u64 = 32_000;
|
||||
|
||||
/// Estimated input tokens of the next request: the last request (its input and answer)
|
||||
/// plus the messages that came after that answer (tool results, user messages), at about
|
||||
/// 3 characters per token; without usage (fresh or just compacted) the whole history.
|
||||
fn estimate_input(last: Option<&crate::types::Usage>, history: &[Message]) -> u64 {
|
||||
let chars_of = |m: &Message| -> u64 {
|
||||
m.parts
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => text.len() as u64,
|
||||
Part::ToolCall { input, .. } => input.to_string().len() as u64,
|
||||
Part::ToolResult { content, images, .. } => content.len() as u64 + images.len() as u64 * 4_500,
|
||||
Part::Image { .. } => 4_500,
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
match last {
|
||||
Some(u) => {
|
||||
let after = history.iter().rposition(|m| m.role == Role::Assistant).map(|i| &history[i + 1..]).unwrap_or(&[]);
|
||||
u.context_tokens() + after.iter().map(chars_of).sum::<u64>() / 3
|
||||
}
|
||||
None => history.iter().map(chars_of).sum::<u64>() / 3 + 8_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// max_tokens for a request: the model's maximum, but no more than the window leaves
|
||||
/// (with a margin for the estimate), and never below a small floor.
|
||||
fn answer_budget(max_output: u64, context: Option<u64>, est_input: u64) -> u64 {
|
||||
let Some(ctx) = context else { return max_output };
|
||||
let room = ctx.saturating_sub(est_input + est_input / 50 + 2_000);
|
||||
max_output.min(room).max(4_096.min(max_output))
|
||||
}
|
||||
|
||||
/// The provider rejected the request because input + output exceed the context window.
|
||||
fn is_context_overflow(e: &anyhow::Error) -> bool {
|
||||
let s = format!("{e:#}").to_lowercase();
|
||||
["input too long", "context length", "context_length", "maximum context", "context window", "prompt is too long", "too many tokens", "reduce the length"].iter().any(|k| s.contains(k))
|
||||
}
|
||||
|
||||
fn needs_fallback(e: &anyhow::Error) -> bool {
|
||||
let s = format!("{e:#}").to_lowercase();
|
||||
["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k))
|
||||
@@ -913,9 +1112,7 @@ async fn stream_step(
|
||||
app.store.add_message(&session.id, &message)?;
|
||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||
}
|
||||
let mut s = app.session(&session.id)?;
|
||||
s.last_usage = Some(usage);
|
||||
app.save_session(s)?;
|
||||
app.update_session(&session.id, |s| s.last_usage = Some(usage))?;
|
||||
return Ok(Step { message, stop_reason });
|
||||
}
|
||||
}
|
||||
@@ -995,11 +1192,12 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||
Ok((title, usage)) => {
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "title");
|
||||
let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string();
|
||||
if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) {
|
||||
if s.title.is_empty() {
|
||||
s.title = title.chars().take(80).collect();
|
||||
let _ = app.save_session(s);
|
||||
}
|
||||
if !title.is_empty() {
|
||||
let _ = app.update_session(&session.id, |s| {
|
||||
if s.title.is_empty() {
|
||||
s.title = title.chars().take(80).collect();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("title generation failed: {e:#}"),
|
||||
@@ -1096,7 +1294,7 @@ fn infer_phase(tool: &str, input: &Value, deep: bool) -> Option<&'static str> {
|
||||
/// Move the craft phase forward (never backwards); `only_from` restricts the transition.
|
||||
fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) {
|
||||
let root = app.root_session_id(session_id);
|
||||
let Ok(mut s) = app.session(&root) else { return };
|
||||
let Ok(s) = app.session(&root) else { return };
|
||||
let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p));
|
||||
if let Some(from) = only_from {
|
||||
if s.phase.as_deref() != Some(from) {
|
||||
@@ -1113,28 +1311,32 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
|
||||
if s.phase.as_deref() == Some(phase) {
|
||||
return;
|
||||
}
|
||||
s.phase = Some(phase.to_string());
|
||||
let _ = app.save_session(s);
|
||||
let _ = app.update_session(&root, |s| s.phase = Some(phase.to_string()));
|
||||
}
|
||||
|
||||
/// Review the last turn against the user's request. Returns feedback for the
|
||||
/// agent, or None when the reviewer approves.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, turn_start: i64, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
|
||||
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, since: i64, todos: Option<&[String]>, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
|
||||
let refine = agent.refine.clone().unwrap_or_default();
|
||||
let spec = refine.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string());
|
||||
let (provider, mut model) = app.registry().resolve(&spec)?;
|
||||
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()
|
||||
.rposition(|m| matches!(m.role, Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { text } if !text.starts_with("<refine-feedback") && !text.starts_with("<hook-"))))
|
||||
.unwrap_or(0);
|
||||
let request: String = history.get(req_idx).map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")).unwrap_or_default();
|
||||
let mut work = transcript_of(&history[(req_idx + 1).min(history.len())..]);
|
||||
// A todo review looks at the work since the last review, the end-of-turn review at the whole turn.
|
||||
let from = match todos {
|
||||
Some(_) => history.iter().position(|m| m.created_at >= since).unwrap_or(history.len()).max(req_idx + 1),
|
||||
None => req_idx + 1,
|
||||
};
|
||||
let mut work = transcript_of(&history[from.min(history.len())..]);
|
||||
if work.len() > 30_000 {
|
||||
let mut start = work.len() - 30_000;
|
||||
while !work.is_char_boundary(start) {
|
||||
@@ -1145,7 +1347,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
// Actual file changes of this turn (checkpoint vs. current content).
|
||||
let mut changes = String::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (path, before) in app.store.checkpoints_since(&session.id, turn_start)? {
|
||||
for (path, before) in app.store.checkpoints_since(&session.id, since)? {
|
||||
if !seen.insert(path.clone()) || changes.len() > 30_000 {
|
||||
continue;
|
||||
}
|
||||
@@ -1159,10 +1361,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
let system = "You review the work of an AI coding agent for the user. Be strict about what matters for the user's request and ignore style nitpicks. \
|
||||
Reply in exactly this format: the first line is APPROVED or CHANGES_NEEDED. For CHANGES_NEEDED, follow with at most 8 concrete, actionable findings as a bullet list \
|
||||
(with file:line where possible). If the important work is done and correct, reply APPROVED.";
|
||||
let focus = match todos {
|
||||
Some(items) => format!(
|
||||
"<completed-todos>\n{}\n</completed-todos>\nThe agent just marked these todo items as completed. Review only whether they are really done and correct; other parts of the request may still be in progress.\n\n",
|
||||
items.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
let body = format!(
|
||||
"<request>\n{request}\n</request>\n\n<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
|
||||
"<request>\n{request}\n</request>\n\n{focus}<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
|
||||
agent.name,
|
||||
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
|
||||
if changes.is_empty() { "(no files changed since the last review)" } else { changes.as_str() }
|
||||
);
|
||||
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) };
|
||||
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
|
||||
@@ -1175,10 +1384,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
}
|
||||
let findings: String = lines.collect::<Vec<_>>().join("\n");
|
||||
let findings = if findings.trim().is_empty() { text.clone() } else { findings };
|
||||
Ok(Some(format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
|
||||
findings.trim()
|
||||
)))
|
||||
Ok(Some(match todos {
|
||||
Some(items) => format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\" todos=\"{}\">\n{}\n</refine-feedback>\nThe review of the todo(s) you just completed found problems, so they are open again. Fix these findings (or explain briefly why one does not apply), verify, then mark the todo(s) completed again – that triggers the next review.",
|
||||
items.len(),
|
||||
findings.trim()
|
||||
),
|
||||
None => format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
|
||||
findings.trim()
|
||||
),
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
|
||||
@@ -1217,7 +1433,7 @@ pub fn transcript_of(history: &[Message]) -> String {
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } => body.push_str(&format!("{text}\n")),
|
||||
Part::Summary { text } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
|
||||
Part::Summary { text, .. } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
|
||||
Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))),
|
||||
Part::ToolResult { name, content, is_error, .. } => {
|
||||
body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000)))
|
||||
@@ -1234,9 +1450,25 @@ pub fn transcript_of(history: &[Message]) -> String {
|
||||
}
|
||||
|
||||
/// Replace the active history with a summary (the old messages stay visible in the UI).
|
||||
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<()> {
|
||||
/// Summarize the history. `auto` = (context tokens, context limit) when triggered by a
|
||||
/// nearly full context; the chat shows the progress and the reason.
|
||||
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
|
||||
let event = |state: &str| Event::Compaction {
|
||||
session_id: session_id.to_string(),
|
||||
state: state.into(),
|
||||
auto: auto.is_some(),
|
||||
tokens: auto.map(|a| a.0),
|
||||
limit: auto.map(|a| a.1),
|
||||
};
|
||||
app.emit(event("started"));
|
||||
let res = compact_inner(app.clone(), session_id, cancel, auto).await;
|
||||
app.emit(event(if res.is_ok() { "done" } else { "failed" }));
|
||||
res
|
||||
}
|
||||
|
||||
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(());
|
||||
}
|
||||
@@ -1273,12 +1505,11 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
if summary.trim().is_empty() {
|
||||
bail!("compaction produced an empty summary");
|
||||
}
|
||||
save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?;
|
||||
let mut s = app.session(session_id)?;
|
||||
s.last_usage = None;
|
||||
let tokens = auto.map(|a| a.0).or_else(|| session.last_usage.as_ref().map(|u| u.context_tokens()));
|
||||
save_user_parts(&app, session_id, vec![Part::Summary { text: summary, auto: auto.is_some(), tokens }])?;
|
||||
app.update_session(session_id, |s| s.last_usage = None)?;
|
||||
// Files must be re-read after compaction before editing.
|
||||
app.with_runtime(session_id, |r| r.read_files.clear());
|
||||
app.save_session(s)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1286,6 +1517,47 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn todo_completion_and_reopening() {
|
||||
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||
let after = serde_json::json!([{"content": "A", "status": "completed"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||
assert_eq!(newly_completed(&before, &after), vec!["A".to_string()]);
|
||||
assert_eq!(newly_completed(&Value::Null, &after), vec!["A".to_string(), "B".to_string()]);
|
||||
assert_eq!(open_todos(&after), vec!["C".to_string()]);
|
||||
let reopened = reopen_todos(&after, &["A".to_string()]);
|
||||
assert_eq!(reopened[0]["status"], "in_progress");
|
||||
assert_eq!(reopened[1]["status"], "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_room_and_overflow() {
|
||||
// The case from production: DeepSeek V4.1 Flash, 1M window, 384K output, 720K input.
|
||||
let (ctx, out) = (1_048_576u64, 393_216u64);
|
||||
let b = answer_budget(out, Some(ctx), 720_000);
|
||||
assert!(720_000 + b <= ctx && b > 300_000, "{b}");
|
||||
assert_eq!(answer_budget(out, Some(ctx), 100_000), out);
|
||||
assert_eq!(answer_budget(16_000, None, 5_000_000), 16_000);
|
||||
assert_eq!(answer_budget(out, Some(ctx), 1_040_000), 4_096);
|
||||
assert!(is_context_overflow(&anyhow!("HTTP 400: [invalid_request_error] Input too long: model limit 1048576 tokens, requested 1108739 tokens")));
|
||||
assert!(is_context_overflow(&anyhow!("This model's maximum context length is 128000 tokens")));
|
||||
assert!(!is_context_overflow(&anyhow!("HTTP 429 rate limited")));
|
||||
// Estimate: last request + what came after its answer (about 3 chars per token).
|
||||
let u = crate::types::Usage { input_tokens: 1_000, cache_read_tokens: 9_000, output_tokens: 500, ..Default::default() };
|
||||
let mut call = Message::new(Role::Assistant, vec![call("c1")]);
|
||||
call.id = "a".into();
|
||||
let result = Message::new(Role::User, vec![Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "x".repeat(3_000), images: vec![], is_error: false, meta: None }]);
|
||||
assert_eq!(estimate_input(Some(&u), &[call.clone(), result.clone()]), 10_500 + 1_000);
|
||||
assert!(estimate_input(None, &[call, result]) >= 1_000 + 8_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_commands_that_move_files() {
|
||||
assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/"));
|
||||
assert!(moves_files("git mv a.png b.png"));
|
||||
assert!(moves_files("cd art;mv x y"));
|
||||
assert!(!moves_files("ls -la movies && cat remove.txt"));
|
||||
}
|
||||
|
||||
fn call(id: &str) -> Part {
|
||||
Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None }
|
||||
}
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
+193
-9
@@ -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,
|
||||
@@ -162,6 +169,8 @@ impl Store {
|
||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
||||
("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings TEXT"),
|
||||
("images", "blob", "ALTER TABLE images ADD COLUMN blob TEXT"),
|
||||
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
||||
] {
|
||||
let exists: bool = conn
|
||||
@@ -239,7 +248,7 @@ impl Store {
|
||||
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
|
||||
for p in &m.parts {
|
||||
let text = match p {
|
||||
Part::Text { text } | Part::Summary { text } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::ToolResult { content, .. } => content.as_str(),
|
||||
_ => continue,
|
||||
};
|
||||
@@ -274,12 +283,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 +318,107 @@ 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);
|
||||
// Copies of generated images (see ImageRecord::blob).
|
||||
for h in conn.prepare("SELECT blob FROM images WHERE blob IS NOT NULL")?.query_map([], |r| r.get::<_, String>(0))? {
|
||||
used.insert(h?);
|
||||
}
|
||||
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 +431,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 {
|
||||
@@ -838,6 +971,10 @@ pub struct ImageRecord {
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
pub created_at: i64,
|
||||
/// Copy of the bytes in the image store: the file may be moved or deleted later
|
||||
/// (agents tidy up folders), the image stays viewable.
|
||||
#[serde(skip)]
|
||||
pub blob: Option<String>,
|
||||
}
|
||||
|
||||
fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
|
||||
@@ -851,21 +988,62 @@ fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
|
||||
width: r.get(6)?,
|
||||
height: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
blob: r.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at";
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at, blob";
|
||||
|
||||
impl Store {
|
||||
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at],
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at, img.blob],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store bytes in the image store; returns their hash.
|
||||
pub fn put_blob(&self, media_type: &str, bytes: &[u8]) -> Result<String> {
|
||||
let hash = crate::blobs::hash(bytes);
|
||||
self.conn.lock().unwrap().execute(
|
||||
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![hash, media_type, bytes, now_ms()],
|
||||
)?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Generated images from before the copies in the image store (id, path).
|
||||
pub fn images_without_copy(&self) -> Result<Vec<(String, String)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, path FROM images WHERE blob IS NULL")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn set_image_blob(&self, id: &str, hash: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE images SET blob = ?2 WHERE id = ?1", params![id, hash])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file or folder was moved: generated images in it follow.
|
||||
pub fn move_image_paths(&self, from: &str, to: &str) -> Result<usize> {
|
||||
let from = from.trim_end_matches('/');
|
||||
let to = to.trim_end_matches('/');
|
||||
Ok(self.conn.lock().unwrap().execute(
|
||||
"UPDATE images SET path = ?2 || substr(path, length(?1) + 1)
|
||||
WHERE path = ?1 OR substr(path, 1, length(?1) + 1) = ?1 || '/'",
|
||||
params![from, to],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// The file of a generated image was found somewhere else.
|
||||
pub fn set_image_path(&self, id: &str, path: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE images SET path = ?2 WHERE id = ?1", params![id, path])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Latest images; `user = None` lists everybody's (admins).
|
||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -1008,13 +1186,14 @@ impl Store {
|
||||
last_status: r.get(6)?,
|
||||
created_at: r.get(7)?,
|
||||
webhook: r.get::<_, Option<String>>(8)?.is_some(),
|
||||
settings: r.get::<_, Option<String>>(9)?.and_then(|j| serde_json::from_str(&j).ok()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn jobs(&self, owner: Option<&str>) -> Result<Vec<crate::jobs::Job>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs
|
||||
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner], Self::job_row)?;
|
||||
@@ -1024,7 +1203,7 @@ impl Store {
|
||||
pub fn job(&self, id: &str) -> Result<Option<crate::jobs::Job>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE id = ?1", [id], Self::job_row)
|
||||
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE id = ?1", [id], Self::job_row)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
@@ -1040,7 +1219,7 @@ impl Store {
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE path = ?1",
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE path = ?1",
|
||||
[path],
|
||||
Self::job_row,
|
||||
)?)
|
||||
@@ -1073,6 +1252,11 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Set (Some) or remove (None) the webhook secret of a job.
|
||||
pub fn job_set_settings(&self, id: &str, settings: &crate::jobs::JobSettings) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE jobs SET settings = ?2 WHERE id = ?1", rusqlite::params![id, serde_json::to_string(settings)?])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn job_set_webhook(&self, id: &str, secret: Option<&str>) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE jobs SET webhook_secret = ?2 WHERE id = ?1", rusqlite::params![id, secret])?;
|
||||
Ok(())
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+39
-5
@@ -18,13 +18,16 @@ use tokio::sync::broadcast;
|
||||
|
||||
/// Output kept for re-attaching clients.
|
||||
const SCROLLBACK: usize = 256 * 1024;
|
||||
/// Detached terminals are stopped after this time.
|
||||
/// Detached terminals outside a chat are stopped after this time; terminals that
|
||||
/// belong to a chat stay open until they are closed, exit or the chat is deleted.
|
||||
const DETACHED_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const MAX_PER_USER: usize = 8;
|
||||
const MAX_PER_USER: usize = 16;
|
||||
|
||||
pub struct Term {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
/// Chat the terminal is a tab of.
|
||||
pub session_id: Option<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub created_at: i64,
|
||||
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
|
||||
@@ -57,6 +60,7 @@ impl Term {
|
||||
json!({
|
||||
"id": self.id,
|
||||
"cwd": self.cwd,
|
||||
"session_id": self.session_id,
|
||||
"created_at": self.created_at,
|
||||
"alive": self.alive(),
|
||||
"attached": *self.attached.lock().unwrap() > 0,
|
||||
@@ -76,6 +80,30 @@ impl Terminals {
|
||||
v.into_iter().map(|(_, i)| i).collect()
|
||||
}
|
||||
|
||||
/// Terminals of one chat (oldest first).
|
||||
pub fn for_session(&self, session_id: &str, owner_id: &str) -> Vec<Value> {
|
||||
let mut v: Vec<_> = self
|
||||
.map
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|t| t.owner_id == owner_id && t.session_id.as_deref() == Some(session_id))
|
||||
.map(|t| (t.created_at, t.info()))
|
||||
.collect();
|
||||
v.sort_by_key(|(c, _)| *c);
|
||||
v.into_iter().map(|(_, i)| i).collect()
|
||||
}
|
||||
|
||||
/// Close all terminals of a deleted chat.
|
||||
pub fn kill_session(&self, session_id: &str) {
|
||||
let gone: Vec<Arc<Term>> = {
|
||||
let mut map = self.map.lock().unwrap();
|
||||
let ids: Vec<String> = map.values().filter(|t| t.session_id.as_deref() == Some(session_id)).map(|t| t.id.clone()).collect();
|
||||
ids.iter().filter_map(|id| map.remove(id)).collect()
|
||||
};
|
||||
gone.iter().for_each(|t| t.kill());
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str, owner_id: &str) -> Option<Arc<Term>> {
|
||||
self.map.lock().unwrap().get(id).filter(|t| t.owner_id == owner_id).cloned()
|
||||
}
|
||||
@@ -100,7 +128,7 @@ impl Terminals {
|
||||
pub fn reap(&self) {
|
||||
let mut map = self.map.lock().unwrap();
|
||||
map.retain(|_, t| {
|
||||
let stale = t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
|
||||
let stale = t.session_id.is_none() && t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
|
||||
let gone = !t.alive() && *t.attached.lock().unwrap() == 0;
|
||||
if stale || gone {
|
||||
t.kill();
|
||||
@@ -113,7 +141,7 @@ impl Terminals {
|
||||
}
|
||||
|
||||
/// Start a shell for `user` in `cwd` (already access-checked).
|
||||
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> Result<Arc<Term>> {
|
||||
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>, cols: u16, rows: u16) -> Result<Arc<Term>> {
|
||||
if app.terminals.list(&user.id).iter().filter(|t| t["alive"] == true).count() >= MAX_PER_USER {
|
||||
anyhow::bail!("too many open terminals (max {MAX_PER_USER}) – close one first");
|
||||
}
|
||||
@@ -129,7 +157,12 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
|
||||
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,
|
||||
@@ -142,6 +175,7 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
|
||||
let term = Arc::new(Term {
|
||||
id: crate::types::new_id(),
|
||||
owner_id: user.id.clone(),
|
||||
session_id,
|
||||
cwd: cwd.to_path_buf(),
|
||||
created_at: crate::types::now_ms(),
|
||||
writer: tokio::sync::Mutex::new(writer),
|
||||
|
||||
@@ -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),
|
||||
}],
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!-- interactive-widget: the on-screen keyboard shrinks the page (Android) instead of
|
||||
shifting it up – the top bar stays in place. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
+16
-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";
|
||||
@@ -12,6 +12,7 @@ import { Settings } from "./components/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
import { Images } from "./components/Images";
|
||||
import { Jobs } from "./components/Jobs";
|
||||
import { JobEditor } from "./components/JobEditor";
|
||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||
import { DialogHost } from "./dialogs";
|
||||
import { startPrefsSync } from "./prefsSync";
|
||||
@@ -80,10 +81,22 @@ 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)} />;
|
||||
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "jobs") content = <Jobs onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "terminal")
|
||||
content = (
|
||||
@@ -102,7 +115,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}>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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";
|
||||
import type { TermInfo } from "./Terminal";
|
||||
|
||||
// xterm and the editor are large: load them only when such a tab is shown.
|
||||
const TermView = lazy(() => import("./Terminal").then((m) => ({ default: m.TermView })));
|
||||
const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files })));
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`;
|
||||
|
||||
/** A file manager tab (stored per user and chat on the server). */
|
||||
interface FileTab {
|
||||
id: string;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[]>([]);
|
||||
// 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(
|
||||
(v: string) => {
|
||||
setTabState(v);
|
||||
sessionStorage.setItem(tabKey(sessionId), v);
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
const setFiles = useCallback(
|
||||
(update: (f: FileTab[]) => FileTab[], delay = 0) =>
|
||||
setFilesState((cur) => {
|
||||
const next = update(cur);
|
||||
clearTimeout(pending.current);
|
||||
pending.current = setTimeout(() => {
|
||||
pending.current = undefined;
|
||||
void put(`/sessions/${sessionId}/tabs`, next).catch((e) => toast(e));
|
||||
}, delay);
|
||||
return next;
|
||||
}),
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
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([]);
|
||||
setTerms([]);
|
||||
void reload();
|
||||
const id = setInterval(reload, POLL_MS);
|
||||
const onFocus = () => void reload();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [sessionId, reload]);
|
||||
|
||||
// A tab closed elsewhere (or a stale stored tab) falls back to the chat.
|
||||
const activeTerm = terms.find((x) => x.id === tab);
|
||||
const activeFiles = files.find((f) => f.id === tab);
|
||||
const current = activeTerm || activeFiles ? tab : "chat";
|
||||
|
||||
const openTerminal = async () => {
|
||||
try {
|
||||
const info = await post<TermInfo>("/terminals", { session_id: sessionId, cols: 100, rows: 30 });
|
||||
await reload();
|
||||
setTab(info.id);
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
}
|
||||
};
|
||||
|
||||
const closeTerminal = async (x: TermInfo, n: number) => {
|
||||
if (x.alive && !(await confirmDialog({ message: t("terminal.closeConfirm", { name: t("terminal.tab", { n }) }), danger: true, confirmLabel: t("terminal.close") }))) return;
|
||||
await del(`/terminals/${x.id}`).catch(() => undefined);
|
||||
if (tab === x.id) setTab("chat");
|
||||
await reload();
|
||||
};
|
||||
|
||||
const openFiles = () => {
|
||||
const id = `files:${Date.now().toString(36)}`;
|
||||
setFiles((f) => [...f, { id, dir: cwd }]);
|
||||
setTab(id);
|
||||
};
|
||||
|
||||
const closeFiles = (id: string) => {
|
||||
setFiles((f) => f.filter((x) => x.id !== id));
|
||||
if (tab === id) setTab("chat");
|
||||
};
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
type Tabs = ReturnType<typeof useChatTabs>;
|
||||
|
||||
const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/";
|
||||
|
||||
/** Tab strip above the chat: the chat, one tab per terminal and file manager, and "+". */
|
||||
export function ChatTabBar({ state }: { state: Tabs }) {
|
||||
const t = useT();
|
||||
const { terms, files, tab, setTab, openTerminal, closeTerminal, openFiles, closeFiles } = state;
|
||||
if (terms.length === 0 && files.length === 0) return null;
|
||||
return (
|
||||
<div className="chat-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === "chat"} className={`chat-tab ${tab === "chat" ? "active" : ""}`} onClick={() => setTab("chat")}>
|
||||
<Icon name="sparkle" /> {t("terminal.chatTab")}
|
||||
</button>
|
||||
{terms.map((x, i) => (
|
||||
<span key={x.id} className={`chat-tab ${tab === x.id ? "active" : ""} ${x.alive ? "" : "dead"}`}>
|
||||
<button role="tab" aria-selected={tab === x.id} className="ghost" onClick={() => setTab(x.id)} title={x.cwd}>
|
||||
<Icon name="terminal" /> {t("terminal.tab", { n: i + 1 })}
|
||||
</button>
|
||||
<button className="ghost close" onClick={() => void closeTerminal(x, i + 1)} aria-label={t("terminal.close")} title={t("terminal.close")}>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{files.map((f) => (
|
||||
<span key={f.id} className={`chat-tab ${tab === f.id ? "active" : ""}`}>
|
||||
<button role="tab" aria-selected={tab === f.id} className="ghost" onClick={() => setTab(f.id)} title={f.dir}>
|
||||
<Icon name="folder" /> {basename(f.dir)}
|
||||
</button>
|
||||
<button className="ghost close" onClick={() => closeFiles(f.id)} aria-label={t("files.closeTab")} title={t("files.closeTab")}>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button className="ghost chat-tab-add" onClick={() => void openTerminal()} title={t("terminal.new")} aria-label={t("terminal.new")}>
|
||||
<Icon name="plus" /> <Icon name="terminal" />
|
||||
</button>
|
||||
<button className="ghost chat-tab-add" onClick={openFiles} title={t("files.newTab")} aria-label={t("files.newTab")}>
|
||||
<Icon name="plus" /> <Icon name="folder" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab contents besides the chat. The active terminal is mounted (it re-attaches and
|
||||
* replays its output); file managers stay mounted while hidden, so unsaved edits survive.
|
||||
*/
|
||||
export function ChatTabPanes({ state }: { state: Tabs }) {
|
||||
const t = useT();
|
||||
const { tab, activeTerm, files, fileDirChanged } = state;
|
||||
return (
|
||||
<Suspense fallback={<div className="empty">{t("common.loading")}</div>}>
|
||||
{activeTerm && <TermView key={activeTerm.id} info={activeTerm} />}
|
||||
{files.map((f) => (
|
||||
<div key={f.id} className="chat-pane chat-files" hidden={tab !== f.id}>
|
||||
<Files embed={{ startDir: f.dir, onDirChange: (d) => fileDirChanged(f.id, d) }} />
|
||||
</div>
|
||||
))}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+147
-23
@@ -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 { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode } from "../prefs";
|
||||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||
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";
|
||||
@@ -24,6 +25,7 @@ import { RewindDialog } from "./RewindDialog";
|
||||
import { ShellsChip } from "./ShellsPanel";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
|
||||
@@ -113,7 +115,36 @@ function BackgroundResult({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)">\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
||||
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)"(?: todos="\d+")?>\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
||||
/** Enforced reviews: a completed todo passed, or a reminder about open todos. */
|
||||
const NOTE_RE = /^<(refine-approved|todo-reminder)(?: reviewer="([^"]*)")?(?: open="\d+")?>\n?([\s\S]*?)\n?<\/(?:refine-approved|todo-reminder)>/;
|
||||
/** Messages from the harness shown as cards instead of user bubbles. */
|
||||
const isHarnessNote = (text: string) => BG_RE.test(text) || REFINE_RE.test(text) || NOTE_RE.test(text);
|
||||
|
||||
/** A todo passed its review, or the agent was sent back to its open todos. */
|
||||
function HarnessNote({ text }: { text: string }) {
|
||||
const t = useT();
|
||||
const m = text.match(NOTE_RE);
|
||||
if (!m) return null;
|
||||
const [, kind, reviewer, body] = m;
|
||||
const items = body.split("\n").map((l) => l.replace(/^- /, "").trim()).filter(Boolean);
|
||||
const approved = kind === "refine-approved";
|
||||
return (
|
||||
<div className={`harness-note ${approved ? "ok" : ""}`}>
|
||||
<Icon name={approved ? "check" : "refresh"} />
|
||||
<span className="grow">
|
||||
<b>{t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })}</b> {items.join(" · ")}
|
||||
</span>
|
||||
{reviewer && <span className="chip">{reviewer}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HarnessMessage({ text }: { text: string }) {
|
||||
if (REFINE_RE.test(text)) return <RefineFeedback text={text} />;
|
||||
if (NOTE_RE.test(text)) return <HarnessNote text={text} />;
|
||||
return <BackgroundResult text={text} />;
|
||||
}
|
||||
|
||||
/** Findings of the review loop, sent back to the agent. */
|
||||
function RefineFeedback({ text }: { text: string }) {
|
||||
@@ -135,7 +166,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);
|
||||
@@ -145,9 +176,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
const allPermissions = useStore((s) => s.permissions);
|
||||
const allQuestions = useStore((s) => s.questions);
|
||||
const waiting = useStore((s) => s.waiting[sessionId]);
|
||||
const compacting = useStore((s) => s.compacting[sessionId]);
|
||||
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
|
||||
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) ?? "");
|
||||
@@ -195,6 +231,17 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
const el = scrollRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||||
const hasSession = !!session;
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [hasSession, sessionId]);
|
||||
|
||||
if (loadError) return <div className="empty">{loadError}</div>;
|
||||
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
||||
@@ -304,6 +351,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 && (
|
||||
<>
|
||||
@@ -314,14 +366,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
)}
|
||||
<button
|
||||
className="small ghost hide-mobile"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("codecraft.termCwd", session.cwd);
|
||||
navigate("/terminal/new");
|
||||
}}
|
||||
onClick={() => void tabs.openTerminal()}
|
||||
title={t("terminal.here")}
|
||||
>
|
||||
<Icon name="terminal" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||
<Icon name="folder" />
|
||||
</button>
|
||||
{!session.parent_id && (
|
||||
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||
<Icon name="fork" />
|
||||
@@ -332,6 +384,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>
|
||||
@@ -365,7 +441,15 @@ 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} />
|
||||
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
||||
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
||||
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
||||
<div
|
||||
@@ -402,20 +486,26 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details key={m.id} className="summary-divider">
|
||||
<summary>{t("chat.compacted")}</summary>
|
||||
<summary>
|
||||
{summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
<Markdown text={summary.text} />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => BG_RE.test(p.text) || REFINE_RE.test(p.text));
|
||||
const texts = allTexts.filter((p) => !BG_RE.test(p.text) && !REFINE_RE.test(p.text));
|
||||
const background = allTexts.filter((p) => isHarnessNote(p.text));
|
||||
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
if (texts.length === 0 && imgs.length === 0) {
|
||||
return background.length ? (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
@@ -423,7 +513,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
<div className="msg-user">
|
||||
{!busy && !readOnly && (
|
||||
@@ -437,8 +527,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>
|
||||
@@ -452,14 +542,24 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||
</div>
|
||||
)}
|
||||
{compacting && (
|
||||
<div className={`compacting-card ${compacting.auto ? "auto" : ""}`} role="status">
|
||||
<Spinner small />
|
||||
<div>
|
||||
<b>{t(compacting.auto ? "chat.compactingAuto" : "chat.compactingManual")}</b>
|
||||
{compacting.auto && compacting.tokens && compacting.limit && (
|
||||
<div className="hint">
|
||||
{t("chat.compactingDetail", { tokens: fmtCompact(compacting.tokens), limit: fmtCompact(compacting.limit), pct: fmtPercent(compacting.tokens / compacting.limit) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(busy || waiting) && (
|
||||
<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} />
|
||||
@@ -497,6 +597,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)} />}
|
||||
@@ -514,6 +615,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
onUpdate={update}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -535,9 +637,10 @@ function UserText({ text }: { text: string }) {
|
||||
const last = rest.pop()!;
|
||||
if (last.trim().endsWith(":")) break;
|
||||
}
|
||||
const body = rest.join("\n").trim();
|
||||
return (
|
||||
<>
|
||||
{rest.join("\n").trimEnd()}
|
||||
{body && <Markdown text={body} breaks />}
|
||||
{files.length > 0 && (
|
||||
<div className="user-files">
|
||||
{files.map((f) => (
|
||||
@@ -584,3 +687,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";
|
||||
@@ -9,6 +9,7 @@ import type { ImageData, Session } from "../types";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { EffortSelect } from "./Regenerate";
|
||||
import { VoiceButton } from "./Voice";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
|
||||
interface Suggestion {
|
||||
@@ -67,6 +68,8 @@ export function Composer({
|
||||
const [effort, setEffort] = useState("");
|
||||
const [interim, setInterim] = useState("");
|
||||
const [picker, setPicker] = useState(false);
|
||||
// Rendered Markdown instead of the text field (to check formatting before sending).
|
||||
const [preview, setPreview] = useState(false);
|
||||
const models = useModels();
|
||||
const [prov, ...mid] = session.model.split("/");
|
||||
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
|
||||
@@ -257,7 +260,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>
|
||||
@@ -265,7 +268,20 @@ export function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{preview && (
|
||||
<div
|
||||
className="composer-preview"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={t("composer.previewEdit")}
|
||||
onClick={() => (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
|
||||
>
|
||||
{text.trim() ? <Markdown text={text} breaks /> : <span className="hint">{t("composer.previewEmpty")}</span>}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
style={preview ? { display: "none" } : undefined}
|
||||
ref={ref}
|
||||
rows={1}
|
||||
value={text}
|
||||
@@ -288,20 +304,20 @@ export function Composer({
|
||||
/>
|
||||
<div className="composer-bar">
|
||||
<div className="composer-options">
|
||||
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
|
||||
<select
|
||||
className="mode-select"
|
||||
value={session.agent}
|
||||
onChange={(e) => onUpdate({ agent: e.target.value })}
|
||||
aria-label={t("composer.mode")}
|
||||
title={agents.find((a) => a.name === session.agent)?.description ?? t("composer.mode")}
|
||||
>
|
||||
{!agents.some((a) => a.name === session.agent) && <option value={session.agent}>{session.agent}</option>}
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
key={a.name}
|
||||
role="tab"
|
||||
aria-selected={session.agent === a.name}
|
||||
className={session.agent === a.name ? "active" : ""}
|
||||
title={a.description}
|
||||
onClick={() => onUpdate({ agent: a.name })}
|
||||
>
|
||||
<option key={a.name} value={a.name} title={a.description}>
|
||||
{a.name}
|
||||
</button>
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
</select>
|
||||
{session.agent === "craft" && (
|
||||
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
|
||||
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
|
||||
@@ -337,6 +353,15 @@ export function Composer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={`ghost ${preview ? "active" : ""}`}
|
||||
style={{ padding: "3px 6px" }}
|
||||
onClick={() => setPreview(!preview)}
|
||||
aria-pressed={preview}
|
||||
title={t(preview ? "composer.previewEdit" : "composer.preview")}
|
||||
>
|
||||
<Icon name={preview ? "edit" : "eye"} />
|
||||
</button>
|
||||
<VoiceButton onText={(d) => setText(text ? `${text.replace(/\s+$/, "")} ${d}` : d)} onInterim={setInterim} />
|
||||
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
||||
<Icon name="clip" />
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get } from "../api";
|
||||
import { getLang, useT } from "../i18n";
|
||||
|
||||
/**
|
||||
* Graphical editor for a 5-field cron expression (minute hour day month weekday,
|
||||
* server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a
|
||||
* custom expression. Shows the next runs as computed by the server.
|
||||
*/
|
||||
type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom";
|
||||
|
||||
interface State {
|
||||
mode: Mode;
|
||||
every: number;
|
||||
minute: number;
|
||||
time: string; // HH:MM
|
||||
days: number[]; // cron weekdays, 0 = Sunday
|
||||
dom: number;
|
||||
custom: string;
|
||||
}
|
||||
|
||||
const MINUTE_STEPS = [5, 10, 15, 20, 30];
|
||||
/** Monday first; cron numbers (0 = Sunday). */
|
||||
const WEEK = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`;
|
||||
|
||||
function expandDays(field: string): number[] | null {
|
||||
const out = new Set<number>();
|
||||
for (const part of field.split(",")) {
|
||||
const m = part.match(/^(\d)(?:-(\d))?$/);
|
||||
if (!m) return null;
|
||||
const a = Number(m[1]);
|
||||
const b = m[2] ? Number(m[2]) : a;
|
||||
if (a > 7 || b > 7 || b < a) return null;
|
||||
for (let d = a; d <= b; d++) out.add(d % 7);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
export function parseCron(expr: string): State {
|
||||
const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() };
|
||||
const f = expr.trim().split(/\s+/);
|
||||
if (f.length !== 5) return base;
|
||||
const [mi, h, dom, mon, dow] = f;
|
||||
const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max;
|
||||
if (mon !== "*") return base;
|
||||
let m;
|
||||
if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) };
|
||||
if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) };
|
||||
if (!num(mi, 59) || !num(h, 23)) return base;
|
||||
const time = hhmm(h, mi);
|
||||
if (dom === "*" && dow === "*") return { ...base, mode: "daily", time };
|
||||
if (dom === "*") {
|
||||
const days = expandDays(dow);
|
||||
return days?.length ? { ...base, mode: "weekly", time, days } : base;
|
||||
}
|
||||
if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) };
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildCron(s: State): string {
|
||||
const [h, m] = s.time.split(":").map(Number);
|
||||
switch (s.mode) {
|
||||
case "minutes":
|
||||
return `*/${s.every} * * * *`;
|
||||
case "hourly":
|
||||
return `${s.minute} * * * *`;
|
||||
case "daily":
|
||||
return `${m} ${h} * * *`;
|
||||
case "weekly": {
|
||||
const days = [...s.days].sort((a, b) => a - b);
|
||||
return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`;
|
||||
}
|
||||
case "monthly":
|
||||
return `${m} ${h} ${s.dom} * *`;
|
||||
default:
|
||||
return s.custom.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const dayName = (d: number, style: "short" | "long") => {
|
||||
// 2023-01-01 was a Sunday.
|
||||
return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style });
|
||||
};
|
||||
|
||||
/** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */
|
||||
const fmtWall = (s: string) => {
|
||||
const [d, hm] = s.split("T");
|
||||
const [y, mo, da] = d.split("-").map(Number);
|
||||
const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" });
|
||||
return `${date}, ${hm}`;
|
||||
};
|
||||
|
||||
export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) {
|
||||
const t = useT();
|
||||
const [s, setS] = useState<State>(() => parseCron(value));
|
||||
const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>();
|
||||
|
||||
// A value from outside (another automation, reset) that the builder did not produce.
|
||||
useEffect(() => {
|
||||
if (value.trim() !== buildCron(s)) setS(parseCron(value));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const expr = value.trim();
|
||||
if (!expr) return setPreview(undefined);
|
||||
let alive = true;
|
||||
const h = setTimeout(() => {
|
||||
get<typeof preview>(`/jobs/schedule?expr=${encodeURIComponent(expr)}`)
|
||||
.then((p) => alive && setPreview(p))
|
||||
.catch(() => alive && setPreview(undefined));
|
||||
}, 250);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(h);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const set = (patch: Partial<State>) => {
|
||||
const next = { ...s, ...patch };
|
||||
// Switching to "custom" starts from the current expression.
|
||||
if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s);
|
||||
setS(next);
|
||||
onChange(buildCron(next));
|
||||
};
|
||||
|
||||
const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"];
|
||||
return (
|
||||
<div className="cron-builder">
|
||||
<div className="cron-modes" role="radiogroup" aria-label={t("cron.label")}>
|
||||
{modes.map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={s.mode === m} className={`chip-btn ${s.mode === m ? "active" : ""}`} onClick={() => set({ mode: m })}>
|
||||
{t(`cron.mode.${m}` as const)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cron-fields">
|
||||
{s.mode === "minutes" && (
|
||||
<label>
|
||||
{t("cron.every")}
|
||||
<select value={s.every} onChange={(e) => set({ every: Number(e.target.value) })}>
|
||||
{MINUTE_STEPS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{t("cron.minutes")}
|
||||
</label>
|
||||
)}
|
||||
{s.mode === "hourly" && (
|
||||
<label>
|
||||
{t("cron.atMinute")}
|
||||
<input type="number" min={0} max={59} value={s.minute} onChange={(e) => set({ minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })} />
|
||||
</label>
|
||||
)}
|
||||
{s.mode === "weekly" && (
|
||||
<div className="cron-days" role="group" aria-label={t("cron.days")}>
|
||||
{WEEK.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
aria-pressed={s.days.includes(d)}
|
||||
className={`chip-btn ${s.days.includes(d) ? "active" : ""}`}
|
||||
title={dayName(d, "long")}
|
||||
onClick={() => set({ days: s.days.includes(d) ? s.days.filter((x) => x !== d) : [...s.days, d] })}
|
||||
>
|
||||
{dayName(d, "short")}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="link-btn" onClick={() => set({ days: [1, 2, 3, 4, 5] })}>
|
||||
{t("cron.workdays")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{s.mode === "monthly" && (
|
||||
<label>
|
||||
{t("cron.onDay")}
|
||||
<select value={s.dom} onChange={(e) => set({ dom: Number(e.target.value) })}>
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}.
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && (
|
||||
<label>
|
||||
{t("cron.at")}
|
||||
<input type="time" value={s.time} onChange={(e) => e.target.value && set({ time: e.target.value })} />
|
||||
</label>
|
||||
)}
|
||||
{s.mode === "custom" && (
|
||||
<label className="cron-custom">
|
||||
<input className="mono" value={s.custom} placeholder="0 3 * * *" spellCheck={false} onChange={(e) => set({ custom: e.target.value })} />
|
||||
<small className="hint">{t("cron.customHint")}</small>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="cron-preview">
|
||||
<code>{value || "–"}</code>
|
||||
{preview?.ok === false && <span className="err">{preview.error}</span>}
|
||||
{preview?.ok && preview.next_local && preview.next_local.length > 0 && (
|
||||
<span>
|
||||
{t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
{preview?.tz && s.mode !== "minutes" && <span className="hint">{t("cron.serverTz", { tz: preview.tz })}</span>}
|
||||
{s.mode === "monthly" && s.dom > 28 && <span className="hint">{t("cron.shortMonths")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
|
||||
import { GitPanel, type GitStatus } from "./GitPanel";
|
||||
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
import { navigate } from "../router";
|
||||
|
||||
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||
|
||||
@@ -55,10 +56,16 @@ function icon(e: Entry) {
|
||||
return <Icon name="file" className="ficon" />;
|
||||
}
|
||||
|
||||
export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
/** Embedded in a chat tab: own start folder, no page header, folder kept by the caller. */
|
||||
export interface FilesEmbed {
|
||||
startDir: string;
|
||||
onDirChange?: (dir: string) => void;
|
||||
}
|
||||
|
||||
export function Files({ onMenu, embed }: { onMenu?: () => void; embed?: FilesEmbed }) {
|
||||
const t = useT();
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const [dir, setDir] = useState<string>(() => localStorage.getItem("codecraft.filesDir") ?? "");
|
||||
const [dir, setDir] = useState<string>(() => embed?.startDir ?? localStorage.getItem("codecraft.filesDir") ?? "");
|
||||
const [listing, setListing] = useState<Listing>();
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -78,7 +85,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
|
||||
setListing(l);
|
||||
setDir(l.path);
|
||||
localStorage.setItem("codecraft.filesDir", l.path);
|
||||
if (embed) embed.onDirChange?.(l.path);
|
||||
else localStorage.setItem("codecraft.filesDir", l.path);
|
||||
setError(undefined);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
@@ -142,7 +150,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
|
||||
// "Edit file" links from other pages (e.g. automations) open the file directly.
|
||||
useEffect(() => {
|
||||
const target = sessionStorage.getItem("codecraft.openFile");
|
||||
const target = embed ? null : sessionStorage.getItem("codecraft.openFile");
|
||||
if (!target) return;
|
||||
sessionStorage.removeItem("codecraft.openFile");
|
||||
setDir(target.slice(0, target.lastIndexOf("/")) || "/");
|
||||
@@ -186,6 +194,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
});
|
||||
};
|
||||
|
||||
/** New chat with the folder as working directory (default model and mode). */
|
||||
const chatHere = async (e: Entry) => {
|
||||
try {
|
||||
const s = await post<{ id: string }>("/sessions", { cwd: e.path });
|
||||
navigate(`/s/${s.id}`);
|
||||
} catch (err) {
|
||||
toast(err);
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (e: Entry) => {
|
||||
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
|
||||
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
|
||||
@@ -324,6 +342,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
<details className="entry-menu">
|
||||
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
|
||||
<div className="menu">
|
||||
{e.is_dir && (
|
||||
<button onClick={() => void chatHere(e)}>{t("files.chatHere")}</button>
|
||||
)}
|
||||
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
||||
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
||||
{!e.is_dir && (
|
||||
@@ -418,12 +439,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div className={embed ? "files-embed-bar" : "topbar"}>
|
||||
{!embed && (
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
)}
|
||||
<div className="grow">
|
||||
<h1>{t("nav.files")}</h1>
|
||||
{!embed && <h1>{t("nav.files")}</h1>}
|
||||
<div className="path">{listing?.path}</div>
|
||||
</div>
|
||||
{listing?.repo && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ApiError, get, patch, post, put } from "../api";
|
||||
import { confirmDialog, toast } from "../dialogs";
|
||||
import { fmtRelative, useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { Spinner } from "./Activity";
|
||||
import { ChatView } from "./ChatView";
|
||||
import { CronBuilder } from "./CronBuilder";
|
||||
import { Icon } from "./Icon";
|
||||
import type { JobInfo } from "./Jobs";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
|
||||
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||
|
||||
interface Settings {
|
||||
name: string;
|
||||
schedule: string;
|
||||
agent: string;
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
timeout_minutes: number;
|
||||
}
|
||||
|
||||
const settingsOf = (j: JobInfo): Settings => ({
|
||||
name: j.spec?.name ?? "",
|
||||
schedule: j.spec?.schedule ?? "0 3 * * *",
|
||||
agent: j.spec?.agent ?? "build",
|
||||
model: j.spec?.model ?? "",
|
||||
permission_mode: j.spec?.permission_mode ?? "auto",
|
||||
timeout_minutes: j.spec?.timeout_minutes ?? 60,
|
||||
});
|
||||
|
||||
type Tab = "prompt" | "settings" | "assistant";
|
||||
|
||||
/**
|
||||
* One automation: its instructions (Markdown file) in an editor, schedule and settings,
|
||||
* and a chat with a model that helps to write the instructions (it edits the file).
|
||||
*/
|
||||
export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const [job, setJob] = useState<JobInfo>();
|
||||
const [settings, setSettings] = useState<Settings>();
|
||||
const [saved, setSaved] = useState<Settings>();
|
||||
const [text, setText] = useState<string>();
|
||||
const [savedText, setSavedText] = useState<string>();
|
||||
const [mtime, setMtime] = useState<number>();
|
||||
const [changedOnDisk, setChangedOnDisk] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [assistant, setAssistant] = useState<string>();
|
||||
const [tab, setTab] = useState<Tab>("prompt");
|
||||
|
||||
const loadJob = useCallback(async () => {
|
||||
const j = await get<JobInfo>(`/jobs/${id}`);
|
||||
setJob(j);
|
||||
const s = settingsOf(j);
|
||||
setSettings((cur) => (cur && saved && JSON.stringify(cur) !== JSON.stringify(saved) ? cur : s));
|
||||
setSaved(s);
|
||||
setAssistant(j.spec?.assistant_session ?? undefined);
|
||||
return j;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const loadFile = useCallback(async (path: string) => {
|
||||
const f = await get<{ content?: string; mtime?: number }>(`/files/read?path=${encodeURIComponent(path)}`);
|
||||
setText(f.content ?? "");
|
||||
setSavedText(f.content ?? "");
|
||||
setMtime(f.mtime);
|
||||
setChangedOnDisk(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadJob()
|
||||
.then((j) => loadFile(j.path))
|
||||
.catch((e) => toast(e.message));
|
||||
}, [loadJob, loadFile]);
|
||||
|
||||
const fileDirty = text !== undefined && text !== savedText;
|
||||
const settingsDirty = !!settings && !!saved && JSON.stringify(settings) !== JSON.stringify(saved);
|
||||
const dirty = fileDirty || settingsDirty;
|
||||
|
||||
// The assistant edits the file: reload it when a turn ends (unless there are unsaved edits).
|
||||
const busy = useStore((s) => (assistant ? !!s.busy[assistant] : false));
|
||||
const wasBusy = useRef(false);
|
||||
const dirtyRef = useRef(fileDirty);
|
||||
dirtyRef.current = fileDirty;
|
||||
useEffect(() => {
|
||||
if (wasBusy.current && !busy && job) {
|
||||
get<{ mtime?: number }>(`/files/stat?path=${encodeURIComponent(job.path)}`)
|
||||
.then((st) => {
|
||||
if (st.mtime && mtime && st.mtime <= mtime) return;
|
||||
if (dirtyRef.current) setChangedOnDisk(true);
|
||||
else void loadFile(job.path);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
wasBusy.current = busy;
|
||||
}, [busy, job, mtime, loadFile]);
|
||||
|
||||
// Unsaved changes: ask before leaving the page.
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [dirty]);
|
||||
|
||||
const save = async (force = false) => {
|
||||
if (!job || !settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (settingsDirty) {
|
||||
const j = await patch<JobInfo>(`/jobs/${id}`, settings);
|
||||
setJob(j);
|
||||
const s = settingsOf(j);
|
||||
setSettings(s);
|
||||
setSaved(s);
|
||||
}
|
||||
if (fileDirty) {
|
||||
const r = await put<{ mtime?: number }>("/files/write", { path: job.path, content: text, expected_mtime: mtime, force });
|
||||
setSavedText(text);
|
||||
setMtime(r.mtime);
|
||||
setChangedOnDisk(false);
|
||||
void loadJob();
|
||||
}
|
||||
toast(t("jobEditor.saved"), "success");
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
if (await confirmDialog({ message: t("jobEditor.conflict"), confirmLabel: t("jobEditor.overwrite"), danger: true })) return save(true);
|
||||
} else toast(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setEnabled = async (enabled: boolean) => {
|
||||
try {
|
||||
setJob(await patch<JobInfo>(`/jobs/${id}`, { enabled }));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
if (dirty && !(await confirmDialog({ message: t("jobEditor.runUnsaved") }))) return;
|
||||
try {
|
||||
const r = await post<{ session_id: string }>(`/jobs/${id}/run`, {});
|
||||
navigate(`/s/${r.session_id}`);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const startAssistant = async () => {
|
||||
try {
|
||||
const r = await post<{ session_id: string }>(`/jobs/${id}/assistant`, {});
|
||||
setAssistant(r.session_id);
|
||||
setTab("assistant");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const back = async () => {
|
||||
if (dirty && !(await confirmDialog({ message: t("jobEditor.discard"), danger: true }))) return;
|
||||
navigate("/jobs");
|
||||
};
|
||||
|
||||
if (!job || !settings) return <div className="empty"><Spinner /></div>;
|
||||
const up = (p: Partial<Settings>) => setSettings({ ...settings, ...p });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={back} title={t("jobEditor.back")} aria-label={t("jobEditor.back")}>
|
||||
<Icon name="arrow-left" />
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{settings.name || t("nav.jobs")}</h1>
|
||||
<div className="path mono">{job.path}</div>
|
||||
</div>
|
||||
<label className="switch" title={t(job.enabled ? "jobs.disable" : "jobs.enable")}>
|
||||
<input type="checkbox" checked={job.enabled} onChange={(e) => void setEnabled(e.target.checked)} />
|
||||
<span>{t(job.enabled ? "jobs.on" : "jobs.off")}</span>
|
||||
</label>
|
||||
<button className="small ghost" onClick={runNow} title={t("jobs.runNow")}>
|
||||
<Icon name="play" /> <span className="hide-mobile">{t("jobs.runNow")}</span>
|
||||
</button>
|
||||
<button className="small primary" disabled={!dirty || saving} onClick={() => void save()}>
|
||||
{saving ? <Spinner small /> : <Icon name="check" />} {t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="job-tabs" role="tablist">
|
||||
{(["prompt", "settings", "assistant"] as Tab[]).map((x) => (
|
||||
<button key={x} role="tab" aria-selected={tab === x} className={tab === x ? "active" : ""} onClick={() => setTab(x)}>
|
||||
{t(`jobEditor.tab.${x}` as const)}
|
||||
{x === "prompt" && fileDirty && " •"}
|
||||
{x === "settings" && settingsDirty && " •"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={`job-editor tab-${tab}`}>
|
||||
<div className="job-editor-main">
|
||||
<section className="job-editor-settings panel">
|
||||
<div className="job-editor-row">
|
||||
<label className="field" style={{ flex: 2 }}>
|
||||
<span>{t("jobs.tplName")}</span>
|
||||
<input value={settings.name} onChange={(e) => up({ name: e.target.value })} />
|
||||
</label>
|
||||
<div className="job-editor-status">
|
||||
<span>
|
||||
{t("jobs.next")}: {job.enabled && job.next_run ? new Date(job.next_run).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"}
|
||||
</span>
|
||||
<span>
|
||||
{t("jobs.last")}: {job.last_run ? fmtRelative(job.last_run) : "–"}
|
||||
{job.last_session_id && (
|
||||
<button className="link-btn" style={{ marginLeft: 6 }} onClick={() => navigate(`/s/${job.last_session_id}`)}>
|
||||
{t("jobs.openRun")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span>{t("jobs.schedule")}</span>
|
||||
<CronBuilder value={settings.schedule} onChange={(schedule) => up({ schedule })} />
|
||||
</div>
|
||||
<div className="job-editor-row">
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplMode")}</span>
|
||||
<select value={settings.agent} onChange={(e) => up({ agent: e.target.value })}>
|
||||
<option value="build">build</option>
|
||||
<option value="plan">plan</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("jobs.permissionMode")}</span>
|
||||
<select value={settings.permission_mode} title={t("jobs.permissionModeHint")} onChange={(e) => up({ 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>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("composer.model")}</span>
|
||||
<ModelSelect value={settings.model} onChange={(model) => up({ model })} emptyLabel={t("jobEditor.defaultModel")} />
|
||||
</label>
|
||||
<label className="field" style={{ maxWidth: 140 }}>
|
||||
<span>{t("jobEditor.timeout")}</span>
|
||||
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
|
||||
</label>
|
||||
</div>
|
||||
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
|
||||
</section>
|
||||
<section className="job-editor-prompt">
|
||||
<div className="job-editor-prompt-head">
|
||||
<b>{t("jobEditor.instructions")}</b>
|
||||
<span className="hint">{t("jobEditor.instructionsHint")}</span>
|
||||
</div>
|
||||
{changedOnDisk && (
|
||||
<div className="notice">
|
||||
{t("jobEditor.changedOnDisk")}{" "}
|
||||
<button className="small" onClick={() => void loadFile(job.path)}>
|
||||
{t("jobEditor.reload")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{job.error && !job.error.includes("empty") && <div className="notice err">{job.error}</div>}
|
||||
{text === undefined ? (
|
||||
<div className="empty"><Spinner /></div>
|
||||
) : (
|
||||
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
||||
<CodeEditor value={text} filename={job.path} onChange={setText} onSave={() => void save()} />
|
||||
</Suspense>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<aside className="job-editor-chat">
|
||||
{assistant ? (
|
||||
<ChatView key={assistant} sessionId={assistant} onMenu={onMenu} />
|
||||
) : (
|
||||
<div className="job-assistant-start">
|
||||
<Icon name="sparkle" />
|
||||
<p>{t("jobEditor.assistantIntro")}</p>
|
||||
<button className="primary" onClick={startAssistant}>
|
||||
{t("jobEditor.assistantStart")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFetch } from "../hooks";
|
||||
import { post } from "../api";
|
||||
import { getLang, useT } from "../i18n";
|
||||
import { JOB_TEMPLATES, SCHEDULES, type JobTemplate } from "../jobTemplates";
|
||||
import { Icon } from "./Icon";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { FolderPicker } from "./NewSession";
|
||||
|
||||
/** Create an automation from a template: pick it, choose the project, adjust, create. */
|
||||
export function JobTemplateDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const t = useT();
|
||||
const de = getLang() === "de";
|
||||
const [tpl, setTpl] = useState<JobTemplate>();
|
||||
const [name, setName] = useState("");
|
||||
const [schedule, setSchedule] = useState("");
|
||||
const [agent, setAgent] = useState("build");
|
||||
const [mode, setMode] = useState("auto");
|
||||
const [model, setModel] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
|
||||
const [error, setError] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const config = useFetch<{ default_model?: string }>("/config").data;
|
||||
useEffect(() => {
|
||||
if (!model && config?.default_model) setModel(config.default_model);
|
||||
}, [config, model]);
|
||||
|
||||
const choose = (x: JobTemplate) => {
|
||||
setTpl(x);
|
||||
setName(de ? x.name.de : x.name.en);
|
||||
setSchedule(x.schedule);
|
||||
setAgent(x.agent);
|
||||
setPrompt(x.prompt);
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await post("/jobs/create", { cwd, name: name.trim(), schedule: schedule.trim(), agent, permission_mode: mode, model: model || undefined, prompt });
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.fromTemplate")} onClick={(e) => e.stopPropagation()}>
|
||||
{!tpl ? (
|
||||
<>
|
||||
<h3>{t("jobs.fromTemplate")}</h3>
|
||||
<div className="job-tpl-grid">
|
||||
{JOB_TEMPLATES.map((x) => (
|
||||
<button key={x.id} className="job-tpl-card" onClick={() => choose(x)}>
|
||||
<Icon name={x.icon} />
|
||||
<span>
|
||||
<b>{de ? x.name.de : x.name.en}</b>
|
||||
<small>{de ? x.desc.de : x.desc.en}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3>
|
||||
<Icon name={tpl.icon} /> {de ? tpl.name.de : tpl.name.en}
|
||||
</h3>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplName")}</span>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<div className="field">
|
||||
<span>{t("jobs.tplProject")}</span>
|
||||
<FolderPicker path={cwd} onPick={setCwd} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span>{t("jobs.tplSchedule")}</span>
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
{SCHEDULES.map((s) => (
|
||||
<button key={s.cron} type="button" className={`chip-btn ${schedule === s.cron ? "active" : ""}`} onClick={() => setSchedule(s.cron)}>
|
||||
{de ? s.de : s.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="mono" value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="0 3 * * *" />
|
||||
<small className="hint">{t("jobs.tplCronHint")}</small>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 10, flexWrap: "wrap" }}>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplMode")}</span>
|
||||
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
|
||||
<option value="build">build</option>
|
||||
<option value="plan">plan</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("composer.permissionMode")}</span>
|
||||
<select value={mode} onChange={(e) => setMode(e.target.value)}>
|
||||
<option value="auto">{t("permissionMode.auto")}</option>
|
||||
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
||||
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("composer.model")}</span>
|
||||
<ModelSelect value={model} onChange={setModel} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplPrompt")}</span>
|
||||
<textarea rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
</label>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setTpl(undefined)}>{t("common.back")}</button>
|
||||
<button className="primary" disabled={busy || !cwd || !name.trim() || !schedule.trim() || !prompt.trim()} onClick={create}>
|
||||
{t("jobs.tplCreate")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+16
-25
@@ -5,9 +5,9 @@ import { navigate } from "../router";
|
||||
import { confirmDialog, toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
import { Spinner } from "./Activity";
|
||||
import { JobTemplateDialog } from "./JobTemplateDialog";
|
||||
import { NewJobDialog } from "./NewJobDialog";
|
||||
|
||||
interface JobInfo {
|
||||
export interface JobInfo {
|
||||
id: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
@@ -17,12 +17,12 @@ interface JobInfo {
|
||||
next_run?: number | null;
|
||||
error?: string;
|
||||
webhook?: boolean;
|
||||
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
|
||||
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null };
|
||||
}
|
||||
|
||||
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
|
||||
|
||||
/** Automations: agents started on a schedule from a Markdown job file. */
|
||||
/** Automations: agents started on a schedule with the instructions from a Markdown file. */
|
||||
export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const [jobs, setJobs] = useState<JobInfo[]>();
|
||||
@@ -68,10 +68,10 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="notice">{t("jobs.intro")}</div>
|
||||
<div className="row" style={{ margin: "12px 0" }}>
|
||||
<button className="primary" onClick={() => setTemplates(true)}>
|
||||
<Icon name="plus" /> {t("jobs.fromTemplate")}
|
||||
<Icon name="plus" /> {t("jobs.new")}
|
||||
</button>
|
||||
</div>
|
||||
{templates && <JobTemplateDialog onClose={() => setTemplates(false)} onCreated={() => void reload()} />}
|
||||
{templates && <NewJobDialog onClose={() => setTemplates(false)} />}
|
||||
{!jobs && <div className="empty"><Spinner /></div>}
|
||||
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
|
||||
{jobs?.map((j) => (
|
||||
@@ -79,7 +79,11 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="job-head">
|
||||
<Icon name="clock" className="job-icon" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3>{j.spec?.name ?? j.path.split("/").pop()}</h3>
|
||||
<h3>
|
||||
<button className="link-btn job-title" onClick={() => navigate(`/jobs/${j.id}`)}>
|
||||
{j.spec?.name ?? j.path.split("/").pop()}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="hint mono">{j.path}</div>
|
||||
</div>
|
||||
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
|
||||
@@ -107,7 +111,7 @@ 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")} · {t(`permissionMode.${j.spec?.permission_mode ?? "auto"}` as any)} · max {j.spec?.timeout_minutes} min
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -163,14 +167,8 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
})}>
|
||||
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
|
||||
</button>
|
||||
<button
|
||||
className="small ghost"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("codecraft.openFile", j.path);
|
||||
navigate("/files");
|
||||
}}
|
||||
>
|
||||
<Icon name="edit" /> {t("jobs.editFile")}
|
||||
<button className="small" onClick={() => navigate(`/jobs/${j.id}`)}>
|
||||
<Icon name="edit" /> {t("jobs.edit")}
|
||||
</button>
|
||||
<span className="grow" />
|
||||
<button
|
||||
@@ -190,20 +188,13 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
|
||||
<button disabled={!path.trim()} onClick={() => act("new", async () => {
|
||||
await post("/jobs", { path: path.trim() });
|
||||
const j = await post<JobInfo>("/jobs", { path: path.trim() });
|
||||
setPath("");
|
||||
navigate(`/jobs/${j.id}`);
|
||||
})}>
|
||||
{t("jobs.registerBtn")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="job-example">{`---
|
||||
name: "Nightly dependency check"
|
||||
schedule: "0 3 * * *"
|
||||
agent: build
|
||||
permission_mode: auto
|
||||
timeout_minutes: 60
|
||||
---
|
||||
Check the project for outdated dependencies …`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,30 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
|
||||
/**
|
||||
* Keep single line breaks as typed (Markdown would join the lines into one paragraph):
|
||||
* a hard break at the end of every line that continues a paragraph. Code blocks stay as
|
||||
* they are.
|
||||
*/
|
||||
export function keepLineBreaks(text: string) {
|
||||
let fence: string | null = null;
|
||||
const lines = text.split("\n");
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
const f = line.match(/^\s*(`{3,}|~{3,})/);
|
||||
if (f) {
|
||||
if (!fence) fence = f[1][0];
|
||||
else if (f[1][0] === fence) fence = null;
|
||||
return line;
|
||||
}
|
||||
const next = lines[i + 1];
|
||||
if (fence || next === undefined || !line.trim() || !next.trim() || / {2}$|\\$/.test(line)) return line;
|
||||
return `${line} `;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text, baseDir, breaks }: { text: string; baseDir?: string; breaks?: boolean }) {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
@@ -81,7 +104,7 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
{breaks ? keepLineBreaks(text) : text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { getLang, useT } from "../i18n";
|
||||
import { JOB_TEMPLATES, type JobTemplate } from "../jobTemplates";
|
||||
import { navigate } from "../router";
|
||||
import { Icon } from "./Icon";
|
||||
import { FolderPicker } from "./NewSession";
|
||||
|
||||
/** Starting point of a new instructions file: headings to fill in (with the assistant). */
|
||||
function example(name: string, de: boolean) {
|
||||
return de
|
||||
? `# ${name}
|
||||
|
||||
## Ziel
|
||||
Was soll diese Automatisierung erreichen? (ein, zwei Sätze)
|
||||
|
||||
## Vorgehen
|
||||
1. …
|
||||
2. …
|
||||
|
||||
## Ergebnis
|
||||
Was am Ende vorliegen soll – z. B. ein kurzer Bericht, geänderte Dateien, ein Commit.
|
||||
|
||||
## Grenzen
|
||||
- Was der Agent nicht tun darf (z. B. nichts pushen, nichts löschen).
|
||||
`
|
||||
: `# ${name}
|
||||
|
||||
## Goal
|
||||
What should this automation achieve? (one or two sentences)
|
||||
|
||||
## Steps
|
||||
1. …
|
||||
2. …
|
||||
|
||||
## Result
|
||||
What should exist at the end – e.g. a short report, changed files, a commit.
|
||||
|
||||
## Limits
|
||||
- What the agent must not do (e.g. no pushing, no deleting).
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* New automation: name, project and a starting point (example or template). Creates the
|
||||
* instructions file (<project>/.codecraft/jobs/<name>.md, switched off) and opens the
|
||||
* editor, where the schedule is set and the instructions are worked out.
|
||||
*/
|
||||
export function NewJobDialog({ onClose }: { onClose: () => void }) {
|
||||
const t = useT();
|
||||
const de = getLang() === "de";
|
||||
const [tpl, setTpl] = useState<JobTemplate>();
|
||||
const [name, setName] = useState("");
|
||||
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
|
||||
const [error, setError] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const choose = (x?: JobTemplate) => {
|
||||
setTpl(x);
|
||||
if (x && (!name.trim() || JOB_TEMPLATES.some((y) => name === y.name.de || name === y.name.en))) setName(de ? x.name.de : x.name.en);
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const n = name.trim();
|
||||
const job = await post<{ id: string }>("/jobs/create", {
|
||||
cwd,
|
||||
name: n,
|
||||
prompt: tpl ? `# ${n}\n\n${tpl.prompt}\n` : example(n, de),
|
||||
schedule: tpl?.schedule,
|
||||
agent: tpl?.agent,
|
||||
});
|
||||
onClose();
|
||||
navigate(`/jobs/${job.id}`);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.new")} onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("jobs.new")}</h3>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplName")}</span>
|
||||
<input autoFocus value={name} placeholder={t("jobs.namePlaceholder")} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<div className="field">
|
||||
<span>{t("jobs.tplProject")}</span>
|
||||
<FolderPicker path={cwd} onPick={setCwd} />
|
||||
<small className="hint">{t("jobs.fileHint")}</small>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span>{t("jobs.startWith")}</span>
|
||||
<div className="job-tpl-grid">
|
||||
<button className={`job-tpl-card ${!tpl ? "active" : ""}`} onClick={() => choose(undefined)}>
|
||||
<Icon name="file-text" />
|
||||
<span>
|
||||
<b>{t("jobs.blank")}</b>
|
||||
<small>{t("jobs.blankDesc")}</small>
|
||||
</span>
|
||||
</button>
|
||||
{JOB_TEMPLATES.map((x) => (
|
||||
<button key={x.id} className={`job-tpl-card ${tpl?.id === x.id ? "active" : ""}`} onClick={() => choose(x)}>
|
||||
<Icon name={x.icon} />
|
||||
<span>
|
||||
<b>{de ? x.name.de : x.name.en}</b>
|
||||
<small>{de ? x.desc.de : x.desc.en}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||
<button className="primary" disabled={busy || !cwd || !name.trim()} onClick={create}>
|
||||
{t("jobs.createAndEdit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { post, uploadFile } from "../api";
|
||||
import { promptDialog, toast } from "../dialogs";
|
||||
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";
|
||||
|
||||
@@ -103,6 +104,7 @@ const ICONS: Record<string, ReactNode> = {
|
||||
),
|
||||
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
|
||||
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
|
||||
chat: svg(<path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm4 6h.01M12 11h.01M16 11h.01" />),
|
||||
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
|
||||
};
|
||||
|
||||
@@ -185,7 +187,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
const [creating, setCreating] = useState<string>();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
|
||||
const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name));
|
||||
const custom = agents.filter((a) => !["build", "plan", "craft", "chat"].includes(a.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (!model && config?.default_model) setModel(config.default_model);
|
||||
@@ -244,7 +246,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
const canStart =
|
||||
!creating &&
|
||||
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
|
||||
(folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
|
||||
(task === "chat" ? !!rootPath : folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
|
||||
|
||||
/** The first message(s) for the agent. */
|
||||
const prompts = (uploaded: string[]): string[] => {
|
||||
@@ -275,7 +277,10 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
try {
|
||||
// 1. Folder
|
||||
let cwd = picked;
|
||||
if (folderMode === "git") {
|
||||
if (task === "chat") {
|
||||
// A plain conversation needs no project: it runs in the user's own folder.
|
||||
cwd = rootPath;
|
||||
} else if (folderMode === "git") {
|
||||
setCreating(t("wizard.cloning"));
|
||||
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
|
||||
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
|
||||
@@ -295,7 +300,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
}
|
||||
if (!cwd) throw new Error("could not create the folder");
|
||||
}
|
||||
localStorage.setItem(LAST_DIR, cwd);
|
||||
if (task !== "chat") localStorage.setItem(LAST_DIR, cwd);
|
||||
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
|
||||
const uploaded: string[] = [];
|
||||
const mkdirs = async (dir: string) => {
|
||||
@@ -327,7 +332,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
agent: task === "cad" ? "build" : task,
|
||||
clean_room: isCraft ? !deep : undefined,
|
||||
});
|
||||
if (isolated && folderMode !== "new") {
|
||||
if (isolated && folderMode !== "new" && task !== "chat") {
|
||||
setCreating(t("worktree.creating"));
|
||||
await post(`/sessions/${s.id}/worktree`, { action: "create" });
|
||||
}
|
||||
@@ -359,6 +364,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
<div className="panel wiz-panel">
|
||||
<Step n={n++} title={t("wizard.whatToDo")}>
|
||||
<div className="wiz-cards">
|
||||
<Card icon="chat" title={t("wizard.task.chat")} desc={t("wizard.task.chatDesc")} active={task === "chat"} onClick={() => setTask("chat")} />
|
||||
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
|
||||
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
|
||||
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
|
||||
@@ -454,7 +460,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
>
|
||||
<textarea
|
||||
value={text}
|
||||
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : "wizard.placeholder.task")}
|
||||
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : task === "chat" ? "wizard.placeholder.chat" : "wizard.placeholder.task")}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length) {
|
||||
@@ -473,7 +479,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>
|
||||
@@ -512,57 +518,61 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
|
||||
<div className="wiz-foot">
|
||||
<div className="wiz-foot-row">
|
||||
<label>{t("wizard.builtBy")}</label>
|
||||
<label>{task === "chat" ? t("composer.model") : t("wizard.builtBy")}</label>
|
||||
<ModelSelect value={model} onChange={setModel} />
|
||||
</div>
|
||||
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
||||
{(["new", "existing", "git"] as const).map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
||||
{ICONS[m === "git" ? "git" : "folder"]}
|
||||
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{folderMode === "git" && (
|
||||
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
|
||||
)}
|
||||
{folderMode !== "existing" && (
|
||||
<div className="wiz-folder">
|
||||
{ICONS.folder}
|
||||
<span className="wiz-folder-text">
|
||||
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
|
||||
{roots.length > 1 ? (
|
||||
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name === "/" ? "workspace" : r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
|
||||
)}
|
||||
<span>/</span>
|
||||
<input
|
||||
className="wiz-folder-name"
|
||||
value={folderName}
|
||||
onChange={(e) => {
|
||||
setFolderName(e.target.value);
|
||||
setNameTouched(true);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
|
||||
{folderMode !== "new" && (
|
||||
<label className="wiz-check-row">
|
||||
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t("worktree.isolate")}</b>
|
||||
<span className="hint"> – {t("worktree.isolateHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
{task !== "chat" && (
|
||||
<>
|
||||
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
||||
{(["new", "existing", "git"] as const).map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
||||
{ICONS[m === "git" ? "git" : "folder"]}
|
||||
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{folderMode === "git" && (
|
||||
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
|
||||
)}
|
||||
{folderMode !== "existing" && (
|
||||
<div className="wiz-folder">
|
||||
{ICONS.folder}
|
||||
<span className="wiz-folder-text">
|
||||
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
|
||||
{roots.length > 1 ? (
|
||||
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name === "/" ? "workspace" : r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
|
||||
)}
|
||||
<span>/</span>
|
||||
<input
|
||||
className="wiz-folder-name"
|
||||
value={folderName}
|
||||
onChange={(e) => {
|
||||
setFolderName(e.target.value);
|
||||
setNameTouched(true);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
|
||||
{folderMode !== "new" && (
|
||||
<label className="wiz-check-row">
|
||||
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t("worktree.isolate")}</b>
|
||||
<span className="hint"> – {t("worktree.isolateHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="wiz-start-row">
|
||||
@@ -593,6 +603,20 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listing?.path]);
|
||||
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||
/** Create a subfolder of the shown folder and select it. */
|
||||
const newFolder = async () => {
|
||||
if (!listing) return;
|
||||
const name = (await promptDialog({ title: t("files.newFolder"), message: t("files.newFolderName") }))?.trim();
|
||||
if (!name) return;
|
||||
if (name.includes("/") || name === "." || name === "..") return toast(t("new.invalidFolderName"));
|
||||
const target = `${listing.path.replace(/\/$/, "")}/${name}`;
|
||||
try {
|
||||
await post("/files/create", { path: target, dir: true });
|
||||
setBrowse(target);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="wiz-picker">
|
||||
{roots.length > 1 && (
|
||||
@@ -613,6 +637,11 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
|
||||
<Icon name="arrow-up" /> {t("new.up")}
|
||||
</button>
|
||||
)}
|
||||
{listing && (
|
||||
<button className="small" onClick={() => void newFolder()}>
|
||||
<Icon name="plus" /> {t("files.newFolder")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="dir-list">
|
||||
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||||
|
||||
@@ -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";
|
||||
@@ -375,6 +376,7 @@ interface ModelRow {
|
||||
name?: string;
|
||||
context?: number;
|
||||
max_output?: number;
|
||||
max_input?: number;
|
||||
reasoning?: boolean;
|
||||
vision?: boolean;
|
||||
effort?: string;
|
||||
@@ -453,6 +455,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
price_multipliers: f.price_multipliers ?? m.price_multipliers,
|
||||
context: m.context ?? f.context,
|
||||
max_output: m.max_output ?? f.max_output,
|
||||
max_input: m.max_input ?? f.max_input ?? undefined,
|
||||
vision: m.vision || !!f.vision,
|
||||
reasoning: m.reasoning || !!f.reasoning,
|
||||
};
|
||||
@@ -615,6 +618,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<th>ID</th>
|
||||
<th>{t("settings.context")}</th>
|
||||
<th>{t("settings.maxOutput")}</th>
|
||||
<th title={t("settings.maxInputHint")}>{t("settings.maxInput")}</th>
|
||||
<th title={t("settings.reasoning")}><Icon name="brain" /></th>
|
||||
<th title={t("settings.vision")}><Icon name="eye" /></th>
|
||||
<th>{t("settings.effort")}</th>
|
||||
@@ -639,6 +643,9 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<td>
|
||||
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={m.max_input ?? ""} placeholder="–" onChange={(e) => setModel(i, { max_input: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
|
||||
</td>
|
||||
@@ -1014,56 +1021,6 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
|
||||
|
||||
// --- raw ----------------------------------------------------------------------
|
||||
|
||||
/** ${VAR} references left in the config: move their values from the environment into it. */
|
||||
function EnvRefs({ onDone }: { onDone: () => void }) {
|
||||
const t = useT();
|
||||
const { data, reload } = useFetch<{ set: string[]; unset: string[] }>("/config/inline-env");
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!data || (data.set.length === 0 && data.unset.length === 0)) return null;
|
||||
const apply = async () => {
|
||||
if (!(await confirmDialog({ title: t("settings.envTitle"), message: t("settings.envConfirm", { names: data.set.join(", ") }), confirmLabel: t("settings.envApply") }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await post<{ set: string[] }>("/config/inline-env");
|
||||
toast(t("settings.envDone", { n: r.set.length }), "success");
|
||||
reload();
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="panel" style={{ marginBottom: 12 }}>
|
||||
<h3>
|
||||
<Icon name="file-text" /> {t("settings.envTitle")}
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>
|
||||
{t("settings.envHint")}
|
||||
</div>
|
||||
{data.set.length > 0 && (
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{data.set.map((n) => (
|
||||
<span key={n} className="chip mono">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
<span className="spacer" />
|
||||
<button className="primary small" onClick={apply} disabled={busy}>
|
||||
{t("settings.envApply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{data.unset.length > 0 && (
|
||||
<div className="hint" style={{ marginTop: 8 }}>
|
||||
{t("settings.envUnset")} <span className="mono">{data.unset.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Raw({ reload }: { reload: () => void }) {
|
||||
const t = useT();
|
||||
const [text, setText] = useState<string>();
|
||||
@@ -1074,7 +1031,6 @@ function Raw({ reload }: { reload: () => void }) {
|
||||
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
||||
return (
|
||||
<>
|
||||
<EnvRefs onDone={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
|
||||
<div className="notice">{t("settings.rawHint")}</div>
|
||||
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
||||
{view}
|
||||
@@ -1090,8 +1046,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",
|
||||
@@ -1103,6 +1059,7 @@ const TAB_ICONS: Record<string, string> = {
|
||||
tools: "toolbox",
|
||||
audit: "scroll",
|
||||
templates: "template",
|
||||
modes: "sparkle",
|
||||
account: "user",
|
||||
raw: "file-text",
|
||||
};
|
||||
@@ -1139,6 +1096,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 />}
|
||||
|
||||
+204
-15
@@ -2,10 +2,10 @@ import { Icon } from "./Icon";
|
||||
import { StatusLeds } from "./StatusLeds";
|
||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { get, patch, post } from "../api";
|
||||
import { get, patch, post, put } 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,114 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
|
||||
/** Order and pinning of folders and projects in the list (per user, all devices). */
|
||||
interface Layout {
|
||||
order: string[];
|
||||
pinned: string[];
|
||||
}
|
||||
|
||||
/** Folders and projects can be moved and pinned; time groups and the archive cannot. */
|
||||
const movable = (id: string) => id.startsWith("folder:") || id.startsWith("project:");
|
||||
|
||||
const DRAG_TYPE = "application/x-codecraft-group";
|
||||
|
||||
/** Header of a group: collapses it; folders and projects also have a menu and can be dragged. */
|
||||
function GroupHeader({
|
||||
g,
|
||||
collapsed,
|
||||
pinned,
|
||||
onToggle,
|
||||
onPin,
|
||||
onMove,
|
||||
onDrop,
|
||||
}: {
|
||||
g: { id: string; label: string; icon?: string; items: Session[] };
|
||||
collapsed: boolean;
|
||||
pinned: boolean;
|
||||
onToggle: () => void;
|
||||
onPin?: () => void;
|
||||
onMove?: (dir: -1 | 1) => void;
|
||||
onDrop?: (dragged: string, after: boolean) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [over, setOver] = useState<"before" | "after">();
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => setMenu(false);
|
||||
window.addEventListener("click", close);
|
||||
window.addEventListener("keydown", close);
|
||||
return () => {
|
||||
window.removeEventListener("click", close);
|
||||
window.removeEventListener("keydown", close);
|
||||
};
|
||||
}, [menu]);
|
||||
const canMove = !!onMove;
|
||||
return (
|
||||
<div
|
||||
className={`session-row group-row ${menu ? "menu-open" : ""} ${over ? `drop-${over}` : ""}`}
|
||||
draggable={canMove}
|
||||
onDragStart={(e) => {
|
||||
if (!canMove) return;
|
||||
e.dataTransfer.setData(DRAG_TYPE, g.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!onDrop || !e.dataTransfer.types.includes(DRAG_TYPE)) return;
|
||||
e.preventDefault();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setOver(e.clientY > r.top + r.height / 2 ? "after" : "before");
|
||||
}}
|
||||
onDragLeave={() => setOver(undefined)}
|
||||
onDrop={(e) => {
|
||||
const id = e.dataTransfer.getData(DRAG_TYPE);
|
||||
const after = over === "after";
|
||||
setOver(undefined);
|
||||
if (id && id !== g.id && onDrop) {
|
||||
e.preventDefault();
|
||||
onDrop(id, after);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="session-group" onClick={onToggle} aria-expanded={!collapsed}>
|
||||
<Icon name={collapsed ? "chevron-right" : "chevron-down"} className="tiny" />
|
||||
{g.icon && <Icon name={g.icon} className="tiny" />}
|
||||
<span className="grow">{g.label}</span>
|
||||
{pinned && <Icon name="pin" className="tiny group-pin" />}
|
||||
{collapsed && <span className="count">{g.items.length}</span>}
|
||||
</button>
|
||||
{canMove && (
|
||||
<button
|
||||
className="session-more ghost"
|
||||
aria-label={t("sidebar.groupMenu")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenu(!menu);
|
||||
}}
|
||||
>
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
)}
|
||||
{menu && (
|
||||
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
|
||||
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(-1))}>
|
||||
<Icon name="arrow-up" /> {t("sidebar.groupUp")}
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(1))}>
|
||||
<Icon name="arrow-down" /> {t("sidebar.groupDown")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -161,14 +271,15 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
const live = sessions.filter((s) => !s.archived);
|
||||
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
|
||||
const rest = live.filter((s) => !s.pinned);
|
||||
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
|
||||
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])];
|
||||
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
|
||||
const loose = rest.filter((s) => !s.folder);
|
||||
if (groupBy === "project") {
|
||||
const byProject = new Map<string, Session[]>();
|
||||
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
|
||||
// Projects with the most recent activity first.
|
||||
for (const [cwd, items] of [...byProject].sort((a, b) => b[1][0].updated_at - a[1][0].updated_at)) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
|
||||
// Order of the projects: see `ordered` below (not by activity – a new message
|
||||
// moves the chat to the top of its project, not the project).
|
||||
for (const [cwd, items] of byProject) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
|
||||
} else {
|
||||
for (const s of loose) {
|
||||
const key = groupKey(s.updated_at);
|
||||
@@ -178,6 +289,81 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
|
||||
return out;
|
||||
}, [sessions, groupBy, t]);
|
||||
// Folders and projects: pinned first, then in the user's order. Groups not ordered yet
|
||||
// (new folder or project) start at the top and are then kept where they are.
|
||||
const [layout, setLayout] = useState<Layout>();
|
||||
useEffect(() => {
|
||||
get<Layout>("/account/sidebar")
|
||||
.then((l) => setLayout({ order: l.order ?? [], pinned: l.pinned ?? [] }))
|
||||
.catch(() => setLayout({ order: [], pinned: [] }));
|
||||
}, []);
|
||||
const ordered = useMemo(() => {
|
||||
const l = layout ?? { order: [], pinned: [] };
|
||||
const head = groups.filter((g) => g.id === "pinned");
|
||||
const tail = groups.filter((g) => g.id !== "pinned" && !movable(g.id));
|
||||
const rank = (id: string) => l.order.indexOf(id);
|
||||
const mid = groups
|
||||
.filter((g) => movable(g.id))
|
||||
.sort((a, b) => {
|
||||
const pa = l.pinned.includes(a.id);
|
||||
const pb = l.pinned.includes(b.id);
|
||||
if (pa !== pb) return pa ? -1 : 1;
|
||||
const ra = rank(a.id);
|
||||
const rb = rank(b.id);
|
||||
if (ra < 0 && rb < 0) return b.items[0].updated_at - a.items[0].updated_at;
|
||||
if (ra < 0 || rb < 0) return ra < 0 ? -1 : 1;
|
||||
return ra - rb;
|
||||
});
|
||||
return { head, mid, tail };
|
||||
}, [groups, layout]);
|
||||
const saveLayout = (next: Layout) => {
|
||||
// Forget groups that no longer exist (no chat in that folder or project).
|
||||
const known = new Set(sessions.flatMap((s) => [`project:${s.cwd}`, ...(s.folder ? [`folder:${s.folder}`] : [])]));
|
||||
const clean = { order: next.order.filter((id) => known.has(id)), pinned: next.pinned.filter((id) => known.has(id)) };
|
||||
setLayout(clean);
|
||||
void put("/account/sidebar", clean).catch((e) => toast(e.message));
|
||||
};
|
||||
// Freeze the position of groups that appeared since the last visit.
|
||||
useEffect(() => {
|
||||
if (!layout) return;
|
||||
const fresh = ordered.mid.map((g) => g.id).filter((id) => !layout.order.includes(id));
|
||||
if (fresh.length) saveLayout({ ...layout, order: [...fresh, ...layout.order] });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ordered, layout]);
|
||||
/** Order of the shown groups with `id` moved: by a step, or next to another group. */
|
||||
const reorder = (ids: string[]) => {
|
||||
if (!layout) return;
|
||||
const shown = new Set(ids);
|
||||
saveLayout({ ...layout, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||||
};
|
||||
const moveGroup = (id: string, dir: -1 | 1) => {
|
||||
if (!layout) return;
|
||||
const ids = ordered.mid.map((g) => g.id);
|
||||
const i = ids.indexOf(id);
|
||||
const j = i + dir;
|
||||
// Only within the pinned or the unpinned part.
|
||||
if (j < 0 || j >= ids.length || layout.pinned.includes(ids[j]) !== layout.pinned.includes(id)) return;
|
||||
[ids[i], ids[j]] = [ids[j], ids[i]];
|
||||
reorder(ids);
|
||||
};
|
||||
const dropGroup = (dragged: string, target: string, after: boolean) => {
|
||||
if (!layout || !movable(dragged)) return;
|
||||
const ids = ordered.mid.map((g) => g.id).filter((id) => id !== dragged);
|
||||
const at = ids.indexOf(target);
|
||||
if (at < 0) return;
|
||||
ids.splice(after ? at + 1 : at, 0, dragged);
|
||||
// Dropped among pinned groups: pinned too (and the other way round).
|
||||
const pinTarget = layout.pinned.includes(target);
|
||||
const pinned = pinTarget ? [...new Set([...layout.pinned, dragged])] : layout.pinned.filter((p) => p !== dragged);
|
||||
const shown = new Set(ids);
|
||||
saveLayout({ pinned, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||||
};
|
||||
const pinGroup = (id: string) => {
|
||||
if (!layout) return;
|
||||
const pinned = layout.pinned.includes(id) ? layout.pinned.filter((p) => p !== id) : [...layout.pinned, id];
|
||||
saveLayout({ ...layout, pinned });
|
||||
};
|
||||
|
||||
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
|
||||
|
||||
return (
|
||||
@@ -210,7 +396,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>
|
||||
@@ -232,17 +418,20 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
</div>
|
||||
)}
|
||||
{!hits &&
|
||||
groups.map((g) => (
|
||||
[...ordered.head, ...ordered.mid, ...ordered.tail].map((g) => (
|
||||
<div key={g.id}>
|
||||
<button className="session-group" onClick={() => toggle(g.id)} aria-expanded={!collapsed.has(g.id)}>
|
||||
<Icon name={collapsed.has(g.id) ? "chevron-right" : "chevron-down"} className="tiny" />
|
||||
{g.icon && <Icon name={g.icon} className="tiny" />}
|
||||
<span className="grow">{g.label}</span>
|
||||
{collapsed.has(g.id) && <span className="count">{g.items.length}</span>}
|
||||
</button>
|
||||
<GroupHeader
|
||||
g={g}
|
||||
collapsed={collapsed.has(g.id)}
|
||||
pinned={!!layout?.pinned.includes(g.id)}
|
||||
onToggle={() => toggle(g.id)}
|
||||
onPin={movable(g.id) ? () => pinGroup(g.id) : undefined}
|
||||
onMove={movable(g.id) ? (dir) => moveGroup(g.id, dir) : undefined}
|
||||
onDrop={movable(g.id) ? (dragged, after) => dropGroup(dragged, g.id, after) : undefined}
|
||||
/>
|
||||
{!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")}>
|
||||
|
||||
@@ -10,9 +10,10 @@ import { onThemeChange } from "../theme";
|
||||
import { toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface TermInfo {
|
||||
export interface TermInfo {
|
||||
id: string;
|
||||
cwd: string;
|
||||
session_id?: string | null;
|
||||
created_at: number;
|
||||
alive: boolean;
|
||||
attached: boolean;
|
||||
@@ -132,7 +133,7 @@ export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }
|
||||
);
|
||||
}
|
||||
|
||||
function TermView({ info }: { info: TermInfo }) {
|
||||
export function TermView({ info }: { info: TermInfo }) {
|
||||
const t = useT();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@@ -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";
|
||||
@@ -158,16 +158,30 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
)}
|
||||
{Array.isArray(meta?.image_ids) ? (
|
||||
<div className="gallery small">
|
||||
{meta.image_ids.map((id: string) => (
|
||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||
<img src={`/api/images/${id}/file`} alt="" />
|
||||
</FileFigure>
|
||||
))}
|
||||
{meta.image_ids.map((id: string, i: number) => {
|
||||
// The file may be gone (moved or deleted later): fall back to the preview the model saw.
|
||||
const preview = result?.images?.[i];
|
||||
return (
|
||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||
<img
|
||||
src={`/api/images/${id}/file`}
|
||||
alt=""
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (preview && !img.dataset.fallback) {
|
||||
img.dataset.fallback = "1";
|
||||
img.src = imageSrc(preview);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileFigure>
|
||||
);
|
||||
})}
|
||||
</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,25 +9,61 @@ import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||
|
||||
type Patch = { path: string[]; value: unknown };
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
read_only: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Tool checkboxes: nothing checked = all tools. */
|
||||
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.
|
||||
const extra = value.filter((v) => !tools.some((x) => x.name === v));
|
||||
return (
|
||||
<div className="tool-picker">
|
||||
<div className="hint">{value.length ? t("agents.toolsSome", { n: value.length }) : t("agents.toolsAll")}</div>
|
||||
<div className="tool-grid">
|
||||
{tools.map((x) => (
|
||||
<label key={x.name} className={`tool-item ${readOnly && !x.read_only ? "disabled" : ""}`} title={x.description}>
|
||||
<input type="checkbox" checked={value.includes(x.name)} disabled={readOnly && !x.read_only} onChange={() => toggle(x.name)} />
|
||||
<span className="mono">{x.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{extra.map((n) => (
|
||||
<label key={n} className="tool-item">
|
||||
<input type="checkbox" checked onChange={() => toggle(n)} />
|
||||
<span className="mono">{n}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
|
||||
documentation, summaries, step-by-step guides and translations.
|
||||
Return only the finished text.`;
|
||||
|
||||
function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: boolean, text: string) => void }) {
|
||||
function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: ToolInfo[]; onSaved: (ok: boolean, text: string) => void }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(!!raw && !raw.prompt && !raw.description && raw.mode === "primary");
|
||||
const [form, setForm] = useState({
|
||||
model: a.model ?? "",
|
||||
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
|
||||
description: raw?.description ?? "",
|
||||
tools: (raw?.tools ?? []).join(", "),
|
||||
tools: (raw?.tools ?? []) as string[],
|
||||
read_only: !!(raw?.read_only ?? a.read_only),
|
||||
style: (raw?.style ?? a.style ?? "coding") as "coding" | "chat",
|
||||
prompt: raw?.prompt ?? "",
|
||||
mode: raw?.mode ?? a.mode,
|
||||
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
||||
refine_model: raw?.refine?.model ?? "",
|
||||
refine_criteria: raw?.refine?.criteria ?? "",
|
||||
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
||||
});
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||
|
||||
const save = async () => {
|
||||
const base = ["agents", a.name];
|
||||
@@ -39,13 +75,15 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
|
||||
patches.push({
|
||||
path: [...base, "refine"],
|
||||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null } : null,
|
||||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
|
||||
});
|
||||
}
|
||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||
if (!builtin) {
|
||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||
patches.push({ path: [...base, "tools"], value: form.tools.trim() ? form.tools.split(/[\s,]+/).filter(Boolean) : null });
|
||||
patches.push({ path: [...base, "tools"], value: form.tools.length ? form.tools : null });
|
||||
patches.push({ path: [...base, "read_only"], value: form.read_only || null });
|
||||
patches.push({ path: [...base, "style"], value: form.style === "chat" ? "chat" : null });
|
||||
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
|
||||
}
|
||||
try {
|
||||
@@ -121,6 +159,11 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
<label>{t("agents.refineCriteria")}</label>
|
||||
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
|
||||
<div className="hint">{t("agents.refineCriteriaHint")}</div>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={form.refine_after_todos} onChange={(e) => setForm({ ...form, refine_after_todos: e.target.checked })} />
|
||||
{t("agents.refineAfterTodos")}
|
||||
</label>
|
||||
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
|
||||
</>
|
||||
)}
|
||||
<label>{t("agents.description")}</label>
|
||||
@@ -133,8 +176,23 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
<option value="subagent">{t("agents.modeSubagent")}</option>
|
||||
<option value="primary">{t("agents.modePrimary")}</option>
|
||||
</select>
|
||||
{form.mode === "primary" && (
|
||||
<>
|
||||
<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>
|
||||
<div className="hint">{t("agents.styleHint")}</div>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
<input value={form.tools} placeholder={t("agents.toolsAll")} onChange={(e) => setForm({ ...form, tools: e.target.value })} className="mono" />
|
||||
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
|
||||
<label>{t("agents.prompt")}</label>
|
||||
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||
</>
|
||||
@@ -159,7 +217,10 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
|
||||
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 }>();
|
||||
const [name, setName] = useState("");
|
||||
@@ -172,13 +233,15 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
||||
}
|
||||
};
|
||||
|
||||
const add = async (template: "writer" | "blank") => {
|
||||
const add = async (template: "writer" | "blank" | "mode") => {
|
||||
const n = name.trim() || (template === "writer" ? "writer" : "");
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||
const value =
|
||||
template === "writer"
|
||||
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
|
||||
: { mode: "subagent", description: "", prompt: "" };
|
||||
: template === "mode"
|
||||
? { mode: "primary" }
|
||||
: { mode: "subagent", description: "", prompt: "" };
|
||||
try {
|
||||
await patch("/config", { patches: [{ path: ["agents", n], value }] });
|
||||
setName("");
|
||||
@@ -194,13 +257,16 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
||||
<div className="notice">{t("agents.intro")}</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||||
<div className="panel">
|
||||
<h3>{t("agents.add")}</h3>
|
||||
<div className="row">
|
||||
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button className="primary" onClick={() => add("mode")} disabled={!name.trim()}>
|
||||
+ {t("agents.addMode")}
|
||||
</button>
|
||||
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
|
||||
<button onClick={() => add("blank")} disabled={!name.trim()}>
|
||||
+ {t("agents.addBlank")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+118
-16
@@ -65,12 +65,6 @@ export const de: Messages = {
|
||||
"settings.budgetWarning": "Warnen ab",
|
||||
"settings.adminKey": "Admin-Key (optional)",
|
||||
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
||||
"settings.envTitle": "Einstellungen aus Umgebungsvariablen",
|
||||
"settings.envHint": "Diese ${NAME}-Verweise holen ihre Werte aus der Server-Umgebung (.env). Übernimm die Werte in die Konfiguration, damit alles hier eingestellt ist – danach können die Variablen aus der .env raus.",
|
||||
"settings.envApply": "In die Konfiguration übernehmen",
|
||||
"settings.envConfirm": "Die aktuellen Werte von {names} in die Konfigurationsdatei schreiben? Die Keys liegen dann in der Config (und in ihren Backups) statt in der Umgebung.",
|
||||
"settings.envDone": "{n} Werte in die Konfiguration übernommen. Du kannst sie jetzt aus der .env entfernen.",
|
||||
"settings.envUnset": "Verwendet, aber nicht gesetzt (bleiben unverändert):",
|
||||
"settings.fetchPrices": "Preise abrufen",
|
||||
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
||||
"stats.perMillion": "pro 1 Mio. Tok.",
|
||||
@@ -91,6 +85,9 @@ export const de: Messages = {
|
||||
"files.newFolderName": "Ordnername:",
|
||||
"files.renameTo": "Neuer Name:",
|
||||
"files.copyTo": "Name der Kopie:",
|
||||
"files.chatHere": "Chat hier öffnen",
|
||||
"files.newTab": "Dateimanager öffnen",
|
||||
"files.closeTab": "Dateimanager schließen",
|
||||
"files.rename": "Umbenennen",
|
||||
"files.duplicate": "Duplizieren",
|
||||
"files.download": "Herunterladen",
|
||||
@@ -218,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",
|
||||
@@ -241,6 +239,9 @@ export const de: Messages = {
|
||||
"wizard.title": "Was möchtest du machen?",
|
||||
"wizard.subtitle": "Aufgabe wählen, beschreiben, was du willst, dann auf Start drücken.",
|
||||
"wizard.whatToDo": "Was möchtest du machen?",
|
||||
"wizard.task.chat": "Einfach chatten",
|
||||
"wizard.task.chatDesc": "Fragen, besprechen, nachschlagen – ohne Projekt und Dateien",
|
||||
"wizard.placeholder.chat": "Worüber möchtest du sprechen?",
|
||||
"wizard.task.craft": "Etwas craften",
|
||||
"wizard.task.craftDesc": "Website, App, Spiel oder altes Programm nachbauen – oder etwas Neues bauen",
|
||||
"wizard.task.build": "Am Code arbeiten",
|
||||
@@ -308,6 +309,9 @@ export const de: Messages = {
|
||||
"rename.generate": "Generieren",
|
||||
"nav.terminal": "Terminal",
|
||||
"terminal.new": "Neues Terminal",
|
||||
"terminal.chatTab": "Chat",
|
||||
"terminal.tab": "Terminal {n}",
|
||||
"terminal.closeConfirm": "{name} schließen? Die Shell und alles, was darin läuft, wird beendet.",
|
||||
"terminal.close": "Terminal schließen",
|
||||
"terminal.here": "Terminal in diesem Ordner öffnen",
|
||||
"terminal.connecting": "Verbinde…",
|
||||
@@ -330,6 +334,10 @@ export const de: Messages = {
|
||||
"wizard.idea.cadBox": "Eine Box mit Schnappdeckel, Innenmaß 80 × 50 × 30 mm",
|
||||
"wizard.idea.cadBracket": "Eine Wandhalterung für ein 25-mm-Rohr mit zwei M4-Schraublöchern",
|
||||
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
||||
"chat.reviewPassed": "Review bestanden:",
|
||||
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
||||
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
||||
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||
"agents.refine": "Review-Schleife",
|
||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||
@@ -340,7 +348,7 @@ export const de: Messages = {
|
||||
"agents.refineShort": "Review ×{n}",
|
||||
"agents.deleteFile": "Agenten-Datei {path} löschen?",
|
||||
"nav.jobs": "Automationen",
|
||||
"jobs.intro": "Agenten, die nach Zeitplan starten. Jede Automation ist eine Markdown-Datei (Auftrag + Zeitplan im Frontmatter) – lass sie dir in einem Chat von einem Agenten anlegen (Tool „schedule“) oder registriere unten eine Datei. Jeder Lauf erscheint als normaler Chat; braucht er eine Freigabe, bekommst du eine Push-Benachrichtigung.",
|
||||
"jobs.intro": "Agenten, die nach Zeitplan starten. Die Anweisungen jeder Automatisierung stehen in einer Markdown-Datei; Name, Zeitplan und Einstellungen legst du hier fest. Mit „Neue Automatisierung“ anlegen und die Anweisungen mit dem Assistenten erarbeiten – oder einen Agenten in einem Chat darum bitten (Tool „schedule“). Jeder Lauf erscheint als normaler Chat.",
|
||||
"jobs.none": "Noch keine Automationen.",
|
||||
"jobs.schedule": "Zeitplan",
|
||||
"jobs.next": "Nächster Lauf",
|
||||
@@ -351,13 +359,12 @@ export const de: Messages = {
|
||||
"jobs.enable": "Aktivieren",
|
||||
"jobs.disable": "Deaktivieren",
|
||||
"jobs.runNow": "Jetzt starten",
|
||||
"jobs.editFile": "Datei bearbeiten",
|
||||
"jobs.remove": "Entfernen",
|
||||
"jobs.deleteConfirm": "Automation „{name}“ entfernen? Die Job-Datei bleibt erhalten.",
|
||||
"jobs.openRun": "Lauf öffnen",
|
||||
"jobs.prompt": "Auftrag",
|
||||
"jobs.register": "Job-Datei registrieren",
|
||||
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Zeitplan im Frontmatter:",
|
||||
"jobs.register": "Vorhandene Datei registrieren",
|
||||
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Anweisungen (ältere Dateien mit Zeitplan im Frontmatter werden übernommen):",
|
||||
"jobs.registerBtn": "Registrieren",
|
||||
"jobs.status.running": "läuft",
|
||||
"jobs.status.done": "fertig",
|
||||
@@ -401,6 +408,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",
|
||||
@@ -451,6 +471,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…",
|
||||
@@ -483,6 +510,13 @@ 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.groupMenu": "Ordner-Optionen",
|
||||
"sidebar.groupPin": "Oben fixieren",
|
||||
"sidebar.groupUnpin": "Nicht mehr fixieren",
|
||||
"sidebar.groupUp": "Nach oben",
|
||||
"sidebar.groupDown": "Nach unten",
|
||||
"sidebar.moveTo": "In Ordner verschieben…",
|
||||
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
||||
"sidebar.archiveOne": "Archivieren",
|
||||
@@ -519,16 +553,12 @@ export const de: Messages = {
|
||||
"backup.s3Keep": "Im Bucket behalten (0 = alle)",
|
||||
"backup.last": "Letztes Backup",
|
||||
"backup.s3Ok": "hochgeladen",
|
||||
"jobs.fromTemplate": "Neue Automation aus Vorlage",
|
||||
"jobs.tplName": "Name",
|
||||
"jobs.tplProject": "Projektordner",
|
||||
"jobs.tplSchedule": "Wann",
|
||||
"jobs.tplCronHint": "Cron: Minute Stunde Tag Monat Wochentag (Zeitzone des Servers).",
|
||||
"jobs.tplMode": "Modus",
|
||||
"jobs.tplPrompt": "Aufgabe für den Agenten",
|
||||
"jobs.tplCreate": "Automation anlegen",
|
||||
"new.title": "Neue Session",
|
||||
"new.directory": "Projektverzeichnis",
|
||||
"new.invalidFolderName": "Ordnernamen dürfen kein „/“ enthalten.",
|
||||
"new.up": "Hoch",
|
||||
"new.noSubdirs": "Keine Unterverzeichnisse",
|
||||
"new.model": "Modell",
|
||||
@@ -562,6 +592,64 @@ export const de: Messages = {
|
||||
"chat.exportMd": "Markdown (.md)",
|
||||
"chat.exportJson": "JSON (.json)",
|
||||
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
||||
"chat.compactedAuto": "Kontext fast voll ({tokens} Tokens) – Unterhaltung automatisch komprimiert · Zusammenfassung",
|
||||
"chat.compactedManual": "Unterhaltung bei {tokens} Tokens komprimiert · Zusammenfassung",
|
||||
"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)",
|
||||
"cron.label": "Zeitplan",
|
||||
"cron.mode.minutes": "Alle paar Minuten",
|
||||
"cron.mode.hourly": "Stündlich",
|
||||
"cron.mode.daily": "Täglich",
|
||||
"cron.mode.weekly": "An Wochentagen",
|
||||
"cron.mode.monthly": "Monatlich",
|
||||
"cron.mode.custom": "Cron-Ausdruck",
|
||||
"cron.every": "Alle",
|
||||
"cron.minutes": "Minuten",
|
||||
"cron.atMinute": "Zur Minute",
|
||||
"cron.days": "Wochentage",
|
||||
"cron.workdays": "Mo–Fr",
|
||||
"cron.onDay": "Am",
|
||||
"cron.at": "um",
|
||||
"cron.customHint": "Minute Stunde Tag Monat Wochentag, z. B. „30 6 * * 1-5“ = Mo–Fr 06:30.",
|
||||
"cron.nextRuns": "Nächste Läufe:",
|
||||
"cron.shortMonths": "Monate ohne diesen Tag werden übersprungen.",
|
||||
"cron.serverTz": "Uhrzeiten in Serverzeit ({tz})",
|
||||
"jobEditor.saved": "Gespeichert",
|
||||
"jobEditor.conflict": "Die Anweisungen wurden inzwischen geändert (z. B. vom Assistenten). Mit deiner Fassung überschreiben?",
|
||||
"jobEditor.overwrite": "Überschreiben",
|
||||
"jobEditor.runUnsaved": "Es gibt ungespeicherte Änderungen – der Lauf verwendet die gespeicherte Fassung. Trotzdem starten?",
|
||||
"jobEditor.discard": "Ungespeicherte Änderungen verwerfen?",
|
||||
"jobEditor.back": "Zurück zu den Automatisierungen",
|
||||
"jobEditor.tab.prompt": "Anweisungen",
|
||||
"jobEditor.tab.settings": "Zeitplan",
|
||||
"jobEditor.tab.assistant": "Assistent",
|
||||
"jobEditor.timeout": "Zeitlimit (Min.)",
|
||||
"jobEditor.bypassHint": "Läuft nachts: Mit „Alles erlauben“ wartet der Lauf nie auf eine Freigabe.",
|
||||
"jobEditor.instructions": "Anweisungen",
|
||||
"jobEditor.instructionsHint": "Der Agent sieht nur diesen Text – keinen Chat, niemanden zum Nachfragen.",
|
||||
"jobEditor.changedOnDisk": "Der Assistent hat die Datei geändert, während du bearbeitet hast.",
|
||||
"jobEditor.reload": "Seine Fassung laden",
|
||||
"jobEditor.assistantIntro": "Erarbeite die Anweisungen zusammen mit einem Modell: Beschreib, was die Automatisierung tun soll – es fragt nach und schreibt die Datei nebenan mit.",
|
||||
"jobEditor.assistantStart": "Assistent starten",
|
||||
"jobEditor.defaultModel": "Server-Standard",
|
||||
"jobs.new": "Neue Automatisierung",
|
||||
"jobs.namePlaceholder": "z. B. Nächtlicher Abhängigkeits-Check",
|
||||
"jobs.fileHint": "Die Anweisungen landen in .codecraft/jobs/<name>.md in diesem Ordner.",
|
||||
"jobs.startWith": "Beginnen mit",
|
||||
"jobs.blank": "Leer",
|
||||
"jobs.blankDesc": "Beispielgliederung zum Ausfüllen mit dem Assistenten",
|
||||
"jobs.createAndEdit": "Anlegen und bearbeiten",
|
||||
"jobs.edit": "Bearbeiten",
|
||||
"jobs.permissionMode": "Freigabemodus",
|
||||
"jobs.permissionModeHint": "Gilt ab dem nächsten Lauf. „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",
|
||||
@@ -579,6 +667,9 @@ export const de: Messages = {
|
||||
"composer.mode": "Modus",
|
||||
"composer.model": "Modell",
|
||||
"composer.permissionMode": "Berechtigungsmodus",
|
||||
"composer.preview": "Vorschau (Markdown)",
|
||||
"composer.previewEdit": "Zurück zum Bearbeiten",
|
||||
"composer.previewEmpty": "Noch nichts zum Anzeigen.",
|
||||
"composer.attach": "Dateien anhängen",
|
||||
|
||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||
@@ -670,6 +761,8 @@ export const de: Messages = {
|
||||
"settings.pricesHint": "Preise pro 1 Mio. Tokens in der eingestellten Währung. Cache-Preise fallen auf den Input-Preis zurück.",
|
||||
"settings.modelsEmptyHint": "Keine Modelle eingetragen: Sie werden automatisch beim Provider abgefragt.",
|
||||
"settings.context": "Kontext",
|
||||
"settings.maxInput": "Max. Eingabe",
|
||||
"settings.maxInputHint": "Eingabe-Limit, falls der Anbieter eines unterhalb des Kontextfensters setzt (leer: das Kontextfenster). Der Chat wird zusammengefasst, bevor die Eingabe es erreicht.",
|
||||
"settings.maxOutput": "Max. Ausgabe",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Bilder",
|
||||
@@ -838,6 +931,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",
|
||||
@@ -848,8 +942,16 @@ export const de: Messages = {
|
||||
"agents.description": "Beschreibung",
|
||||
"agents.descriptionHint": "Anhand dieser Beschreibung entscheidet der Hauptagent, wann er delegiert.",
|
||||
"agents.mode": "Art",
|
||||
"agents.addMode": "Neuer Modus",
|
||||
"agents.style": "Art",
|
||||
"agents.styleCoding": "Programmieren – Projekt-Kontext und Coding-Ablauf",
|
||||
"agents.styleChat": "Unterhaltung – nur dieser Prompt, kein Projekt",
|
||||
"agents.styleHint": "Unterhaltungs-Modi bekommen nur deinen Prompt plus Datum und persönliches Gedächtnis, wie der eingebaute Chat-Modus.",
|
||||
"agents.readOnlyLabel": "Nur lesen",
|
||||
"agents.readOnlyHint": "Nur Werkzeuge, die nichts verändern (wie der Plan-Modus)",
|
||||
"agents.toolsSome": "{n} Werkzeuge ausgewählt – nur diese sind verfügbar",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "alle Tools (z. B. read, glob, grep)",
|
||||
"agents.toolsAll": "Nichts ausgewählt = alle Werkzeuge",
|
||||
"agents.prompt": "Anweisungen",
|
||||
"agents.reset": "Zurücksetzen",
|
||||
"agents.resetConfirm": "„{name}“ auf die eingebauten Standardwerte zurücksetzen?",
|
||||
|
||||
+118
-16
@@ -64,12 +64,6 @@ export const en = {
|
||||
"settings.budgetWarning": "Warn at",
|
||||
"settings.adminKey": "Admin key (optional)",
|
||||
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
||||
"settings.envTitle": "Settings from environment variables",
|
||||
"settings.envHint": "These ${NAME} references take their values from the server's environment (.env). Move the values into the configuration so everything is set here – then remove the variables from .env.",
|
||||
"settings.envApply": "Move into the configuration",
|
||||
"settings.envConfirm": "Write the current values of {names} into the configuration file? Keys are then stored in the config (and in its backups) instead of the environment.",
|
||||
"settings.envDone": "{n} values moved into the configuration. You can now remove them from .env.",
|
||||
"settings.envUnset": "Referenced but not set (left as they are):",
|
||||
"settings.fetchPrices": "Fetch prices",
|
||||
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
||||
"stats.perMillion": "per 1M tok",
|
||||
@@ -90,6 +84,9 @@ export const en = {
|
||||
"files.newFolderName": "Folder name:",
|
||||
"files.renameTo": "New name:",
|
||||
"files.copyTo": "Name of the copy:",
|
||||
"files.chatHere": "Open chat here",
|
||||
"files.newTab": "Open file manager",
|
||||
"files.closeTab": "Close file manager",
|
||||
"files.rename": "Rename",
|
||||
"files.duplicate": "Duplicate",
|
||||
"files.download": "Download",
|
||||
@@ -217,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",
|
||||
@@ -240,6 +238,9 @@ export const en = {
|
||||
"wizard.title": "What do you want to do?",
|
||||
"wizard.subtitle": "Pick a task, say what you want, then press Start.",
|
||||
"wizard.whatToDo": "What do you want to do?",
|
||||
"wizard.task.chat": "Just chat",
|
||||
"wizard.task.chatDesc": "Ask, discuss, look things up – no project, no files",
|
||||
"wizard.placeholder.chat": "What would you like to talk about?",
|
||||
"wizard.task.craft": "Craft something",
|
||||
"wizard.task.craftDesc": "Recreate a website, app, game or old program – or build something new",
|
||||
"wizard.task.build": "Work on code",
|
||||
@@ -307,6 +308,9 @@ export const en = {
|
||||
"rename.generate": "Generate",
|
||||
"nav.terminal": "Terminal",
|
||||
"terminal.new": "New terminal",
|
||||
"terminal.chatTab": "Chat",
|
||||
"terminal.tab": "Terminal {n}",
|
||||
"terminal.closeConfirm": "Close {name}? The shell and everything running in it are stopped.",
|
||||
"terminal.close": "Close terminal",
|
||||
"terminal.here": "Open a terminal in this folder",
|
||||
"terminal.connecting": "Connecting…",
|
||||
@@ -329,6 +333,10 @@ export const en = {
|
||||
"wizard.idea.cadBox": "A box with a snap-on lid, inner size 80 × 50 × 30 mm",
|
||||
"wizard.idea.cadBracket": "A wall bracket for a 25 mm pipe with two M4 screw holes",
|
||||
"activity.reviewing": "Reviewing the result (round {round})…",
|
||||
"chat.reviewPassed": "Review passed:",
|
||||
"chat.todoReminder": "Back to work – {n} open todos:",
|
||||
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
||||
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
||||
"chat.reviewRound": "Review {round}: changes requested",
|
||||
"agents.refine": "Review loop",
|
||||
"agents.refineRounds": "Review rounds per task",
|
||||
@@ -339,7 +347,7 @@ export const en = {
|
||||
"agents.refineShort": "Review ×{n}",
|
||||
"agents.deleteFile": "Delete agent file {path}?",
|
||||
"nav.jobs": "Automations",
|
||||
"jobs.intro": "Agents that start on a schedule. Each automation is a Markdown file (prompt + schedule in the frontmatter) – ask an agent in any chat to create one (tool “schedule”) or register a file below. Every run appears as a normal chat; if it needs permission you get a push notification.",
|
||||
"jobs.intro": "Agents that start on a schedule. Each automation has its instructions in a Markdown file; name, schedule and settings are set here. Create one with “New automation” and work out the instructions with the assistant – or ask an agent in any chat (tool “schedule”). Every run appears as a normal chat.",
|
||||
"jobs.none": "No automations yet.",
|
||||
"jobs.schedule": "Schedule",
|
||||
"jobs.next": "Next run",
|
||||
@@ -350,13 +358,12 @@ export const en = {
|
||||
"jobs.enable": "Enable",
|
||||
"jobs.disable": "Disable",
|
||||
"jobs.runNow": "Run now",
|
||||
"jobs.editFile": "Edit file",
|
||||
"jobs.remove": "Remove",
|
||||
"jobs.deleteConfirm": "Remove the automation “{name}”? The job file stays.",
|
||||
"jobs.openRun": "Open run",
|
||||
"jobs.prompt": "Instructions",
|
||||
"jobs.register": "Register a job file",
|
||||
"jobs.registerHint": "Path to a Markdown file with a schedule in its frontmatter:",
|
||||
"jobs.register": "Register an existing file",
|
||||
"jobs.registerHint": "Path to a Markdown file with instructions (older files with a schedule in the frontmatter are taken over):",
|
||||
"jobs.registerBtn": "Register",
|
||||
"jobs.status.running": "running",
|
||||
"jobs.status.done": "done",
|
||||
@@ -400,6 +407,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",
|
||||
@@ -450,6 +470,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…",
|
||||
@@ -482,6 +509,13 @@ 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.groupMenu": "Folder options",
|
||||
"sidebar.groupPin": "Pin to the top",
|
||||
"sidebar.groupUnpin": "Unpin",
|
||||
"sidebar.groupUp": "Move up",
|
||||
"sidebar.groupDown": "Move down",
|
||||
"sidebar.moveTo": "Move to folder…",
|
||||
"sidebar.removeFromFolder": "Remove from folder",
|
||||
"sidebar.archiveOne": "Archive",
|
||||
@@ -518,16 +552,12 @@ export const en = {
|
||||
"backup.s3Keep": "Keep in bucket (0 = all)",
|
||||
"backup.last": "Last backup",
|
||||
"backup.s3Ok": "uploaded",
|
||||
"jobs.fromTemplate": "New automation from a template",
|
||||
"jobs.tplName": "Name",
|
||||
"jobs.tplProject": "Project folder",
|
||||
"jobs.tplSchedule": "When",
|
||||
"jobs.tplCronHint": "Cron: minute hour day month weekday (server time zone).",
|
||||
"jobs.tplMode": "Mode",
|
||||
"jobs.tplPrompt": "Task for the agent",
|
||||
"jobs.tplCreate": "Create automation",
|
||||
"new.title": "New session",
|
||||
"new.directory": "Project directory",
|
||||
"new.invalidFolderName": "Folder names cannot contain “/”.",
|
||||
"new.up": "Up",
|
||||
"new.noSubdirs": "No subdirectories",
|
||||
"new.model": "Model",
|
||||
@@ -561,6 +591,64 @@ export const en = {
|
||||
"chat.exportMd": "Markdown (.md)",
|
||||
"chat.exportJson": "JSON (.json)",
|
||||
"chat.empty": "Send a message to start.",
|
||||
"chat.compactedAuto": "Context nearly full ({tokens} tokens) – conversation compacted automatically · summary",
|
||||
"chat.compactedManual": "Conversation compacted at {tokens} tokens · summary",
|
||||
"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)",
|
||||
"cron.label": "Schedule",
|
||||
"cron.mode.minutes": "Every few minutes",
|
||||
"cron.mode.hourly": "Hourly",
|
||||
"cron.mode.daily": "Daily",
|
||||
"cron.mode.weekly": "On weekdays",
|
||||
"cron.mode.monthly": "Monthly",
|
||||
"cron.mode.custom": "Cron expression",
|
||||
"cron.every": "Every",
|
||||
"cron.minutes": "minutes",
|
||||
"cron.atMinute": "At minute",
|
||||
"cron.days": "Days of the week",
|
||||
"cron.workdays": "Mon–Fri",
|
||||
"cron.onDay": "On day",
|
||||
"cron.at": "at",
|
||||
"cron.customHint": "Minute hour day month weekday, e.g. “30 6 * * 1-5” = Mon–Fri 06:30.",
|
||||
"cron.nextRuns": "Next runs:",
|
||||
"cron.shortMonths": "Months without this day are skipped.",
|
||||
"cron.serverTz": "Times in server time ({tz})",
|
||||
"jobEditor.saved": "Saved",
|
||||
"jobEditor.conflict": "The instructions were changed on disk meanwhile (e.g. by the assistant). Overwrite them with your version?",
|
||||
"jobEditor.overwrite": "Overwrite",
|
||||
"jobEditor.runUnsaved": "There are unsaved changes – the run uses the saved version. Start anyway?",
|
||||
"jobEditor.discard": "Discard the unsaved changes?",
|
||||
"jobEditor.back": "Back to automations",
|
||||
"jobEditor.tab.prompt": "Instructions",
|
||||
"jobEditor.tab.settings": "Schedule",
|
||||
"jobEditor.tab.assistant": "Assistant",
|
||||
"jobEditor.timeout": "Time limit (min)",
|
||||
"jobEditor.bypassHint": "Runs at night: with “Allow everything” the run never waits for approval.",
|
||||
"jobEditor.instructions": "Instructions",
|
||||
"jobEditor.instructionsHint": "The agent sees only this text – no chat, nobody to ask.",
|
||||
"jobEditor.changedOnDisk": "The assistant changed the file while you were editing.",
|
||||
"jobEditor.reload": "Load its version",
|
||||
"jobEditor.assistantIntro": "Work out the instructions together with a model: describe what the automation should do – it asks back and writes the file next to you.",
|
||||
"jobEditor.assistantStart": "Start assistant",
|
||||
"jobEditor.defaultModel": "Server default",
|
||||
"jobs.new": "New automation",
|
||||
"jobs.namePlaceholder": "e.g. Nightly dependency check",
|
||||
"jobs.fileHint": "The instructions go to .codecraft/jobs/<name>.md in this folder.",
|
||||
"jobs.startWith": "Start with",
|
||||
"jobs.blank": "Blank",
|
||||
"jobs.blankDesc": "Example structure to fill in with the assistant",
|
||||
"jobs.createAndEdit": "Create and edit",
|
||||
"jobs.edit": "Edit",
|
||||
"jobs.permissionMode": "Approval mode",
|
||||
"jobs.permissionModeHint": "Applies from the next run. “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",
|
||||
@@ -578,6 +666,9 @@ export const en = {
|
||||
"composer.mode": "Mode",
|
||||
"composer.model": "Model",
|
||||
"composer.permissionMode": "Permission mode",
|
||||
"composer.preview": "Preview (Markdown)",
|
||||
"composer.previewEdit": "Back to editing",
|
||||
"composer.previewEmpty": "Nothing to preview yet.",
|
||||
"composer.attach": "Attach files",
|
||||
|
||||
"command.compact": "Summarize the conversation to free up context",
|
||||
@@ -669,6 +760,8 @@ export const en = {
|
||||
"settings.pricesHint": "Prices per 1M tokens in the configured currency. Cache prices default to the input price.",
|
||||
"settings.modelsEmptyHint": "No models listed: they are discovered automatically from the provider.",
|
||||
"settings.context": "Context",
|
||||
"settings.maxInput": "Max input",
|
||||
"settings.maxInputHint": "Input limit, if the provider sets one below the context window (empty: the context window). The chat is compacted before the input reaches it.",
|
||||
"settings.maxOutput": "Max output",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Vision",
|
||||
@@ -837,6 +930,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",
|
||||
@@ -847,8 +941,16 @@ export const en = {
|
||||
"agents.description": "Description",
|
||||
"agents.descriptionHint": "The main agent decides by this description when to delegate.",
|
||||
"agents.mode": "Type",
|
||||
"agents.addMode": "New mode",
|
||||
"agents.style": "Kind",
|
||||
"agents.styleCoding": "Coding – project context and coding workflow",
|
||||
"agents.styleChat": "Conversation – only this prompt, no project",
|
||||
"agents.styleHint": "Conversation modes get just your prompt plus date and personal memory, like the built-in chat mode.",
|
||||
"agents.readOnlyLabel": "Read-only",
|
||||
"agents.readOnlyHint": "Only tools that change nothing (like plan mode)",
|
||||
"agents.toolsSome": "{n} tools selected – only these are available",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "all tools (e.g. read, glob, grep)",
|
||||
"agents.toolsAll": "Nothing selected = all tools",
|
||||
"agents.prompt": "Instructions",
|
||||
"agents.reset": "Reset",
|
||||
"agents.resetConfirm": "Reset \"{name}\" to the built-in defaults?",
|
||||
|
||||
@@ -71,13 +71,3 @@ export const JOB_TEMPLATES: JobTemplate[] = [
|
||||
"Compare the README and other documentation with the current code: commands, configuration options, APIs and examples that are outdated, missing or wrong. Do not change files. Report concrete corrections.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Quick schedule choices for the dialog. */
|
||||
export const SCHEDULES: { cron: string; en: string; de: string }[] = [
|
||||
{ cron: "0 3 * * *", en: "daily 03:00", de: "täglich 03:00" },
|
||||
{ cron: "0 8 * * 1-5", en: "weekdays 08:00", de: "werktags 08:00" },
|
||||
{ cron: "0 7 * * 1", en: "Mondays 07:00", de: "montags 07:00" },
|
||||
{ cron: "0 16 * * 5", en: "Fridays 16:00", de: "freitags 16:00" },
|
||||
{ cron: "0 9 1 * *", en: "monthly on the 1st", de: "monatlich am 1." },
|
||||
{ cron: "0 * * * *", en: "hourly", de: "stündlich" },
|
||||
];
|
||||
@@ -10,6 +10,43 @@ import { captureShare } from "./share";
|
||||
applyTheme();
|
||||
captureShare();
|
||||
registerSW({ immediate: true });
|
||||
undoKeyboardShift();
|
||||
|
||||
/**
|
||||
* Browsers that do not resize the page for the on-screen keyboard (iOS Safari, older
|
||||
* Android) scroll the whole page up to show the input – and do not always scroll it back
|
||||
* when the keyboard closes, leaving the top bar (menu) out of reach. The app never
|
||||
* scrolls the page itself (only its panes), so any page offset is undone once the
|
||||
* visible area grows again.
|
||||
*/
|
||||
function undoKeyboardShift() {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
let last = vv.height;
|
||||
const reset = () => {
|
||||
const el = document.scrollingElement ?? document.documentElement;
|
||||
if (window.scrollY !== 0 || el.scrollTop !== 0 || document.body.scrollTop !== 0) {
|
||||
window.scrollTo(0, 0);
|
||||
el.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
vv.addEventListener("resize", () => {
|
||||
// Grown back (keyboard closed or closing): after the browser's own adjustment.
|
||||
if (vv.height > last + 1) {
|
||||
requestAnimationFrame(reset);
|
||||
setTimeout(reset, 350);
|
||||
}
|
||||
last = vv.height;
|
||||
});
|
||||
// Keyboard closed by leaving the input (no resize on some browsers).
|
||||
document.addEventListener("focusout", () =>
|
||||
setTimeout(() => {
|
||||
const a = document.activeElement;
|
||||
if (!a || !(a instanceof HTMLInputElement || a instanceof HTMLTextAreaElement || (a as HTMLElement).isContentEditable)) reset();
|
||||
}, 300),
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -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("+")}` : "/");
|
||||
}
|
||||
@@ -47,6 +47,8 @@ export interface State {
|
||||
queue: Record<string, QueueItem[]>;
|
||||
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
|
||||
waiting: Record<string, string | undefined>;
|
||||
/** Compaction running in a session (auto = context nearly full). */
|
||||
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
|
||||
}
|
||||
|
||||
let state: State = {
|
||||
@@ -67,6 +69,7 @@ let state: State = {
|
||||
queue: {},
|
||||
steps: {},
|
||||
waiting: {},
|
||||
compacting: {},
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -188,6 +191,14 @@ export function applyEvent(ev: ServerEvent) {
|
||||
case "waiting":
|
||||
setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } }));
|
||||
break;
|
||||
case "compaction":
|
||||
setState((s) => ({
|
||||
compacting: {
|
||||
...s.compacting,
|
||||
[ev.session_id]: ev.state === "started" ? { auto: ev.auto, tokens: ev.tokens, limit: ev.limit, since: Date.now() } : undefined,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "mcp_status":
|
||||
setState(() => ({ mcp: ev.servers }));
|
||||
break;
|
||||
|
||||
@@ -299,6 +299,25 @@ button.session-group:hover {
|
||||
.session-row .session-item {
|
||||
padding-right: 30px;
|
||||
}
|
||||
/* Group headers (folders, projects): menu button, drag and drop. */
|
||||
.group-row .session-group {
|
||||
padding-right: 30px;
|
||||
}
|
||||
.group-row .session-more {
|
||||
top: 4px;
|
||||
}
|
||||
.group-row[draggable="true"] .session-group {
|
||||
cursor: grab;
|
||||
}
|
||||
.group-row.drop-before {
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
.group-row.drop-after {
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
.session-group .group-pin {
|
||||
color: var(--accent);
|
||||
}
|
||||
.session-more {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -508,6 +527,20 @@ button.session-group:hover {
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */
|
||||
.msg-user .markdown {
|
||||
white-space: normal;
|
||||
}
|
||||
.composer-preview {
|
||||
min-height: 44px;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px dashed var(--bg3);
|
||||
background: var(--bg1);
|
||||
cursor: text;
|
||||
}
|
||||
.msg-user .msg-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -721,6 +754,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;
|
||||
@@ -1002,6 +1068,13 @@ button.session-group:hover {
|
||||
.form label {
|
||||
color: var(--fg2);
|
||||
}
|
||||
.form label.check {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.form .hint {
|
||||
grid-column: 2;
|
||||
font-size: 12px;
|
||||
@@ -3098,6 +3171,28 @@ button.danger-solid {
|
||||
.refine-card summary .icon {
|
||||
color: var(--purple);
|
||||
}
|
||||
.harness-note {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 6px 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid var(--blue);
|
||||
border-radius: 4px;
|
||||
background: var(--bg1);
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.harness-note .icon {
|
||||
color: var(--blue);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.harness-note.ok {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
.harness-note.ok .icon {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* --- automations ---------------------------------------------------------------- */
|
||||
.job.off {
|
||||
@@ -3127,6 +3222,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;
|
||||
}
|
||||
@@ -3569,6 +3669,217 @@ button.danger-solid {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.job-tpl-card.active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.job-tpl .job-tpl-grid {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.job-title {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* --- automation editor ------------------------------------------------------ */
|
||||
.job-tabs {
|
||||
display: none;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
}
|
||||
.job-tabs button {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 8px 6px;
|
||||
color: var(--muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.job-tabs button.active {
|
||||
color: var(--fg0);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.job-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 1fr);
|
||||
}
|
||||
.job-editor-main {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.job-editor-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.job-editor-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.job-editor-row .field {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
margin: 0;
|
||||
}
|
||||
.job-editor-row select,
|
||||
.job-editor-row input {
|
||||
width: 100%;
|
||||
}
|
||||
.job-editor-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.job-editor-prompt {
|
||||
flex: 1;
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.job-editor-prompt-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
background: var(--bg1);
|
||||
}
|
||||
.job-editor-prompt .notice {
|
||||
margin: 8px;
|
||||
}
|
||||
.job-editor-chat {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--bg3);
|
||||
}
|
||||
.job-editor-chat .topbar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.job-assistant-start {
|
||||
margin: auto;
|
||||
max-width: 340px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.job-assistant-start .icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--accent);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.job-tabs {
|
||||
display: flex;
|
||||
}
|
||||
.job-editor {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.job-editor-chat {
|
||||
border-left: none;
|
||||
}
|
||||
.job-editor.tab-prompt .job-editor-settings,
|
||||
.job-editor.tab-settings .job-editor-prompt,
|
||||
.job-editor.tab-prompt .job-editor-chat,
|
||||
.job-editor.tab-settings .job-editor-chat,
|
||||
.job-editor.tab-assistant .job-editor-main {
|
||||
display: none;
|
||||
}
|
||||
.job-editor.tab-prompt .job-editor-main {
|
||||
padding: 0;
|
||||
}
|
||||
.job-editor.tab-prompt .job-editor-prompt {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- schedule builder ---------------------------------------------------------- */
|
||||
.cron-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cron-modes,
|
||||
.cron-days {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.cron-modes .chip-btn,
|
||||
.cron-days .chip-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cron-days .chip-btn {
|
||||
min-width: 44px;
|
||||
}
|
||||
.cron-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.cron-fields label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.cron-fields input[type="number"] {
|
||||
width: 70px;
|
||||
}
|
||||
.cron-custom {
|
||||
flex-direction: column;
|
||||
align-items: stretch !important;
|
||||
flex: 1;
|
||||
}
|
||||
.cron-custom input {
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
}
|
||||
.cron-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
align-items: baseline;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
.cron-preview code {
|
||||
background: var(--bg2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.cron-preview .err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.chip.price-extra {
|
||||
margin-left: 4px;
|
||||
@@ -3603,3 +3914,274 @@ button.danger-solid {
|
||||
.plan-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- chat tabs (chat + terminals) ---------------------------------------------- */
|
||||
.chat-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 10px 0;
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
overflow-x: auto;
|
||||
flex: none;
|
||||
}
|
||||
.chat-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: none;
|
||||
color: var(--fg2);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
span.chat-tab {
|
||||
padding: 0 4px 0 0;
|
||||
}
|
||||
span.chat-tab > button {
|
||||
padding: 6px 4px 6px 12px;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.chat-tab .close {
|
||||
padding: 2px 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.chat-tab .close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.chat-tab:hover {
|
||||
background: var(--bg1);
|
||||
color: var(--fg0);
|
||||
}
|
||||
.chat-tab.active {
|
||||
color: var(--fg0);
|
||||
border-bottom-color: var(--accent);
|
||||
background: var(--bg1);
|
||||
}
|
||||
.chat-tab.dead {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.chat-tab-add {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.chat-pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.files-embed-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
flex: none;
|
||||
}
|
||||
.files-embed-bar .path {
|
||||
font-size: 12px;
|
||||
color: var(--fg3);
|
||||
font-family: var(--mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-picker .hint {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 4px 12px;
|
||||
}
|
||||
.tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-item.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.mode-select {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.compacting-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg1);
|
||||
font-size: 13px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -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 =
|
||||
@@ -19,7 +24,7 @@ export type Part =
|
||||
is_error: boolean;
|
||||
meta?: any;
|
||||
}
|
||||
| { type: "summary"; text: string };
|
||||
| { type: "summary"; text: string; auto?: boolean; tokens?: number };
|
||||
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
@@ -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 {
|
||||
@@ -118,6 +127,7 @@ export type ServerEvent =
|
||||
| { type: "waiting"; session_id: string; reason: string | null }
|
||||
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
||||
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
||||
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
|
||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||
| { type: "resync" };
|
||||
|
||||
@@ -131,6 +141,7 @@ export interface ModelInfo {
|
||||
name: string;
|
||||
context?: number;
|
||||
max_output?: number | null;
|
||||
max_input?: number | null;
|
||||
reasoning: boolean;
|
||||
vision: boolean;
|
||||
effort?: string | null;
|
||||
@@ -150,11 +161,13 @@ export interface AgentInfo {
|
||||
description: string;
|
||||
mode: "primary" | "subagent";
|
||||
read_only: boolean;
|
||||
/** "coding" or "chat" (plain conversation prompt). */
|
||||
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;
|
||||
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
|
||||
/** Source file of Markdown agents. */
|
||||
path?: string | null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,94 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00018": {
|
||||
en: [
|
||||
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
|
||||
"Long chats are compacted in time instead of failing with “input too long”",
|
||||
],
|
||||
de: [
|
||||
"Modi mit Review: jedes erledigte Todo wird geprüft, und der Agent arbeitet weiter, bis die Liste abgehakt ist – für Läufe über Nacht",
|
||||
"Lange Chats werden rechtzeitig zusammengefasst, statt mit „Eingabe zu lang“ abzubrechen",
|
||||
],
|
||||
},
|
||||
"202611-00017": {
|
||||
en: ["Your messages are shown as Markdown; the eye button next to the text field previews it before sending"],
|
||||
de: ["Deine Nachrichten werden als Markdown dargestellt; das Augen-Symbol neben dem Textfeld zeigt vor dem Senden eine Vorschau"],
|
||||
},
|
||||
"202611-00014": {
|
||||
en: ["Folder picker (new automation, new chat): create a new folder right there"],
|
||||
de: ["Ordnerauswahl (neue Automatisierung, neuer Chat): direkt einen neuen Ordner anlegen"],
|
||||
},
|
||||
"202611-00013": {
|
||||
en: [
|
||||
"New automation editor: instructions, a graphical schedule and an assistant chat that writes the instructions with you",
|
||||
"Chat list: drag folders and projects into order or pin them (⋯ on the folder); new messages no longer move the whole project up",
|
||||
],
|
||||
de: [
|
||||
"Neuer Editor für Automatisierungen: Anweisungen, grafischer Zeitplan und ein Assistent-Chat, der die Anweisungen mit dir schreibt",
|
||||
"Chatliste: Ordner und Projekte per Drag & Drop sortieren oder fixieren (⋯ am Ordner); neue Nachrichten schieben nicht mehr das ganze Projekt nach oben",
|
||||
],
|
||||
},
|
||||
"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",
|
||||
"New session → “Just chat” for plain conversation",
|
||||
"Own modes: Settings → Agents & commands → New mode",
|
||||
"The chat shows when the conversation is compacted (automatically or on request)",
|
||||
"File manager: “Open chat here” in a folder's menu",
|
||||
"Terminal client downloadable from the server again",
|
||||
],
|
||||
de: [
|
||||
"Terminals und Dateimanager als Tabs im Chat – Terminals laufen weiter, auch für ein anderes Gerät",
|
||||
"Neue Sitzung → „Einfach chatten“ für reine Unterhaltung",
|
||||
"Eigene Modi: Einstellungen → Agents & Befehle → Neuer Modus",
|
||||
"Der Chat zeigt an, wenn die Unterhaltung komprimiert wird (automatisch oder auf Wunsch)",
|
||||
"Dateimanager: „Chat hier öffnen“ im Ordner-Menü",
|
||||
"Terminal-Client wieder direkt vom Server herunterladbar",
|
||||
],
|
||||
},
|
||||
"202611-00002": {
|
||||
en: [
|
||||
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",
|
||||
|
||||
Reference in new issue
Block a user