Compare commits

...
33 Commits
Author SHA1 Message Date
Richard Zink ca14ba3338 Release v202611-00017 2026-11-10 14:01:43 +01:00
Richard ZinkandClaude Opus 5.5 97e66d2118 Markdown in user messages, with a preview in the composer
User messages are rendered with the same Markdown component as the model's
answers; single line breaks are kept (hard breaks outside code blocks), so
plain multi-line messages look as before. The eye button next to the text
field shows the formatted message before sending. The text sent to the model
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:01:43 +01:00
Richard Zink b3a298fb7c Release v202611-00016 2026-11-10 13:49:53 +01:00
Richard ZinkandClaude Opus 5.5 61219dbca2 Phone: keyboard no longer leaves the page shifted
Mobile browsers scroll the whole page up to show the focused input and do not
always scroll it back when the keyboard closes; the app never scrolls the page
itself, so the top bar (menu) stayed out of reach.

- Viewport interactive-widget=resizes-content: on Android the keyboard shrinks
  the page instead of shifting it.
- Elsewhere (iOS Safari) the page offset is undone when the visual viewport
  grows again or the input loses focus.
- The chat keeps following the latest message when its pane shrinks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:49:53 +01:00
Richard Zink 9fd92d5b39 Release v202611-00015 2026-11-10 12:37:14 +01:00
Richard ZinkandClaude Opus 5.5 b99f71526a Generated images survive moves and renames
Agents tidy up folders after generating images (sort into subfolders, rename
revisions); the image records kept the old path, so /api/images/<id>/file
answered 404 and only the requesting device still showed them from its cache.

- Generated images get a copy in the image store (images.blob, kept by the
  blob GC); existing ones are copied once on start-up.
- Records follow: after bash commands with mv/rename (missing images of the
  owner from the last two weeks, found by name or by content hash), when moved
  with the file manager (file or folder), and on access (served from the copy,
  relocated in the background; without a copy found by file name).
- Chat gallery falls back to the preview the model saw when the file is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:37:13 +01:00
Richard Zink 90d65ec654 Release v202611-00014 2026-11-10 10:35:48 +01:00
Richard ZinkandClaude Opus 5.5 7298102e93 Folder picker: create a new folder (new automation, new chat)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:35:35 +01:00
Richard Zink 538aac9122 Release v202611-00013 2026-11-10 10:05:21 +01:00
Richard ZinkandClaude Opus 5.5 05c215f685 Automation editor with graphical schedule and assistant; movable chat list groups
Automations
- Job files hold only the instructions; name, schedule, mode, model, approval
  mode and time limit are settings in the database (jobs.settings). Existing
  files are adopted on start-up (frontmatter into the database and removed from
  the file); plain Markdown files registered later get default settings and stay
  switched off until scheduled.
- New automation: name, project and a starting point (blank example structure or
  a template) create .codecraft/jobs/<name>.md and open the editor: settings with
  a graphical cron builder (next runs in server time via /jobs/schedule), the
  instructions in the Markdown editor and an assistant chat
  (POST /jobs/{id}/assistant) whose model edits the file; the editor reloads it
  after each turn and warns about conflicts with unsaved edits.
- The schedule tool writes prompt-only files with settings.

Chat list
- Folders and projects keep a user-defined order and can be pinned (drag and
  drop, or the group's menu), stored per user via /account/sidebar. A new
  message moves the chat to the top of its group, no longer the project itself.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:46:16 +01:00
Richard Zink 4244b9370c Release v202611-00008 2026-11-09 18:43:38 +01:00
Richard ZinkandClaude Opus 5.5 dae76a5636 OpenAI wire format: no name on tool messages (opencode/Anthropic rejected it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:42:00 +01:00
Richard ZinkandClaude Opus 5.5 ff8ff585a2 Instructions per chat (system prompt addition; UI dialog and Telegram /prompt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:14:20 +01:00
Richard ZinkandClaude Opus 5.5 434aa84d40 Telegram: find the token in pasted text, no password autofill, clear error
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:33:12 +01:00
Richard Zink 0a368e493d Release v202611-00007 2026-11-09 16:53:51 +01:00
Richard ZinkandClaude Opus 5.5 011756ca3d Telegram bot as a chat interface (per user, paired, CLI-like commands)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:50:51 +01:00
Richard Zink b7c8741a7d Release v202611-00006 2026-11-09 15:39:50 +01:00
Richard ZinkandClaude Opus 5.5 bf99292f43 Docker CLI in the image (static client for a mounted host socket)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:39:48 +01:00
Richard ZinkandClaude Opus 5.5 36de035dd8 move-server.sh: --docker-sock to give the container the target's Docker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:30:29 +01:00
Richard ZinkandClaude Opus 5.5 b59c5b8890 docs: guide for moving CodeCraft to another host (Unraid, bind mounts, tools disk)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:56:23 +01:00
Richard ZinkandClaude Opus 5.5 eef17eb591 move-server.sh: --tools-dir for agent installs and /tmp on a separate disk
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:54:21 +01:00
Richard Zink 6b1d7f91b8 Release v202611-00005 2026-11-09 12:24:12 +01:00
Richard ZinkandClaude Opus 5.5 0f4bfa0ab0 Git identity from the settings for all agent shells, terminals and the git panel
Set GIT_AUTHOR_*/GIT_COMMITTER_* from Settings → Account → Git (also inside the
sandbox), so a repository's own user.name/user.email no longer wins; tell agents
not to change the git identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:22:25 +01:00
61 changed files with 4849 additions and 360 deletions

No files matched your search

+158
View File
@@ -3,6 +3,164 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [v202611-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 ## [v202611-00004] – 2026-11-09
### Added ### Added
Generated
+68
View File
@@ -52,6 +52,21 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "alloc-no-stdlib"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -153,6 +168,18 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "async-compression"
version = "0.4.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.92" version = "0.1.92"
@@ -363,6 +390,27 @@ dependencies = [
"hybrid-array", "hybrid-array",
] ]
[[package]]
name = "brotli"
version = "9.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "6.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.13.1" version = "1.13.1"
@@ -682,6 +730,24 @@ dependencies = [
"static_assertions", "static_assertions",
] ]
[[package]]
name = "compression-codecs"
version = "0.4.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.10.2" version = "0.10.2"
@@ -3040,6 +3106,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@@ -4034,6 +4101,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [ dependencies = [
"async-compression",
"bitflags 2.13.2", "bitflags 2.13.2",
"bytes", "bytes",
"futures-core", "futures-core",
+10
View File
@@ -67,6 +67,14 @@ RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
&& mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \ && 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 && 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 ------------------------------------------------------------------ # --- runtime ------------------------------------------------------------------
# Node is included so agents can work on JS projects and run npx-based MCP servers. # Node is included so agents can work on JS projects and run npx-based MCP servers.
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
@@ -85,6 +93,8 @@ RUN npm install -g typescript@5 typescript-language-server pyright && npm cache
COPY --from=server /codecraft /usr/local/bin/codecraft COPY --from=server /codecraft /usr/local/bin/codecraft
COPY --from=web /web/dist /app/web COPY --from=web /web/dist /app/web
COPY --from=cli /out /app/cli 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/ghidra /app/ghidra
COPY server/resources /app/resources COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space # The agent's home lives on the data volume: tools it installs in user space
+17 -6
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00004" src="https://img.shields.io/badge/version-v202611--00004-d69e2e?style=flat-square"> <img alt="Version v202611-00017" src="https://img.shields.io/badge/version-v202611--00017-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square"> <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust"> <img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react"> <img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
@@ -103,6 +103,10 @@ copies the workspace, stops CodeCraft, streams the data volume to `<dir>/data`,
`docker-compose.yml` (and a `start.sh` for hosts without Compose) with bind mounts, starts the `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 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). 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 ## How it works
@@ -198,17 +202,24 @@ platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codec
<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%"> <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> </p>
An automation is a Markdown file with a cron schedule in its front matter An automation is an agent that starts on a schedule. Its instructions are a Markdown file
(`.codecraft/jobs/<name>.md`). Each run is a normal chat session, so you can read and continue (`.codecraft/jobs/<name>.md`, only the prompt); name, schedule, mode, model and limits are set
it; if it needs a permission, you get a push notification. Ask an agent in any chat to set one in the UI, with a graphical schedule editor (every few minutes, hourly, daily, on weekdays,
up, start from a template (dependency check, security review of recent commits, test suite monthly or a cron expression). *New automation* creates the file – blank with an example
health, daily summary, TODO report, documentation freshness) or write the file yourself. 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 **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. shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed to the agent.
## Integrations ## 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. - **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 - **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider `Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
+2 -2
View File
@@ -421,7 +421,7 @@ impl App {
_ => None, _ => None,
}; };
if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) { if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) {
self.images.push(ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) }); self.images.push(ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
} }
} }
} }
@@ -840,7 +840,7 @@ impl App {
for (cmd, args) in tries { for (cmd, args) in tries {
if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() { if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() {
if out.status.success() && out.stdout.starts_with(b"\x89PNG") { if out.status.success() && out.stdout.starts_with(b"\x89PNG") {
self.images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) }); self.images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
self.status = format!("image {} attached", self.images.len()); self.status = format!("image {} attached", self.images.len());
return; return;
} }
+1 -1
View File
@@ -408,7 +408,7 @@ pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
return Ok(Output { return Ok(Output {
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()), content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
is_error: false, is_error: false,
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }], images: vec![ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
diff: None, diff: None,
}); });
} }
+1
View File
@@ -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. - 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. - 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. - 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. - Reference code as path:line.
- Answer in the language the user writes in."#; - Answer in the language the user writes in."#;
+21 -2
View File
@@ -47,10 +47,13 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
Part::Image { image } => parts.push(json!({ Part::Image { image } => parts.push(json!({
"type": "image_url", "image_url": {"url": data_url(image)} "type": "image_url", "image_url": {"url": data_url(image)}
})), })),
Part::ToolResult { call_id, name, content, images, is_error, .. } => { Part::ToolResult { call_id, content, images, is_error, .. } => {
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() }; let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
// No "name": it is not part of the tool message in the Chat
// Completions API, and gateways that translate to Anthropic
// (opencode) reject named participants.
out.push(json!({ out.push(json!({
"role": "tool", "tool_call_id": call_id, "name": name, "role": "tool", "tool_call_id": call_id,
"content": if text.is_empty() { "(no output)".into() } else { text } "content": if text.is_empty() { "(no output)".into() } else { text }
})); }));
tool_images.extend(images.iter().cloned()); tool_images.extend(images.iter().cloned());
@@ -328,6 +331,22 @@ mod tests {
(text, reasoning) (text, reasoning)
} }
#[test]
fn tool_messages_have_no_name() {
use crate::types::{Message, Part, Role};
let call = Part::ToolCall { id: "c1".into(), name: "bash".into(), input: serde_json::json!({"command": "ls"}), extra: None };
let result = Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "ok".into(), images: vec![], is_error: false, meta: None };
let msgs = vec![
Message::new(Role::User, vec![Part::Text { text: "hi".into() }]),
Message::new(Role::Assistant, vec![call]),
Message::new(Role::User, vec![result]),
];
let wire = super::to_wire_messages("sys", &msgs);
let tool = wire.iter().find(|m| m["role"] == "tool").expect("tool message");
assert_eq!(tool["tool_call_id"], "c1");
assert!(wire.iter().all(|m| m.get("name").is_none()), "no message may carry a name: {wire:?}");
}
#[test] #[test]
fn splits_think_tags_across_chunks() { fn splits_think_tags_across_chunks() {
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]); let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
+23 -2
View File
@@ -10,11 +10,25 @@ pub enum Role {
Assistant, Assistant,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ImageData { pub struct ImageData {
pub media_type: String, pub media_type: String,
/// Base64-encoded bytes. /// Base64-encoded bytes. Empty when the image is stored separately (`blob`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub data: String, pub data: String,
/// Server only: SHA-256 of the bytes in the image store; the UI loads
/// /api/blobs/<blob> and the server fills `data` in before a model request.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
/// Size in bytes of a stored image (shown in the UI without fetching it).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
impl ImageData {
pub fn new(media_type: impl Into<String>, data: String) -> Self {
Self { media_type: media_type.into(), data, blob: None, size: None }
}
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -166,6 +180,13 @@ pub struct Session {
/// User-defined group in the session list. /// User-defined group in the session list.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<String>, pub folder: Option<String>,
/// Instructions the user set for this chat; added to the system prompt of every turn.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub instructions: String,
/// Approvals and questions of this chat (and its subagents) are not sent to Telegram,
/// unless it is the chat currently open in Telegram.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub telegram_quiet: bool,
} }
impl Session { impl Session {
+143
View File
@@ -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.
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Roll out a published CodeCraft image to a remote installation that was set up with
# scripts/move-server.sh (docker-compose.yml + .env in one folder on the target).
#
# scripts/release.sh # build and publish a new version (amd64 + arm64)
# scripts/deploy.sh # roll the newest tag out to the target
#
# Steps:
# 1. check that the image exists in the registry for the target's architecture,
# 2. wait until no agent or terminal runs on the target (unless --force),
# 3. set CODECRAFT_VERSION in the target's .env, docker compose pull && up -d
# (or start.sh on hosts without Compose),
# 4. run scripts/smoke-test.sh on the target; on failure the previous version is
# started again.
#
# Usage: scripts/deploy.sh [vYYYYMM-NNNNN] [--target user@host:/dir] [--force]
# [--wait MINUTES] [--dry-run]
# vYYYYMM-NNNNN version to deploy (default: the newest vYYYYMM-NNNNN git tag)
# --target installation folder on the target (default: DEPLOY_TARGET from .env)
# --force restart even if agents or terminals are running (they end)
# --wait N how long to wait for an idle server before giving up (default 15)
# --dry-run show the steps only
#
# Environment:
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
# CONTAINER container name on the target (default codecraft)
# PORT port of CodeCraft on the target, for the smoke test (default: PORT
# from the target's .env, else 8787)
set -euo pipefail
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
CONTAINER="${CONTAINER:-codecraft}"
tag="" target="" force=0 wait_min=15 dry=0
while (($#)); do
case "$1" in
--target) target="$2"; shift ;;
--force) force=1 ;;
--wait) wait_min="$2"; shift ;;
--dry-run) dry=1 ;;
-h|--help) sed -n '2,29p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;;
*) tag="$1" ;;
esac
shift
done
cd "$(git rev-parse --show-toplevel)"
step() { printf '\n==> %s\n' "$*"; }
fail() { echo "error: $*" >&2; exit 1; }
q() { printf '%q' "$1"; }
env_get() { [[ -f .env ]] && sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
target="${target:-$(env_get DEPLOY_TARGET || true)}"
[[ "$target" == *:/* ]] || fail "no target – pass --target user@host:/dir or set DEPLOY_TARGET in .env"
host="${target%%:*}"
dir="${target#*:}"; dir="${dir%/}"
remote() { ssh -o BatchMode=yes "$host" "$1"; }
run_remote() { if (( dry )); then echo "[dry-run] on $host: $1"; else remote "$1"; fi; }
if [[ -z "$tag" ]]; then
git fetch --tags --quiet origin || true
tag=$(git tag -l 'v[0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9]' | sort | tail -1)
[[ -n "$tag" ]] || fail "no release tag found"
fi
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
step "Checking $host:$dir and the image $REGISTRY:$tag"
remote "test -f $(q "$dir/.env") && test -f $(q "$dir/docker-compose.yml")" \
|| fail "$dir on $host has no .env/docker-compose.yml – set it up with scripts/move-server.sh"
current=$(remote "sed -n 's/^CODECRAFT_VERSION=//p' $(q "$dir/.env") | tail -1")
case "$(remote 'uname -m')" in
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
esac
if [[ -n "$arch" ]]; then
# Captured first: `inspect | grep -q` fails under pipefail when grep exits early (SIGPIPE).
manifests=$(docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null || true)
grep -q "Platform:.*linux/$arch" <<<"$manifests" \
|| fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first"
fi
echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})"
[[ "$current" == "$tag" ]] && echo "note: $tag is already configured – the container is recreated anyway"
# Busy = the container runs more than the server process (agents' shells, terminals, ...).
busy() { (( $(remote "docker top $(q "$CONTAINER") -o pid 2>/dev/null | tail -n +2 | wc -l") > 1 )); }
if (( ! force )) && remote "docker inspect -f '{{.State.Running}}' $(q "$CONTAINER") 2>/dev/null" | grep -q true; then
step "Waiting until no agent or terminal runs (up to $wait_min min, --force skips this)"
deadline=$(( $(date +%s) + wait_min * 60 ))
while busy; do
(( $(date +%s) < deadline )) || fail "still busy after $wait_min min – try later or use --force"
sleep 15
done
echo "idle"
fi
step "Updating to $tag"
run_remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$tag/' .env \
&& (grep -q '^CODECRAFT_VERSION=' .env || echo 'CODECRAFT_VERSION=$tag' >> .env) \
&& docker pull -q $(q "$REGISTRY:$tag") >/dev/null \
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
step "Smoke test"
port="${PORT:-$(remote "sed -n 's/^PORT=//p' $(q "$dir/.env") | tail -1")}"; port="${port:-8787}"
if (( dry )); then
echo "[dry-run] smoke test on $host: http://127.0.0.1:$port ${tag#v}"
else
ssh -o BatchMode=yes "$host" "cat > $(q "$dir/smoke-test.sh")" <scripts/smoke-test.sh
if ! remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${tag#v}"; then
if [[ -n "$current" && "$current" != "$tag" ]]; then
echo "smoke test failed – going back to $current" >&2
remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$current/' .env \
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
fi
fail "deploy of $tag failed"
fi
fi
step "Done"
echo "$host runs CodeCraft $tag."
+50 -9
View File
@@ -23,22 +23,34 @@
# #
# Options: # Options:
# --workspace DIR project folder on the target (default <dir>/workspace) # --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) # --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) # --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) # --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) # --name NAME container name on the target (default codecraft)
# --source-container C container to stop here (default codecraft) # --source-container C container to stop here (default codecraft)
# --source-volume V data volume here (default codecraft-data) # --source-volume V data volume here (default codecraft-data)
# --source-workspace D project folder here (default: WORKSPACE of .env) # --source-workspace D project folder here (default: WORKSPACE of .env)
# --exclude PATTERN leave this out of the workspace copy (rsync pattern, relative to the
# workspace, e.g. /old-project/ or /app/target/); repeatable
# --no-stop do not stop the source (only for tests – the copy may be inconsistent) # --no-stop do not stop the source (only for tests – the copy may be inconsistent)
# --dry-run show the steps only # --dry-run show the steps only
set -euo pipefail set -euo pipefail
target="" ws_target="" uid="" gid="" port="" bind="" name="codecraft" target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0 src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
excludes=()
while (($#)); do while (($#)); do
case "$1" in case "$1" in
--workspace) ws_target="$2"; shift ;; --workspace) ws_target="$2"; shift ;;
--tools-dir) tools_dir="$2"; shift ;;
--docker-sock) docker_sock=1 ;;
--uid) uid="$2"; shift ;; --uid) uid="$2"; shift ;;
--gid) gid="$2"; shift ;; --gid) gid="$2"; shift ;;
--port) port="$2"; shift ;; --port) port="$2"; shift ;;
@@ -47,9 +59,10 @@ while (($#)); do
--source-container) src_container="$2"; shift ;; --source-container) src_container="$2"; shift ;;
--source-volume) src_volume="$2"; shift ;; --source-volume) src_volume="$2"; shift ;;
--source-workspace) ws_source="$2"; shift ;; --source-workspace) ws_source="$2"; shift ;;
--exclude) excludes+=(--exclude "$2"); shift ;;
--no-stop) stop=0 ;; --no-stop) stop=0 ;;
--dry-run) dry=1 ;; --dry-run) dry=1 ;;
-h|--help) sed -n '2,36p' "$0"; exit 0 ;; -h|--help) sed -n '2,38p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;; -*) echo "unknown option $1" >&2; exit 2 ;;
*) target="$1" ;; *) target="$1" ;;
esac esac
@@ -59,7 +72,8 @@ done
cd "$(git rev-parse --show-toplevel)" cd "$(git rev-parse --show-toplevel)"
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; } [[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
env_get() { sed -n "s/^$1=//p" .env | tail -1; } # Value of a .env variable, without surrounding quotes.
env_get() { sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
host="${target%%:*}" host="${target%%:*}"
dir="${target#*:}" dir="${target#*:}"
@@ -84,9 +98,9 @@ remote() {
} }
q() { printf '%q' "$1"; } q() { printf '%q' "$1"; }
sync_workspace() { sync_workspace() {
if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/" if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
} }
put_file() { # put_file <remote path> < content put_file() { # put_file <remote path> < content
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
@@ -97,10 +111,15 @@ step "Checking source and target"
docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found" docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
[[ -d "$ws_source" ]] || fail "workspace $ws_source not found" [[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
echo "source: container $src_container, volume $src_volume, workspace $ws_source" echo "source: container $src_container, volume $src_volume, workspace $ws_source"
echo "target: $host:$dir (data in $dir/data, workspace $ws_target), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version" 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 "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"))\"" \ 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)" || 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)" step "Copying the workspace (first pass, CodeCraft keeps running)"
sync_workspace sync_workspace
@@ -118,9 +137,29 @@ else
| remote "tar -C $(q "$dir/data") -xpf -" | remote "tar -C $(q "$dir/data") -xpf -"
fi 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)" step "Copying the workspace (changes since the first pass)"
sync_workspace sync_workspace
remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") 2>/dev/null || echo 'note: could not chown (not root) – check file owners'" 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" step "Writing docker-compose.yml, .env and start.sh"
put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json
@@ -145,6 +184,8 @@ services:
volumes: volumes:
- $dir/data:/data - $dir/data:/data
- \${WORKSPACE}:/workspace - \${WORKSPACE}:/workspace
$tool_mounts
$sock_compose
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
shm_size: "1gb" shm_size: "1gb"
@@ -163,7 +204,7 @@ val() { sed -n "s/^\$1=//p" .env | tail -1; }
docker rm -f $name >/dev/null 2>&1 || true docker rm -f $name >/dev/null 2>&1 || true
exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\ exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
-p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\ -p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" \\ -v $dir/data:/data -v "\$(val WORKSPACE):/workspace" $tool_flags$sock_flags\\
--add-host host.docker.internal:host-gateway --shm-size 1g \\ --add-host host.docker.internal:host-gateway --shm-size 1g \\
--security-opt seccomp=$dir/docker/seccomp-sandbox.json \\ --security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
--security-opt apparmor=unconfined --security-opt systempaths=unconfined \\ --security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
+2 -2
View File
@@ -28,7 +28,7 @@ shell-words = "1"
libc = "0.2" libc = "0.2"
ignore = "0.4" ignore = "0.4"
regex = "1" 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"] } rusqlite = { version = "0.41", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -38,7 +38,7 @@ tokio-tungstenite = "0.29"
tokio-util = "0.7" tokio-util = "0.7"
toml = "1" toml = "1"
toml_edit = "0.25" toml_edit = "0.25"
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] } tower-http = { version = "0.7", features = ["fs", "cors", "trace", "compression-gzip", "compression-br"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4", "v7"] } uuid = { version = "1", features = ["v4", "v7"] }
+327 -28
View File
@@ -80,6 +80,7 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action)) .route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs)) .route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
.route("/sessions/{id}/export", get(export_session)) .route("/sessions/{id}/export", get(export_session))
.route("/blobs/{hash}", get(get_blob))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/prompt", post(prompt)) .route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort)) .route("/sessions/{id}/abort", post(abort))
@@ -136,8 +137,10 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}/file", get(image_file)) .route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings)) .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/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/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/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", get(crate::cli_api::get_templates))
.route("/templates/{scope}", put(crate::cli_api::put_templates)) .route("/templates/{scope}", put(crate::cli_api::put_templates))
.route("/cli/config", get(crate::cli_api::config)) .route("/cli/config", get(crate::cli_api::config))
@@ -148,7 +151,9 @@ pub fn router(app: AppState) -> Router {
.route("/account/tokens/{id}", delete(auth::delete_api_token)) .route("/account/tokens/{id}", delete(auth::delete_api_token))
.route("/jobs", get(list_jobs).post(register_job)) .route("/jobs", get(list_jobs).post(register_job))
.route("/jobs/create", post(create_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}/run", post(run_job))
.route("/jobs/{id}/webhook", post(job_webhook_settings)) .route("/jobs/{id}/webhook", post(job_webhook_settings))
.route("/terminals", get(list_terminals).post(open_terminal)) .route("/terminals", get(list_terminals).post(open_terminal))
@@ -170,8 +175,7 @@ pub fn router(app: AppState) -> Router {
.find(|p| p.join("index.html").exists()) .find(|p| p.join("index.html").exists())
.unwrap_or_else(|| PathBuf::from("./web/dist")) .unwrap_or_else(|| PathBuf::from("./web/dist"))
}); });
let index = web_dir.join("index.html"); let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(missing_static_file(web_dir.clone()));
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
Router::new() Router::new()
.nest("/api", api) .nest("/api", api)
@@ -180,9 +184,54 @@ pub fn router(app: AppState) -> Router {
.route("/cli/install.sh", get(cli_install_script)) .route("/cli/install.sh", get(cli_install_script))
.route("/cli/download/{target}", get(cli_download)) .route("/cli/download/{target}", get(cli_download))
.fallback_service(static_files) .fallback_service(static_files)
.layer(compression())
.with_state(app) .with_state(app)
} }
/// Paths that are not a file of the web build. Usually a client-side route → index.html,
/// except for:
/// - `/sw-<build>.js` of an earlier build: installed apps check their service worker at
/// that URL for updates. Answering with index.html (not a script) makes the update
/// fail, so the old worker kept serving the old app shell forever – which broke when
/// the API changed. They get the kill switch /sw.js instead: it removes the old worker
/// and reloads, and the page then registers the current worker.
/// - other missing scripts/styles (old chunks): 404 instead of HTML.
fn missing_static_file(web_dir: PathBuf) -> axum::routing::MethodRouter {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
axum::routing::any(move |uri: axum::http::Uri| {
let web_dir = web_dir.clone();
async move {
let path = uri.path();
let name = path.trim_start_matches('/');
if name.starts_with("sw-") && name.ends_with(".js") && !name.contains('/') {
return match tokio::fs::read(web_dir.join("sw.js")).await {
Ok(js) => ([(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache, no-store")], js).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
};
}
if [".js", ".mjs", ".css", ".map"].iter().any(|ext| path.ends_with(ext)) {
return StatusCode::NOT_FOUND.into_response();
}
match tokio::fs::read(web_dir.join("index.html")).await {
Ok(html) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], html).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
})
}
/// gzip/brotli for JSON, HTML, JS and CSS. Images and SSE streams are left alone (default
/// predicate), and so are binary downloads (already compressed or large).
fn compression() -> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate + Clone> {
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
let predicate = DefaultPredicate::new()
.and(NotForContentType::const_new("application/octet-stream"))
.and(NotForContentType::const_new("application/zip"))
.and(NotForContentType::const_new("application/gzip"));
tower_http::compression::CompressionLayer::new().compress_when(predicate)
}
fn cli_dir() -> PathBuf { fn cli_dir() -> PathBuf {
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli")) std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
} }
@@ -327,6 +376,44 @@ async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Q
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id)))) 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). /// The user's personal modes (name -> settings).
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
Ok(Json(json!(prompts::personal_modes(&app, &user.id)))) Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
@@ -535,6 +622,9 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
pinned: false, pinned: false,
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
@@ -704,7 +794,8 @@ struct ExportQ {
/// Download a session as Markdown (default) or JSON. /// Download a session as Markdown (default) or JSON.
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> { async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
let s = readable(&app, &user, &id)?; let s = readable(&app, &user, &id)?;
let messages = app.store.messages(&id)?; // JSON exports are self-contained: images inline instead of store references.
let messages = if q.format == "json" { app.store.messages_for_model(&id)? } else { app.store.messages(&id)? };
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string(); let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base }; let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
let (body, mime, ext) = if q.format == "json" { let (body, mime, ext) = if q.format == "json" {
@@ -743,12 +834,19 @@ struct UpdateSession {
archived: Option<bool>, archived: Option<bool>,
/// "" removes the folder. /// "" removes the folder.
folder: Option<String>, folder: Option<String>,
/// Instructions for this chat ("" clears them).
instructions: Option<String>,
/// Approvals and questions of this chat are not sent to Telegram.
telegram_quiet: Option<bool>,
} }
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult { async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
writable(&app, &user, &id)?; writable(&app, &user, &id)?;
// Organising the list must not move the session to "today". // Organising the list must not move the session to "today".
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none(); let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none() && body.instructions.is_none();
if body.instructions.as_ref().is_some_and(|i| i.len() > 20_000) {
return Err(ApiError(StatusCode::BAD_REQUEST, "instructions too long (max 20,000 characters)".into()));
}
if let Some(m) = &body.model { if let Some(m) = &body.model {
app.registry().resolve(m)?; app.registry().resolve(m)?;
} }
@@ -785,6 +883,12 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(p) = body.permission_mode { if let Some(p) = body.permission_mode {
s.permission_mode = p; s.permission_mode = p;
} }
if let Some(i) = body.instructions {
s.instructions = i.trim().to_string();
}
if let Some(q) = body.telegram_quiet {
s.telegram_quiet = q;
}
})?; })?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
} }
@@ -831,9 +935,33 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
crate::shells::kill_session(&app, &id); crate::shells::kill_session(&app, &id);
app.terminals.kill_session(&id); app.terminals.kill_session(&id);
app.store.delete_session(&id)?; app.store.delete_session(&id)?;
// Images only this session used.
let a = app.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = a.store.gc_blobs() {
tracing::warn!("image cleanup: {e:#}");
}
});
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
/// so it never changes and the browser may keep it.
async fn get_blob(Extension(_user): Extension<User>, State(app): State<AppState>, Path(hash): Path<String>) -> Result<axum::response::Response, ApiError> {
if !crate::blobs::is_hash(&hash) {
return Err(not_found("image"));
}
let (mime, bytes) = app.store.blob(&hash)?.ok_or_else(|| not_found("image"))?;
Ok((
[
(axum::http::header::CONTENT_TYPE, mime),
(axum::http::header::CACHE_CONTROL, "private, max-age=31536000, immutable".to_string()),
],
bytes,
)
.into_response())
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct PromptBody { struct PromptBody {
text: String, text: String,
@@ -858,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 { 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 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. // Built-in commands.
if text == "/compact" { if text == "/compact" {
if app.is_busy(&id) { if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
} }
tokio::spawn(do_compact(app.clone(), id.clone())); 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 cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd); let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut prompt_text = text.clone(); let mut prompt_text = text.clone();
if text.starts_with('/') { 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; prompt_text = expanded;
if agent.is_some() || clean_room.is_some() { if agent.is_some() || clean_room.is_some() {
let mut s = session.clone(); app.update_session(&id, |s| {
if let Some(a) = agent { if let Some(a) = agent {
s.agent = a; s.agent = a;
} }
if clean_room.is_some() { if clean_room.is_some() {
s.clean_room = clean_room; s.clean_room = clean_room;
} }
app.save_session(s)?; })?;
} }
} }
} }
set_turn_effort(&app, &id, body.effort.as_deref())?; set_turn_effort(app, &id, effort)?;
let mut parts = vec![Part::Text { text: prompt_text }]; let mut parts = vec![Part::Text { text: prompt_text }];
for image in body.images { for image in images {
parts.push(Part::Image { image }); parts.push(Part::Image { image });
} }
match runner::submit(app.clone(), id, parts, "user")? { match runner::submit(app.clone(), id, parts, "user")? {
runner::Submitted::Started => Ok(Json(json!({"ok": true}))), runner::Submitted::Started => Ok(json!({"ok": true})),
runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))), runner::Submitted::Queued(item) => Ok(json!({"ok": true, "queued": item})),
} }
} }
@@ -916,7 +1051,7 @@ async fn flush_queue(Extension(user): Extension<User>, State(app): State<AppStat
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?}))) 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(); let cancel = tokio_util::sync::CancellationToken::new();
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone())); app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true }); app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
@@ -1428,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> { 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 img = own_image(&app, &user, &id)?;
let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?; 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() {
let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) {
Some("jpg") | Some("jpeg") => "image/jpeg", Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp", Some("webp") => "image/webp",
_ => "image/png", _ => "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 { async fn delete_image(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
@@ -1489,15 +1637,24 @@ async fn list_jobs(Extension(user): Extension<User>, State(app): State<AppState>
Ok(Json(json!(jobs))) 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)] #[derive(Deserialize)]
struct RegisterJob { struct RegisterJob {
path: String, 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 { 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)?; 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 = 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, ""); app.audit(Some(&user), "job.register", &job.path, "");
Ok(Json(crate::jobs::describe(&job))) Ok(Json(crate::jobs::describe(&job)))
} }
@@ -1506,22 +1663,38 @@ async fn register_job(Extension(user): Extension<User>, State(app): State<AppSta
struct CreateJob { struct CreateJob {
/// Project folder; the file goes to <cwd>/.codecraft/jobs/<name>.md. /// Project folder; the file goes to <cwd>/.codecraft/jobs/<name>.md.
cwd: String, cwd: String,
#[serde(flatten)] name: String,
job: crate::jobs::NewJob, /// 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 { 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)?; let dir = crate::files::checked_path(&app, &user, &b.cwd, true)?;
if b.job.name.trim().is_empty() || b.job.prompt.trim().is_empty() { if b.prompt.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "name and prompt are required".into())); return Err(ApiError(StatusCode::BAD_REQUEST, "the instructions are empty".into()));
} }
if !crate::permission::MODES.contains(&b.job.permission_mode.as_str()) { let mut settings = crate::jobs::JobSettings::new(&b.name);
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); 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. // Inside the checked folder: the file name is a slug of the name.
let path = crate::jobs::new_job_path(&dir, &b.job.name); let path = crate::jobs::new_job_path(&dir, &b.name);
crate::jobs::write_file(&path, &b.job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; let job = crate::jobs::create(&app.store, &user.id, &path, settings, &b.prompt, b.enabled).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let job = app.store.job_register(&user.id, &path.display().to_string())?;
app.audit(Some(&user), "job.create", &job.path, ""); app.audit(Some(&user), "job.create", &job.path, "");
Ok(Json(crate::jobs::describe(&job))) Ok(Json(crate::jobs::describe(&job)))
} }
@@ -1529,10 +1702,46 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
#[derive(Deserialize)] #[derive(Deserialize)]
struct UpdateJob { struct UpdateJob {
enabled: Option<bool>, enabled: Option<bool>,
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 { async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
let job = own_job(&app, &user, &id)?; let job = own_job(&app, &user, &id)?;
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 { if let Some(e) = b.enabled {
app.store.job_set_enabled(&job.id, e)?; app.store.job_set_enabled(&job.id, e)?;
} }
@@ -1540,6 +1749,96 @@ async fn update_job(Extension(user): Extension<User>, State(app): State<AppState
Ok(Json(crate::jobs::describe(&job))) 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 { async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let job = own_job(&app, &user, &id)?; let job = own_job(&app, &user, &id)?;
app.store.job_delete(&job.id)?; app.store.job_delete(&job.id)?;
+28
View File
@@ -131,9 +131,37 @@ impl App {
}); });
crate::auth::bootstrap(&app)?; crate::auth::bootstrap(&app)?;
app.clone().start_mcp(); app.clone().start_mcp();
app.clone().start_image_migration();
Ok(app) Ok(app)
} }
/// Move inline images of older chats to the image store, drop unused images and
/// shrink the database file (in the background; chats load meanwhile as before).
fn start_image_migration(self: Arc<Self>) {
tokio::task::spawn_blocking(move || {
match self.store.migrate_inline_images() {
Ok(0) => {}
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
}
match 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>) { fn start_mcp(self: Arc<Self>) {
let servers = self.config().mcp.clone(); let servers = self.config().mcp.clone();
let mcp = self.mcp.clone(); let mcp = self.mcp.clone();
+135
View File
@@ -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}));
}
}
+17
View File
@@ -231,6 +231,23 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(b)) 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). /// Personal memory (applies to all projects, web and CLI).
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let m: String = app.store.user_setting(&user.id, "memory")?; let m: String = app.store.user_setting(&user.id, "memory")?;
+1 -1
View File
@@ -129,7 +129,7 @@ async fn render_page(pdf: &str, page: u32) -> Result<ImageData> {
let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle); let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle);
let mut buf = Vec::new(); let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?; img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
Ok(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) }) Ok(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
} }
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> { fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
+4
View File
@@ -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()); 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??; 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}))) Ok(Json(json!({"ok": true, "path": to})))
} }
+7 -2
View File
@@ -44,6 +44,8 @@ tokio::task_local! {
static SANDBOX: Option<crate::sandbox::SandboxSpec>; static SANDBOX: Option<crate::sandbox::SandboxSpec>;
/// The user's git config (credentials, identity) for unsandboxed runs. /// The user's git config (credentials, identity) for unsandboxed runs.
static GITCONFIG: Option<std::path::PathBuf>; 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> { 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() { if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
c.env("GIT_CONFIG_GLOBAL", cfg); c.env("GIT_CONFIG_GLOBAL", cfg);
} }
c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default());
c 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 { 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 spec = crate::sandbox::spec_for(&app, Some(&user.id));
let cfg = crate::gitcreds::global_config(&app, &user); 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 { 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> { 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 spec = crate::sandbox::spec_for(app, Some(&user.id));
let cfg = crate::gitcreds::global_config(app, user); let cfg = crate::gitcreds::global_config(app, user);
let env = crate::gitcreds::identity_env(app, &user.id);
let s = session.clone(); 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()) { if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
session.cwd = cwd.to_string(); session.cwd = cwd.to_string();
} }
+18
View File
@@ -35,6 +35,24 @@ pub struct GitSettings {
pub credentials: Vec<GitCredential>, 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 { pub fn home_of(app: &App, user: &User) -> PathBuf {
app.data_dir().join("users").join(&user.username).join("home") app.data_dir().join("users").join(&user.username).join("home")
} }
+127 -1
View File
@@ -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 name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
let path = req.dir.join(name); let path = req.dir.join(name);
tokio::fs::write(&path, &bytes).await?; 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 { let rec = ImageRecord {
id: new_id(), id: new_id(),
user_id: req.user_id.clone(), 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), width: img["width"].as_i64().unwrap_or(0),
height: img["height"].as_i64().unwrap_or(0), height: img["height"].as_i64().unwrap_or(0),
created_at: now_ms(), created_at: now_ms(),
blob,
}; };
app.store.add_image(&rec)?; app.store.add_image(&rec)?;
records.push(rec); records.push(rec);
@@ -252,13 +255,105 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
Ok(records) 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. /// Downscaled copy for the model's context.
fn preview_for_model(path: &Path) -> Option<ImageData> { fn preview_for_model(path: &Path) -> Option<ImageData> {
let img = image::open(path).ok()?; let img = image::open(path).ok()?;
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle); let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut buf = Vec::new(); let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?; img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) }) Some(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
} }
pub struct GenerateImage; pub struct GenerateImage;
@@ -363,6 +458,37 @@ impl Tool for GenerateImage {
mod tests { mod tests {
use super::*; 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 { fn m(id: &str) -> ImageModel {
with_defaults(ImageModel { id: id.into(), ..Default::default() }) with_defaults(ImageModel { id: id.into(), ..Default::default() })
} }
+232 -63
View File
@@ -1,20 +1,23 @@
//! Scheduled agents ("automations"): a Markdown file with a cron schedule in //! Scheduled agents ("automations"). The instructions are a Markdown file (only the
//! its frontmatter starts an agent session at the given times. //! 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 //! ```markdown
//! --- //! ---
//! name: Nightly dependency check //! name: Nightly dependency check
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone //! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
//! agent: build # mode (default build) //! agent: build
//! model: provider/model # optional, default: server default model //! permission_mode: auto
//! permission_mode: auto # default auto; "bypass" runs without asking //! timeout_minutes: 60
//! timeout_minutes: 60 # optional, default 60
//! --- //! ---
//! Check for outdated dependencies and open a summary … //! 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 //! They are adopted once (settings into the database, the frontmatter removed from the
//! is a normal chat session owned by the job's owner. //! 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::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str}; use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
@@ -40,6 +43,70 @@ pub struct Job {
pub created_at: i64, pub created_at: i64,
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created). /// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
pub webhook: bool, 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. /// Parsed job file.
@@ -52,15 +119,89 @@ pub struct JobSpec {
pub permission_mode: String, pub permission_mode: String,
pub timeout_minutes: u64, pub timeout_minutes: u64,
pub cwd: Option<String>, pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>,
pub prompt: 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> { pub fn parse(path: &Path) -> Result<JobSpec> {
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?; let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (meta, body) = crate::prompts::frontmatter(&text); 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()))?; 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()); let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
if !crate::permission::MODES.contains(&permission_mode.as_str()) { if !crate::permission::MODES.contains(&permission_mode.as_str()) {
bail!("invalid permission_mode '{permission_mode}'"); bail!("invalid permission_mode '{permission_mode}'");
@@ -76,6 +217,7 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
permission_mode, permission_mode,
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60), 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()), cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
assistant_session: None,
prompt: body.trim().to_string(), 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/`. /// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf { 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); let p = PathBuf::from(c);
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) }; 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. /// Job with its parsed file and next run, for the UI and the tool.
pub fn describe(job: &Job) -> Value { pub fn describe(job: &Job) -> Value {
let mut v = json!(job); let mut v = json!(job);
match parse(Path::new(&job.path)) { match spec(job) {
Ok(spec) => { Ok(spec) => {
v["spec"] = json!(spec); v["spec"] = json!(spec);
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000); 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 }); 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 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. /// 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> { pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
let path = PathBuf::from(&job.path); let path = PathBuf::from(&job.path);
let spec = match parse(&path) { let spec = match spec(job) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
app.store.job_finished(&job.id, None, &format!("error: {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, pinned: false,
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
}; };
let session = app.save_session(session)?; let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?; app.store.job_started(&job.id, &session.id, now)?;
@@ -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. /// Background scheduler: checks every 30 s which jobs are due.
pub fn spawn_scheduler(app: Arc<App>) { pub fn spawn_scheduler(app: Arc<App>) {
tokio::spawn(async move { 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 { loop {
tokio::time::sleep(Duration::from_secs(30)).await; tokio::time::sleep(Duration::from_secs(30)).await;
let Ok(jobs) = app.store.jobs(None) else { continue }; let Ok(jobs) = app.store.jobs(None) else { continue };
let now = now_ms(); let now = now_ms();
for job in jobs.into_iter().filter(|j| j.enabled) { 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). // Due if a scheduled time passed since the last run (or since creation).
let base = job.last_run.unwrap_or(job.created_at); let base = job.last_run.unwrap_or(job.created_at);
let Some(next) = next_run(&spec.schedule, base) else { continue }; 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. /// Tool: create, list, run and delete automations.
pub struct Schedule; 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 { fn default_agent() -> String {
"build".into() "build".into()
} }
@@ -259,28 +408,20 @@ fn default_timeout() -> u64 {
60 60
} }
/// Write (and validate) a job file. /// Write the instructions file, register it and store the settings.
pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> { pub fn create(store: &crate::store::Store, owner: &str, path: &Path, mut settings: JobSettings, prompt: &str, enabled: bool) -> Result<Job> {
croner::Cron::from_str(&j.schedule).map_err(|e| anyhow!("invalid schedule '{}': {e}", j.schedule))?; settings.validate()?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'").replace('\n', " ")); if path.exists() {
let plain = |s: &str| s.chars().filter(|c| c.is_alphanumeric() || "-_/.:".contains(*c)).collect::<String>(); bail!("{} already exists", path.display());
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)));
} }
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() { if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
} }
std::fs::write(path, format!("{fm}{}\n", j.prompt.trim()))?; std::fs::write(path, format!("{}\n", prompt.trim()))?;
match parse(path) { let job = store.job_register(owner, &path.display().to_string())?;
Ok(spec) => Ok(spec), store.job_set_settings(&job.id, &settings)?;
Err(e) => { store.job_set_enabled(&job.id, enabled)?;
let _ = std::fs::remove_file(path); store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
Err(e)
}
}
} }
/// Free file name for a new job in `<project>/.codecraft/jobs/`. /// Free file name for a new job in `<project>/.codecraft/jobs/`.
@@ -302,10 +443,11 @@ impl Tool for Schedule {
"schedule" "schedule"
} }
fn description(&self) -> String { fn description(&self) -> String {
"Manage automations: agents that run on a cron schedule from a Markdown job file \ "Manage automations: agents that run on a cron schedule. The instructions are a Markdown file \
(.codecraft/jobs/<name>.md in the working directory). Actions: \ (.codecraft/jobs/<name>.md in the working directory, only the prompt); name, schedule, mode, model and \
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the job file and registers it; \ limits are settings of the automation. Actions: \
`register` (path) registers an existing job file; `list`; `run` (id) starts a run now; \ `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 \ `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. \ 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." 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 prompt = arg_str(&input, "prompt")?;
let rel = format!(".codecraft/jobs/{}.md", slug(name)); let rel = format!(".codecraft/jobs/{}.md", slug(name));
let path = ctx.resolve(&rel)?; let path = ctx.resolve(&rel)?;
write_file( let settings = JobSettings {
&path,
&NewJob {
name: name.into(),
schedule: schedule.into(), schedule: schedule.into(),
agent: input["agent"].as_str().unwrap_or("build").into(), agent: input["agent"].as_str().unwrap_or("build").into(),
model: input["model"].as_str().map(String::from), model: input["model"].as_str().map(String::from),
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(), permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60), timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
prompt: prompt.into(), ..JobSettings::new(name)
}, };
)?; let job = create(&ctx.app.store, &owner, &path, settings, prompt, true)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
let d = describe(&job); let d = describe(&job);
Ok(ToolOutput::text(format!( Ok(ToolOutput::text(format!(
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.", "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" => { "register" => {
let path = ctx.resolve(arg_str(&input, "path")?)?; let path = ctx.resolve(arg_str(&input, "path")?)?;
parse(&path)?; if !path.is_file() {
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?; 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))) Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
} }
"list" => { "list" => {
@@ -447,6 +587,35 @@ mod tests {
assert_eq!(job_cwd(&p, &s), dir); assert_eq!(job_cwd(&p, &s), dir);
std::fs::remove_dir_all(&dir).unwrap(); 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 ------------------------------------------------------------- // --- incoming webhooks -------------------------------------------------------------
+3
View File
@@ -4,6 +4,7 @@ mod api;
mod app; mod app;
mod auth; mod auth;
mod backup; mod backup;
mod blobs;
mod cli_api; mod cli_api;
mod budget; mod budget;
mod config; mod config;
@@ -30,6 +31,7 @@ mod runner;
mod sandbox; mod sandbox;
mod shells; mod shells;
mod store; mod store;
mod telegram;
mod terminal; mod terminal;
mod tools; mod tools;
mod types; mod types;
@@ -73,6 +75,7 @@ async fn main() -> Result<()> {
health::spawn_checker(app.clone()); health::spawn_checker(app.clone());
digest::spawn(app.clone()); digest::spawn(app.clone());
budget::spawn_alerts(app.clone()); budget::spawn_alerts(app.clone());
telegram::spawn(app.clone());
backup::spawn(app.clone()); backup::spawn(app.clone());
let router = api::router(app); let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?; let listener = tokio::net::TcpListener::bind(&bind).await?;
+2
View File
@@ -212,6 +212,8 @@ impl McpServer {
match c["type"].as_str().unwrap_or_default() { match c["type"].as_str().unwrap_or_default() {
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()), "text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
"image" => out.images.push(ImageData { "image" => out.images.push(ImageData {
blob: None,
size: None,
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(), media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
data: c["data"].as_str().unwrap_or_default().into(), data: c["data"].as_str().unwrap_or_default().into(),
}), }),
+11
View File
@@ -216,6 +216,17 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
/// The plain chat mode: no coding instructions, project files or memory – only the /// The plain chat mode: no coding instructions, project files or memory – only the
/// date, the model and what the user keeps in their personal memory. /// date, the model and what the user keeps in their personal memory.
/// Add the instructions the user set for this chat (they come last, so they win).
pub fn with_chat_instructions(mut system: String, instructions: &str) -> String {
if !instructions.trim().is_empty() {
system.push_str(&format!(
"\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n",
instructions.trim()
));
}
system
}
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String { fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
let mut s = agent.prompt.clone(); let mut s = agent.prompt.clone();
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d"))); s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
+28 -5
View File
@@ -291,9 +291,9 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten()); let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
let tools = available_tools(&app, &agent, owner.as_ref()); let tools = available_tools(&app, &agent, owner.as_ref());
let history = active_history(app.store.messages(session_id)?); let history = active_history(app.store.messages_for_model(session_id)?);
let req = ChatRequest { let req = ChatRequest {
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
messages: history, messages: history,
tools: tools.iter().map(|t| t.def()).collect(), tools: tools.iter().map(|t| t.def()).collect(),
max_tokens: model.max_output.unwrap_or(16_000), max_tokens: model.max_output.unwrap_or(16_000),
@@ -563,7 +563,15 @@ async fn execute_call(
// Pass depth through the agent name channel: subagents run with depth+1. // Pass depth through the agent name channel: subagents run with depth+1.
run_task(app, &ctx, input, depth).await run_task(app, &ctx, input, depth).await
} else { } 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. // Diagnostics: report new errors of the changed file right away.
let res = match res { let res = match res {
@@ -609,6 +617,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> { 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 prompt = tools::arg_str(&input, "prompt")?.to_string();
let description = input["description"].as_str().unwrap_or("subagent").to_string(); let description = input["description"].as_str().unwrap_or("subagent").to_string();
@@ -704,6 +717,8 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
pinned: false, pinned: false,
archived: false, archived: false,
folder: None, folder: None,
instructions: String::new(),
telegram_quiet: false,
}; };
let child = app.save_session(child)?; let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -1125,7 +1140,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
model.effort = Some("medium".into()); model.effort = Some("medium".into());
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) }); app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
let result = async { let result = async {
let history = active_history(app.store.messages(&session.id)?); let history = active_history(app.store.messages_for_model(&session.id)?);
// The user's request: the last user message with text that is not feedback/tool output. // The user's request: the last user message with text that is not feedback/tool output.
let req_idx = history let req_idx = history
.iter() .iter()
@@ -1250,7 +1265,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken,
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> { async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
let session = app.session(session_id)?; let session = app.session(session_id)?;
let history = active_history(app.store.messages(session_id)?); let history = active_history(app.store.messages_for_model(session_id)?);
if history.is_empty() { if history.is_empty() {
return Ok(()); return Ok(());
} }
@@ -1299,6 +1314,14 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
mod tests { mod tests {
use super::*; use super::*;
#[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 { fn call(id: &str) -> Part {
Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None } Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None }
} }
+27 -1
View File
@@ -18,6 +18,8 @@ pub struct SandboxSpec {
pub roots: Vec<PathBuf>, pub roots: Vec<PathBuf>,
/// Host directory mounted as the user's home. /// Host directory mounted as the user's home.
pub home: PathBuf, pub home: PathBuf,
/// Extra environment (the user's git identity).
pub env: Vec<(String, String)>,
} }
impl SandboxSpec { impl SandboxSpec {
@@ -35,6 +37,8 @@ pub async fn probe() -> Result<(), String> {
} }
let res = match Command::new("bwrap") let res = match Command::new("bwrap")
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"]) .args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
// The dynamic loader lives in /lib64 on x86_64 (/lib on arm64).
.args(["--ro-bind-try", "/lib64", "/lib64", "--ro-bind-try", "/lib32", "/lib32"])
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"]) .args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
.env_clear() .env_clear()
.output() .output()
@@ -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 home = app.data_dir().join("users").join(&user.username).join("home");
let _ = std::fs::create_dir_all(&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`. /// 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]); push(&[&"--setenv", &k, &v]);
} }
for (k, v) in &spec.env {
push(&[&"--setenv", k, v]);
}
push(&[&"--chdir", &cwd, &program]); push(&[&"--chdir", &cwd, &program]);
for a in args { for a in args {
push(&[a]); push(&[a]);
} }
c 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");
}
}
+192 -8
View File
@@ -1,6 +1,6 @@
//! SQLite persistence for sessions and messages. //! SQLite persistence for sessions and messages.
use crate::types::{Message, Part, Session, now_ms}; use crate::types::{ImageData, Message, Part, Session, now_ms};
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use anyhow::Result; use anyhow::Result;
@@ -90,6 +90,13 @@ impl Store {
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at); CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
-- Images of messages, referenced by hash from the message JSON (see blobs.rs).
CREATE TABLE IF NOT EXISTS blobs (
hash TEXT PRIMARY KEY,
media_type TEXT NOT NULL,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS queue ( CREATE TABLE IF NOT EXISTS queue (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -162,6 +169,8 @@ impl Store {
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"), ("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"), ("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"), ("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"), ("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
] { ] {
let exists: bool = conn let exists: bool = conn
@@ -274,12 +283,21 @@ impl Store {
} }
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> { pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
// Images go to the blob table (encoded before taking the lock).
let mut m = m.clone();
let blobs = crate::blobs::externalize(&mut m);
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
for b in &blobs {
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![b.hash, b.media_type, b.bytes, now_ms()],
)?;
}
conn.execute( conn.execute(
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2, "INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3) (SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
ON CONFLICT(id) DO UPDATE SET data = excluded.data", ON CONFLICT(id) DO UPDATE SET data = excluded.data",
params![m.id, session_id, serde_json::to_string(m)?], params![m.id, session_id, serde_json::to_string(&m)?],
)?; )?;
conn.execute( conn.execute(
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1", "UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
@@ -300,6 +318,107 @@ impl Store {
Ok(out) Ok(out)
} }
/// Messages with the bytes of their images filled in – for model requests and exports.
pub fn messages_for_model(&self, session_id: &str) -> Result<Vec<Message>> {
let mut out = self.messages(session_id)?;
let conn = self.conn.lock().unwrap();
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for m in &mut out {
crate::blobs::for_each_image(m, |img| fill_image(&conn, &mut cache, img));
}
Ok(out)
}
/// Fill in the bytes of one image reference (no-op for inline images).
pub fn resolve_image(&self, img: &ImageData) -> ImageData {
let mut img = img.clone();
let conn = self.conn.lock().unwrap();
fill_image(&conn, &mut std::collections::HashMap::new(), &mut img);
img
}
/// One stored image: (media type, bytes).
pub fn blob(&self, hash: &str) -> Result<Option<(String, Vec<u8>)>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT media_type, data FROM blobs WHERE hash = ?1", [hash], |r| Ok((r.get(0)?, r.get(1)?)))
.optional()?)
}
/// Move inline images of older messages to the blob table (once, after the update
/// that introduced it). Does not touch `updated_at` of the sessions.
pub fn migrate_inline_images(&self) -> Result<usize> {
let ids: Vec<String> = {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id FROM messages WHERE data LIKE '%\"media_type\"%\"data\":\"%'")?;
let rows = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
rows
};
let mut moved = 0;
for id in ids {
let data: Option<String> = {
let conn = self.conn.lock().unwrap();
conn.query_row("SELECT data FROM messages WHERE id = ?1", [&id], |r| r.get(0)).optional()?
};
let Some(data) = data else { continue };
let Ok(mut m) = serde_json::from_str::<Message>(&data) else { continue };
let blobs = crate::blobs::externalize(&mut m);
if blobs.is_empty() {
continue;
}
let conn = self.conn.lock().unwrap();
for b in &blobs {
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![b.hash, b.media_type, b.bytes, now_ms()],
)?;
}
conn.execute("UPDATE messages SET data = ?2 WHERE id = ?1", params![id, serde_json::to_string(&m)?])?;
moved += blobs.len();
}
Ok(moved)
}
/// Delete images no message refers to any more (deleted or truncated sessions).
pub fn gc_blobs(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let mut used = std::collections::HashSet::new();
let mut stmt = conn.prepare("SELECT data FROM messages WHERE data LIKE '%\"blob\":\"%'")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let data: String = row.get(0)?;
for (i, _) in data.match_indices("\"blob\":\"") {
if let Some(h) = data.get(i + 8..i + 72) {
used.insert(h.to_string());
}
}
}
drop(rows);
drop(stmt);
// 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". /// Remove all messages after (and including) the given one – used for "edit & resend".
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> { pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
@@ -312,6 +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. /// One model request, as recorded for statistics.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct UsageRecord { pub struct UsageRecord {
@@ -838,6 +971,10 @@ pub struct ImageRecord {
pub width: i64, pub width: i64,
pub height: i64, pub height: i64,
pub created_at: 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> { 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)?, width: r.get(6)?,
height: r.get(7)?, height: r.get(7)?,
created_at: r.get(8)?, 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 { impl Store {
pub fn add_image(&self, img: &ImageRecord) -> Result<()> { pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"), &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], 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(()) 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). /// Latest images; `user = None` lists everybody's (admins).
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> { pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
@@ -1008,13 +1186,14 @@ impl Store {
last_status: r.get(6)?, last_status: r.get(6)?,
created_at: r.get(7)?, created_at: r.get(7)?,
webhook: r.get::<_, Option<String>>(8)?.is_some(), 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>> { pub fn jobs(&self, owner: Option<&str>) -> Result<Vec<crate::jobs::Job>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( 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", WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
)?; )?;
let rows = stmt.query_map([owner], Self::job_row)?; 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>> { pub fn job(&self, id: &str) -> Result<Option<crate::jobs::Job>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
Ok(conn 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()?) .optional()?)
} }
@@ -1040,7 +1219,7 @@ impl Store {
} }
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
Ok(conn.query_row( 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], [path],
Self::job_row, Self::job_row,
)?) )?)
@@ -1073,6 +1252,11 @@ impl Store {
} }
/// Set (Some) or remove (None) the webhook secret of a job. /// 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<()> { 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])?; self.conn.lock().unwrap().execute("UPDATE jobs SET webhook_secret = ?2 WHERE id = ?1", rusqlite::params![id, secret])?;
Ok(()) Ok(())
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -157,7 +157,12 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..])) pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
} }
None => { 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) { match crate::gitcreds::global_config(app, user) {
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg), Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
None => c, None => c,
+4 -1
View File
@@ -150,9 +150,12 @@ 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"); 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. // 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()); 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)) { if let Some(u) = owner {
if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) {
c.env("GIT_CONFIG_GLOBAL", cfg); c.env("GIT_CONFIG_GLOBAL", cfg);
} }
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
}
c c
} }
}) })
+1 -1
View File
@@ -186,7 +186,7 @@ impl Tool for OpenScad {
continue; continue;
} }
if let Ok(bytes) = std::fs::read(&out) { if let Ok(bytes) = std::fs::read(&out) {
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }); images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
files.push(out); files.push(out);
} }
} }
+2
View File
@@ -27,6 +27,8 @@ pub(crate) fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
let mut buf = Vec::new(); let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?; img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
Ok(ImageData { Ok(ImageData {
blob: None,
size: None,
media_type: "image/png".into(), media_type: "image/png".into(),
data: base64::engine::general_purpose::STANDARD.encode(&buf), data: base64::engine::general_purpose::STANDARD.encode(&buf),
}) })
+2
View File
@@ -107,6 +107,8 @@ impl Tool for Read {
return Ok(ToolOutput { return Ok(ToolOutput {
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()), content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
images: vec![ImageData { images: vec![ImageData {
blob: None,
size: None,
media_type: mt.into(), media_type: mt.into(),
data: base64::engine::general_purpose::STANDARD.encode(&bytes), data: base64::engine::general_purpose::STANDARD.encode(&bytes),
}], }],
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="theme-color" content="#282828" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" /> <link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+2
View File
@@ -12,6 +12,7 @@ import { Settings } from "./components/Settings";
import { Stats } from "./components/Stats"; import { Stats } from "./components/Stats";
import { Images } from "./components/Images"; import { Images } from "./components/Images";
import { Jobs } from "./components/Jobs"; import { Jobs } from "./components/Jobs";
import { JobEditor } from "./components/JobEditor";
import { Spinner, useTitleActivity } from "./components/Activity"; import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs"; import { DialogHost } from "./dialogs";
import { startPrefsSync } from "./prefsSync"; import { startPrefsSync } from "./prefsSync";
@@ -95,6 +96,7 @@ export function App() {
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />; else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />; else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
else if (section === "images") content = <Images 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 === "jobs") content = <Jobs onMenu={() => setMenuOpen(true)} />;
else if (section === "terminal") else if (section === "terminal")
content = ( content = (
+75 -11
View File
@@ -1,18 +1,19 @@
import { takePrefill } from "../share"; import { takePrefill } from "../share";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure"; import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api"; import { del, get, patch, post } from "../api";
import { useModels } from "../hooks"; import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { dismissError, loadSession, loadSessions, useStore } from "../store"; import { dismissError, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types"; import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity"; import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode } from "../prefs"; import { useReasoningMode, useTodosCollapsed } from "../prefs";
import { Composer } from "./Composer"; import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog"; import { ShareDialog } from "./ShareDialog";
import { RenameDialog } from "./RenameDialog"; import { RenameDialog } from "./RenameDialog";
import { InstructionsDialog } from "./InstructionsDialog";
import { RegenerateButton } from "./Regenerate"; import { RegenerateButton } from "./Regenerate";
import { WorktreeChip } from "./WorktreeChip"; import { WorktreeChip } from "./WorktreeChip";
import { QueueList } from "./QueueList"; import { QueueList } from "./QueueList";
@@ -151,6 +152,9 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const isAdmin = useStore((s) => s.me?.role === "admin"); const isAdmin = useStore((s) => s.me?.role === "admin");
const tabs = useChatTabs(sessionId, session?.cwd ?? ""); const tabs = useChatTabs(sessionId, session?.cwd ?? "");
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
// The Telegram switch in the ⋯ menu only matters with a paired bot.
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
const [editingInstructions, setEditingInstructions] = useState(false);
const models = useModels(); const models = useModels();
const [loadError, setLoadError] = useState<string>(); const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState(() => takePrefill(sessionId) ?? ""); const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
@@ -198,6 +202,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const el = scrollRef.current; const el = scrollRef.current;
if (el && stick.current) el.scrollTop = el.scrollHeight; 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 (loadError) return <div className="empty">{loadError}</div>;
if (!session) return <div className="empty">{t("common.loading")}</div>; if (!session) return <div className="empty">{t("common.loading")}</div>;
@@ -307,6 +322,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<ShellsChip sessionId={sessionId} busy={busy} /> <ShellsChip sessionId={sessionId} busy={busy} />
{isAdmin && <BudgetChip />} {isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>} {session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
{!!session.instructions && (
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
<Icon name="edit" /> {t("instructions.chip")}
</button>
)}
{readOnly && <span className="chip">{t("chat.readOnly")}</span>} {readOnly && <span className="chip">{t("chat.readOnly")}</span>}
{!readOnly && ( {!readOnly && (
<> <>
@@ -335,6 +355,30 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="more" /> <Icon name="more" />
</summary> </summary>
<div className="menu-pop-body"> <div className="menu-pop-body">
<a
href="#"
onClick={(e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
setEditingInstructions(true);
}}
>
<Icon name="edit" /> {t("instructions.title")}
</a>
{telegramPaired && !session.parent_id && (
<a
href="#"
role="menuitemcheckbox"
aria-checked={!session.telegram_quiet}
title={t("chat.telegramHint")}
onClick={(e) => {
e.preventDefault();
void update({ telegram_quiet: !session.telegram_quiet });
}}
>
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
</a>
)}
<a href={`/api/sessions/${sessionId}/export?format=md`} download> <a href={`/api/sessions/${sessionId}/export?format=md`} download>
<Icon name="download" /> {t("chat.exportMd")} <Icon name="download" /> {t("chat.exportMd")}
</a> </a>
@@ -454,8 +498,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{imgs.length > 0 && ( {imgs.length > 0 && (
<div className="user-files"> <div className="user-files">
{imgs.map((p, i) => ( {imgs.map((p, i) => (
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}> <FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" /> <img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
</FileFigure> </FileFigure>
))} ))}
</div> </div>
@@ -486,10 +530,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} /> <ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)} )}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && ( {openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<div className="todos"> <TodoPanel todos={todos} />
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
<TodoList todos={todos} />
</div>
)} )}
{permissions.map((p) => ( {permissions.map((p) => (
<PermissionCard key={p.id} req={p} currentId={sessionId} /> <PermissionCard key={p.id} req={p} currentId={sessionId} />
@@ -527,6 +568,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</div> </div>
)} )}
</div> </div>
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />} {editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />} {sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />} {rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
@@ -566,9 +608,10 @@ function UserText({ text }: { text: string }) {
const last = rest.pop()!; const last = rest.pop()!;
if (last.trim().endsWith(":")) break; if (last.trim().endsWith(":")) break;
} }
const body = rest.join("\n").trim();
return ( return (
<> <>
{rest.join("\n").trimEnd()} {body && <Markdown text={body} breaks />}
{files.length > 0 && ( {files.length > 0 && (
<div className="user-files"> <div className="user-files">
{files.map((f) => ( {files.map((f) => (
@@ -615,3 +658,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
</div> </div>
); );
} }
/** Tasks of the model above the composer; folds to one line (current task, progress). */
function TodoPanel({ todos }: { todos: Todo[] }) {
const t = useT();
const [collapsed, setCollapsed] = useTodosCollapsed();
const done = todos.filter((x) => x.status === "completed").length;
const current = todos.find((x) => x.status === "in_progress");
return (
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
<span>{t("tool.todos")}</span>
<span className="todos-count">
{done}/{todos.length}
</span>
{collapsed && current && <span className="todos-current">{current.content}</span>}
</button>
{!collapsed && <TodoList todos={todos} />}
</div>
);
}
+27 -2
View File
@@ -1,6 +1,6 @@
import { TemplatePicker } from "./TemplatePicker"; import { TemplatePicker } from "./TemplatePicker";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { formatSize } from "./FileFigure"; import { formatSize, imageSrc } from "./FileFigure";
import { uploadFile } from "../api"; import { uploadFile } from "../api";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch, useModels } from "../hooks"; import { useAgents, useFetch, useModels } from "../hooks";
@@ -9,6 +9,7 @@ import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect"; import { ModelSelect } from "./ModelSelect";
import { EffortSelect } from "./Regenerate"; import { EffortSelect } from "./Regenerate";
import { VoiceButton } from "./Voice"; import { VoiceButton } from "./Voice";
import { Markdown } from "./Markdown";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion { interface Suggestion {
@@ -67,6 +68,8 @@ export function Composer({
const [effort, setEffort] = useState(""); const [effort, setEffort] = useState("");
const [interim, setInterim] = useState(""); const [interim, setInterim] = useState("");
const [picker, setPicker] = useState(false); 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 models = useModels();
const [prov, ...mid] = session.model.split("/"); const [prov, ...mid] = session.model.split("/");
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning; 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) => ( {images.map((img, i) => (
<div className="att" key={i}> <div className="att" key={i}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" /> <img src={imageSrc(img)} alt="" />
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}> <button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" /> <Icon name="close" />
</button> </button>
@@ -265,7 +268,20 @@ export function Composer({
))} ))}
</div> </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 <textarea
style={preview ? { display: "none" } : undefined}
ref={ref} ref={ref}
rows={1} rows={1}
value={text} value={text}
@@ -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} /> <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")}> <label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
<Icon name="clip" /> <Icon name="clip" />
+217
View File
@@ -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>
);
}
+11
View File
@@ -1,5 +1,6 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import type { ImageData } from "../types";
/** Human-readable file size. */ /** Human-readable file size. */
export function formatSize(n: number) { export function formatSize(n: number) {
@@ -32,6 +33,16 @@ export function basename(p: string) {
} }
/** Bytes of a base64 payload. */ /** Bytes of a base64 payload. */
/** URL of an image of a message: inline (data URL) or from the image store. */
export function imageSrc(img: ImageData) {
return img.data ? `data:${img.media_type};base64,${img.data}` : `/api/blobs/${img.blob}`;
}
/** Size in bytes of an image of a message. */
export function imageSize(img: ImageData) {
return img.data ? base64Size(img.data) : img.size;
}
export function base64Size(b64: string) { export function base64Size(b64: string) {
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
return Math.floor((b64.length * 3) / 4) - pad; return Math.floor((b64.length * 3) / 4) - pad;
+55
View File
@@ -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>
);
}
+298
View File
@@ -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>
</>
);
}
-134
View File
@@ -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
View File
@@ -5,9 +5,9 @@ import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs"; import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { Spinner } from "./Activity"; import { Spinner } from "./Activity";
import { JobTemplateDialog } from "./JobTemplateDialog"; import { NewJobDialog } from "./NewJobDialog";
interface JobInfo { export interface JobInfo {
id: string; id: string;
path: string; path: string;
enabled: boolean; enabled: boolean;
@@ -17,12 +17,12 @@ interface JobInfo {
next_run?: number | null; next_run?: number | null;
error?: string; error?: string;
webhook?: boolean; 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" }) : "–"); 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 }) { export function Jobs({ onMenu }: { onMenu: () => void }) {
const t = useT(); const t = useT();
const [jobs, setJobs] = useState<JobInfo[]>(); const [jobs, setJobs] = useState<JobInfo[]>();
@@ -68,10 +68,10 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="notice">{t("jobs.intro")}</div> <div className="notice">{t("jobs.intro")}</div>
<div className="row" style={{ margin: "12px 0" }}> <div className="row" style={{ margin: "12px 0" }}>
<button className="primary" onClick={() => setTemplates(true)}> <button className="primary" onClick={() => setTemplates(true)}>
<Icon name="plus" /> {t("jobs.fromTemplate")} <Icon name="plus" /> {t("jobs.new")}
</button> </button>
</div> </div>
{templates && <JobTemplateDialog onClose={() => setTemplates(false)} onCreated={() => void reload()} />} {templates && <NewJobDialog onClose={() => setTemplates(false)} />}
{!jobs && <div className="empty"><Spinner /></div>} {!jobs && <div className="empty"><Spinner /></div>}
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>} {jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
{jobs?.map((j) => ( {jobs?.map((j) => (
@@ -79,7 +79,11 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="job-head"> <div className="job-head">
<Icon name="clock" className="job-icon" /> <Icon name="clock" className="job-icon" />
<div style={{ flex: 1, minWidth: 0 }}> <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 className="hint mono">{j.path}</div>
</div> </div>
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}> <label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
@@ -107,7 +111,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
</span> </span>
<span>{t("jobs.mode")}</span> <span>{t("jobs.mode")}</span>
<span> <span>
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min {j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {t(`permissionMode.${j.spec?.permission_mode ?? "auto"}` as any)} · max {j.spec?.timeout_minutes} min
</span> </span>
</div> </div>
)} )}
@@ -163,14 +167,8 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
})}> })}>
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")} {busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
</button> </button>
<button <button className="small" onClick={() => navigate(`/jobs/${j.id}`)}>
className="small ghost" <Icon name="edit" /> {t("jobs.edit")}
onClick={() => {
sessionStorage.setItem("codecraft.openFile", j.path);
navigate("/files");
}}
>
<Icon name="edit" /> {t("jobs.editFile")}
</button> </button>
<span className="grow" /> <span className="grow" />
<button <button
@@ -190,20 +188,13 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="row" style={{ gap: 8 }}> <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)} /> <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 () => { <button disabled={!path.trim()} onClick={() => act("new", async () => {
await post("/jobs", { path: path.trim() }); const j = await post<JobInfo>("/jobs", { path: path.trim() });
setPath(""); setPath("");
navigate(`/jobs/${j.id}`);
})}> })}>
{t("jobs.registerBtn")} {t("jobs.registerBtn")}
</button> </button>
</div> </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> </div>
</div> </div>
+25 -2
View File
@@ -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 ( return (
<div className="markdown"> <div className="markdown">
<ReactMarkdown <ReactMarkdown
@@ -81,7 +104,7 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
}, },
}} }}
> >
{text} {breaks ? keepLineBreaks(text) : text}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
); );
+128
View File
@@ -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>
);
}
+22 -2
View File
@@ -1,11 +1,12 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { post, uploadFile } from "../api"; import { post, uploadFile } from "../api";
import { promptDialog, toast } from "../dialogs";
import { useAgents, useFetch } from "../hooks"; import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n"; import { useT, type Key } from "../i18n";
import type { ImageData } from "../types"; import type { ImageData } from "../types";
import { readImage } from "./Composer"; import { readImage } from "./Composer";
import { formatSize } from "./FileFigure"; import { formatSize, imageSrc } from "./FileFigure";
import { ModelSelect } from "./ModelSelect"; import { ModelSelect } from "./ModelSelect";
import { takePrefill } from "../share"; import { takePrefill } from "../share";
@@ -478,7 +479,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<div className="attachments"> <div className="attachments">
{images.map((img, i) => ( {images.map((img, i) => (
<div className="att" key={`i${i}`}> <div className="att" key={`i${i}`}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" /> <img src={imageSrc(img)} alt="" />
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}> <button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" /> <Icon name="close" />
</button> </button>
@@ -602,6 +603,20 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [listing?.path]); }, [listing?.path]);
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : ""; 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 ( return (
<div className="wiz-picker"> <div className="wiz-picker">
{roots.length > 1 && ( {roots.length > 1 && (
@@ -622,6 +637,11 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
<Icon name="arrow-up" /> {t("new.up")} <Icon name="arrow-up" /> {t("new.up")}
</button> </button>
)} )}
{listing && (
<button className="small" onClick={() => void newFolder()}>
<Icon name="plus" /> {t("files.newFolder")}
</button>
)}
</div> </div>
<div className="dir-list"> <div className="dir-list">
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>} {listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
+2 -1
View File
@@ -3,6 +3,7 @@ import { del, post } from "../api";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { useStore } from "../store"; import { useStore } from "../store";
import type { QueueItem } from "../types"; import type { QueueItem } from "../types";
import { imageSrc } from "./FileFigure";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { confirmDialog, promptDialog, toast } from "../dialogs";
const EMPTY: QueueItem[] = []; const EMPTY: QueueItem[] = [];
@@ -30,7 +31,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
{it.source === "background" && <span className="chip">{t("tool.background")}</span>} {it.source === "background" && <span className="chip">{t("tool.background")}</span>}
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span> <span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
{images.map((p, i) => ( {images.map((p, i) => (
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" /> <img key={i} src={imageSrc(p.image)} alt="" />
))} ))}
{it.source !== "background" && ( {it.source !== "background" && (
<button <button
+197 -11
View File
@@ -2,7 +2,7 @@ import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds"; import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; 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 { promptDialog, toast } from "../dialogs";
import { fmtRelative, useT, type Key } from "../i18n"; import { fmtRelative, useT, type Key } from "../i18n";
import { navigate, openBeside } from "../router"; import { navigate, openBeside } from "../router";
@@ -102,6 +102,113 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
); );
} }
/** 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 }) { export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
const t = useT(); const t = useT();
const sessions = useStore((s) => s.sessions); const sessions = useStore((s) => s.sessions);
@@ -164,14 +271,15 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
const live = sessions.filter((s) => !s.archived); const live = sessions.filter((s) => !s.archived);
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin"); for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
const rest = live.filter((s) => !s.pinned); 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"); 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); const loose = rest.filter((s) => !s.folder);
if (groupBy === "project") { if (groupBy === "project") {
const byProject = new Map<string, Session[]>(); const byProject = new Map<string, Session[]>();
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]); for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
// Projects with the most recent activity first. // Order of the projects: see `ordered` below (not by activity – a new message
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"); // 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 { } else {
for (const s of loose) { for (const s of loose) {
const key = groupKey(s.updated_at); const key = groupKey(s.updated_at);
@@ -181,6 +289,81 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive"); for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
return out; return out;
}, [sessions, groupBy, t]); }, [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]); const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
return ( return (
@@ -235,14 +418,17 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
</div> </div>
)} )}
{!hits && {!hits &&
groups.map((g) => ( [...ordered.head, ...ordered.mid, ...ordered.tail].map((g) => (
<div key={g.id}> <div key={g.id}>
<button className="session-group" onClick={() => toggle(g.id)} aria-expanded={!collapsed.has(g.id)}> <GroupHeader
<Icon name={collapsed.has(g.id) ? "chevron-right" : "chevron-down"} className="tiny" /> g={g}
{g.icon && <Icon name={g.icon} className="tiny" />} collapsed={collapsed.has(g.id)}
<span className="grow">{g.label}</span> pinned={!!layout?.pinned.includes(g.id)}
{collapsed.has(g.id) && <span className="count">{g.items.length}</span>} onToggle={() => toggle(g.id)}
</button> 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) && {!collapsed.has(g.id) &&
g.items.map((s) => ( g.items.map((s) => (
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} 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} />
+20 -6
View File
@@ -1,5 +1,5 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { base64Size, FileFigure } from "./FileFigure"; import { FileFigure, imageSize, imageSrc } from "./FileFigure";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { useStore } from "../store"; import { useStore } from "../store";
@@ -158,16 +158,30 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
)} )}
{Array.isArray(meta?.image_ids) ? ( {Array.isArray(meta?.image_ids) ? (
<div className="gallery small"> <div className="gallery small">
{meta.image_ids.map((id: string) => ( {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"> <FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
<img src={`/api/images/${id}/file`} alt="" /> <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> </FileFigure>
))} );
})}
</div> </div>
) : ( ) : (
result?.images?.map((img, i) => ( result?.images?.map((img, i) => (
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}> <FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" /> <img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
</FileFigure> </FileFigure>
)) ))
)} )}
+2
View File
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
import { ApiTokens } from "./ApiTokens"; import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall"; import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory"; import { Memory } from "./Memory";
import { Telegram } from "./Telegram";
export function Account() { export function Account() {
const t = useT(); const t = useT();
@@ -51,6 +52,7 @@ export function Account() {
<Memory /> <Memory />
<GitAccount /> <GitAccount />
<CliInstall /> <CliInstall />
<Telegram />
<ApiTokens /> <ApiTokens />
</> </>
); );
+104
View File
@@ -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>
);
}
+78 -9
View File
@@ -344,7 +344,7 @@ export const de: Messages = {
"agents.refineShort": "Review ×{n}", "agents.refineShort": "Review ×{n}",
"agents.deleteFile": "Agenten-Datei {path} löschen?", "agents.deleteFile": "Agenten-Datei {path} löschen?",
"nav.jobs": "Automationen", "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.none": "Noch keine Automationen.",
"jobs.schedule": "Zeitplan", "jobs.schedule": "Zeitplan",
"jobs.next": "Nächster Lauf", "jobs.next": "Nächster Lauf",
@@ -355,13 +355,12 @@ export const de: Messages = {
"jobs.enable": "Aktivieren", "jobs.enable": "Aktivieren",
"jobs.disable": "Deaktivieren", "jobs.disable": "Deaktivieren",
"jobs.runNow": "Jetzt starten", "jobs.runNow": "Jetzt starten",
"jobs.editFile": "Datei bearbeiten",
"jobs.remove": "Entfernen", "jobs.remove": "Entfernen",
"jobs.deleteConfirm": "Automation „{name}“ entfernen? Die Job-Datei bleibt erhalten.", "jobs.deleteConfirm": "Automation „{name}“ entfernen? Die Job-Datei bleibt erhalten.",
"jobs.openRun": "Lauf öffnen", "jobs.openRun": "Lauf öffnen",
"jobs.prompt": "Auftrag", "jobs.prompt": "Auftrag",
"jobs.register": "Job-Datei registrieren", "jobs.register": "Vorhandene Datei registrieren",
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Zeitplan im Frontmatter:", "jobs.registerHint": "Pfad zu einer Markdown-Datei mit Anweisungen (ältere Dateien mit Zeitplan im Frontmatter werden übernommen):",
"jobs.registerBtn": "Registrieren", "jobs.registerBtn": "Registrieren",
"jobs.status.running": "läuft", "jobs.status.running": "läuft",
"jobs.status.done": "fertig", "jobs.status.done": "fertig",
@@ -405,6 +404,19 @@ export const de: Messages = {
"tokens.title": "API-Tokens", "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.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:", "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.copy": "Kopieren",
"tokens.copied": "Kopiert", "tokens.copied": "Kopiert",
"tokens.name": "Name", "tokens.name": "Name",
@@ -496,6 +508,11 @@ export const de: Messages = {
"sidebar.unpin": "Nicht mehr anpinnen", "sidebar.unpin": "Nicht mehr anpinnen",
"sidebar.openBeside": "Daneben öffnen", "sidebar.openBeside": "Daneben öffnen",
"chat.closePane": "Spalte schließen", "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.moveTo": "In Ordner verschieben…",
"sidebar.removeFromFolder": "Aus Ordner entfernen", "sidebar.removeFromFolder": "Aus Ordner entfernen",
"sidebar.archiveOne": "Archivieren", "sidebar.archiveOne": "Archivieren",
@@ -532,16 +549,12 @@ export const de: Messages = {
"backup.s3Keep": "Im Bucket behalten (0 = alle)", "backup.s3Keep": "Im Bucket behalten (0 = alle)",
"backup.last": "Letztes Backup", "backup.last": "Letztes Backup",
"backup.s3Ok": "hochgeladen", "backup.s3Ok": "hochgeladen",
"jobs.fromTemplate": "Neue Automation aus Vorlage",
"jobs.tplName": "Name", "jobs.tplName": "Name",
"jobs.tplProject": "Projektordner", "jobs.tplProject": "Projektordner",
"jobs.tplSchedule": "Wann",
"jobs.tplCronHint": "Cron: Minute Stunde Tag Monat Wochentag (Zeitzone des Servers).",
"jobs.tplMode": "Modus", "jobs.tplMode": "Modus",
"jobs.tplPrompt": "Aufgabe für den Agenten",
"jobs.tplCreate": "Automation anlegen",
"new.title": "Neue Session", "new.title": "Neue Session",
"new.directory": "Projektverzeichnis", "new.directory": "Projektverzeichnis",
"new.invalidFolderName": "Ordnernamen dürfen kein „/“ enthalten.",
"new.up": "Hoch", "new.up": "Hoch",
"new.noSubdirs": "Keine Unterverzeichnisse", "new.noSubdirs": "Keine Unterverzeichnisse",
"new.model": "Modell", "new.model": "Modell",
@@ -580,6 +593,59 @@ export const de: Messages = {
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert", "chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
"chat.compactingManual": "Unterhaltung wird komprimiert", "chat.compactingManual": "Unterhaltung wird komprimiert",
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter", "chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
"chat.telegram": "Fragen an Telegram senden",
"chat.telegramHint": "Freigaben und Rückfragen dieses Chats auch per Telegram schicken (der gerade in Telegram geöffnete Chat meldet sich immer)",
"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.compacted": "Unterhaltung komprimiert – Zusammenfassung",
"chat.context": "Kontext", "chat.context": "Kontext",
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage", "chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
@@ -597,6 +663,9 @@ export const de: Messages = {
"composer.mode": "Modus", "composer.mode": "Modus",
"composer.model": "Modell", "composer.model": "Modell",
"composer.permissionMode": "Berechtigungsmodus", "composer.permissionMode": "Berechtigungsmodus",
"composer.preview": "Vorschau (Markdown)",
"composer.previewEdit": "Zurück zum Bearbeiten",
"composer.previewEmpty": "Noch nichts zum Anzeigen.",
"composer.attach": "Dateien anhängen", "composer.attach": "Dateien anhängen",
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben", "command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
+78 -9
View File
@@ -343,7 +343,7 @@ export const en = {
"agents.refineShort": "Review ×{n}", "agents.refineShort": "Review ×{n}",
"agents.deleteFile": "Delete agent file {path}?", "agents.deleteFile": "Delete agent file {path}?",
"nav.jobs": "Automations", "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.none": "No automations yet.",
"jobs.schedule": "Schedule", "jobs.schedule": "Schedule",
"jobs.next": "Next run", "jobs.next": "Next run",
@@ -354,13 +354,12 @@ export const en = {
"jobs.enable": "Enable", "jobs.enable": "Enable",
"jobs.disable": "Disable", "jobs.disable": "Disable",
"jobs.runNow": "Run now", "jobs.runNow": "Run now",
"jobs.editFile": "Edit file",
"jobs.remove": "Remove", "jobs.remove": "Remove",
"jobs.deleteConfirm": "Remove the automation “{name}”? The job file stays.", "jobs.deleteConfirm": "Remove the automation “{name}”? The job file stays.",
"jobs.openRun": "Open run", "jobs.openRun": "Open run",
"jobs.prompt": "Instructions", "jobs.prompt": "Instructions",
"jobs.register": "Register a job file", "jobs.register": "Register an existing file",
"jobs.registerHint": "Path to a Markdown file with a schedule in its frontmatter:", "jobs.registerHint": "Path to a Markdown file with instructions (older files with a schedule in the frontmatter are taken over):",
"jobs.registerBtn": "Register", "jobs.registerBtn": "Register",
"jobs.status.running": "running", "jobs.status.running": "running",
"jobs.status.done": "done", "jobs.status.done": "done",
@@ -404,6 +403,19 @@ export const en = {
"tokens.title": "API tokens", "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.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:", "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.copy": "Copy",
"tokens.copied": "Copied", "tokens.copied": "Copied",
"tokens.name": "Name", "tokens.name": "Name",
@@ -495,6 +507,11 @@ export const en = {
"sidebar.unpin": "Unpin", "sidebar.unpin": "Unpin",
"sidebar.openBeside": "Open beside", "sidebar.openBeside": "Open beside",
"chat.closePane": "Close this column", "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.moveTo": "Move to folder…",
"sidebar.removeFromFolder": "Remove from folder", "sidebar.removeFromFolder": "Remove from folder",
"sidebar.archiveOne": "Archive", "sidebar.archiveOne": "Archive",
@@ -531,16 +548,12 @@ export const en = {
"backup.s3Keep": "Keep in bucket (0 = all)", "backup.s3Keep": "Keep in bucket (0 = all)",
"backup.last": "Last backup", "backup.last": "Last backup",
"backup.s3Ok": "uploaded", "backup.s3Ok": "uploaded",
"jobs.fromTemplate": "New automation from a template",
"jobs.tplName": "Name", "jobs.tplName": "Name",
"jobs.tplProject": "Project folder", "jobs.tplProject": "Project folder",
"jobs.tplSchedule": "When",
"jobs.tplCronHint": "Cron: minute hour day month weekday (server time zone).",
"jobs.tplMode": "Mode", "jobs.tplMode": "Mode",
"jobs.tplPrompt": "Task for the agent",
"jobs.tplCreate": "Create automation",
"new.title": "New session", "new.title": "New session",
"new.directory": "Project directory", "new.directory": "Project directory",
"new.invalidFolderName": "Folder names cannot contain “/”.",
"new.up": "Up", "new.up": "Up",
"new.noSubdirs": "No subdirectories", "new.noSubdirs": "No subdirectories",
"new.model": "Model", "new.model": "Model",
@@ -579,6 +592,59 @@ export const en = {
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically", "chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
"chat.compactingManual": "Compacting the conversation", "chat.compactingManual": "Compacting the conversation",
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards", "chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
"chat.telegram": "Send questions to Telegram",
"chat.telegramHint": "Also send approvals and questions of this chat via Telegram (the chat currently open in Telegram always reports)",
"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.compacted": "Conversation compacted – summary",
"chat.context": "Context", "chat.context": "Context",
"chat.contextHint": "{tokens} tokens in the last request", "chat.contextHint": "{tokens} tokens in the last request",
@@ -596,6 +662,9 @@ export const en = {
"composer.mode": "Mode", "composer.mode": "Mode",
"composer.model": "Model", "composer.model": "Model",
"composer.permissionMode": "Permission mode", "composer.permissionMode": "Permission mode",
"composer.preview": "Preview (Markdown)",
"composer.previewEdit": "Back to editing",
"composer.previewEmpty": "Nothing to preview yet.",
"composer.attach": "Attach files", "composer.attach": "Attach files",
"command.compact": "Summarize the conversation to free up context", "command.compact": "Summarize the conversation to free up context",
-10
View File
@@ -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.", "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" },
];
+37
View File
@@ -10,6 +10,43 @@ import { captureShare } from "./share";
applyTheme(); applyTheme();
captureShare(); captureShare();
registerSW({ immediate: true }); 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( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
+18
View File
@@ -25,3 +25,21 @@ export function useReasoningMode(): ReasoningMode {
getReasoningMode, getReasoningMode,
); );
} }
const TODOS_KEY = "codecraft.todosCollapsed";
/** Task list above the composer folded to a one-line summary (per device). */
export function useTodosCollapsed(): [boolean, (v: boolean) => void] {
const v = useSyncExternalStore(
(l) => {
listeners.add(l);
return () => listeners.delete(l);
},
() => localStorage.getItem(TODOS_KEY) === "1",
);
const set = (c: boolean) => {
localStorage.setItem(TODOS_KEY, c ? "1" : "0");
listeners.forEach((l) => l());
};
return [v, set];
}
+386
View File
@@ -299,6 +299,25 @@ button.session-group:hover {
.session-row .session-item { .session-row .session-item {
padding-right: 30px; 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 { .session-more {
position: absolute; position: absolute;
top: 6px; top: 6px;
@@ -508,6 +527,20 @@ button.session-group:hover {
word-break: break-word; word-break: break-word;
position: relative; 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 { .msg-user .msg-actions {
position: absolute; position: absolute;
top: 4px; top: 4px;
@@ -721,6 +754,39 @@ button.session-group:hover {
margin: 8px 0; margin: 8px 0;
font-size: 13px; font-size: 13px;
} }
.todos-head {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 0;
background: none;
border: none;
color: var(--muted);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.todos-head:hover {
color: var(--fg1);
}
.todos-count {
font-variant-numeric: tabular-nums;
}
.todos-current {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--accent);
font-weight: 600;
}
.todos.collapsed {
padding-block: 6px;
}
.todos ul { .todos ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -3127,6 +3193,11 @@ button.danger-solid {
.job-grid > span:nth-child(odd) { .job-grid > span:nth-child(odd) {
color: var(--fg3); color: var(--fg3);
} }
.job-grid select {
max-width: 100%;
font-size: 13px;
padding: 2px 6px;
}
.job-prompt { .job-prompt {
margin-top: 10px; margin-top: 10px;
} }
@@ -3569,6 +3640,217 @@ button.danger-solid {
border-color: var(--accent); border-color: var(--accent);
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 { .chip.price-extra {
margin-left: 4px; margin-left: 4px;
@@ -3770,3 +4052,107 @@ span.chat-tab > button {
display: none; display: none;
} }
} }
.instructions-modal {
width: min(640px, calc(100vw - 32px));
}
/* --- short screens ----------------------------------------------------------
* Square phones (e.g. Clicks Communicator, ~393x400 CSS px with a hardware
* keyboard) and phones in landscape: height is the scarce dimension, so bars,
* the composer and the new-chat wizard get tighter and the chat keeps the room. */
@media (max-height: 560px) {
.topbar {
min-height: 40px;
padding: 4px 10px;
padding-top: max(4px, env(safe-area-inset-top));
gap: 4px;
}
.topbar button.ghost {
padding: 4px 6px;
}
.chat {
padding: 10px 12px 14px;
}
.msg {
margin-bottom: 10px;
}
.composer-wrap {
padding: 6px 10px;
padding-bottom: max(6px, env(safe-area-inset-bottom));
}
.composer textarea {
min-height: 38px;
max-height: 30vh;
padding: 8px 10px;
}
/* One placeholder line instead of two (the textarea grows with its content). */
.composer textarea::placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.composer-bar {
margin-top: 4px;
gap: 4px;
}
.composer-bar select {
max-width: 150px;
padding: 2px 4px;
}
.composer-actions button {
padding: 4px 10px;
}
.todos {
margin: 4px 0;
padding: 6px 10px;
}
.page {
padding: 12px;
}
.wiz-title {
font-size: 19px;
margin: 0 0 8px;
}
.wiz-sub {
display: none;
}
.wiz-panel {
padding: 12px;
}
.wiz-step {
margin-bottom: 16px;
}
.wiz-step h3 {
font-size: 16px;
margin-bottom: 8px;
}
.wiz-cards,
.wiz-cards.five {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.wiz-card {
padding: 8px 10px;
gap: 8px;
min-height: 44px;
font-size: 14px;
}
.wiz-card.active {
padding: 7px 9px;
}
.wiz-card-text {
padding-right: 14px;
}
.wiz-card-desc {
display: none;
}
.wiz-icon {
width: 20px;
height: 20px;
}
.wiz-num {
width: 24px;
height: 24px;
font-size: 12px;
}
}
+10 -1
View File
@@ -2,7 +2,12 @@
export interface ImageData { export interface ImageData {
media_type: string; media_type: string;
data: string; /** Base64 bytes; absent when the server stores the image separately (blob). */
data?: string;
/** SHA-256 of a stored image: GET /api/blobs/<blob>. */
blob?: string;
/** Size in bytes of a stored image. */
size?: number;
} }
export type Part = export type Part =
@@ -67,6 +72,10 @@ export interface Session {
pinned?: boolean; pinned?: boolean;
archived?: boolean; archived?: boolean;
folder?: string | null; folder?: string | null;
/** Instructions for this chat (added to the system prompt). */
instructions?: string;
/** Approvals and questions of this chat are not sent to Telegram. */
telegram_quiet?: boolean;
} }
export interface QuestionOption { export interface QuestionOption {
+46
View File
@@ -3,6 +3,52 @@
* under "unreleased"; scripts/release.sh renames the key to the release version. * under "unreleased"; scripts/release.sh renames the key to the release version.
*/ */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = { export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-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": { "202611-00004": {
en: [ en: [
"Chats side by side: “Open beside” in a chat's menu in the sidebar", "Chats side by side: “Open beside” in a chat's menu in the sidebar",