Compare commits

...
Author SHA1 Message Date
Richard Zink 5d5c0f8124 Release v202611-00037 2026-11-15 13:53:13 +01:00
Richard ZinkandClaude Sonnet 5.5 f77b101847 Ask about the chat's own folder when deleting a chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 13:53:10 +01:00
Richard Zink 12a3198e77 Release v202611-00036 2026-11-15 13:04:03 +01:00
Richard ZinkandClaude Sonnet 5.5 4e6f74d477 Offer all CodeCraft tools to the Claude Code CLI over an MCP bridge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 13:04:00 +01:00
Richard Zink 658148ada7 Release v202611-00035 2026-11-15 12:33:26 +01:00
Richard ZinkandClaude Sonnet 5.5 b6e4500233 Fix SSH key creation as a uid without passwd entry (ssh-keygen, ssh-keyscan, ssh-add wrappers)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 12:33:24 +01:00
Richard Zink 126c7719b9 Release v202611-00034 2026-11-15 12:15:04 +01:00
Richard ZinkandClaude Sonnet 5.5 fccaee4b94 Fix ssh/scp/sftp for containers running as a uid without passwd entry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 12:15:02 +01:00
Richard Zink d58b23d35f Release v202611-00033 2026-11-15 11:47:34 +01:00
Richard ZinkandClaude Sonnet 5.5 6e8e739906 SSH keys in account settings; wrappers for agents, terminal and git
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 11:47:31 +01:00
Richard Zink 247ddcf14a Release v202611-00032 2026-11-15 10:52:10 +01:00
Richard ZinkandClaude Sonnet 5.5 f0cd404f59 Tell agents about stored git credentials; give Claude Code the user's git config
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 10:52:07 +01:00
Richard Zink ec95f9b8ad Release v202611-00031 2026-11-15 09:54:04 +01:00
Richard ZinkandClaude Sonnet 5.5 99ba3835f3 Show images read by agents; keep tool-result images with Claude Code
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 09:54:00 +01:00
Richard Zink 92a99c6a65 Release v202611-00030 2026-11-15 09:24:58 +01:00
Richard ZinkandClaude Sonnet 5.5 4c7d67cabf Add /clear command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 09:24:56 +01:00
Richard Zink 4423148552 Release v202611-00029 2026-11-15 08:20:53 +01:00
Richard ZinkandClaude Sonnet 5.5 388ca96419 Stats history with year filter, folding and XLSX/CSV export; snippets, read-only links, commit suggestions, weekly review, offline copies, server speech-to-text, planning model, job reports; fal.ai health fix
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 08:20:52 +01:00
Richard Zink c08c6c7436 Release v202611-00028 2026-11-14 23:53:20 +01:00
Richard ZinkandClaude Sonnet 5.5 1b3e3af4b7 More chat icons; message menu inside the bubble
- 18 more icons (gamepad, computer, food, ...) with a test that keeps the
  server whitelist and the client picker identical.
- The ⋯ menu of a message moves into the bubble foot (next to the time); its
  popup opens upwards inside the bubble and is limited to the bubble's width.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 23:53:14 +01:00
Richard Zink 44dd6978d4 Release v202611-00027 2026-11-14 23:32:10 +01:00
Richard ZinkandClaude Sonnet 5.5 8a90f83db5 Changelog: bubbles, chat icons, colour tags
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 23:32:03 +01:00
Richard ZinkandClaude Sonnet 5.5 ce0d8afe69 Chat bubbles, icons for chats, more vivid and distinct colour tags
- Bubbles: own messages right-aligned in their own colour, agent answers left.
- Chat icons (22, validated on the server), shown in list, top bar, project page.
- Colour tags: eight fixed hues (new: pink) with theme-matched lightness and the
  highest chroma sRGB allows; stronger background tint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 23:31:52 +01:00
Richard Zink f34a0c7a73 Release v202611-00026 2026-11-14 22:31:47 +01:00
Richard ZinkandClaude Sonnet 5.5 62fe5bf467 Claude Code engine, image viewer, colour tags, checks per turn and more
- Claude Code as an engine: chats on a "claude-code/<alias>" model run through
  the claude CLI (stream-json); tool approvals go through our permission
  system. Sign in from the web UI (URL + pasted code), CLI version and update
  from Settings; the Docker image contains the CLI.
- Small models (8k context) for side tasks: input is cut to the model's
  context window; one-line chat status, "What does this do?" on approvals,
  "Plan as automation" (job draft from a chat).
- Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark
  up, use in message), jump to previous own message, message menu, time under
  every message.
- Approvals: push actions (allow/reject), "always allow in this project"
  (never for dangerous commands) with revoke.
- Per turn: changed files with diffs and rollback; project check command with
  a green/red badge and "let the agent fix it".
- Colour tags for chats in the colours of the active theme.
- Project defaults for new chats, project page (timeline), offline queue.

Tested in a throwaway container with a mock model (8k small model): 26 API
checks passed (permissions, diffs, checks, defaults, job draft, side-task
budget, Claude Code status/login URL, unsigned-in chat).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 22:30:12 +01:00
Richard Zink 084452abf6 Release v202611-00025 2026-11-12 12:12:19 +01:00
Richard ZinkandClaude Opus 5.5 8b4bd59572 mcp_search also points to built-in tools and their availability
Models looked for generate_3d with mcp_search, which only searched MCP
servers, and concluded it did not exist (the chat had been in plan mode, then
switched to build). The search now lists matching built-in tools first:
"available – call it directly" or "not in the read-only mode plan – ask the
user to switch to build mode".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:12:19 +01:00
Richard Zink c3df4a23cc Release v202611-00024 2026-11-12 11:38:25 +01:00
Richard ZinkandClaude Opus 5.5 48621f21d6 generate_3d: 3D models with fal.ai (image, text, or image steered by text)
- New tool: image-to-3D (project file or an image attached to the chat,
  optional back/left/right views) or text-to-3D; image + prompt refines the
  image with the prompt first (the 3D models take no text with an image).
  Styles textured / geometry / lowpoly, optional PBR, face count. Runs through
  fal.ai's queue API with progress, saves GLB + preview, books the cost
  (published prices, overridable per model). Request bodies for the Hunyuan3D,
  Tripo and Trellis families; defaults [images] model_3d / model_3d_text.
- Chat card: "Open in the 3D view"; media overview: kind "model" (also
  STL/3MF/OBJ written by agents) with previews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:38:24 +01:00
Richard Zink d7baa77922 Release v202611-00023 2026-11-12 07:58:23 +01:00
Richard ZinkandClaude Opus 5.5 1e8418336d Value opencode Go plan requests; per-duration cache prices; usage under answers
- opencode Go reports plan requests as cost 0 with the usage in allowance;
  ModelConfig::cost values them at the model's list prices (charged amounts
  and free requests stay as reported). Rows booked at 0 are valued once on
  start (idempotent).
- opencode's price list now has per-duration cache prices for some models
  ({"30m": ..., "12h": ...}): the shortest duration is used, also in tiers.
  Saving the provider's models failed with "untagged enum ModelEntry".
- Each answer shows tokens sent/received and the cost of the turn's requests
  (usage.cost filled when messages are saved and loaded).
- The chat title keeps a minimum width in a crowded top bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:58:23 +01:00
Richard Zink 33612bf6d4 Release v202611-00022 2026-11-11 11:04:20 +01:00
Richard ZinkandClaude Opus 5.5 ffecd62b83 Theme "Tintenblau" (dark and light)
Palette from the Tintenblau desktop theme (colors.toml, neovim.lua, zed.json):
ink blue with cream text, paper with dark ink. Also in the terminal client's
palettes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:04:19 +01:00
Richard Zink c77dcf5890 Release v202611-00021 2026-11-11 10:01:07 +01:00
Richard ZinkandClaude Opus 5.5 9c80f2c857 Commits of agents carry a co-author trailer naming the model
A git wrapper (docker/git-coauthor -> /usr/local/lib/codecraft/bin/git) adds
--trailer "$CODECRAFT_COMMIT_TRAILER" right after the commit subcommand; the
agent shell puts it first on PATH and sets the trailer in the script itself
(works inside the sandbox, which clears the environment). Default text:
"Co-Authored-By: {model} ({provider}) via CodeCraft" (server.commit_trailer,
Settings -> General; empty = off). Git skips duplicates (amend, model wrote it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:01:07 +01:00
Richard Zink 1253430ed4 Release v202611-00020 2026-11-11 07:47:05 +01:00
Richard ZinkandClaude Opus 5.5 e7d4b9ff76 Reverse engineering tools per mode; raw firmware in decompile; skill
- AgentConfig.reverse_tools (Settings -> Agents & commands) gives any mode
  binary_info and decompile; the clean-room check of the tools applies to
  craft only (ToolCtx.clean_room = clean room && craft).
- decompile: optional processor (Ghidra language id) and base_address for raw
  images (BinaryLoader); separate cache per processor/address.
- Skill "reverse-engineering": identify, convert HEX, processor table for
  common chips, decompile, explore, notes in analysis/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:47:05 +01:00
Richard Zink b71dfc49c3 Release v202611-00019 2026-11-11 07:32:19 +01:00
Richard ZinkandClaude Opus 5.5 3b0764450a Long chats stay fast; search in a chat; media overview
Performance (measured with 5,900 messages: open 9 s -> 0.7 s, typing 440 ms -> 25 ms)
- GET /sessions/{id}?limit=N loads the end of a chat (from the last summary
  on, at most N); /sessions/{id}/messages?before|after|around loads more
  (scrolling up stops at a summary unless asked). Without parameters the
  full history as before (terminal client).
- Chat view: memoised message list (typing, streaming and status changes no
  longer re-render the history), finished turns fold into "N steps · M files"
  with the answer visible, tool card bodies and summaries are built only when
  opened, scroll-to-bottom only when content changes.

Search: /sessions/{id}/search over the whole chat; a hit loads its part
(around) and marks the message, "back to the latest messages" returns.

Media: /sessions/{id}/media lists images, videos and audio of a chat and its
subagents (generated images, tool output, files written, media in answers,
attachments); ⋯ -> Media shows them with filters, a large view and a jump to
the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:32:18 +01:00
Richard Zink 6e9eda28b5 Release v202611-00018 2026-11-10 23:12:50 +01:00
Richard ZinkandClaude Opus 5.5 bd896794fd Answer budget within the context window; enforced reviews of completed todos
Context window
- Requests asked for the model's full max_output, but providers count
  input + output against the window: long chats with large-output models
  (DeepSeek V4.1 Flash, 384k) failed with "input too long" before automatic
  compaction (85 %) kicked in. max_tokens is now what the window leaves
  (estimated input = last usage + messages since), the chat is compacted when
  less than 32k would remain for an answer, and a context-overflow error
  compacts and retries once.
- ModelConfig.max_input (catalogue limit.input, editable in the provider
  settings) lowers the compaction threshold for models with an input limit.

Enforced reviews (modes with refine.rounds > 0, refine.after_todos default on)
- Every todo marked completed is reviewed against the work since the last
  review; with findings it is reopened and the feedback goes to the agent
  (max. rounds per todo). Passed reviews leave a visible note.
- Ending the turn with open todos sends the agent back to work while it makes
  progress (max. 10 reminders in a row without progress); such modes may take
  up to 2,000 steps per turn. Reaching the step limit is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:12:49 +01:00
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
115 changed files with 14521 additions and 672 deletions

No files matched your search

+402
View File
@@ -3,6 +3,408 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
## [v202611-00037] – 2026-11-15
### Added
- **Deleting a chat asks about its folder**: if the chat has a folder of its own (inside your folders,
not shared with any other chat, no project folder), you can delete it with the chat – with the
number of files, size and a warning for git repositories. The server checks again before deleting.
## [v202611-00036] – 2026-11-15
### Added
- **Claude Code gets all CodeCraft tools** (image generation, 3D, OpenSCAD, browser, scheduling,
diagnostics, reverse engineering, skills, ask_user, the server's MCP tools …) over an MCP bridge,
as `mcp__codecraft__<name>`. Tools the CLI has itself (read, write, edit, bash, grep …) are not
offered twice. Calls go through the usual approval cards and rules.
## [v202611-00035] – 2026-11-15
### Fixed
- Creating an SSH key failed with "No user exists for uid 99": `ssh-keygen`, `ssh-keyscan` and
`ssh-add` get the same passwd workaround as `ssh`.
## [v202611-00034] – 2026-11-15
### Fixed
- `ssh`, `scp` and `sftp` failed with "No user exists for uid 99" because the container runs as a uid
without passwd entry; the image now wraps them with nss_wrapper (also used by the key wrappers).
## [v202611-00033] – 2026-11-15
### Added
- **SSH keys** (Settings → Account): generate (ed25519) or import keys; agents, the terminal and the
git panel use them automatically for `ssh`, `scp`, `sftp`, `rsync` and git over ssh. Hosts are
trusted on first contact; the public key can be copied for the server's `authorized_keys`.
## [v202611-00032] – 2026-11-15
### Fixed
- Agents now know that git credentials are stored (hosts, no secrets) and no longer search the
environment for them; the system prompt explains `git push` just works and how to read the token
for REST calls without printing it. Claude Code runs get the user's git credentials and identity too.
## [v202611-00031] – 2026-11-15
### Fixed
- Images read by an agent (Read tool) are shown right away; with Claude Code they were dropped.
## [v202611-00030] – 2026-11-15
### Added
- **`/clear`**: the model forgets the conversation so far; the chat keeps showing it, a divider marks
the cut. Also starts a fresh session with Claude Code.
## [v202611-00029] – 2026-11-15
### Added
- **Statistics**: sections and models fold; long history with a year filter (monthly bars for long
ranges); export of the shown figures as XLSX (all tables) or CSV (one table).
- **Prompt snippets**: own text blocks, inserted with `/name` in the message field (Settings → Account).
- **Read-only links**: share a chat's text with people without an account (valid 1–90 days, revocable).
- **Git panel**: the small model suggests a commit message for the staged changes.
- **Weekly review** as a push notification (Mondays 08:00: chats, checks, costs; opt-in).
- **Speech-to-text on the server** (`server.transcribe_model`) for browsers without dictation (Firefox).
- **Offline**: the chat list and the last opened chats stay readable without a connection.
- **Planning model** setting: plan mode always uses it, other modes the chat's model.
- **Automations** send their report as a push notification when a run ends (switchable).
### Fixed
- fal.ai: the provider check no longer reports "API key rejected" when only the model listing is not offered.
## [v202611-00028] – 2026-11-14
### Added
- **18 more chat icons** (gamepad, computer, food, coffee, home, book, code, camera, cart, heart,
star, globe, calendar, car, plane, bulb, money, palette).
### Changed
- **The ⋯ menu of a message sits inside its bubble** (in the foot next to the time, also in your own
bubbles); its popup opens upwards within the bubble and is no wider than the bubble.
## [v202611-00027] – 2026-11-14
### Added
- **Chat bubbles:** your messages on the right in their own colour (the chat's colour tag if it
has one), the agent's answers on the left; tool-only steps and system notes stay without bubble.
- **Icons for chats:** pick one of 22 icons in the ⋯ menu (chat and session list); it shows in the
list, the top bar and the project page, in the chat's colour, and is part of the project default.
### Changed
- **Colour tags are more vivid and more distinct:** eight fixed hues (new: pink), drawn with a
lightness that fits the theme (light/dark) and the highest chroma sRGB allows, so no two tags
look alike; backgrounds are tinted more strongly. Browsers without relative colours keep the
theme's own colours.
## [v202611-00026] – 2026-11-14
### Added
- **Claude Code as an engine:** chats with a "Claude Code" model (Opus, Sonnet, Haiku, default) are
run by the `claude` CLI. Its events become normal chat messages; its tool approvals go through
CodeCraft's permission system (cards, push buttons, "allow for this project", diffs, sandbox).
Sign in from the web UI (Settings → Account → Claude Code: open the sign-in URL, paste the code),
see the CLI version and install updates (admins; installed into the persistent home). The Docker
image now contains the CLI.
- **Small models (8k context) for side tasks:** `small_model` jobs cut their input to the model's
context window. New with it: a one-line status per chat in the session list, "What does this
do?" on approvals, and "Plan as automation" (drafts job instructions from a chat).
- **Mobile:** the top bar no longer pushes the page sideways (extra actions moved into the ⋯ menu);
an image viewer for all images (zoom, swipe between images, mark up and attach to the message,
use in the message); jump to the previous own message; message menu (copy, quote, fork from
here, edit and resend); date and time under every message.
- **Approvals:** push notifications can allow or reject right away; "always allow in this project"
is remembered per folder (never for dangerous commands) and can be revoked in the ⋯ menu.
- **Changes per turn:** files changed by a turn with diffs and "roll back to before this turn".
- **Check after each turn:** a command per project (e.g. `cargo test`) runs after turns that
changed files; green/red badge with output and "let the agent fix it".
- **Colour tags for chats:** tinted chat background and a badge in the list, drawn in the colours
of the active theme.
- **Project defaults, project page, offline queue:** new chats in a folder inherit model, mode,
colour, instructions and folder; a timeline of a project's chats (sidebar → project → ⋯);
messages written without a connection are sent when it is back.
## [v202611-00025] – 2026-11-12
### Fixed
- **Models did not find built-in tools via `mcp_search`** (e.g. `generate_3d` in a chat that
had just been switched from plan to build): the search only looked at MCP servers. It now also
lists matching built-in tools and says whether the current mode has them ("available – call it
directly", or "not in the read-only mode plan – ask the user to switch to build").
## [v202611-00024] – 2026-11-12
### Added
- **3D models with fal.ai (`generate_3d`):** from an image (a project file or one pasted into the
chat, optional back/left/right views), from a text prompt, or from an image steered by a prompt
(the image is refined with the prompt first – the 3D models take no text with an image).
Styles textured, geometry (untextured, for 3D printing) and low-poly, optional PBR. Uses fal.ai's
queue (takes minutes, with progress), saves a GLB plus a preview image, books the cost.
Defaults: Hunyuan3D v3 image-to-3D / text-to-3D (`[images] model_3d`, `model_3d_text`); Tripo
and Trellis endpoints work too.
- The chat card offers "Open in the 3D view"; the media overview lists 3D models (also STL/3MF
written by the agent) with their previews.
## [v202611-00023] – 2026-11-12
### Added
- **Tokens and cost under every answer:** a small line under each answer of the model shows the
tokens sent/received and the cost of all requests of that turn.
### Fixed
- **Total cost of chats stopped growing (opencode Go):** opencode now reports plan requests as
"nothing charged" (cost 0, usage in `allowance`), and CodeCraft took the 0. Plan requests are
valued at the model's list prices again; earlier ones are corrected once on start.
- **Saving opencode Go models failed after fetching prices** ("data did not match any variant of
untagged enum ModelEntry"): opencode lists cache prices per duration for some models
(`{"30m": …, "12h": …}`); the shortest duration is used now.
- The chat title in the top bar no longer shrinks to a single letter when there are many chips.
## [v202611-00022] – 2026-11-11
### Added
- **Theme "Tintenblau"** (dark: ink blue with cream text; light: paper with dark ink), from the
Tintenblau desktop theme; also in the terminal client.
## [v202611-00021] – 2026-11-11
### Added
- **Commits of agents name the model:** every `git commit` an agent makes gets the trailer
`Co-Authored-By: <model> (<provider>) via CodeCraft` (a git wrapper in the agent shell, also in
the sandbox; your own commits stay unchanged). The text is set under *Settings → General*
(`server.commit_trailer`, `{model}`, `{provider}`, `{model_id}`; empty = off).
## [v202611-00020] – 2026-11-11
### Added
- **Reverse engineering outside craft:** *Settings → Agents & commands* can give any mode the
`binary_info` and `decompile` (Ghidra) tools; the clean-room rule stays a craft rule.
- **Raw firmware in `decompile`:** optional `processor` (Ghidra language id, e.g.
`ARM:LE:32:Cortex`) and `base_address` for images without a file format (.bin, converted .hex).
- **Skill "reverse-engineering":** how to identify, decompile and explore programs and firmware
(processor table for common chips, HEX conversion, notes in `analysis/`).
## [v202611-00019] – 2026-11-11
### Added
- **Search in a chat:** the magnifier in the chat's top bar searches all of it (messages, tool
output) on the server; a hit loads its part of the chat and marks the message.
- **Media of a chat:** *⋯ → Media* shows every image, video and audio file of the chat and its
subagents – generated images, screenshots, files the agent wrote, media in answers, your
attachments – filterable, large view, "show in chat" jumps to the message.
### Changed
- **Long chats stay fast:** a chat opens with its messages since the last summary (at most 200);
older ones load when scrolling up, the history before a summary on request. Finished turns
fold into one line ("42 steps · 3 files", the answer stays visible). Typing, streaming and
status changes no longer re-render the whole history, and tool output is only built when a
card is opened. Measured with a chat of 5,900 messages: opening 9 s → 0.7 s (phone-like
CPU: 22 s → 1 s), typing 440 ms → 25 ms per key.
## [v202611-00018] – 2026-11-10
### Added
- **Enforced reviews:** when a review is configured for a mode, every todo the agent marks
completed is reviewed; with findings the todo is reopened and the agent has to fix them and
complete it again (up to the configured rounds per todo). Ending the turn with open todos sends
the agent back to work as long as it makes progress – for unattended runs, e.g. overnight
(such modes may take up to 2,000 steps per turn). The chat shows passed reviews and these
reminders; *Settings → Modes* can switch it off per mode (then only one review at the end of a
turn, as before).
- **Max input per model:** providers that limit the input below the context window (e.g. 272k of
400k) are taken into account; fetched from the model catalogue, editable in the provider settings.
### Fixed
- **Context overflow instead of compaction** (e.g. DeepSeek V4.1 Flash with 384k max output):
requests asked for the model's full max output, and providers count input + output against the
window, so long chats failed before automatic compaction kicked in. The output budget is now what
the window leaves, the chat is compacted when no reasonable room for an answer is left, and a
"context too long" error compacts and retries once instead of stopping. A turn that hits the
step limit now says so.
## [v202611-00017] – 2026-11-10
### Added
- **Markdown in your messages:** your messages are shown formatted like the model's answers
(headings, lists, tables, code blocks, quotes, bold/italic); single line breaks stay as typed.
The eye button next to the text field shows a preview of the formatted message before sending.
## [v202611-00016] – 2026-11-10
### Fixed
- **Phone: page stayed shifted after the keyboard closed** – the top bar (menu) was out of
reach. On Android the keyboard now shrinks the page instead of shifting it
(`interactive-widget=resizes-content`; the chat stays at its latest message); browsers that
still shift the page (iOS Safari) get it scrolled back once the keyboard closes.
## [v202611-00015] – 2026-11-10
### Fixed
- **Generated images missing on other devices:** when an agent moved or renamed a generated
image afterwards (e.g. sorted it into subfolders), the gallery pointed to the old file – only
the device that had it in its browser cache still showed it. Now:
- generated images are also kept in the image store and shown from there when the file is gone
(existing images get their copy on the first start);
- their entries follow moves and renames: after an agent's `mv`/`rename`, when moved with the
file manager, and on the next view (found by file name, or by content when renamed);
- in the chat, an image that cannot be found falls back to the preview the model saw.
## [v202611-00014] – 2026-11-10
### Added
- **New folder in the folder picker:** creating an automation or a chat, *New folder* creates a
subfolder of the shown folder and selects it.
## [v202611-00013] – 2026-11-10
### Added
- **Automation editor:** *New automation* asks for name, project and a starting point (blank
example structure or a template), creates `.codecraft/jobs/<name>.md` and opens an editor:
the instructions in a Markdown editor, schedule and settings next to them, and an assistant
chat in which a model works out the instructions with you and edits the file (reloaded after
each answer). New automations start switched off.
- **Graphical schedule:** every few minutes, hourly, daily, on weekdays, monthly or a cron
expression, with the next runs in server time.
- **Chat list:** folders and projects can be dragged into any order or moved and pinned to the
top via their menu; the order is kept per user on all devices. A new message moves the chat
to the top of its folder or project, no longer the whole project.
### Changed
- **Automation files hold only the instructions;** name, schedule, mode, model, approval mode
and time limit are settings of the automation. Existing job files are taken over on the first
start (settings into the database, the frontmatter removed from the file). Registering a
plain Markdown file creates a switched-off automation to be scheduled in the editor.
## [v202611-00012] – 2026-11-10
### Added
- **Approval mode of automations in the list:** *Automations* shows the approval mode of each
automation as a selection; the change is written into the job file and applies from the next
run. "Allow everything" lets nightly runs work without waiting for approvals (they used to
wait for an answer via Telegram until the timeout).
- **Telegram per chat:** *⋯ → Send questions to Telegram* (shown with a paired bot) decides
whether approvals and questions of that chat and its subagents are sent to Telegram. New chats
from the web UI and automation runs start with it switched off (they stay in the web UI);
chats started from Telegram and existing chats keep reporting. The chat currently open in
Telegram always reports.
## [v202611-00011] – 2026-11-09
### Added
- **Collapsible task list:** the model's task list above the composer folds to one line
(progress and current task) with a click on its heading; the choice is kept per device.
- **Square and short screens** (e.g. Clicks Communicator, phones in landscape): tighter top bar,
composer and new-chat wizard, so the chat keeps most of the height.
### Fixed
- **Blank page on phones after the update:** installed apps (PWA) checked their service worker
at its old URL, got the web page instead of a script and therefore kept the previous app
version forever. With the new image format of v202611-00010 that old version crashed after
showing the chat for a moment. Old service worker URLs now get the clean-up worker, which
removes the stale worker and reloads the current version. Missing scripts/styles answer
404 instead of the web page.
## [v202611-00010] – 2026-11-09
### Changed
- **Chats with many images load much faster:** images (screenshots, generated images, pasted
images) are stored separately instead of inside the messages and load on demand when they
scroll into view, cached by the browser. Large PNGs without transparency are stored as JPEG.
Older chats are converted once on the first start; the database usually shrinks a lot.
Example: a chat with 120 screenshots loads 1.5 MB instead of 66 MB.
- **Compressed responses:** the API and the web UI are sent with gzip/brotli (not images,
downloads or live streams).
## [v202611-00009] – 2026-11-09
### Fixed
- **Sandbox on x86_64:** the start-up check of bubblewrap did not mount `/lib64`, so it failed
on amd64 hosts and shell commands of non-admin users were disabled.
## [v202611-00008] – 2026-11-09
### Added
- **Instructions per chat:** *⋯ → Instructions for this chat* (Telegram: `/prompt`) sets rules
that apply to every message of that chat on top of the mode – language, tone, the part of the
project to work on. A chip in the header shows when a chat has them; forks keep them.
### Fixed
- OpenAI-compatible providers: tool results no longer carry a `name` field. Gateways that
translate to Anthropic (opencode) rejected the request with "anthropic-messages cannot
preserve named conversation participants".
- Telegram: the token field is no longer filled in by the browser's password manager, the bot
token is found in whatever is pasted (also the whole BotFather message), and a wrong format
gets a clear message instead of "HTTP 404".
## [v202611-00007] – 2026-11-09
### Added
- **Telegram bot as a chat interface:** every user can connect their own bot (*Settings →
Account → Telegram*: token from @BotFather, then `/start <code>` pairs it with their Telegram
account – other chats are ignored). Messages, photos and files go to the current session; the
agent's steps show live, the answer comes formatted, images from tools come as photos, and
approvals and questions have buttons. Commands like the terminal client: `/sessions`,
`/session`, `/new`, `/clear`, `/mode`, `/model`, `/permissions`, `/effort`, `/stop`, `/status`,
`/costs`, `/memory`, `/jobs`, `/compact`, `/help`. The server polls Telegram – no public
webhook needed.
## [v202611-00006] – 2026-11-09
### Added
- The image contains the Docker CLI (static client). With the host's `/var/run/docker.sock`
mounted (and its group), admins' agents and terminals can use the host's Docker;
`scripts/move-server.sh --docker-sock` sets this up on a new host.
- `scripts/move-server.sh --tools-dir DIR`: agent home, users' homes and `/tmp` on a separate disk.
## [v202611-00005] – 2026-11-09
### Fixed
- Commits by agents, terminals and the git panel always use the name and e-mail from
*Settings → Account → Git* – also in repositories that have their own `user.name`/`user.email`
(agents had set those as a workaround while no identity was configured). Agents are told not
to change the git identity.
## [v202611-00004] – 2026-11-09
### Added
Generated
+68
View File
@@ -52,6 +52,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -153,6 +168,18 @@ dependencies = [
"password-hash",
]
[[package]]
name = "async-compression"
version = "0.4.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-trait"
version = "0.1.92"
@@ -363,6 +390,27 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "brotli"
version = "9.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "6.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.13.1"
@@ -682,6 +730,24 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "compression-codecs"
version = "0.4.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
[[package]]
name = "const-oid"
version = "0.10.2"
@@ -3040,6 +3106,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -4034,6 +4101,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [
"async-compression",
"bitflags 2.13.2",
"bytes",
"futures-core",
+21 -2
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 \
&& cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64
# --- Docker CLI (static client; the daemon is the host's via the mounted socket) --
FROM toolchain AS dockercli
ARG TARGETARCH
ARG DOCKER_VERSION=29.9.3
RUN case "$TARGETARCH" in amd64) arch=x86_64 ;; arm64) arch=aarch64 ;; *) echo "unsupported $TARGETARCH" >&2; exit 1 ;; esac \
&& curl -fsSL "https://download.docker.com/linux/static/stable/$arch/docker-$DOCKER_VERSION.tgz" | tar -xz -C /tmp docker/docker \
&& install -m 0755 /tmp/docker/docker /docker
# --- runtime ------------------------------------------------------------------
# Node is included so agents can work on JS projects and run npx-based MCP servers.
FROM node:22-bookworm-slim
@@ -77,15 +85,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
openscad xvfb xauth \
openssh-client rsync mc \
openssh-client libnss-wrapper rsync mc \
&& rm -rf /var/lib/apt/lists/*
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
# installed by agents: rustup component add rust-analyzer, go install …).
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
RUN npm install -g typescript@5 typescript-language-server pyright @anthropic-ai/claude-code && npm cache clean --force
COPY --from=server /codecraft /usr/local/bin/codecraft
COPY --from=web /web/dist /app/web
COPY --from=cli /out /app/cli
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
COPY --from=dockercli /docker /usr/local/bin/docker
COPY server/ghidra /app/ghidra
# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer).
COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git
# ssh, scp, sftp and the ssh-* tools (the server creates keys with ssh-keygen) also work when the container runs as a uid without passwd entry.
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh
COPY --chmod=755 docker/ssh-nss /usr/local/bin/scp
COPY --chmod=755 docker/ssh-nss /usr/local/bin/sftp
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keygen
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keyscan
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-add
COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
+22 -6
View File
@@ -11,7 +11,7 @@
</p>
<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-00037" src="https://img.shields.io/badge/version-v202611--00037-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
@@ -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
published image there and runs `scripts/smoke-test.sh`. The old container is only stopped, so
you can roll back. `--help` lists the options (owner, port, bind address, dry run).
`--tools-dir /mnt/disks/ssd/codecraft` puts everything agents install or download – the agent
home (`/data/home`: toolchains, pip/npm/cargo packages, caches), the users' homes and `/tmp` – on
a separate disk; the data folder keeps only the database, configuration and backups. Step by step,
with rollback and troubleshooting: [docs/MOVING.md](docs/MOVING.md).
## How it works
@@ -172,6 +176,10 @@ output limit, reasoning effort, extra request options and prices per million tok
- **Images (fal.ai):** the agent drafts cheap previews (FLUX schnell), asks which one to
refine, and creates the final image with the best model for the purpose – Nano Banana Pro
refines the chosen draft, Ideogram handles lettering, Recraft design work.
- **3D models (fal.ai):** `generate_3d` turns an image (also one pasted into the chat, plus
optional back/left/right views), a text prompt, or an image steered by a prompt into a GLB
model – textured, untextured for 3D printing, or low-poly (Hunyuan3D v3 by default,
`[images] model_3d` / `model_3d_text`). Open it in the 3D view of the file manager.
## Terminal client
@@ -198,17 +206,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%">
</p>
An automation is a Markdown file with a cron schedule in its front matter
(`.codecraft/jobs/<name>.md`). Each run is a normal chat session, so you can read and continue
it; if it needs a permission, you get a push notification. Ask an agent in any chat to set one
up, start from a template (dependency check, security review of recent commits, test suite
health, daily summary, TODO report, documentation freshness) or write the file yourself.
An automation is an agent that starts on a schedule. Its instructions are a Markdown file
(`.codecraft/jobs/<name>.md`, only the prompt); name, schedule, mode, model and limits are set
in the UI, with a graphical schedule editor (every few minutes, hourly, daily, on weekdays,
monthly or a cron expression). *New automation* creates the file – blank with an example
structure or from a template (dependency check, security review of recent commits, test suite
health, daily summary, TODO report, documentation freshness) – and opens an editor with an
assistant chat in which a model works out the instructions with you and writes the file. You
can also ask an agent in any chat to set one up. Each run is a normal chat session, so you can
read and continue it.
**Webhooks** start an automation from outside, e.g. on every push: the request is checked with a
shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed to the agent.
## Integrations
- **Telegram:** connect your own bot (*Settings → Account → Telegram*) and use CodeCraft from
Telegram – sessions, modes, models, approvals with buttons, photos and files; `/help` in the
bot lists the commands.
- **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client.
- **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
@@ -285,6 +300,7 @@ in turn are based on:
| Coffee | Dark, Latte | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) | MIT |
| Solarized | Dark, Light | [Solarized by Ethan Schoonover](https://ethanschoonover.com/solarized/) | MIT |
| Tokyo Night | Night, Day | [folke/tokyonight.nvim](https://github.com/folke/tokyonight.nvim) | Apache-2.0 |
| Tintenblau | Dark, Light | original (zink/tintenblau desktop theme) | private |
Further standard themes (palettes re-created from the published colour specifications):
+2 -2
View File
@@ -421,7 +421,7 @@ impl App {
_ => None,
};
if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) {
self.images.push(ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
self.images.push(ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
}
}
}
@@ -840,7 +840,7 @@ impl App {
for (cmd, args) in tries {
if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() {
if out.status.success() && out.stdout.starts_with(b"\x89PNG") {
self.images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
self.images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
self.status = format!("image {} attached", self.images.len());
return;
}
+54
View File
@@ -701,6 +701,60 @@
"syntax-type": "#b58900",
"syntax-variable": "#586e75"
},
"tintenblau-dark": {
"accent": "#7aa0cc",
"accent2": "#d4b46a",
"aqua": "#96b4bc",
"bg0": "#232a44",
"bg1": "#2b3352",
"bg2": "#353e60",
"bg3": "#4a5478",
"blue": "#7aa0cc",
"fg0": "#f5efe3",
"fg1": "#eae4d6",
"fg2": "#d6d0c2",
"fg3": "#bdcfd4",
"green": "#96b4bc",
"muted": "#8e96b4",
"on-accent": "#232a44",
"purple": "#c896b2",
"red": "#d47a6a",
"syntax-comment": "#8e96b4",
"syntax-function": "#7aa0cc",
"syntax-keyword": "#c896b2",
"syntax-meta": "#baa0c4",
"syntax-number": "#96b4bc",
"syntax-string": "#d47a6a",
"syntax-type": "#d4b46a",
"syntax-variable": "#eae4d6"
},
"tintenblau-light": {
"accent": "#2a3f6f",
"accent2": "#6a4a2c",
"aqua": "#3a5a6a",
"bg0": "#f0ead9",
"bg1": "#e6dfd0",
"bg2": "#dbd3c1",
"bg3": "#c9c0ac",
"blue": "#3a5a8a",
"fg0": "#0f1628",
"fg1": "#1e2a4a",
"fg2": "#3a3f5a",
"fg3": "#5e5a6a",
"green": "#4a5a3a",
"muted": "#8a8694",
"on-accent": "#f0ead9",
"purple": "#5a4a72",
"red": "#8a3028",
"syntax-comment": "#8a8694",
"syntax-function": "#3a5a6a",
"syntax-keyword": "#2a3f6f",
"syntax-meta": "#4a5a3a",
"syntax-number": "#5a4a72",
"syntax-string": "#8a3028",
"syntax-type": "#6a4a2c",
"syntax-variable": "#1e2a4a"
},
"tokyonight-dark": {
"accent": "#7aa2f7",
"accent2": "#ff9e64",
+1 -1
View File
@@ -408,7 +408,7 @@ pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
return Ok(Output {
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
is_error: false,
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
images: vec![ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
diff: None,
});
}
+25 -1
View File
@@ -64,6 +64,9 @@ pub struct ModelConfig {
pub context: Option<u64>,
#[serde(default)]
pub max_output: Option<u64>,
/// Input limit when the provider sets one below the context window (e.g. 272k of 400k).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_input: Option<u64>,
#[serde(default)]
pub reasoning: bool,
#[serde(default)]
@@ -156,7 +159,13 @@ impl ModelConfig {
/// Cost of one request now. Cache prices default to the input price.
/// The provider's own figure wins when it reports one (opencode).
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
match u.cost {
// A plan request (opencode Go): nothing charged, the usage counts toward the plan –
// its value at the model's list prices.
Some(c) if c == 0.0 && u.allowance.is_some_and(|a| a > 0.0) => self.cost_at(u, chrono::Utc::now()),
Some(c) => c,
None => self.cost_at(u, chrono::Utc::now()),
}
}
/// Cost of one request at a given time: context tiers and time factors applied.
@@ -184,6 +193,21 @@ mod price_tests {
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
}
#[test]
fn plan_requests_are_valued_at_list_prices() {
let m = ModelConfig { input_price: Some(1.0), output_price: Some(2.0), ..Default::default() };
let mut u = usage(1_000_000, 1_000_000, 0);
u.cost = Some(0.0);
u.allowance = Some(0.4);
assert_eq!(m.cost(&u), 3.0);
// Charged by the provider: that amount; free without a plan: free.
u.cost = Some(0.7);
assert_eq!(m.cost(&u), 0.7);
u.cost = Some(0.0);
u.allowance = None;
assert_eq!(m.cost(&u), 0.0);
}
#[test]
fn tiers_and_multipliers() {
let m = ModelConfig {
+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.
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
- Git identity and credentials come from the user's settings: never set user.name, user.email or credential helpers yourself (no `git config user.*`, no `-c user.*`).
- Reference code as path:line.
- Answer in the language the user writes in."#;
+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!({
"type": "image_url", "image_url": {"url": data_url(image)}
})),
Part::ToolResult { call_id, name, content, images, is_error, .. } => {
Part::ToolResult { call_id, content, images, is_error, .. } => {
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
// No "name": it is not part of the tool message in the Chat
// Completions API, and gateways that translate to Anthropic
// (opencode) reject named participants.
out.push(json!({
"role": "tool", "tool_call_id": call_id, "name": name,
"role": "tool", "tool_call_id": call_id,
"content": if text.is_empty() { "(no output)".into() } else { text }
}));
tool_images.extend(images.iter().cloned());
@@ -328,6 +331,22 @@ mod tests {
(text, reasoning)
}
#[test]
fn tool_messages_have_no_name() {
use crate::types::{Message, Part, Role};
let call = Part::ToolCall { id: "c1".into(), name: "bash".into(), input: serde_json::json!({"command": "ls"}), extra: None };
let result = Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "ok".into(), images: vec![], is_error: false, meta: None };
let msgs = vec![
Message::new(Role::User, vec![Part::Text { text: "hi".into() }]),
Message::new(Role::Assistant, vec![call]),
Message::new(Role::User, vec![result]),
];
let wire = super::to_wire_messages("sys", &msgs);
let tool = wire.iter().find(|m| m["role"] == "tool").expect("tool message");
assert_eq!(tool["tool_call_id"], "c1");
assert!(wire.iter().all(|m| m.get("name").is_none()), "no message may carry a name: {wire:?}");
}
#[test]
fn splits_think_tags_across_chunks() {
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
+66 -3
View File
@@ -10,11 +10,25 @@ pub enum Role {
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ImageData {
pub media_type: String,
/// Base64-encoded bytes.
/// Base64-encoded bytes. Empty when the image is stored separately (`blob`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub data: String,
/// Server only: SHA-256 of the bytes in the image store; the UI loads
/// /api/blobs/<blob> and the server fills `data` in before a model request.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
/// Size in bytes of a stored image (shown in the UI without fetching it).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
impl ImageData {
pub fn new(media_type: impl Into<String>, data: String) -> Self {
Self { media_type: media_type.into(), data, blob: None, size: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -166,8 +180,34 @@ pub struct Session {
/// User-defined group in the session list.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<String>,
/// Instructions the user set for this chat; added to the system prompt of every turn.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub instructions: String,
/// Approvals and questions of this chat (and its subagents) are not sent to Telegram,
/// unless it is the chat currently open in Telegram.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub telegram_quiet: bool,
/// One-line "where things stand", written by the small model after a turn (session list).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub summary: String,
/// Colour tag of the chat (a palette name, see `SESSION_COLORS`); the client maps it to the theme.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
/// Icon of the chat (a name from `SESSION_ICONS`), shown in the list and the top bar.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
/// Names of the colour tags: the client draws them with the colours of the active theme.
pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"];
/// Icons a chat can get (names of the client's icon set).
pub const SESSION_ICONS: &[&str] = &[
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
"users", "toolbox", "plug", "scroll", "eye", "template",
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
];
impl Session {
pub fn clean_room(&self, default: bool) -> bool {
self.clean_room.unwrap_or(default)
@@ -222,6 +262,8 @@ pub enum Event {
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
/// a nearly full context (tokens of limit).
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
/// The project's check command ran after a turn: status "running" | "passed" | "failed" | "timeout".
CheckResult { session_id: String, message_id: String, status: String, exit_code: Option<i32>, duration_ms: u64, output: String },
}
impl Event {
@@ -244,7 +286,8 @@ impl Event {
| Waiting { session_id, .. }
| QueueUpdated { session_id, .. }
| StepStarted { session_id, .. }
| Compaction { session_id, .. } => Some(session_id),
| Compaction { session_id, .. }
| CheckResult { session_id, .. } => Some(session_id),
McpStatus { .. } => None,
}
}
@@ -257,3 +300,23 @@ pub fn new_id() -> String {
pub fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
#[cfg(test)]
mod icon_tests {
use super::*;
/// The client's picker (ColorSwatches.tsx) and the server's whitelist must list the same names.
#[test]
fn session_icons_match_the_client() {
let ts = include_str!("../../web/src/components/ColorSwatches.tsx");
let list = ts.split("SESSION_ICONS = [").nth(1).and_then(|r| r.split("] as const").next()).expect("icon list in ColorSwatches.tsx");
let mut client: Vec<&str> = list.split('"').skip(1).step_by(2).collect();
let mut server: Vec<&str> = SESSION_ICONS.to_vec();
client.sort();
server.sort();
assert_eq!(client, server);
let mut dedup = server.clone();
dedup.dedup();
assert_eq!(dedup.len(), server.len(), "duplicate icon names");
}
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# git for the shells of CodeCraft agents: `git commit` gets the co-author trailer from
# $CODECRAFT_COMMIT_TRAILER (model, provider, CodeCraft). Everything else goes to the real
# git unchanged. Git itself skips the trailer when the message already ends with it.
real=${CODECRAFT_REAL_GIT:-/usr/bin/git}
[[ -n $CODECRAFT_COMMIT_TRAILER ]] || exec "$real" "$@"
# The subcommand comes after git's global options (-C dir, -c key=value, --git-dir=…).
i=1
while (( i <= $# )); do
case ${!i} in
-C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) i=$((i + 2)) ;;
--) break ;;
-*) i=$((i + 1)) ;;
*) break ;;
esac
done
if (( i <= $# )) && [[ ${!i} == commit ]]; then
# Right after "commit", so it stays an option even before "-- <paths>".
exec "$real" "${@:1:i}" --trailer "$CODECRAFT_COMMIT_TRAILER" "${@:i+1}"
fi
exec "$real" "$@"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# ssh, scp and sftp for containers that run as an arbitrary uid (docker-compose `user:`):
# OpenSSH refuses to start without a passwd entry for the uid ("No user exists for uid N").
# Without an entry, nss_wrapper provides one for this process; otherwise nothing changes.
if ! getent passwd "$(id -u)" >/dev/null 2>&1; then
d=${TMPDIR:-/tmp}/codecraft-nss-$(id -u)
mkdir -p "$d" 2>/dev/null
printf 'codecraft:x:%s:%s:CodeCraft:%s:/bin/sh\n' "$(id -u)" "$(id -g)" "${HOME:-/}" > "$d/passwd"
printf 'codecraft:x:%s:\n' "$(id -g)" > "$d/group"
export NSS_WRAPPER_PASSWD="$d/passwd" NSS_WRAPPER_GROUP="$d/group"
export LD_PRELOAD="libnss_wrapper.so${LD_PRELOAD:+:$LD_PRELOAD}"
fi
exec "/usr/bin/$(basename "$0")" "$@"
+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:
# --workspace DIR project folder on the target (default <dir>/workspace)
# --tools-dir DIR separate disk for what agents install and download: the agent home
# (/data/home), the users' homes (/data/users) and /tmp are bind
# mounts from DIR/home, DIR/users and DIR/tmp (default: inside <dir>/data)
# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env)
# --docker-sock give the container the target's Docker (bind mount of
# /var/run/docker.sock plus its group). Root-equivalent access to the
# target host for admins' agents and terminals; sandboxed users do not
# see it, and docker commands still need approval
# --port N host port on the target (default: PORT of .env)
# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1)
# --name NAME container name on the target (default codecraft)
# --source-container C container to stop here (default codecraft)
# --source-volume V data volume here (default codecraft-data)
# --source-workspace D project folder here (default: WORKSPACE of .env)
# --exclude PATTERN leave this out of the workspace copy (rsync pattern, relative to the
# workspace, e.g. /old-project/ or /app/target/); repeatable
# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
# --dry-run show the steps only
set -euo pipefail
target="" ws_target="" 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
excludes=()
while (($#)); do
case "$1" in
--workspace) ws_target="$2"; shift ;;
--tools-dir) tools_dir="$2"; shift ;;
--docker-sock) docker_sock=1 ;;
--uid) uid="$2"; shift ;;
--gid) gid="$2"; shift ;;
--port) port="$2"; shift ;;
@@ -47,9 +59,10 @@ while (($#)); do
--source-container) src_container="$2"; shift ;;
--source-volume) src_volume="$2"; shift ;;
--source-workspace) ws_source="$2"; shift ;;
--exclude) excludes+=(--exclude "$2"); shift ;;
--no-stop) stop=0 ;;
--dry-run) dry=1 ;;
-h|--help) sed -n '2,36p' "$0"; exit 0 ;;
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;;
*) target="$1" ;;
esac
@@ -59,7 +72,8 @@ done
cd "$(git rev-parse --show-toplevel)"
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
env_get() { sed -n "s/^$1=//p" .env | tail -1; }
# Value of a .env variable, without surrounding quotes.
env_get() { sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
host="${target%%:*}"
dir="${target#*:}"
@@ -84,9 +98,9 @@ remote() {
}
q() { printf '%q' "$1"; }
sync_workspace() {
if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi
if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/"
else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
}
put_file() { # put_file <remote path> < content
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
@@ -97,10 +111,15 @@ step "Checking source and target"
docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
[[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
echo "source: container $src_container, volume $src_volume, workspace $ws_source"
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 "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \
|| fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)"
if [[ -n "$tools_dir" ]]; then
remote "mkdir -p $(q "$tools_dir") && test ! -e $(q "$tools_dir/home") && test ! -e $(q "$tools_dir/users")" \
|| fail "$tools_dir already has home/ or users/ – refusing to overwrite (move them away first)"
fi
step "Copying the workspace (first pass, CodeCraft keeps running)"
sync_workspace
@@ -118,9 +137,29 @@ else
| remote "tar -C $(q "$dir/data") -xpf -"
fi
if [[ -n "$tools_dir" ]]; then
step "Moving the agent home and the users' homes to $tools_dir"
remote "mkdir -p $(q "$tools_dir/tmp") $(q "$dir/data/home") $(q "$dir/data/users") \
&& mv $(q "$dir/data/home") $(q "$tools_dir/home") && mv $(q "$dir/data/users") $(q "$tools_dir/users") \
&& mkdir -p $(q "$dir/data/home") $(q "$dir/data/users") && chmod 1777 $(q "$tools_dir/tmp")"
fi
step "Copying the workspace (changes since the first pass)"
sync_workspace
remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") 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"
put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json
@@ -145,6 +184,8 @@ services:
volumes:
- $dir/data:/data
- \${WORKSPACE}:/workspace
$tool_mounts
$sock_compose
extra_hosts:
- "host.docker.internal:host-gateway"
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
exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
-p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" \\
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" $tool_flags$sock_flags\\
--add-host host.docker.internal:host-gateway --shm-size 1g \\
--security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
--security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
+2 -2
View File
@@ -28,7 +28,7 @@ shell-words = "1"
libc = "0.2"
ignore = "0.4"
regex = "1"
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form", "multipart"] }
rusqlite = { version = "0.41", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -38,7 +38,7 @@ tokio-tungstenite = "0.29"
tokio-util = "0.7"
toml = "1"
toml_edit = "0.25"
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
tower-http = { version = "0.7", features = ["fs", "cors", "trace", "compression-gzip", "compression-br"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4", "v7"] }
@@ -0,0 +1,64 @@
---
name: reverse-engineering
description: Analyse programs and firmware – identify the format, inspect with binary_info, decompile with Ghidra (decompile tool, also raw firmware with processor/base address), explore the output and write down findings. For file formats, protocols, bugs and interoperability.
---
# Reverse engineering of programs and firmware
You analyse a binary to understand how it works – a file format, a protocol, an algorithm,
a bug, or how to talk to a device. Work from cheap to expensive and write down what you learn.
Only analyse binaries the user owns or may analyse (their own builds, their devices, programs
for interoperability). Decompiled code is third-party material: never commit it and never paste
it into the user's source – describe behaviour in your own words.
## 1. Identify
- `file <path>` – format, architecture, stripped or not.
- Executables and libraries (PE .exe/.dll, ELF, Mach-O): `binary_info` – sections, imports,
exports, strings. Imports and strings tell a lot (file names, error messages, protocol
keywords, library names) before any decompiling.
- Archives and update packages: unpack first (`unzip`, `7z x`), then identify the parts.
- Intel HEX / S-record firmware: convert to a raw image –
`objcopy -I ihex -O binary fw.hex fw.bin` (the lowest address in the HEX file is the base
address). An ELF from the firmware's build (e.g. `zephyr.elf`, `firmware.elf`) is far better
than a raw image: symbols and addresses are included – look for it first.
- Raw images: look for clues with `strings -n 6 fw.bin | head -200` and
`python3 -c` one-liners (vector table, magic numbers, embedded file systems).
## 2. Raw firmware: processor and load address
`decompile` needs both for images without a file format:
| Chip family | `processor` | typical `base_address` |
|---|---|---|
| ARM Cortex-M (STM32, nRF52, RP2040, SAMD) | `ARM:LE:32:Cortex` | STM32 `0x08000000`, nRF52 `0x00000000`, RP2040 `0x10000000` |
| ARM Cortex-A / ARMv7 | `ARM:LE:32:v7` | from the bootloader / datasheet |
| AArch64 | `AARCH64:LE:64:v8A` | |
| ESP32 (Xtensa) | `Xtensa:LE:32:default` | per segment (use the ELF if possible) |
| AVR (Arduino) | `AVR8:LE:16:atmega256` or `AVR8:LE:16:default` | `0x0` |
| MIPS (routers) | `MIPS:BE:32:default` or `MIPS:LE:32:default` | e.g. `0x80000000` |
Cortex-M check: the first word of the image is the initial stack pointer (0x2000xxxx = RAM),
the second the reset vector – it points just behind the base address. If the decompiled output
is mostly garbage, the processor or base address is wrong: fix it and run again (results are
cached per file *and* processor/address).
## 3. Decompile
- `decompile` with the path (plus `processor`/`base_address` for raw images). Large programs:
start with `max_functions` (e.g. 2000), widen when needed.
- Output in `.codecraft/decompiled/<name>/`: `functions.tsv` (address, name, size, file,
signature), `functions/*.c`, `strings.tsv`, `imports.tsv`, `exports.tsv`.
## 4. Explore
- Start from what you know: a string, an import or an export.
`grep -l "SetCommState" .codecraft/decompiled/<name>/functions` finds the functions that use it.
- Follow the calls by reading the functions; note addresses (`FUN_00401a30`) as you go.
- Broad searches through thousands of functions: delegate them to an `explore` subagent with a
precise question and let it report file:line.
- File formats: compare the reading/writing code with a sample file (`python3` to dump the bytes)
until every field is explained.
## 5. Write it down
Keep findings in `analysis/notes.md` (create it; it is not committed): structure of the
program, important functions with addresses, data structures, file formats and protocols with
field tables, open questions. Make sure `.gitignore` excludes `analysis/`, `original/` and
`.codecraft/decompiled/`. At the end, report what you found and how sure you are – mark guesses
as guesses.
+1028 -51
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -20,6 +20,8 @@ pub enum PermissionReply {
Once,
/// Allow this tool (or bash command prefix) for the rest of the session.
Always,
/// Like `Always`, and remembered for the project folder (all chats, survives restarts).
Project,
Reject,
}
@@ -131,9 +133,42 @@ impl App {
});
crate::auth::bootstrap(&app)?;
app.clone().start_mcp();
app.clone().start_image_migration();
Ok(app)
}
/// Move inline images of older chats to the image store, drop unused images and
/// shrink the database file (in the background; chats load meanwhile as before).
fn start_image_migration(self: Arc<Self>) {
tokio::task::spawn_blocking(move || {
match self.store.migrate_inline_images() {
Ok(0) => {}
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
}
match crate::budget::value_plan_requests(&self) {
Ok(0) => {}
Ok(n) => tracing::info!("valued {n} plan requests (opencode Go) at list prices"),
Err(e) => tracing::warn!("valuing plan requests: {e:#}"),
}
match crate::images::backfill_copies(&self) {
Ok(0) => {}
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
Err(e) => tracing::warn!("copies of generated images: {e:#}"),
}
match self.store.gc_blobs() {
Ok(0) => {}
Ok(n) => tracing::info!("removed {n} unused images"),
Err(e) => tracing::warn!("image cleanup: {e:#}"),
}
match self.store.vacuum_if_worthwhile() {
Ok(true) => tracing::info!("database compacted"),
Ok(false) => {}
Err(e) => tracing::warn!("database compaction: {e:#}"),
}
});
}
fn start_mcp(self: Arc<Self>) {
let servers = self.config().mcp.clone();
let mcp = self.mcp.clone();
+6
View File
@@ -186,6 +186,12 @@ fn is_public(path: &str) -> bool {
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter()
.any(|p| path.ends_with(p))
// Read-only chat links (the token is the credential).
|| path.starts_with("/public/chat/")
|| path.starts_with("/api/public/chat/")
// MCP bridge for the Claude Code CLI (per-run token).
|| path.starts_with("/mcp-bridge/")
|| path.starts_with("/api/mcp-bridge/")
}
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
+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}));
}
}
+73 -10
View File
@@ -361,6 +361,49 @@ fn opencode_when(w: &Value) -> Option<When> {
Some((days, hours, from, until))
}
/// Plan requests (opencode Go) that were booked at 0: value them at the model's list
/// prices of the time of the request. Idempotent – valued rows no longer match.
pub fn value_plan_requests(app: &App) -> Result<usize> {
let mut n = 0;
for (row, ts, spec, usage) in app.store.plan_requests_without_cost()? {
let Ok((_, model)) = app.registry().resolve(&spec) else { continue };
let Some(at) = chrono::DateTime::from_timestamp_millis(ts) else { continue };
let cost = model.cost_at(&usage, at);
if cost > 0.0 {
app.store.set_usage_cost(row, cost)?;
n += 1;
}
}
Ok(n)
}
/// A price from opencode's list: a number, or one price per cache duration
/// (`{"30m": 0.125, "12h": 0.25}`) – then the shortest duration (the default caching).
fn price_of(v: &Value) -> Value {
match v {
Value::Number(_) => v.clone(),
Value::Object(o) => {
let secs = |k: &str| -> Option<f64> {
let (num, unit) = k.split_at(k.find(|c: char| c.is_ascii_alphabetic())?);
let n: f64 = num.parse().ok()?;
Some(n * match unit {
"s" => 1.0,
"m" => 60.0,
"h" => 3600.0,
"d" => 86400.0,
_ => return None,
})
};
o.iter()
.filter(|(_, x)| x.is_number())
.min_by(|a, b| secs(a.0).unwrap_or(f64::MAX).total_cmp(&secs(b.0).unwrap_or(f64::MAX)))
.map(|(_, x)| x.clone())
.unwrap_or(Value::Null)
}
_ => Value::Null,
}
}
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
fn opencode_model(m: &Value) -> Value {
let cost = &m["cost"]["text"];
@@ -371,13 +414,13 @@ fn opencode_model(m: &Value) -> Value {
for o in side["overrides"].as_array().into_iter().flatten() {
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
let t = tiers.entry(gt).or_default();
t.insert(field.into(), o["price"].clone());
t.insert(field.into(), price_of(&o["price"]));
if field == "input_price" {
if o["cache"]["read"].is_number() {
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
}
if o["cache"]["write"].is_number() {
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
for (key, k) in [("read", "cache_read_price"), ("write", "cache_write_price")] {
let p = price_of(&o["cache"][key]);
if !p.is_null() {
t.insert(k.into(), p);
}
}
}
}
@@ -409,12 +452,13 @@ fn opencode_model(m: &Value) -> Value {
"id": m["id"],
"context": m["limit"]["context"],
"max_output": m["limit"]["output"],
"max_input": m["limit"]["input"],
"vision": images,
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
"input_price": input["price"],
"output_price": output["price"],
"cache_read_price": input["cache"]["read"],
"cache_write_price": input["cache"]["write"],
"input_price": price_of(&input["price"]),
"output_price": price_of(&output["price"]),
"cache_read_price": price_of(&input["cache"]["read"]),
"cache_write_price": price_of(&input["cache"]["write"]),
"price_tiers": price_tiers,
"price_multipliers": price_multipliers,
"notes": notes,
@@ -480,6 +524,25 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
#[cfg(test)]
mod tests {
#[test]
fn prices_per_cache_duration() {
use super::{opencode_model, price_of};
use serde_json::{Value, json};
assert_eq!(price_of(&json!(0.5)), json!(0.5));
assert_eq!(price_of(&json!({"12h": 0.25, "30m": 0.125})), json!(0.125));
assert_eq!(price_of(&json!({"1h": 2.0, "5m": 1.25})), json!(1.25));
assert_eq!(price_of(&Value::Null), Value::Null);
// A model of opencode's list with per-duration cache write prices.
let m = json!({"id": "gpt-6-luna", "limit": {"context": 1050000, "input": 922000, "output": 128000},
"cost": {"text": {"input": {"price": 0.1, "cache": {"read": 0.01, "write": {"30m": 0.125, "12h": 0.25}},
"overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.2, "cache": {"read": 0.02, "write": {"30m": 0.25, "12h": 0.5}}}]},
"output": {"price": 0.5, "overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.75}]}}}});
let row = opencode_model(&m);
assert_eq!(row["cache_write_price"], json!(0.125));
assert_eq!(row["price_tiers"][0]["cache_write_price"], json!(0.25));
assert_eq!(row["max_input"], json!(922000));
}
#[test]
fn opencode_cost_conversion() {
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
+87
View File
@@ -0,0 +1,87 @@
//! A check command per project folder (tests, build, lint) that runs after every turn that changed
//! files. The result is a green/red badge on the turn; a failed run can be handed to the agent.
use crate::app::App;
use crate::types::{Event, Role, Session, now_ms};
use anyhow::{Result, anyhow, bail};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(10 * 60);
const MAX_OUTPUT: usize = 12_000;
/// Last `max` bytes of the output (failures show at the end).
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut start = s.len() - max;
while !s.is_char_boundary(start) {
start += 1;
}
format!("…\n{}", &s[start..])
}
fn command(app: &App, session: &Session, script: &str) -> Result<tokio::process::Command> {
let script = format!("exec 2>&1\n{script}");
let cwd = std::path::Path::new(&session.cwd);
if let Some(spec) = crate::sandbox::spec_for(app, session.owner_id.as_deref()) {
if !crate::sandbox::available() {
bail!("the sandbox (bubblewrap) is not available");
}
return Ok(crate::sandbox::command(&spec, cwd, "bash", &["-c", &script]));
}
let mut c = tokio::process::Command::new("bash");
c.arg("-c").arg(&script).current_dir(cwd).env("CODECRAFT", "1").env("CI", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
Ok(c)
}
/// Run the project's check for the turn that ended with `message_id` and publish the result.
pub async fn run(app: &Arc<App>, session: &Session, message_id: &str) -> Result<()> {
let (cmd, _) = app.store.project_check(&session.cwd)?.ok_or_else(|| anyhow!("no check command set for this project"))?;
let sid = session.id.clone();
let emit = |status: &str, code: Option<i32>, ms: u64, out: &str| {
let _ = app.store.set_turn_check(&sid, message_id, status, code, ms, out);
app.emit(Event::CheckResult { session_id: sid.clone(), message_id: message_id.into(), status: status.into(), exit_code: code, duration_ms: ms, output: out.into() });
};
emit("running", None, 0, "");
let started = now_ms();
let mut c = match command(app, session, &cmd) {
Ok(c) => c,
Err(e) => {
emit("failed", None, 0, &format!("{e:#}"));
return Ok(());
}
};
let res = tokio::time::timeout(TIMEOUT, c.stdin(Stdio::null()).kill_on_drop(true).output()).await;
let ms = (now_ms() - started).max(0) as u64;
match res {
Err(_) => emit("timeout", None, ms, &format!("Stopped after {} minutes.", TIMEOUT.as_secs() / 60)),
Ok(Err(e)) => emit("failed", None, ms, &format!("could not run the command: {e}")),
Ok(Ok(o)) => {
let mut text = String::from_utf8_lossy(&o.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&o.stderr));
emit(if o.status.success() { "passed" } else { "failed" }, o.status.code(), ms, &tail(text.trim(), MAX_OUTPUT));
}
}
Ok(())
}
/// After a turn: run the check if the project has one and the turn changed files.
pub fn spawn_after_turn(app: Arc<App>, session_id: String, turn_started: i64) {
tokio::spawn(async move {
let Ok(session) = app.session(&session_id) else { return };
if session.parent_id.is_some() {
return;
}
let Ok(Some((_, true))) = app.store.project_check(&session.cwd) else { return };
if app.store.checkpoints_since(&session_id, turn_started).map(|c| c.is_empty()).unwrap_or(true) {
return;
}
let Some(last) = app.store.messages(&session_id).ok().and_then(|m| m.into_iter().rev().find(|m| m.role == Role::Assistant)) else { return };
if let Err(e) = run(&app, &session, &last.id).await {
tracing::debug!("check failed to start: {e:#}");
}
});
}
+933
View File
@@ -0,0 +1,933 @@
//! Claude Code as an engine: a chat whose model is "claude-code/<alias>" is not run by our own
//! agent loop but by the `claude` CLI (`-p`, stream-json). Its events become normal chat
//! messages; its tool approvals (`can_use_tool`) go through our permission system, so cards,
//! push actions, "allow for this project" and the sandbox work as for the native agent.
//!
//! Login is done in the web UI: `claude auth login` runs in a PTY, the sign-in URL is shown
//! and the code the Anthropic page displays is pasted back (see the `login_*` functions).
use crate::app::{App, PermissionReply};
use crate::config::ModelConfig;
use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command};
use crate::provider::{ChatRequest, Provider, StreamEvent};
use crate::store::UsageRecord;
use crate::types::{Event, ImageData, Message, Part, Role, Session, Usage, new_id, now_ms};
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub const PROVIDER: &str = "claude-code";
/// The `claude` binary of the server user (outside the sandbox); None = not installed.
/// Where updates from the web UI are installed (npm prefix in the persistent home); it is first in
/// `binary()`'s search and is made readable inside the sandbox, so one update serves all users.
fn managed_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".npm-global"))
}
fn managed_binary() -> Option<PathBuf> {
managed_dir().map(|d| d.join("bin/claude")).filter(|p| p.is_file())
}
pub fn binary() -> Option<PathBuf> {
if let Some(m) = managed_binary() {
return Some(m);
}
let mut dirs: Vec<PathBuf> = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
if let Some(h) = std::env::var_os("HOME") {
dirs.push(PathBuf::from(&h).join(".local/bin"));
dirs.push(PathBuf::from(&h).join(".npm-global/bin"));
}
dirs.into_iter().map(|d| d.join("claude")).find(|p| p.is_file())
}
/// Registered like a provider so the model picker, validation and statistics need no special cases;
/// requests never reach it (the runner hands such chats to `run`).
pub struct Marker;
#[async_trait]
impl Provider for Marker {
async fn stream(&self, _req: ChatRequest, _tx: mpsc::Sender<StreamEvent>, _cancel: CancellationToken) -> Result<()> {
bail!("this model runs through Claude Code and cannot be used for background tasks – set a small_model in the configuration")
}
async fn list_models(&self) -> Result<Vec<ModelConfig>> {
Ok(models())
}
}
pub fn models() -> Vec<ModelConfig> {
[("default", "Claude Code (default model)"), ("opus", "Claude Code · Opus"), ("sonnet", "Claude Code · Sonnet"), ("haiku", "Claude Code · Haiku")]
.into_iter()
.map(|(id, name)| ModelConfig { id: id.into(), name: Some(name.into()), vision: true, reasoning: true, ..Default::default() })
.collect()
}
pub fn is_claude_code(model_spec: &str) -> bool {
model_spec.starts_with("claude-code/")
}
/// A command running `claude` for this user: in the user's sandbox, or directly with a minimal
/// environment (no server secrets; the CLI uses its own sign-in from HOME).
fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Result<tokio::process::Command> {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
if let Some(spec) = crate::sandbox::spec_for(app, owner) {
if !crate::sandbox::available() {
bail!("Claude Code is disabled for this account: the sandbox (bubblewrap) is not available");
}
// An update installed from the web UI lives outside the image: mounted read-only, called by path.
if let (Some(bin), Some(dir)) = (managed_binary(), managed_dir()) {
let program = bin.display().to_string();
return Ok(crate::sandbox::command_with(&spec, cwd, &[dir.as_path()], &program, &refs));
}
return Ok(crate::sandbox::command(&spec, cwd, "claude", &refs));
}
let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?;
let mut c = tokio::process::Command::new(bin);
c.current_dir(cwd).env_clear();
for k in ["PATH", "HOME", "LANG", "USER", "SHELL"] {
if let Some(v) = std::env::var_os(k) {
c.env(k, v);
}
}
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").args(args);
// The user's git credentials and identity (the CLI runs git for the agent).
if let Some(user) = owner.and_then(|id| app.store.user(id).ok().flatten()) {
if let Some(cfg) = crate::gitcreds::global_config(app, &user) {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(crate::gitcreds::identity_env(app, &user.id));
c.envs(crate::sshkeys::env(app, &user));
}
Ok(c)
}
// --- tool mapping -----------------------------------------------------------------------
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
fn map_tool(name: &str, input: &Value) -> (String, Value) {
// Our own tools reach the CLI over MCP: shown and judged under their usual names.
if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
return (ours.to_string(), input.clone());
}
let mut v = input.clone();
let rename = |v: &mut Value, from: &str, to: &str| {
if let Some(x) = v.get(from).cloned() {
v[to] = x;
if let Some(o) = v.as_object_mut() {
o.remove(from);
}
}
};
let ours = match name {
"Bash" => "bash",
"Read" => "read",
"Write" => "write",
"Edit" | "MultiEdit" | "NotebookEdit" => "edit",
"Glob" => "glob",
"Grep" => "grep",
"WebFetch" => "web_fetch",
"WebSearch" => "web_search",
"Task" | "Agent" => "task",
other => return (other.to_lowercase(), v),
};
rename(&mut v, "file_path", "path");
rename(&mut v, "notebook_path", "path");
(ours.to_string(), v)
}
struct CcTool {
name: String,
}
impl ToolInfo for CcTool {
fn name(&self) -> &str {
&self.name
}
fn read_only(&self) -> bool {
matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list")
// One of our tools (MCP bridge): as it declares itself.
|| crate::tools::builtin_tools().iter().any(|t| t.name() == self.name && t.read_only())
}
fn is_edit(&self) -> bool {
matches!(self.name.as_str(), "write" | "edit")
}
}
fn unified(path: &str, old: &str, new: &str) -> String {
similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(path, path).to_string()
}
/// Diff of a pending write/edit (shown on the approval card and kept as the result's diff).
fn preview_diff(name: &str, input: &Value) -> Option<String> {
let path = input["path"].as_str()?;
let old = std::fs::read_to_string(path).unwrap_or_default();
match name {
"write" => Some(unified(path, &old, input["content"].as_str()?)),
"edit" => {
let (from, to) = (input["old_string"].as_str()?, input["new_string"].as_str()?);
let new = if input["replace_all"].as_bool() == Some(true) { old.replace(from, to) } else { old.replacen(from, to, 1) };
Some(unified(path, &old, &new))
}
_ => None,
}
}
fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
match name {
"bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()),
"write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(),
_ => match crate::tools::builtin_tools().iter().find(|t| t.name() == name) {
Some(t) => t.title(input),
None => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()),
},
}
}
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
let (name, input) = map_tool(claude_name, claude_input);
if claude_name == "AskUserQuestion" {
return (false, Some("Questions to the user are not available in this mode – decide yourself or state your assumption.".into()));
}
let info = CcTool { name: name.clone() };
let cfg = app.config();
let root = app.root_session_id(&session.id);
let (mut decision, approved) = app.with_runtime(&root, |r| {
(Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(&info, &input), r.approved.clone())
});
if decision == Decision::Ask {
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
if !project.is_empty() {
let mut all = approved;
all.extend(project);
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(&info, &input);
}
}
match decision {
Decision::Deny => return (false, Some("Denied by the permission rules in the configuration.".into())),
Decision::Allow => {}
Decision::Ask => {
let diff = preview_diff(&name, &input);
let (reply, message, _) = app.request_permission(&session.id, &name, title_of(&name, &input, claude_input), input.clone(), diff, cancel.clone()).await;
match reply {
PermissionReply::Reject => {
let mut text = "The user rejected this tool call.".to_string();
if let Some(m) = message.filter(|m| !m.trim().is_empty()) {
text.push_str(&format!(" User feedback: {m}"));
}
return (false, Some(text));
}
PermissionReply::Always | PermissionReply::Project => {
let mut keys: Vec<(String, bool)> = Vec::new();
if name == "bash" {
for part in split_command(input["command"].as_str().unwrap_or("")).unwrap_or_default() {
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
}
} else {
keys.push((name.clone(), true));
}
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
if reply == PermissionReply::Project {
for (k, ok) in &keys {
if *ok {
let _ = app.store.add_project_approval(&session.cwd, k);
}
}
}
}
PermissionReply::Once => {}
}
}
}
// The state before the agent's change (turn changes, rewind).
if matches!(name.as_str(), "write" | "edit") {
if let Some(path) = input["path"].as_str() {
let before = std::fs::read(path).ok();
let _ = app.store.add_checkpoint(&session.id, path, before.as_deref());
}
}
(true, None)
}
// --- the turn ---------------------------------------------------------------------------
struct Cur {
id: String,
api_id: String,
parts: Vec<Part>,
usage: Usage,
model: Option<String>,
started: i64,
}
fn tokens(u: &Value) -> Usage {
Usage {
input_tokens: u["input_tokens"].as_u64().unwrap_or(0),
output_tokens: u["output_tokens"].as_u64().unwrap_or(0),
cache_read_tokens: u["cache_read_input_tokens"].as_u64().unwrap_or(0),
cache_write_tokens: u["cache_creation_input_tokens"].as_u64().unwrap_or(0),
cost: None,
allowance: None,
}
}
fn finalize(app: &App, session_id: &str, cur: &mut Option<Cur>, model_id: &str) {
let Some(c) = cur.take() else { return };
if c.parts.is_empty() {
return;
}
let mut m = Message::new(Role::Assistant, c.parts);
m.id = c.id;
m.created_at = c.started;
m.model = Some(format!("{PROVIDER}/{}", c.model.as_deref().unwrap_or(model_id)));
m.usage = Some(c.usage.clone());
if app.store.add_message(session_id, &m).is_ok() {
app.emit(Event::MessageAdded { session_id: session_id.into(), message: m });
}
}
/// Text of a tool result: a string, or a list of blocks.
fn result_text(content: &Value) -> String {
match content {
Value::String(s) => s.clone(),
Value::Array(a) => a.iter().filter_map(|b| b["text"].as_str()).collect::<Vec<_>>().join("\n"),
other => other.to_string(),
}
}
/// Images inside a tool result (the CLI's Read of a picture): base64 blocks.
fn result_images(content: &Value) -> Vec<ImageData> {
content
.as_array()
.into_iter()
.flatten()
.filter(|b| b["type"] == "image" && b["source"]["type"] == "base64")
.filter_map(|b| {
let data = b["source"]["data"].as_str().filter(|d| !d.is_empty())?;
Some(ImageData { media_type: b["source"]["media_type"].as_str().unwrap_or("image/png").to_string(), data: data.to_string(), blob: None, size: None })
})
.collect()
}
/// Images of the prompt as files the CLI can read (it has no inline image input in this mode).
fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
use base64::Engine;
let dir = Path::new(cwd).join(".codecraft/uploads");
let mut out = Vec::new();
for (i, p) in parts.iter().enumerate() {
let Part::Image { image } = p else { continue };
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue };
let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png");
let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms()));
if std::fs::create_dir_all(&dir).is_ok() {
let _ = std::fs::write(dir.join(".gitignore"), "*\n");
if std::fs::write(&path, bytes).is_ok() {
out.push(path.display().to_string());
}
}
}
out
}
/// Run one turn of a "claude-code/…" chat. Returns the final text.
pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<String> {
let session = app.session(session_id)?;
let owner = session.owner_id.clone();
let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string();
let history = crate::runner::active_history(app.store.messages(session_id)?);
let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else {
bail!("nothing to do: no user message");
};
let mut prompt = history[last_user].parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n\n");
let images = save_images(&session.cwd, &history[last_user].parts);
if !images.is_empty() {
prompt.push_str(&format!("\n\nAttached images (read them with the Read tool):\n{}", images.iter().map(|p| format!("- {p}")).collect::<Vec<_>>().join("\n")));
}
// Continue the CLI's own conversation; without one (new chat, fork, model switch) give it the history as context.
let key = format!("claude_sid:{session_id}");
let resume: Option<String> = owner.as_deref().and_then(|u| app.store.user_setting::<Option<String>>(u, &key).ok().flatten());
if resume.is_none() && last_user > 0 {
let earlier = crate::runner::transcript_of(&history[..last_user]);
prompt = format!("<previous-conversation>\n{}\n</previous-conversation>\n\n{prompt}", crate::runner::fit(&earlier, 20_000));
}
let mode = match (session.agent.as_str(), session.permission_mode.as_str()) {
("plan", _) => "plan",
_ => "default",
};
let mut args: Vec<String> = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-prompt-tool", "stdio", "--permission-mode", mode]
.iter()
.map(|s| s.to_string())
.collect();
if model_id != "default" {
args.extend(["--model".into(), model_id.clone()]);
}
if let Some(r) = &resume {
args.extend(["--resume".into(), r.clone()]);
}
// CodeCraft's own tools for the CLI (MCP, per-run token).
let bridge = crate::mcp_bridge::register(session_id);
args.extend(["--mcp-config".into(), crate::mcp_bridge::config_json(&app, &bridge.token)]);
let mut appended = session.instructions.trim().to_string();
if let Some(note) = crate::mcp_bridge::prompt_section(&crate::mcp_bridge::tools_for(&app, &session)) {
appended = format!("{appended}\n{note}").trim().to_string();
}
for extra in owner.as_deref().into_iter().flat_map(|u| [crate::gitcreds::prompt_section(&app, u), crate::sshkeys::prompt_section(&app, u)]).flatten() {
appended = format!("{appended}\n{extra}").trim().to_string();
}
if !appended.is_empty() {
args.extend(["--append-system-prompt".into(), appended]);
}
let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?;
let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true).spawn().map_err(|e| anyhow!("could not start Claude Code: {e}"))?;
let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
let stderr_tail = Arc::new(Mutex::new(String::new()));
{
let tail = stderr_tail.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(l)) = lines.next_line().await {
let mut t = tail.lock().unwrap();
t.push_str(&l);
t.push('\n');
if t.len() > 4000 {
let cut = t.len() - 3000;
let cut = (cut..t.len()).find(|i| t.is_char_boundary(*i)).unwrap_or(t.len());
t.drain(..cut);
}
}
});
}
let first = json!({"type": "user", "message": {"role": "user", "content": prompt}});
stdin.write_all(format!("{first}\n").as_bytes()).await?;
let mut lines = BufReader::new(child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?).lines();
let started = now_ms();
let mut cur: Option<Cur> = None;
let mut calls: HashMap<String, String> = HashMap::new();
let mut diffs: HashMap<String, String> = HashMap::new();
let mut final_text = String::new();
let mut got_result = false;
let mut failure: Option<String> = None;
loop {
let line = tokio::select! {
l = lines.next_line() => l?,
_ = cancel.cancelled() => { let _ = child.start_kill(); break; }
};
let Some(line) = line else { break };
let Ok(ev) = serde_json::from_str::<Value>(&line) else { continue };
let sub = ev["parent_tool_use_id"].as_str().is_some();
match ev["type"].as_str().unwrap_or("") {
"system" if ev["subtype"] == "init" => {
if let (Some(sid), Some(u)) = (ev["session_id"].as_str(), owner.as_deref()) {
let _ = app.store.set_user_setting(u, &key, &Some(sid.to_string()));
}
}
"stream_event" if !sub => {
let e = &ev["event"];
match e["type"].as_str().unwrap_or("") {
"message_start" => {
finalize(&app, session_id, &mut cur, &model_id);
let u = tokens(&e["message"]["usage"]);
let id = new_id();
app.emit(Event::StepStarted { session_id: session_id.into(), message_id: id.clone(), started_at: now_ms(), prompt_tokens: u.input_tokens + u.cache_read_tokens + u.cache_write_tokens, estimated: false });
cur = Some(Cur { id, api_id: e["message"]["id"].as_str().unwrap_or("").into(), parts: vec![], usage: u, model: e["message"]["model"].as_str().map(String::from), started: now_ms() });
}
"content_block_delta" => {
if let Some(c) = &cur {
match e["delta"]["type"].as_str().unwrap_or("") {
"text_delta" => app.emit(Event::TextDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["text"].as_str().unwrap_or("").into() }),
"thinking_delta" => app.emit(Event::ReasoningDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["thinking"].as_str().unwrap_or("").into() }),
_ => {}
}
}
}
"message_delta" => {
if let Some(c) = &mut cur {
if let Some(o) = e["usage"]["output_tokens"].as_u64() {
c.usage.output_tokens = o;
}
}
}
"message_stop" => finalize(&app, session_id, &mut cur, &model_id),
_ => {}
}
}
"assistant" if !sub => {
let api_id = ev["message"]["id"].as_str().unwrap_or("").to_string();
if cur.as_ref().is_none_or(|c| c.api_id != api_id) {
finalize(&app, session_id, &mut cur, &model_id);
cur = Some(Cur { id: new_id(), api_id, parts: vec![], usage: tokens(&ev["message"]["usage"]), model: ev["message"]["model"].as_str().map(String::from), started: now_ms() });
}
let c = cur.as_mut().unwrap();
for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() {
let part = match b["type"].as_str().unwrap_or("") {
"text" => Part::Text { text: b["text"].as_str().unwrap_or("").to_string() },
"thinking" => Part::Reasoning { text: b["thinking"].as_str().unwrap_or("").to_string(), signature: b["signature"].as_str().map(String::from) },
"tool_use" => {
let (name, input) = map_tool(b["name"].as_str().unwrap_or(""), &b["input"]);
let id = b["id"].as_str().unwrap_or("").to_string();
calls.insert(id.clone(), name.clone());
Part::ToolCall { id, name, input, extra: None }
}
_ => continue,
};
if !c.parts.contains(&part) && !matches!(&part, Part::ToolCall { id, .. } if c.parts.iter().any(|p| matches!(p, Part::ToolCall { id: i, .. } if i == id))) {
c.parts.push(part);
}
}
}
"user" if !sub => {
finalize(&app, session_id, &mut cur, &model_id);
let mut parts = Vec::new();
for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() {
if b["type"] != "tool_result" {
continue;
}
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
let is_error = b["is_error"].as_bool().unwrap_or(false);
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) };
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta });
}
if !parts.is_empty() {
let m = Message::new(Role::User, parts);
if app.store.add_message(session_id, &m).is_ok() {
app.emit(Event::MessageAdded { session_id: session_id.into(), message: m });
}
}
}
"control_request" if ev["request"]["subtype"] == "can_use_tool" => {
finalize(&app, session_id, &mut cur, &model_id);
let req = &ev["request"];
let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
if allow {
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
if let (Some(d), Some(id)) = (preview_diff(&name, &input), req["tool_use_id"].as_str()) {
diffs.insert(id.to_string(), d);
}
}
let response = if allow { json!({"behavior": "allow", "updatedInput": req["input"]}) } else { json!({"behavior": "deny", "message": message.unwrap_or_default()}) };
let reply = json!({"type": "control_response", "response": {"subtype": "success", "request_id": ev["request_id"], "response": response}});
if stdin.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
break;
}
}
"result" => {
finalize(&app, session_id, &mut cur, &model_id);
got_result = true;
final_text = ev["result"].as_str().unwrap_or("").to_string();
if ev["is_error"].as_bool().unwrap_or(false) || ev["subtype"].as_str().is_some_and(|s| s != "success") {
failure = Some(if final_text.trim().is_empty() { format!("Claude Code stopped: {}", ev["subtype"].as_str().unwrap_or("error")) } else { final_text.clone() });
}
let u = tokens(&ev["usage"]);
let (p, m) = (PROVIDER, model_id.as_str());
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: session_id.into(),
root_session_id: app.root_session_id(session_id),
provider: p.into(),
model: m.into(),
agent: "claude-code".into(),
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
cache_read_tokens: u.cache_read_tokens,
cache_write_tokens: u.cache_write_tokens,
ttft_ms: 0,
duration_ms: (now_ms() - started).max(0) as u64,
cost: ev["total_cost_usd"].as_f64().unwrap_or(0.0),
error: failure.is_some(),
user_id: None,
allowance: None,
});
break;
}
_ => {}
}
}
finalize(&app, session_id, &mut cur, &model_id);
drop(stdin);
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
let _ = child.start_kill();
if cancel.is_cancelled() {
return Ok(final_text);
}
if let Some(f) = failure {
// An expired sign-in is the common case: say where to fix it.
if f.to_lowercase().contains("login") || f.to_lowercase().contains("authenticat") || f.contains("401") {
bail!("Claude Code is not signed in (or the sign-in expired): Settings → Claude Code → Sign in. ({f})");
}
bail!("{f}");
}
if !got_result {
let tail = stderr_tail.lock().unwrap().trim().to_string();
if tail.to_lowercase().contains("login") || tail.to_lowercase().contains("authenticat") {
bail!("Claude Code is not signed in: Settings → Claude Code → Sign in.");
}
bail!("Claude Code ended without an answer{}", if tail.is_empty() { String::new() } else { format!(": {tail}") });
}
Ok(final_text)
}
// --- sign-in from the web UI ----------------------------------------------------------------
/// `claude auth login` running in a PTY for one user: its output (with the sign-in URL) and stdin.
struct Login {
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
out: Arc<Mutex<String>>,
child: Mutex<Option<tokio::process::Child>>,
exited: Arc<Mutex<Option<i32>>>,
started: Instant,
}
static LOGINS: OnceLock<Mutex<HashMap<String, Arc<Login>>>> = OnceLock::new();
fn logins() -> &'static Mutex<HashMap<String, Arc<Login>>> {
LOGINS.get_or_init(Default::default)
}
/// Terminal escape sequences removed.
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
match chars.peek() {
Some('[') => {
chars.next();
for n in chars.by_ref() {
if ('@'..='~').contains(&n) {
break;
}
}
}
Some(']') => {
chars.next();
for n in chars.by_ref() {
if n == '\x07' {
break;
}
}
}
_ => {
chars.next();
}
}
} else if c != '\r' {
out.push(c);
}
}
out
}
fn find_url(text: &str) -> Option<String> {
let t = strip_ansi(text);
let start = t.find("https://")?;
let url: String = t[start..].chars().take_while(|c| !c.is_whitespace()).collect();
// Complete once whitespace follows it (the output arrives in chunks); the wide PTY avoids wrapping.
let complete = t.len() > start + url.len();
(complete && (url.contains("oauth") || url.contains("authorize"))).then_some(url)
}
fn stop_login(user_id: &str) {
if let Some(l) = logins().lock().unwrap().remove(user_id) {
if let Some(mut c) = l.child.lock().unwrap().take() {
let _ = c.start_kill();
}
}
}
/// Start `claude auth login` for the user and return the sign-in URL to open in any browser.
/// `console` = Anthropic Console (API billing) instead of the Claude subscription.
pub async fn login_start(app: &Arc<App>, user_id: &str, console: bool) -> Result<String> {
stop_login(user_id);
let (pty, pts) = pty_process::open()?;
pty.resize(pty_process::Size::new(24, 500))?;
let mode = if console { "--console" } else { "--claudeai" };
let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/"));
let cmd = match crate::sandbox::spec_for(app, Some(user_id)) {
Some(spec) => {
if !crate::sandbox::available() {
bail!("the sandbox (bubblewrap) is not available");
}
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, &cwd, &[], false, "env", &["BROWSER=/bin/false", "claude", "auth", "login", mode]))
}
None => {
let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?;
let mut c = pty_process::Command::new(bin).args(["auth", "login", mode]).current_dir(&cwd).env_clear().env("BROWSER", "/bin/false").env("TERM", "xterm-256color");
for k in ["PATH", "HOME", "LANG", "USER"] {
if let Some(v) = std::env::var_os(k) {
c = c.env(k, v);
}
}
c
}
};
let child = cmd.kill_on_drop(true).spawn(pts).map_err(|e| anyhow!("could not start `claude auth login`: {e}"))?;
let (mut reader, writer) = pty.into_split();
let login = Arc::new(Login { writer: tokio::sync::Mutex::new(writer), out: Arc::default(), child: Mutex::new(Some(child)), exited: Arc::default(), started: Instant::now() });
{
let (out, exited, l) = (login.out.clone(), login.exited.clone(), login.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
loop {
match reader.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let mut o = out.lock().unwrap();
o.push_str(&String::from_utf8_lossy(&buf[..n]));
if o.len() > 64_000 {
let cut = o.len() - 32_000;
let cut = (cut..o.len()).find(|i| o.is_char_boundary(*i)).unwrap_or(o.len());
o.drain(..cut);
}
}
}
}
let code = l.child.lock().unwrap().as_mut().and_then(|c| c.try_wait().ok().flatten()).and_then(|s| s.code()).unwrap_or(0);
*exited.lock().unwrap() = Some(code);
});
}
logins().lock().unwrap().insert(user_id.to_string(), login.clone());
// The URL shows up within a few seconds.
for _ in 0..60 {
if let Some(u) = find_url(&login.out.lock().unwrap()) {
return Ok(u);
}
if login.exited.lock().unwrap().is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
let out = strip_ansi(&login.out.lock().unwrap());
stop_login(user_id);
bail!("no sign-in URL received from `claude auth login`{}", if out.trim().is_empty() { String::new() } else { format!(": {}", out.trim().chars().take(300).collect::<String>()) })
}
/// Hand the code from the sign-in page to the waiting CLI; true once it reports success.
pub async fn login_code(user_id: &str, code: &str) -> Result<bool> {
let login = logins().lock().unwrap().get(user_id).cloned().ok_or_else(|| anyhow!("no sign-in in progress – start it again"))?;
if login.started.elapsed() > Duration::from_secs(15 * 60) {
stop_login(user_id);
bail!("the sign-in expired – start it again");
}
let code: String = code.chars().filter(|c| !c.is_control()).collect::<String>().trim().to_string();
if code.is_empty() || code.len() > 2000 {
bail!("empty or invalid code");
}
login.writer.lock().await.write_all(format!("{code}\r").as_bytes()).await?;
for _ in 0..80 {
if login.exited.lock().unwrap().is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
let exited = *login.exited.lock().unwrap();
let out = strip_ansi(&login.out.lock().unwrap());
stop_login(user_id);
match exited {
Some(0) => Ok(true),
_ => bail!("sign-in failed: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("no answer from the CLI").trim()),
}
}
pub fn login_cancel(user_id: &str) {
stop_login(user_id);
}
/// `claude auth status` for the user (their sandbox home or the server's): the CLI's JSON.
pub async fn status(app: &App, user_id: &str) -> Value {
let mut v = status_inner(app, user_id).await;
if v["installed"] == true {
if let (Some(o), Some(i)) = (v.as_object_mut(), version_info(false).await.as_object()) {
o.extend(i.clone());
}
}
v
}
async fn status_inner(app: &App, user_id: &str) -> Value {
let Some(_) = binary().or_else(|| crate::sandbox::spec_for(app, Some(user_id)).map(|_| PathBuf::from("claude"))) else {
return json!({"installed": false});
};
let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/"));
let mut cmd = match command(app, Some(user_id), &cwd, &["auth".into(), "status".into(), "--json".into()]) {
Ok(c) => c,
Err(e) => return json!({"installed": true, "error": format!("{e:#}")}),
};
let out = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await;
let in_progress = logins().lock().unwrap().contains_key(user_id);
match out {
Ok(Ok(o)) => {
let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);
json!({
"installed": true,
"logged_in": v["loggedIn"].as_bool().unwrap_or(false),
"method": v["authMethod"], "email": v["email"], "org": v["orgName"], "expires": v["loginExpiresAt"],
"login_in_progress": in_progress,
})
}
Ok(Err(e)) => json!({"installed": false, "error": e.to_string()}),
Err(_) => json!({"installed": true, "error": "timeout", "login_in_progress": in_progress}),
}
}
pub async fn logout(app: &App, user_id: &str) -> Result<()> {
let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/"));
let mut cmd = command(app, Some(user_id), &cwd, &["auth".into(), "logout".into()])?;
let _ = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await;
Ok(())
}
// --- version and update -------------------------------------------------------------------------
/// "2.1.325" or "2.1.325 (Claude Code)" → [2, 1, 325].
fn version_key(v: &str) -> Vec<u64> {
v.split_whitespace().next().unwrap_or("").trim_start_matches('v').split('.').map(|p| p.parse().unwrap_or(0)).collect()
}
fn newer_version(latest: &str, current: &str) -> bool {
version_key(latest) > version_key(current)
}
/// Version of the installed CLI (what `claude --version` prints).
async fn installed_version() -> Option<String> {
let bin = binary()?;
let out = tokio::time::timeout(Duration::from_secs(10), tokio::process::Command::new(bin).arg("--version").stdin(Stdio::null()).kill_on_drop(true).output()).await.ok()?.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let v = text.split_whitespace().next()?.to_string();
(!v.is_empty()).then_some(v)
}
static LATEST: OnceLock<Mutex<Option<(Instant, String)>>> = OnceLock::new();
/// Latest release on npm (cached for 15 minutes; `force` asks again).
async fn latest_version(force: bool) -> Option<String> {
let cache = LATEST.get_or_init(Default::default);
if !force {
if let Some((t, v)) = cache.lock().unwrap().as_ref() {
if t.elapsed() < Duration::from_secs(15 * 60) {
return Some(v.clone());
}
}
}
let v: Value = crate::provider::http_client().get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest").timeout(Duration::from_secs(10)).send().await.ok()?.json().await.ok()?;
let version = v["version"].as_str()?.to_string();
*cache.lock().unwrap() = Some((Instant::now(), version.clone()));
Some(version)
}
/// Ask the registry for the latest version now (the "check" button).
pub async fn refresh_latest() {
let _ = latest_version(true).await;
}
/// {version, latest, update_available} of the CLI on the server.
pub async fn version_info(force: bool) -> Value {
let (current, latest) = tokio::join!(installed_version(), latest_version(force));
let update = match (&current, &latest) {
(Some(c), Some(l)) => newer_version(l, c),
_ => false,
};
json!({"version": current, "latest": latest, "update_available": update})
}
static UPDATING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Install the latest version: a native install updates itself (`claude update`), otherwise npm installs
/// into the persistent home (`~/.npm-global`, ahead of the image's copy). Returns the tool output.
pub async fn update() -> Result<String> {
use std::sync::atomic::Ordering;
if UPDATING.swap(true, Ordering::SeqCst) {
bail!("an update is already running");
}
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
UPDATING.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
let _reset = Reset;
let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server"))?;
let native = std::fs::canonicalize(&bin).is_ok_and(|p| p.to_string_lossy().contains("/.local/share/claude/"));
let mut cmd = if native {
let mut c = tokio::process::Command::new(&bin);
c.arg("update");
c
} else {
let prefix = managed_dir().ok_or_else(|| anyhow!("no HOME"))?;
let mut c = tokio::process::Command::new("npm");
c.args(["install", "-g", "--no-audit", "--no-fund", "@anthropic-ai/claude-code@latest"]).env("NPM_CONFIG_PREFIX", prefix);
c
};
cmd.stdin(Stdio::null()).kill_on_drop(true);
for k in ["PATH", "HOME", "LANG", "USER"] {
if let Some(v) = std::env::var_os(k) {
cmd.env(k, v);
}
}
let out = tokio::time::timeout(Duration::from_secs(300), cmd.output()).await.map_err(|_| anyhow!("the update timed out after 5 minutes"))?.map_err(|e| anyhow!("could not run the updater: {e}"))?;
let mut text = String::from_utf8_lossy(&out.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&out.stderr));
let tail: String = { let t = text.trim(); t.chars().rev().take(1500).collect::<Vec<_>>().into_iter().rev().collect() };
if !out.status.success() {
bail!("the update failed: {tail}");
}
// Ask the registry again next time.
if let Some(c) = LATEST.get() {
*c.lock().unwrap() = None;
}
Ok(tail)
}
#[cfg(test)]
mod tests {
#[test]
fn tool_result_images_are_kept() {
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
let imgs = result_images(&content);
assert_eq!(imgs.len(), 1);
assert_eq!((imgs[0].media_type.as_str(), imgs[0].data.as_str()), ("image/jpeg", "QUJD"));
assert!(result_images(&json!("plain text")).is_empty());
}
use super::*;
#[test]
fn finds_the_login_url_only_when_complete() {
let partial = "Opening browser…\r\nIf the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&cli";
assert_eq!(find_url(partial), None);
let full = format!("{partial}ent_id=1&state=x\r\nPaste code here if prompted > ");
assert_eq!(find_url(&full).as_deref(), Some("https://claude.com/cai/oauth/authorize?code=true&client_id=1&state=x"));
assert_eq!(strip_ansi("\x1b[2Khello\x1b[0m\r\n"), "hello\n");
}
#[test]
fn compares_cli_versions() {
assert!(newer_version("2.1.325", "2.1.311"));
assert!(newer_version("2.2.0", "2.1.999"));
assert!(!newer_version("2.1.325", "2.1.325 (Claude Code)"));
assert!(!newer_version("2.1.9", "2.1.10"));
}
#[test]
fn maps_claude_tools_to_ours() {
let (n, i) = map_tool("Write", &json!({"file_path": "/a/b.txt", "content": "x"}));
assert_eq!((n.as_str(), i["path"].as_str()), ("write", Some("/a/b.txt")));
assert!(i.get("file_path").is_none());
assert_eq!(map_tool("Bash", &json!({"command": "ls"})).0, "bash");
assert_eq!(map_tool("mcp__x__y", &json!({})).0, "mcp__x__y");
assert!(CcTool { name: "edit".into() }.is_edit() && CcTool { name: "read".into() }.read_only());
}
}
+40
View File
@@ -231,6 +231,46 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(b))
}
/// Personal prompt snippets (`/name` in the composer): [{id, name, text}].
pub async fn get_snippets(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let v: Value = app.store.user_setting(&user.id, "snippets")?;
Ok(Json(if v.is_array() { v } else { json!([]) }))
}
pub async fn put_snippets(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
let ok = b.as_array().is_some_and(|a| {
a.len() <= 100
&& a.iter().all(|x| {
let name = x["name"].as_str().unwrap_or("");
!name.is_empty() && name.len() <= 40 && !name.contains(char::is_whitespace) && x["text"].as_str().is_some_and(|t| !t.is_empty() && t.len() <= 8000)
})
});
if !ok {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid snippets (name: one word, up to 40 characters; text: up to 8000)".into()));
}
let clean: Vec<Value> = b.as_array().unwrap().iter().map(|x| json!({"id": x["id"].as_str().filter(|i| i.len() <= 64).map(str::to_string).unwrap_or_else(crate::types::new_id), "name": x["name"], "text": x["text"]})).collect();
let v = Value::Array(clean);
app.store.set_user_setting(&user.id, "snippets", &v)?;
Ok(Json(v))
}
/// Order and pinning of the groups (folders, projects) in the chat list – per user, so
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let v: Value = app.store.user_setting(&user.id, "sidebar")?;
Ok(Json(if v.is_null() { json!({"order": [], "pinned": []}) } else { v }))
}
pub async fn put_sidebar(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
let ids = |k: &str| b[k].as_array().is_some_and(|a| a.iter().all(|x| x.as_str().is_some_and(|s| s.len() <= 1000)));
if !ids("order") || !ids("pinned") || b.to_string().len() > 100_000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid chat list layout".into()));
}
let v = json!({"order": b["order"], "pinned": b["pinned"]});
app.store.set_user_setting(&user.id, "sidebar", &v)?;
Ok(Json(v))
}
/// Personal memory (applies to all projects, web and CLI).
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let m: String = app.store.user_setting(&user.id, "memory")?;
+32
View File
@@ -177,6 +177,19 @@ pub struct ImagesConfig {
/// Optional fal.ai Admin key to query the account balance.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub admin_key: String,
/// fal.ai model for 3D models from images (generate_3d).
#[serde(default = "default_model_3d")]
pub model_3d: String,
/// fal.ai model for 3D models from a text prompt only.
#[serde(default = "default_model_3d_text")]
pub model_3d_text: String,
}
fn default_model_3d() -> String {
"fal-ai/hunyuan3d-v3/image-to-3d".into()
}
fn default_model_3d_text() -> String {
"fal-ai/hunyuan3d-v3/text-to-3d".into()
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
@@ -215,6 +228,8 @@ impl Default for ImagesConfig {
models: vec![ImageModel { id: "fal-ai/flux/schnell".into(), name: Some("FLUX.1 schnell".into()), price: Some(0.003), ..Default::default() }],
monthly_budget: None,
admin_key: String::new(),
model_3d: default_model_3d(),
model_3d_text: default_model_3d_text(),
}
}
}
@@ -287,6 +302,9 @@ pub struct ServerConfig {
pub data_dir: String,
pub default_model: String,
pub small_model: String,
/// Speech-to-text for browsers without dictation (Firefox): "provider/model" of an
/// OpenAI-compatible /audio/transcriptions endpoint, e.g. "openai/whisper-1". Empty = off.
pub transcribe_model: String,
pub auto_compact_threshold: f64,
/// Currency label for cost statistics (display only).
pub currency: String,
@@ -307,8 +325,13 @@ pub struct ServerConfig {
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
#[serde(skip_serializing_if = "String::is_empty")]
pub metrics_token: String,
/// Trailer added to every git commit an agent makes ({model}, {provider}, {model_id});
/// empty = off.
pub commit_trailer: String,
}
pub const DEFAULT_COMMIT_TRAILER: &str = "Co-Authored-By: {model} ({provider}) via CodeCraft";
impl Default for ServerConfig {
fn default() -> Self {
Self {
@@ -317,6 +340,7 @@ impl Default for ServerConfig {
data_dir: "./data".into(),
default_model: String::new(),
small_model: String::new(),
transcribe_model: String::new(),
auto_compact_threshold: 0.85,
currency: "USD".into(),
max_parallel_subagents: 4,
@@ -327,6 +351,7 @@ impl Default for ServerConfig {
max_upload_mb: 500,
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
metrics_token: String::new(),
commit_trailer: DEFAULT_COMMIT_TRAILER.into(),
}
}
}
@@ -398,6 +423,10 @@ pub struct AgentConfig {
/// Review loop after each turn of this mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refine: Option<RefineConfig>,
/// Reverse engineering tools (binary_info, decompile with Ghidra) in this mode;
/// craft has them anyway (deep mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reverse_tools: Option<bool>,
}
/// Refinement loop: after the agent finishes, a reviewer checks the result
@@ -413,6 +442,9 @@ pub struct RefineConfig {
/// Review criteria; empty = the mode's defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub criteria: Option<String>,
/// Also review every todo the agent marks completed (reopened when findings come back).
#[serde(default = "yes")]
pub after_todos: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
+49 -1
View File
@@ -2,7 +2,7 @@
//! user via the user setting "cost_push").
use crate::app::App;
use chrono::{Local, Timelike};
use chrono::{Datelike, Local, Timelike};
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
@@ -54,11 +54,59 @@ pub fn summary(app: &App, user_id: &str) -> Option<Value> {
}))
}
/// The last seven days for one user (None when nothing happened).
pub fn weekly(app: &App, user_id: &str) -> Option<Value> {
let now = Local::now();
let since = now.timestamp_millis() - 7 * 86_400_000;
let offset = now.offset().local_minus_utc() as i64 / 60;
let stats = app.store.global_stats(since, now.timestamp_millis() + 1, offset, Some(user_id)).ok()?;
let requests = stats["total"]["requests"].as_i64().unwrap_or(0);
let (chats, failed, checks) = app.store.weekly_counts(user_id, since).ok()?;
if requests == 0 && chats == 0 {
return None;
}
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let currency = app.config().server.currency.clone();
let cost = money(stats["total"]["cost"].as_f64().unwrap_or(0.0), &currency, de);
let top = stats["top_sessions"].as_array().and_then(|a| a.first()).and_then(|s| s["title"].as_str()).filter(|t| !t.is_empty()).map(str::to_string);
let mut parts = if de { vec![format!("{chats} Chats aktiv"), format!("{requests} Anfragen · {cost}")] } else { vec![format!("{chats} chats active"), format!("{requests} requests · {cost}")] };
if checks > 0 {
parts.push(if de { format!("Checks: {failed} von {checks} fehlgeschlagen") } else { format!("checks: {failed} of {checks} failed") });
}
if let Some(t) = top {
parts.push(if de { format!("teuerster Chat: {t}") } else { format!("most expensive chat: {t}") });
}
Some(json!({
"kind": "digest",
"title": if de { "CodeCraft – Wochenrückblick" } else { "CodeCraft – weekly review" },
"body": parts.join("\n"),
"tag": "weekly-digest",
"url": "/#/stats",
}))
}
pub fn spawn(app: Arc<App>) {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let now = Local::now();
// Weekly review: Monday morning, opt-in (user setting "weekly_push").
if now.hour() == 8 && now.weekday() == chrono::Weekday::Mon {
let week = now.format("%G-W%V").to_string();
for user in app.store.users().unwrap_or_default() {
let on: bool = app.store.user_setting(&user.id, "weekly_push").unwrap_or(false);
let last: String = app.store.user_setting(&user.id, "weekly_push_last").unwrap_or_default();
if !on || user.disabled || last == week {
continue;
}
let _ = app.store.set_user_setting(&user.id, "weekly_push_last", &week);
if let Some(payload) = weekly(&app, &user.id) {
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
tracing::info!(user = %user.username, sent, errors = errors.len(), "weekly review");
}
}
}
if now.hour() != HOUR {
continue;
}
+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 mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
Ok(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
Ok(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
}
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
+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());
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})))
}
+18 -2
View File
@@ -44,6 +44,8 @@ tokio::task_local! {
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
/// The user's git config (credentials, identity) for unsandboxed runs.
static GITCONFIG: Option<std::path::PathBuf>;
/// The user's commit identity (overrides a repository's own user.name/email).
static GITENV: Vec<(String, String)>;
}
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
@@ -63,6 +65,7 @@ async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default());
c
}
};
@@ -171,7 +174,9 @@ fn parse_status(raw: &str) -> Value {
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
let cfg = crate::gitcreds::global_config(&app, &user);
SANDBOX.scope(spec, GITCONFIG.scope(cfg, handle_inner(user, app, b))).await
let mut env = crate::gitcreds::identity_env(&app, &user.id);
env.extend(crate::sshkeys::env(&app, &user));
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 {
@@ -250,6 +255,15 @@ async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
git_ok(&repo, &args, 60).await?;
json!({"ok": true})
}
"suggest" => {
let diff = git_ok(&repo, &["diff", "--cached", "--no-color", "--no-ext-diff", "--stat", "--patch", "-U2"], 60).await?;
if diff.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "nothing is staged".into()));
}
let recent = git_ok(&repo, &["log", "-n", "8", "--pretty=%s"], 15).await.unwrap_or_default();
let message = crate::runner::suggest_commit_message(&app, &user.id, &diff, &recent).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, e.to_string()))?;
json!({"message": message})
}
"commit" => {
let msg = b.message.as_deref().unwrap_or("").trim().to_string();
if msg.is_empty() && !b.amend {
@@ -388,8 +402,10 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
let spec = crate::sandbox::spec_for(app, Some(&user.id));
let cfg = crate::gitcreds::global_config(app, user);
let mut env = crate::gitcreds::identity_env(app, &user.id);
env.extend(crate::sshkeys::env(app, user));
let s = session.clone();
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
session.cwd = cwd.to_string();
}
+60
View File
@@ -35,6 +35,53 @@ pub struct GitSettings {
pub credentials: Vec<GitCredential>,
}
/// The user's commit identity as environment variables. They take precedence over a
/// repository's own `user.name`/`user.email`, so commits made by agents, terminals and
/// the git panel always carry the identity from the settings.
pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default();
let mut env = Vec::new();
let (name, email) = (s.name.trim(), s.email.trim());
if !name.is_empty() {
env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string()));
env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string()));
}
if !email.is_empty() {
env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string()));
env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string()));
}
env
}
/// What an agent needs to know about the stored git access (no secrets): git already has the
/// credentials, so it must not look for them. None when nothing is stored.
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
render_prompt(&app.store.user_setting(user_id, "git").unwrap_or_default())
}
fn render_prompt(s: &GitSettings) -> Option<String> {
let creds: Vec<&GitCredential> = s.credentials.iter().filter(|c| !c.host.trim().is_empty() && !c.token.is_empty()).collect();
if creds.is_empty() {
return None;
}
let mut t = String::from("\n# Git access\nThe user has stored git credentials for these hosts (HTTPS):\n");
for c in &creds {
t.push_str(&format!("- {} (user {})\n", c.host.trim(), c.username.trim()));
}
t.push_str(
"git uses them automatically through its credential helper: `git clone`, `fetch`, `pull` and `push` with an https:// URL of these hosts just work, no login or token needed. \
Do NOT ask the user for a token and do NOT search the environment, config files, `gh`, SSH keys or the keychain for credentials – they are not there, and the command line shows none because git keeps them in its credential store. \
Use https:// remote URLs (SSH keys are not set up). \
For a REST API of such a host (pull requests, issues, releases) take the token straight from git without printing it, e.g. \
`curl -s -H \"Authorization: token $(printf 'protocol=https\\nhost=HOST\\n\\n' | git credential fill | sed -n 's/^password=//p')\" https://HOST/api/...` \
(GitHub: `Authorization: Bearer`). Never echo the token or write it into files, commits or your answer.\n",
);
if !s.name.trim().is_empty() || !s.email.trim().is_empty() {
t.push_str(&format!("Commits are made as {} <{}> automatically.\n", s.name.trim(), s.email.trim()));
}
Some(t)
}
pub fn home_of(app: &App, user: &User) -> PathBuf {
app.data_dir().join("users").join(&user.username).join("home")
}
@@ -141,6 +188,19 @@ pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
mod tests {
use super::*;
#[test]
fn prompt_names_hosts_but_never_tokens() {
assert!(render_prompt(&GitSettings::default()).is_none());
let settings = GitSettings { name: "Ann".into(), email: "ann@x.org".into(), credentials: vec![GitCredential { host: "git.x.org".into(), username: "ann".into(), token: "s3cret-token".into(), protocol: String::new() }] };
let p = render_prompt(&settings).unwrap();
println!("{p}");
assert!(p.contains("git.x.org (user ann)") && p.contains("git credential fill") && p.contains("Ann <ann@x.org>"));
assert!(!p.contains("s3cret-token"));
// Entries without a token are not offered.
let none = GitSettings { credentials: vec![GitCredential { host: "h".into(), username: "u".into(), ..Default::default() }], ..Default::default() };
assert!(render_prompt(&none).is_none());
}
#[test]
fn keeps_masked_tokens() {
let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() };
+13 -2
View File
@@ -42,8 +42,9 @@ pub async fn probe(provider: Arc<dyn crate::provider::Provider>) -> (ProviderSta
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
Err(_) => (Some("timed out".to_string()), 0),
};
// The host answered but has no model list: reachable.
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
// The host answered but has no model list: reachable. (fal.ai's router answers 403
// "endpoint_not_priced" for /models: the key is fine, the listing is just not offered.)
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405") || e.contains("endpoint_not_priced")));
let state = match &error {
None => "ok",
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
@@ -83,3 +84,13 @@ pub fn spawn_checker(app: Arc<App>) {
}
});
}
#[cfg(test)]
mod tests {
#[test]
fn unpriced_model_listing_is_not_an_auth_failure() {
let e = r#"provider returned HTTP 403 Forbidden: {"detail":"Endpoint openrouter/router/openai/v1/models is not available: no billing definition.","type":"endpoint_not_priced"}"#.to_string();
let kept = Some(e).filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405") || e.contains("endpoint_not_priced")));
assert!(kept.is_none());
}
}
+131 -5
View File
@@ -38,7 +38,7 @@ pub struct GenerateRequest {
pub references: Vec<PathBuf>,
}
fn slug(s: &str) -> String {
pub(crate) fn slug(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
@@ -121,7 +121,7 @@ fn pick(all: &[ImageModel], stage: &str, purpose: Option<&str>, with_reference:
}
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
pub(crate) async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
let mut out = Vec::new();
for p in paths {
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
@@ -135,7 +135,7 @@ async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
Ok(out)
}
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
pub(crate) async fn download(url: &str) -> Result<(Vec<u8>, String)> {
if let Some(rest) = url.strip_prefix("data:") {
let (meta, data) = rest.split_once(',').context("malformed data URI")?;
let ctype = meta.split(';').next().unwrap_or("image/png").to_string();
@@ -213,6 +213,8 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
let path = req.dir.join(name);
tokio::fs::write(&path, &bytes).await?;
// A copy in the image store keeps it viewable when the file is moved or deleted.
let blob = app.store.put_blob(&ctype, &bytes).map_err(|e| tracing::warn!("image copy: {e:#}")).ok();
let rec = ImageRecord {
id: new_id(),
user_id: req.user_id.clone(),
@@ -223,6 +225,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
width: img["width"].as_i64().unwrap_or(0),
height: img["height"].as_i64().unwrap_or(0),
created_at: now_ms(),
blob,
};
app.store.add_image(&rec)?;
records.push(rec);
@@ -252,13 +255,105 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
Ok(records)
}
/// A generated image whose file is gone: the same file name below its original folder
/// (or the folder above it), e.g. after an agent sorted the images into subfolders.
/// Only inside `roots`; looks at a few thousand entries at most.
pub fn find_moved(path: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
let name = path.file_name()?;
search_near(path, roots, |p, _| p.file_name() == Some(name))
}
/// Like `find_moved`, but by content: an image file with the same bytes (renamed).
pub fn find_by_content(path: &Path, roots: &[PathBuf], hash: &str, size: u64) -> Option<PathBuf> {
search_near(path, roots, |p, len| {
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
len == size && matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg" | "webp")) && std::fs::read(p).is_ok_and(|b| crate::blobs::hash(&b) == hash)
})
}
/// Files below the folder of `path` and the folder above it (3 levels, hidden folders
/// skipped, inside `roots`) for which `matches(path, size)` holds.
fn search_near(path: &Path, roots: &[PathBuf], matches: impl Fn(&Path, u64) -> bool) -> Option<PathBuf> {
let dir = path.parent()?;
let mut budget = 5000usize;
for base in [Some(dir), dir.parent()].into_iter().flatten() {
if !roots.iter().any(|r| base.starts_with(r)) {
continue;
}
let mut stack = vec![(base.to_path_buf(), 0usize)];
while let Some((d, depth)) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else { continue };
for e in entries.flatten() {
if budget == 0 {
return None;
}
budget -= 1;
let p = e.path();
let Ok(ft) = e.file_type() else { continue };
if ft.is_file() && p != path && matches(&p, e.metadata().map(|m| m.len()).unwrap_or(0)) {
return Some(p);
}
let hidden = p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.') || n == "node_modules");
if ft.is_dir() && depth < 3 && !hidden {
stack.push((p, depth + 1));
}
}
}
}
None
}
/// Where the file of a generated image is now: by name, then (with a copy in the image
/// store) by content. Corrects the stored path when found.
pub fn relocate(app: &App, rec: &ImageRecord) -> Option<PathBuf> {
let path = Path::new(&rec.path);
let roots = app.user_roots(rec.user_id.as_deref());
let found = find_moved(path, &roots).or_else(|| {
let hash = rec.blob.as_deref()?;
let (_, bytes) = app.store.blob(hash).ok()??;
find_by_content(path, &roots, hash, bytes.len() as u64)
})?;
let _ = app.store.set_image_path(&rec.id, &found.display().to_string());
Some(found)
}
/// After an agent moved or renamed files: find the generated images of `owner` from the
/// last two weeks whose files are gone and correct their paths. Returns how many.
pub fn relocate_missing(app: &App, owner: Option<&str>) -> usize {
let since = now_ms() - 14 * 86_400_000;
let Ok(list) = app.store.images(owner, 500) else { return 0 };
list.iter().filter(|r| r.created_at >= since && !Path::new(&r.path).exists()).filter(|r| relocate(app, r).is_some()).count()
}
/// Copies in the image store for generated images from before they were kept (once per
/// image, files up to 25 MB): later moves and renames are then found by content.
pub fn backfill_copies(app: &App) -> Result<usize> {
let mut n = 0;
for (id, path) in app.store.images_without_copy()? {
let Ok(meta) = std::fs::metadata(&path) else { continue };
if !meta.is_file() || meta.len() > 25 * 1024 * 1024 {
continue;
}
let Ok(bytes) = std::fs::read(&path) else { continue };
let mime = match Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
_ => "image/png",
};
let hash = app.store.put_blob(mime, &bytes)?;
app.store.set_image_blob(&id, &hash)?;
n += 1;
}
Ok(n)
}
/// Downscaled copy for the model's context.
fn preview_for_model(path: &Path) -> Option<ImageData> {
pub(crate) fn preview_for_model(path: &Path) -> Option<ImageData> {
let img = image::open(path).ok()?;
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
Some(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
}
pub struct GenerateImage;
@@ -363,6 +458,37 @@ impl Tool for GenerateImage {
mod tests {
use super::*;
#[test]
fn finds_moved_files_and_keeps_their_copies() {
let dir = std::env::temp_dir().join(format!("cc-img-moved-{}", std::process::id()));
let art = dir.join("art");
std::fs::create_dir_all(art.join("older")).unwrap();
std::fs::write(art.join("older/20261110-a.png"), b"x").unwrap();
// Moved into a subfolder: found; renamed or outside the roots: not.
assert_eq!(find_moved(&art.join("20261110-a.png"), std::slice::from_ref(&dir)), Some(art.join("older/20261110-a.png")));
assert_eq!(find_moved(&art.join("20261110-b.png"), std::slice::from_ref(&dir)), None);
assert_eq!(find_moved(&art.join("20261110-a.png"), &[dir.join("elsewhere")]), None);
// Renamed: found by content.
std::fs::write(art.join("older/level-5.png"), b"renamed bytes").unwrap();
let h = crate::blobs::hash(b"renamed bytes");
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &h, 13), Some(art.join("older/level-5.png")));
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &crate::blobs::hash(b"other"), 13), None);
// The image store keeps the copy of a generated image.
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
let hash = store.put_blob("image/png", b"png bytes").unwrap();
store
.add_image(&ImageRecord { id: "i".into(), user_id: None, session_id: None, prompt: "p".into(), model: "m".into(), path: "/gone.png".into(), width: 1, height: 1, created_at: 0, blob: Some(hash.clone()) })
.unwrap();
assert_eq!(store.gc_blobs().unwrap(), 0);
// Moved with the file manager: the entry follows (file or folder, not look-alikes).
assert_eq!(store.move_image_paths("/gone.png", "/art/new.png").unwrap(), 1);
assert_eq!(store.move_image_paths("/art", "/bilder/").unwrap(), 1);
assert_eq!(store.move_image_paths("/bild", "/x").unwrap(), 0);
assert_eq!(store.image("i").unwrap().unwrap().path, "/bilder/new.png");
assert_eq!(store.blob(&hash).unwrap().unwrap().1, b"png bytes");
std::fs::remove_dir_all(&dir).unwrap();
}
fn m(id: &str) -> ImageModel {
with_defaults(ImageModel { id: id.into(), ..Default::default() })
}
+286 -68
View File
@@ -1,20 +1,23 @@
//! Scheduled agents ("automations"): a Markdown file with a cron schedule in
//! its frontmatter starts an agent session at the given times.
//! Scheduled agents ("automations"). The instructions are a Markdown file (only the
//! prompt, e.g. `<project>/.codecraft/jobs/<name>.md`); name, schedule, mode, model and
//! limits are settings of the automation in the database, edited in the UI.
//!
//! Older job files carried the settings as frontmatter:
//!
//! ```markdown
//! ---
//! name: Nightly dependency check
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
//! agent: build # mode (default build)
//! model: provider/model # optional, default: server default model
//! permission_mode: auto # default auto; "bypass" runs without asking
//! timeout_minutes: 60 # optional, default 60
//! agent: build
//! permission_mode: auto
//! timeout_minutes: 60
//! ---
//! Check for outdated dependencies and open a summary …
//! ```
//!
//! The file is re-read on every run, so edits apply to the next run. Each run
//! is a normal chat session owned by the job's owner.
//! They are adopted once (settings into the database, the frontmatter removed from the
//! file). The file is re-read on every run, so edits apply to the next run. Each run is
//! a normal chat session owned by the job's owner.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
@@ -40,6 +43,78 @@ pub struct Job {
pub created_at: i64,
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
pub webhook: bool,
/// None: not adopted yet (settings still in the file's frontmatter).
#[serde(skip)]
pub settings: Option<JobSettings>,
}
/// Settings of an automation (database; the file holds only the prompt).
#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)]
pub struct JobSettings {
pub name: String,
/// 5-field cron in the server's time zone.
pub schedule: String,
#[serde(default = "default_agent")]
pub agent: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_mode")]
pub permission_mode: String,
#[serde(default = "default_timeout")]
pub timeout_minutes: u64,
/// Working directory (absolute or relative to the file); default: the project of the file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
/// Chat in which the user works out the prompt with a model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>,
/// Push notification with the report when a run ends.
#[serde(default = "default_notify")]
pub notify: bool,
}
fn default_notify() -> bool {
true
}
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,
notify: true,
}
}
pub fn validate(&mut self) -> Result<()> {
self.name = self.name.trim().chars().take(120).collect();
if self.name.is_empty() {
bail!("the automation needs a name");
}
self.schedule = self.schedule.trim().to_string();
check_schedule(&self.schedule)?;
if !crate::permission::MODES.contains(&self.permission_mode.as_str()) {
bail!("invalid permission_mode '{}'", self.permission_mode);
}
self.model = self.model.take().map(|m| m.trim().to_string()).filter(|m| !m.is_empty());
self.timeout_minutes = self.timeout_minutes.clamp(1, 24 * 60);
Ok(())
}
}
/// Valid 5-field cron expression?
pub fn check_schedule(schedule: &str) -> Result<()> {
if schedule.split_whitespace().count() != 5 {
bail!("invalid schedule '{schedule}': expected 5 fields (minute hour day month weekday)");
}
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
Ok(())
}
/// Parsed job file.
@@ -52,15 +127,92 @@ pub struct JobSpec {
pub permission_mode: String,
pub timeout_minutes: u64,
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>,
pub notify: bool,
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(),
notify: s.notify,
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(),
notify: self.notify,
}
}
}
/// Settings and prompt of a job: settings from the database, the prompt from its file
/// (a leftover frontmatter is ignored); not yet adopted jobs from the frontmatter.
pub fn spec(job: &Job) -> Result<JobSpec> {
let path = Path::new(&job.path);
let Some(settings) = &job.settings else { return parse(path) };
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (_, body) = crate::prompts::frontmatter(&text);
if body.trim().is_empty() {
bail!("{}: the instructions are empty", path.display());
}
Ok(JobSpec::from_settings(settings, body.trim().to_string()))
}
/// Move the frontmatter of an older job file into the database and remove it from the
/// file. Files without frontmatter get default settings (named after the file) and are
/// switched off until the user sets a schedule. Returns the updated job.
pub fn adopt(store: &crate::store::Store, job: Job) -> Result<Job> {
if job.settings.is_some() {
return Ok(job);
}
let path = Path::new(&job.path);
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (meta, body) = crate::prompts::frontmatter(&text);
let settings = if meta.is_empty() {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("automation").replace(['-', '_'], " ");
store.job_set_enabled(&job.id, false)?;
JobSettings::new(&stem)
} else {
let spec = parse(path)?;
// Prompt only from now on.
std::fs::write(path, format!("{}\n", body.trim()))?;
spec.settings()
};
store.job_set_settings(&job.id, &settings)?;
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
}
/// Settings from the frontmatter of an older job file.
pub fn parse(path: &Path) -> Result<JobSpec> {
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (meta, body) = crate::prompts::frontmatter(&text);
let unquote = |s: &String| s.trim().trim_matches('"').trim_matches('\'').to_string();
// Values may carry a trailing comment (`auto # default`).
let unquote = |s: &String| {
let v = s.trim();
let v = if v.starts_with('"') || v.starts_with('\'') { v } else { v.split(" #").next().unwrap_or(v).trim() };
v.trim_matches('"').trim_matches('\'').to_string()
};
let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"<cron>\"`", path.display()))?;
croner::Cron::from_str(&schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
check_schedule(&schedule)?;
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
bail!("invalid permission_mode '{permission_mode}'");
@@ -76,6 +228,8 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
permission_mode,
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
assistant_session: None,
notify: meta.get("notify").map(|v| unquote(v) != "false").unwrap_or(true),
prompt: body.trim().to_string(),
})
}
@@ -89,7 +243,13 @@ pub fn next_run(schedule: &str, after_ms: i64) -> Option<i64> {
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
if let Some(c) = &spec.cwd {
cwd_of(&path.display().to_string(), spec.cwd.as_deref())
}
/// Working directory for a job file and its optional `cwd` setting.
pub fn cwd_of(file: &str, cwd: Option<&str>) -> PathBuf {
let path = Path::new(file);
if let Some(c) = cwd {
let p = PathBuf::from(c);
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
}
@@ -106,13 +266,19 @@ fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
/// Job with its parsed file and next run, for the UI and the tool.
pub fn describe(job: &Job) -> Value {
let mut v = json!(job);
match parse(Path::new(&job.path)) {
match spec(job) {
Ok(spec) => {
v["spec"] = json!(spec);
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000);
v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None });
}
Err(e) => v["error"] = json!(format!("{e:#}")),
Err(e) => {
v["error"] = json!(format!("{e:#}"));
// The settings stay editable when the file is missing or empty.
if let Some(st) = &job.settings {
v["spec"] = json!(JobSpec::from_settings(st, String::new()));
}
}
}
v
}
@@ -125,7 +291,7 @@ pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
/// Start a run; `payload` (webhook body) is appended to the instructions.
pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
let path = PathBuf::from(&job.path);
let spec = match parse(&path) {
let spec = match spec(job) {
Ok(s) => s,
Err(e) => {
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
@@ -164,6 +330,12 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
pinned: false,
archived: false,
folder: None,
instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
summary: String::new(),
color: None,
icon: None,
};
let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?;
@@ -182,6 +354,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
let job_id = job.id.clone();
let sid = session.id.clone();
let timeout = Duration::from_secs(spec.timeout_minutes * 60);
let (spec_name, spec_notify) = (spec.name.clone(), spec.notify);
tokio::spawn(async move {
let started = std::time::Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await;
@@ -189,24 +362,65 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
if started.elapsed() > timeout {
crate::runner::abort(&app2, &sid);
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
report(&app2, &sid, &spec_name, spec_notify, "timeout").await;
return;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
report(&app2, &sid, &spec_name, spec_notify, "done").await;
});
Ok(session.id)
}
/// Push the report of a finished run (the agent's last message) to the owner.
async fn report(app: &App, session_id: &str, name: &str, notify: bool, status: &str) {
if !notify {
return;
}
let Ok(session) = app.session(session_id) else { return };
let Some(owner) = session.owner_id.clone() else { return };
let prefs: Value = app.store.user_setting(&owner, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let last = app
.store
.messages(session_id)
.unwrap_or_default()
.into_iter()
.rev()
.find(|m| matches!(m.role, crate::types::Role::Assistant))
.map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n"))
.unwrap_or_default();
let flat: String = last.split_whitespace().collect::<Vec<_>>().join(" ");
let body: String = if flat.chars().count() > 220 { format!("{}…", flat.chars().take(220).collect::<String>()) } else { flat };
let title = match (status, de) {
("timeout", true) => format!("{name} – Zeitlimit erreicht"),
("timeout", false) => format!("{name} – timed out"),
(_, true) => format!("{name} – fertig"),
(_, false) => format!("{name} – finished"),
};
let payload = json!({"kind": "job", "title": title, "body": body, "tag": format!("job-{session_id}"), "url": format!("/#/s/{session_id}")});
let (sent, _) = crate::push::send_to_user(app, &owner, &payload).await;
tracing::info!(job = name, sent, "automation report");
}
/// Background scheduler: checks every 30 s which jobs are due.
pub fn spawn_scheduler(app: Arc<App>) {
tokio::spawn(async move {
// Older job files: settings from the frontmatter into the database.
for job in app.store.jobs(None).unwrap_or_default().into_iter().filter(|j| j.settings.is_none()) {
let path = job.path.clone();
match adopt(&app.store, job) {
Ok(_) => tracing::info!("automation {path}: settings moved from the file into the database"),
Err(e) => tracing::warn!("automation {path}: {e:#}"),
}
}
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let Ok(jobs) = app.store.jobs(None) else { continue };
let now = now_ms();
for job in jobs.into_iter().filter(|j| j.enabled) {
let Ok(spec) = parse(Path::new(&job.path)) else { continue };
let Ok(spec) = spec(&job) else { continue };
// Due if a scheduled time passed since the last run (or since creation).
let base = job.last_run.unwrap_or(job.created_at);
let Some(next) = next_run(&spec.schedule, base) else { continue };
@@ -233,22 +447,6 @@ pub fn spawn_scheduler(app: Arc<App>) {
/// Tool: create, list, run and delete automations.
pub struct Schedule;
/// Fields of a job file written by the agent tool or the "from template" dialog.
#[derive(Debug, serde::Deserialize)]
pub struct NewJob {
pub name: String,
pub schedule: String,
#[serde(default = "default_agent")]
pub agent: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_mode")]
pub permission_mode: String,
#[serde(default = "default_timeout")]
pub timeout_minutes: u64,
pub prompt: String,
}
fn default_agent() -> String {
"build".into()
}
@@ -259,28 +457,20 @@ fn default_timeout() -> u64 {
60
}
/// Write (and validate) a job file.
pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
croner::Cron::from_str(&j.schedule).map_err(|e| anyhow!("invalid schedule '{}': {e}", j.schedule))?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'").replace('\n', " "));
let plain = |s: &str| s.chars().filter(|c| c.is_alphanumeric() || "-_/.:".contains(*c)).collect::<String>();
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(&j.name), q(&j.schedule), plain(&j.agent));
if let Some(m) = j.model.as_deref().filter(|m| !m.is_empty()) {
fm.push_str(&format!("model: {}\n", plain(m)));
/// Write the instructions file, register it and store the settings.
pub fn create(store: &crate::store::Store, owner: &str, path: &Path, mut settings: JobSettings, prompt: &str, enabled: bool) -> Result<Job> {
settings.validate()?;
if path.exists() {
bail!("{} already exists", path.display());
}
fm.push_str(&format!("permission_mode: {}\n", plain(&j.permission_mode)));
fm.push_str(&format!("timeout_minutes: {}\n---\n", j.timeout_minutes.clamp(1, 24 * 60)));
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, format!("{fm}{}\n", j.prompt.trim()))?;
match parse(path) {
Ok(spec) => Ok(spec),
Err(e) => {
let _ = std::fs::remove_file(path);
Err(e)
}
}
std::fs::write(path, format!("{}\n", prompt.trim()))?;
let job = store.job_register(owner, &path.display().to_string())?;
store.job_set_settings(&job.id, &settings)?;
store.job_set_enabled(&job.id, enabled)?;
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
}
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
@@ -302,10 +492,11 @@ impl Tool for Schedule {
"schedule"
}
fn description(&self) -> String {
"Manage automations: agents that run on a cron schedule from a Markdown job file \
(.codecraft/jobs/<name>.md in the working directory). Actions: \
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the job file and registers it; \
`register` (path) registers an existing job file; `list`; `run` (id) starts a run now; \
"Manage automations: agents that run on a cron schedule. The instructions are a Markdown file \
(.codecraft/jobs/<name>.md in the working directory, only the prompt); name, schedule, mode, model and \
limits are settings of the automation. Actions: \
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the file and registers it; \
`register` (path) registers an existing Markdown file (switched off until a schedule is set in the UI); `list`; `run` (id) starts a run now; \
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
The prompt must be self-contained: the run starts a fresh session without this conversation."
@@ -348,19 +539,15 @@ impl Tool for Schedule {
let prompt = arg_str(&input, "prompt")?;
let rel = format!(".codecraft/jobs/{}.md", slug(name));
let path = ctx.resolve(&rel)?;
write_file(
&path,
&NewJob {
name: name.into(),
schedule: schedule.into(),
agent: input["agent"].as_str().unwrap_or("build").into(),
model: input["model"].as_str().map(String::from),
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
prompt: prompt.into(),
},
)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
let settings = JobSettings {
schedule: schedule.into(),
agent: input["agent"].as_str().unwrap_or("build").into(),
model: input["model"].as_str().map(String::from),
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
..JobSettings::new(name)
};
let job = create(&ctx.app.store, &owner, &path, settings, prompt, true)?;
let d = describe(&job);
Ok(ToolOutput::text(format!(
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
@@ -372,8 +559,10 @@ impl Tool for Schedule {
}
"register" => {
let path = ctx.resolve(arg_str(&input, "path")?)?;
parse(&path)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
if !path.is_file() {
bail!("{} is not a file", path.display());
}
let job = adopt(&ctx.app.store, ctx.app.store.job_register(&owner, &path.display().to_string())?)?;
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
}
"list" => {
@@ -447,6 +636,35 @@ mod tests {
assert_eq!(job_cwd(&p, &s), dir);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn adopts_frontmatter_and_creates_prompt_only_files() {
let dir = std::env::temp_dir().join(format!("cc-job-adopt-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
// Older file: settings move into the database, the file keeps only the prompt.
let p = dir.join(".codecraft/jobs/old.md");
std::fs::write(&p, "---\nname: \"Old\"\nschedule: \"0 4 * * *\"\npermission_mode: bypass # comment\n---\nDo old things.\n").unwrap();
let job = adopt(&store, store.job_register("u", &p.display().to_string()).unwrap()).unwrap();
let st = job.settings.clone().unwrap();
assert_eq!((st.name.as_str(), st.schedule.as_str(), st.permission_mode.as_str()), ("Old", "0 4 * * *", "bypass"));
assert_eq!(std::fs::read_to_string(&p).unwrap(), "Do old things.\n");
assert_eq!(spec(&job).unwrap().prompt, "Do old things.");
assert!(job.enabled);
// Plain Markdown: default settings, switched off.
let q = dir.join(".codecraft/jobs/weekly-report.md");
std::fs::write(&q, "Write a report.\n").unwrap();
let job = adopt(&store, store.job_register("u", &q.display().to_string()).unwrap()).unwrap();
assert_eq!(job.settings.as_ref().unwrap().name, "weekly report");
assert!(!job.enabled);
// New automation.
let r = dir.join(".codecraft/jobs/new.md");
assert!(create(&store, "u", &r, JobSettings { schedule: "nope".into(), ..JobSettings::new("N") }, "x", false).is_err());
let job = create(&store, "u", &r, JobSettings::new("New"), "# New\n\nGoal", false).unwrap();
assert_eq!(spec(&job).unwrap().prompt, "# New\n\nGoal");
assert!(check_schedule("0 3 * *").is_err() && check_schedule("*/15 8-18 * * 1-5").is_ok());
std::fs::remove_dir_all(&dir).unwrap();
}
}
// --- incoming webhooks -------------------------------------------------------------
+10
View File
@@ -4,6 +4,9 @@ mod api;
mod app;
mod auth;
mod backup;
mod blobs;
mod checks;
mod claude_code;
mod cli_api;
mod budget;
mod config;
@@ -21,6 +24,9 @@ mod lsp;
mod mentions;
mod metrics;
mod mcp;
mod mcp_bridge;
mod media;
mod mesh3d;
mod permission;
mod prompts;
mod push;
@@ -29,7 +35,10 @@ mod reverse;
mod runner;
mod sandbox;
mod shells;
mod sshkeys;
mod stats_export;
mod store;
mod telegram;
mod terminal;
mod tools;
mod types;
@@ -73,6 +82,7 @@ async fn main() -> Result<()> {
health::spawn_checker(app.clone());
digest::spawn(app.clone());
budget::spawn_alerts(app.clone());
telegram::spawn(app.clone());
backup::spawn(app.clone());
let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?;
+43 -2
View File
@@ -212,6 +212,8 @@ impl McpServer {
match c["type"].as_str().unwrap_or_default() {
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
"image" => out.images.push(ImageData {
blob: None,
size: None,
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
data: c["data"].as_str().unwrap_or_default().into(),
}),
@@ -556,6 +558,42 @@ impl McpManager {
/// Search the on-demand MCP tools by keywords.
pub struct McpSearch;
/// Built-in tools matching the search words, with their availability in the current mode.
fn builtin_matches(ctx: &ToolCtx, words: &[&str]) -> String {
if words.is_empty() {
return String::new();
}
let app = &ctx.app;
let owner_id = app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
let owner_user = owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
let root = app.root_for(owner_id.as_deref(), &ctx.cwd);
let agent = crate::prompts::find_agent_for(app, &root, &ctx.cwd, &ctx.agent, owner_id.as_deref());
let available: std::collections::HashSet<String> = crate::runner::available_tools(app, &agent, owner_user.as_ref()).iter().map(|t| t.name().to_string()).collect();
let mut hits: Vec<(usize, String)> = crate::tools::builtin_tools()
.iter()
.map(|t| {
let name = t.name().to_lowercase();
let desc = t.description().to_lowercase();
let score: usize = words.iter().map(|w| if name.contains(w) { 3 } else if desc.contains(w) { 1 } else { 0 }).sum();
let state = if available.contains(t.name()) {
"available – call it directly".to_string()
} else if agent.read_only && !t.read_only() {
format!("exists, but not in the read-only mode \"{}\" – ask the user to switch to build mode", agent.name)
} else {
format!("exists, but not available in mode \"{}\" (or disabled in the settings)", agent.name)
};
(score, format!("- {}: {state}", t.name()))
})
.filter(|(s, _)| *s >= 2 || words.len() == 1 && *s >= 1)
.collect();
hits.sort_by(|a, b| b.0.cmp(&a.0));
if hits.is_empty() {
return String::new();
}
let list: Vec<String> = hits.into_iter().take(4).map(|(_, l)| l).collect();
format!("Built-in tools (not MCP – call them directly by name, not via mcp_call):\n{}\n\n", list.join("\n"))
}
#[async_trait]
impl Tool for McpSearch {
fn name(&self) -> &str {
@@ -600,10 +638,13 @@ impl Tool for McpSearch {
.filter(|(s, _)| *s > 0 || words.is_empty())
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0));
// Models also look here for built-in tools (e.g. generate_3d): say that they exist,
// whether this mode has them, and that they are called directly.
let builtin = if server.is_none() { builtin_matches(ctx, &words) } else { String::new() };
if scored.is_empty() {
return Ok(ToolOutput::text("No matching tools. Try other keywords or another server."));
return Ok(ToolOutput::text(if builtin.is_empty() { "No matching tools. Try other keywords or another server.".to_string() } else { builtin }));
}
let mut out = String::new();
let mut out = builtin;
for (_, t) in scored.iter().take(8) {
let desc: String = t.info.description.chars().take(300).collect();
out.push_str(&format!("## {}\n{}\nInput schema: {}\n\n", t.full_name, desc, t.info.input_schema));
+252
View File
@@ -0,0 +1,252 @@
//! CodeCraft's tools for the Claude Code CLI, as an MCP server (streamable HTTP, JSON answers).
//!
//! The CLI only knows its own tools. For every run the server registers a secret token and
//! passes `--mcp-config` with `http://127.0.0.1:<port>/api/mcp-bridge/<token>`; the CLI then
//! lists and calls the tools like any MCP tool (`mcp__codecraft__<name>`). The token is the
//! credential (the route is public) and binds the calls to the chat that started the run.
//! Permissions are asked by the CLI through the usual `can_use_tool` request, so the approval
//! cards, rules and project approvals work as for the CLI's own tools.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use tokio_util::sync::CancellationToken;
pub const SERVER: &str = "codecraft";
/// Prefix the CLI gives our tools.
pub const PREFIX: &str = "mcp__codecraft__";
/// Our tools the CLI brings itself (same job): not offered twice.
const CLI_HAS: &[&str] = &["read", "write", "edit", "list", "glob", "grep", "bash", "bash_output", "kill_shell", "web_fetch", "web_search", "todo_write", "task", "change_directory"];
static TOKENS: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(Default::default);
/// A registered run; the token stops working when this is dropped.
pub struct Registration {
pub token: String,
}
impl Drop for Registration {
fn drop(&mut self) {
TOKENS.lock().unwrap().remove(&self.token);
}
}
pub fn register(session_id: &str) -> Registration {
let token = format!("{}{}", crate::types::new_id(), crate::types::new_id());
TOKENS.lock().unwrap().insert(token.clone(), session_id.to_string());
Registration { token }
}
/// `--mcp-config` value for a run.
pub fn config_json(app: &App, token: &str) -> String {
let port = app.config().server.bind.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(8787);
json!({"mcpServers": {SERVER: {"type": "http", "url": format!("http://127.0.0.1:{port}/api/mcp-bridge/{token}")}}}).to_string()
}
/// The tools the CLI gets for a chat: what the chat's own agent would have (mode, images,
/// the server's MCP tools …) without the ones the CLI has itself.
pub fn tools_for(app: &Arc<App>, session: &crate::types::Session) -> Vec<Arc<dyn Tool>> {
let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let agent = crate::prompts::find_agent_for(app, &root, &cwd, &session.agent, session.owner_id.as_deref());
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
crate::runner::available_tools(app, &agent, owner.as_ref()).into_iter().filter(|t| !CLI_HAS.contains(&t.name())).collect()
}
/// Short note for the system prompt: what the extra tools are called.
pub fn prompt_section(tools: &[Arc<dyn Tool>]) -> Option<String> {
if tools.is_empty() {
return None;
}
let names: Vec<String> = tools.iter().map(|t| format!("{PREFIX}{}", t.name())).collect();
Some(format!(
"\n# CodeCraft tools\nBesides your own tools you have the tools of the CodeCraft server (MCP server `{SERVER}`): {}. \
Use them for what they do – e.g. image generation (generate_image), 3D models, OpenSCAD, scheduling automations, diagnostics, the browser. \
If one is not in your tool list, load it with ToolSearch (query `select:<name>`).\n",
names.join(", ")
))
}
// --- protocol ----------------------------------------------------------------------------
fn tools_list(tools: &[Arc<dyn Tool>]) -> Value {
json!({"tools": tools.iter().map(|t| json!({"name": t.name(), "description": t.description(), "inputSchema": t.schema()})).collect::<Vec<_>>()})
}
fn call_result(res: anyhow::Result<ToolOutput>) -> Value {
match res {
Ok(out) => {
let mut content = vec![json!({"type": "text", "text": out.content})];
for img in out.images.iter().filter(|i| !i.data.is_empty()) {
content.push(json!({"type": "image", "data": img.data, "mimeType": img.media_type}));
}
json!({"content": content, "isError": out.is_error})
}
Err(e) => json!({"content": [{"type": "text", "text": format!("Error: {e:#}")}], "isError": true}),
}
}
fn answer(id: &Value, result: Value) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
}
fn failure(id: &Value, code: i64, message: &str) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})).into_response()
}
pub async fn handle(State(app): State<Arc<App>>, Path(token): Path<String>, Json(req): Json<Value>) -> Response {
let Some(session_id) = TOKENS.lock().unwrap().get(&token).cloned() else {
return (StatusCode::NOT_FOUND, "unknown or finished run").into_response();
};
let id = req["id"].clone();
let method = req["method"].as_str().unwrap_or("");
// Notifications have no answer.
if req.get("id").is_none() || method.starts_with("notifications/") {
return StatusCode::ACCEPTED.into_response();
}
match method {
"initialize" => answer(
&id,
json!({
"protocolVersion": req["params"]["protocolVersion"].as_str().unwrap_or("2025-03-26"),
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")},
}),
),
"ping" => answer(&id, json!({})),
"tools/list" | "tools/call" => {
let Ok(session) = app.session(&session_id) else { return failure(&id, -32000, "the chat is gone") };
let tools = tools_for(&app, &session);
if method == "tools/list" {
return answer(&id, tools_list(&tools));
}
let name = req["params"]["name"].as_str().unwrap_or("");
let Some(tool) = tools.iter().find(|t| t.name() == name).cloned() else {
return failure(&id, -32602, &format!("unknown tool '{name}'"));
};
let cwd = PathBuf::from(&session.cwd);
let agent = crate::prompts::find_agent_for(&app, &app.root_for(session.owner_id.as_deref(), &cwd), &cwd, &session.agent, session.owner_id.as_deref());
let ctx = ToolCtx {
app: app.clone(),
session_id: session_id.clone(),
call_id: crate::types::new_id(),
cwd,
cancel: CancellationToken::new(),
agent: agent.name.clone(),
roots: app.user_roots(session.owner_id.as_deref()),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room: session.clean_room(app.config().craft.clean_room) && agent.name == "craft",
owner_id: None,
};
let input = req["params"]["arguments"].clone();
let input = if input.is_null() { json!({}) } else { input };
answer(&id, call_result(tool.run(input, &ctx).await))
}
_ => failure(&id, -32601, "method not found"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ImageData;
#[test]
fn registration_lives_as_long_as_the_run() {
let r = register("s1");
assert_eq!(TOKENS.lock().unwrap().get(&r.token).map(String::as_str), Some("s1"));
let t = r.token.clone();
drop(r);
assert!(!TOKENS.lock().unwrap().contains_key(&t));
}
#[test]
fn tool_results_carry_text_and_images() {
let out = ToolOutput {
content: "saved".into(),
images: vec![ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None }, ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None }],
..Default::default()
};
let v = call_result(Ok(out));
assert_eq!(v["isError"], false);
let c = v["content"].as_array().unwrap();
assert_eq!(c.len(), 2); // the image without data is left out
assert_eq!((c[1]["type"].as_str(), c[1]["mimeType"].as_str()), (Some("image"), Some("image/png")));
assert_eq!(call_result(Err(anyhow::anyhow!("boom")))["isError"], true);
}
#[test]
fn cli_builtins_are_not_offered_twice() {
let all = crate::tools::builtin_tools();
let ours: Vec<&str> = all.iter().map(|t| t.name()).filter(|n| !CLI_HAS.contains(n)).collect();
for n in ["generate_image", "generate_3d", "openscad", "browser", "schedule", "remember", "diagnostics", "ask_user", "skill"] {
assert!(ours.contains(&n), "{n} missing");
}
assert!(!ours.contains(&"read") && !ours.contains(&"bash"));
let list = tools_list(&all.into_iter().filter(|t| t.name() == "remember").collect::<Vec<_>>());
assert!(list["tools"][0]["inputSchema"].is_object());
}
/// The whole protocol against a real App: initialize, list, call, unknown token.
#[tokio::test]
async fn protocol_roundtrip_with_a_real_app() {
let dir = std::env::temp_dir().join(format!("cc-bridge-{}", std::process::id()));
std::fs::create_dir_all(dir.join("ws")).unwrap();
let toml = crate::config::EXAMPLE_CONFIG.lines().map(|l| {
if l.starts_with("workspace = ") { format!("workspace = \"{}\"", dir.join("ws").display()) }
else if l.starts_with("data_dir = ") { format!("data_dir = \"{}\"", dir.join("data").display()) }
else { l.to_string() }
}).collect::<Vec<_>>().join("\n");
let cfg = crate::config::Config::parse(&toml).unwrap();
let app = App::new(dir.join("config.toml"), cfg).await.unwrap();
let now = crate::types::now_ms();
let session = crate::types::Session {
id: crate::types::new_id(), title: String::new(), cwd: dir.join("ws").display().to_string(), model: "x/y".into(), agent: "build".into(), permission_mode: "bypass".into(),
parent_id: None, owner_id: None, 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: String::new(), telegram_quiet: true, summary: String::new(), color: None, icon: None,
};
let session = app.save_session(session).unwrap();
let reg = register(&session.id);
let rpc = |req: Value| {
let app = app.clone();
let token = reg.token.clone();
async move {
let resp = handle(State(app), Path(token), Json(req)).await;
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, serde_json::from_slice::<Value>(&bytes).unwrap_or(Value::Null))
}
};
let (st, init) = rpc(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert!(init["result"]["capabilities"]["tools"].is_object());
let (st, _) = rpc(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await;
assert_eq!(st, StatusCode::ACCEPTED);
let (_, list) = rpc(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).await;
let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"remember") && names.contains(&"openscad") && names.contains(&"browser"), "{names:?}");
assert!(!names.contains(&"read") && !names.contains(&"bash"), "{names:?}");
// A real tool call: `remember` writes the project memory.
let (_, call) = rpc(json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "remember", "arguments": {"note": "bridge works"}}})).await;
assert_eq!(call["result"]["isError"], false, "{call}");
let mem = std::fs::read_to_string(dir.join("ws/.codecraft/memory.md")).unwrap_or_default();
assert!(mem.contains("bridge works"), "{mem}");
let (_, bad) = rpc(json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "bash", "arguments": {"command": "id"}}})).await;
assert!(bad["error"].is_object());
// A finished run's token is dead.
let token = reg.token.clone();
drop(reg);
let resp = handle(State(app.clone()), Path(token), Json(json!({"jsonrpc": "2.0", "id": 5, "method": "ping"}))).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let _ = std::fs::remove_dir_all(&dir);
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Media overview of a chat: every image, video and audio file that was shown in it –
//! generated images, images in tool results (screenshots), media files the agent wrote,
//! media referenced in answers, and the user's own attachments. Includes subagents.
use crate::app::App;
use crate::types::{Message, Part, Role};
use serde::Serialize;
use serde_json::Value;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, PartialEq)]
pub struct MediaItem {
/// "image", "video", "audio" or "model" (3D).
pub kind: &'static str,
pub url: String,
pub name: String,
/// "generated", "tool", "written", "answer" or "upload".
pub source: &'static str,
pub created_at: i64,
/// Message to jump to (only for messages of the chat itself, not of subagents).
#[serde(skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
/// 3D models: absolute path (opened in the 3D view) and a preview image.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
}
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"];
const VIDEO: &[&str] = &["mp4", "webm", "mov", "m4v", "ogv"];
const AUDIO: &[&str] = &["mp3", "wav", "ogg", "oga", "m4a", "flac", "opus", "aac"];
const MODEL: &[&str] = &["glb", "gltf", "stl", "3mf", "obj", "step", "stp"];
/// Kind of a media file by its extension.
pub fn kind_of(path: &str) -> Option<&'static str> {
let ext = Path::new(path.split(['?', '#']).next().unwrap_or(path)).extension()?.to_str()?.to_ascii_lowercase();
if IMAGE.contains(&ext.as_str()) {
Some("image")
} else if VIDEO.contains(&ext.as_str()) {
Some("video")
} else if AUDIO.contains(&ext.as_str()) {
Some("audio")
} else if MODEL.contains(&ext.as_str()) {
Some("model")
} else {
None
}
}
fn kind_of_mime(mime: &str) -> &'static str {
if mime.starts_with("video/") {
"video"
} else if mime.starts_with("audio/") {
"audio"
} else {
"image"
}
}
/// Like JavaScript's encodeURIComponent.
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
/// Absolute, normalised path of `p` (relative to `cwd`).
fn resolve(p: &str, cwd: &Path) -> PathBuf {
let joined = if p.starts_with('/') { PathBuf::from(p) } else { cwd.join(p) };
crate::tools::normalize(&joined)
}
/// Media file paths in a text: Markdown images/links and plain paths with a media extension.
fn paths_in(text: &str) -> Vec<String> {
let mut out = Vec::new();
for token in text.split(|c: char| c.is_whitespace() || "()[]<>\"'`,;".contains(c)) {
let t = token.trim_end_matches(['.', ':', '!', '?']);
if t.is_empty() || t.contains("://") || t.starts_with("data:") {
continue;
}
if kind_of(t).is_some() && t.chars().any(|c| c == '/' || c == '.') {
out.push(t.to_string());
}
}
out
}
/// All media of a chat and its subagents, newest first.
pub fn collect(app: &App, session_id: &str) -> anyhow::Result<Vec<MediaItem>> {
let mut sessions = vec![session_id.to_string()];
// Subagents (and theirs).
let mut i = 0;
while i < sessions.len() && sessions.len() < 500 {
let children = app.store.child_sessions(&sessions[i])?;
sessions.extend(children);
i += 1;
}
let mut out: Vec<MediaItem> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut push = |item: MediaItem, out: &mut Vec<MediaItem>| {
if seen.insert(item.url.clone()) {
out.push(item);
}
};
for sid in &sessions {
let Some(session) = app.store.get_session(sid)? else { continue };
let cwd = PathBuf::from(&session.cwd);
let own = sid == session_id;
// Tool results are shown in the card of the call: jump to that message.
let mut call_msg: Option<String> = None;
for m in app.store.messages(sid)? {
collect_message(&m, &cwd, own, call_msg.as_deref(), &mut |item| push(item, &mut out));
if m.role == Role::Assistant {
call_msg = Some(m.id.clone());
}
}
}
// Generated images whose tool result was not found (e.g. removed by a rewind).
for img in app.store.images_of_sessions(&sessions)? {
push(
MediaItem { kind: "image", url: format!("/api/images/{}/file", img.id), name: file_name(&img.path), source: "generated", created_at: img.created_at, message_id: None, path: None, preview: None },
&mut out,
);
}
// Names of generated images (their files).
let names: std::collections::HashMap<String, String> = app.store.images_of_sessions(&sessions)?.into_iter().map(|i| (format!("/api/images/{}/file", i.id), file_name(&i.path))).collect();
for item in &mut out {
if let Some(n) = names.get(&item.url) {
item.name = n.clone();
}
}
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(out)
}
fn raw_url(p: &Path) -> String {
format!("/api/files/raw?path={}", encode(&p.display().to_string()))
}
fn file_name(p: &str) -> String {
Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p).to_string()
}
fn collect_message(m: &Message, cwd: &Path, own: bool, call_msg: Option<&str>, add: &mut dyn FnMut(MediaItem)) {
let mid = own.then(|| m.id.clone());
let result_mid = own.then(|| call_msg.unwrap_or(&m.id).to_string());
let at = m.created_at;
let file = |p: &str, source: &'static str| -> Option<MediaItem> {
let kind = kind_of(p)?;
let abs = resolve(p, cwd);
abs.is_file().then(|| MediaItem { kind, url: raw_url(&abs), name: file_name(p), source, created_at: at, message_id: mid.clone(), path: Some(abs.display().to_string()), preview: None })
};
for p in &m.parts {
match p {
Part::Image { image } if m.role == Role::User => {
if let Some(h) = &image.blob {
add(MediaItem { kind: kind_of_mime(&image.media_type), url: format!("/api/blobs/{h}"), name: "image".into(), source: "upload", created_at: at, message_id: mid.clone(), path: None, preview: None });
}
}
// generate_3d: the model with its preview (not the preview as a separate image).
Part::ToolResult { meta: Some(meta), .. } if meta["model_file"].is_string() => {
let file = PathBuf::from(meta["model_file"].as_str().unwrap_or_default());
if file.is_file() {
let preview = meta["preview"].as_str().map(PathBuf::from).filter(|p| p.is_file()).map(|p| raw_url(&p));
add(MediaItem { kind: "model", url: raw_url(&file), name: file_name(&file.display().to_string()), source: "generated", created_at: at, message_id: result_mid.clone(), path: Some(file.display().to_string()), preview });
}
}
Part::ToolResult { images, meta, .. } => {
let ids: Vec<&str> = meta.as_ref().and_then(|v| v["image_ids"].as_array()).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
if !ids.is_empty() {
for id in ids {
add(MediaItem { kind: "image", url: format!("/api/images/{id}/file"), name: "image".into(), source: "generated", created_at: at, message_id: result_mid.clone(), path: None, preview: None });
}
} else {
for img in images {
if let Some(h) = &img.blob {
add(MediaItem { kind: kind_of_mime(&img.media_type), url: format!("/api/blobs/{h}"), name: "screenshot".into(), source: "tool", created_at: at, message_id: result_mid.clone(), path: None, preview: None });
}
}
}
}
Part::ToolCall { name, input, .. } if matches!(name.as_str(), "write" | "edit") => {
if let Some(item) = input["path"].as_str().and_then(|p| file(p, "written")) {
add(item);
}
}
Part::Text { text } if m.role == Role::Assistant => {
for p in paths_in(text) {
if let Some(item) = file(&p, "answer") {
add(item);
}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinds_paths_and_encoding() {
assert_eq!(kind_of("a/b.PNG"), Some("image"));
assert_eq!(kind_of("clip.mp4?v=1"), Some("video"));
assert_eq!(kind_of("song.flac"), Some("audio"));
assert_eq!(kind_of("main.rs"), None);
assert_eq!(paths_in("Saved to `art/final/hero.png` and ![x](shots/a.webp). See https://x.org/y.png"), vec!["art/final/hero.png", "shots/a.webp"]);
assert_eq!(encode("/w/ä b.png"), "%2Fw%2F%C3%A4%20b.png");
}
}
+430
View File
@@ -0,0 +1,430 @@
//! 3D models with fal.ai (generate_3d): from an image (plus optional back/left/right
//! views), from a text prompt, or from an image steered by text – the 3D models take no
//! text with an image, so the image is first refined with the prompt (image edit model)
//! and then converted. The result is a GLB file (plus a preview image) in the project.
use crate::app::App;
use crate::config::expand_env;
use crate::store::UsageRecord;
use crate::tools::{Tool, ToolCtx, ToolOutput};
use crate::types::now_ms;
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
const STYLES: &[&str] = &["textured", "geometry", "lowpoly"];
/// Request body for a fal.ai 3D endpoint (the families name their fields differently).
fn request_body(model: &str, prompt: Option<&str>, images: &Views, style: &str, pbr: bool, faces: Option<u64>) -> Value {
let mut b = json!({});
if model.contains("tripo") {
// Tripo: image_url / prompt, texture + pbr flags, face_limit.
if let Some(front) = &images.front {
b["image_url"] = json!(front);
}
if let Some(p) = prompt {
b["prompt"] = json!(p);
}
b["texture"] = json!(style != "geometry");
b["pbr"] = json!(style != "geometry" && pbr);
if let Some(f) = faces {
b["face_limit"] = json!(f);
}
if style == "lowpoly" {
b["face_limit"] = json!(faces.unwrap_or(20_000));
}
} else if model.contains("trellis") {
if let Some(front) = &images.front {
b["image_url"] = json!(front);
}
} else {
// Hunyuan3D: front + optional views, generate_type, enable_pbr, face_count.
if let Some(front) = &images.front {
b["input_image_url"] = json!(front);
}
for (k, v) in [("back_image_url", &images.back), ("left_image_url", &images.left), ("right_image_url", &images.right)] {
if let Some(v) = v {
b[k] = json!(v);
}
}
if let Some(p) = prompt {
b["prompt"] = json!(p);
}
b["generate_type"] = json!(match style {
"geometry" => "Geometry",
"lowpoly" => "LowPoly",
_ => "Normal",
});
b["enable_pbr"] = json!(style != "geometry" && pbr);
if let Some(f) = faces {
b["face_count"] = json!(f.clamp(40_000, 1_500_000));
}
}
b
}
/// Data URIs of the views (front required for image-to-3D).
#[derive(Default)]
struct Views {
front: Option<String>,
back: Option<String>,
left: Option<String>,
right: Option<String>,
}
impl Views {
fn extra(&self) -> usize {
[&self.back, &self.left, &self.right].iter().filter(|v| v.is_some()).count()
}
}
/// Price of one generation (USD) by the published fal.ai prices; a price configured for
/// the model under [images] models wins.
fn price(app: &App, model: &str, style: &str, pbr: bool, multiview: bool, faces: bool) -> f64 {
if let Some(p) = app.config().images.models.iter().find(|m| m.id == model).and_then(|m| m.price) {
return p;
}
if model.contains("tripo") {
return match style {
"geometry" => 0.20,
_ => 0.30,
};
}
if model.contains("hunyuan") {
let base = match style {
"geometry" => 0.225,
"lowpoly" => 0.45,
_ => 0.375,
};
let extras = [pbr && style != "geometry", multiview, faces].iter().filter(|x| **x).count() as f64;
return base + 0.15 * extras;
}
0.0
}
/// URL of the queue API for fal.ai (requests that take minutes).
fn queue_base(app: &App) -> String {
let base = expand_env(&app.config().images.base_url);
if base.contains("://fal.run") { "https://queue.fal.run".into() } else { base.trim_end_matches('/').to_string() }
}
/// Submit to fal.ai's queue and wait for the result, reporting progress.
async fn run_queued(app: &App, ctx: &ToolCtx, model: &str, body: &Value) -> Result<Value> {
let key = expand_env(&app.config().images.api_key);
let client = crate::provider::http_client();
let submit = client
.post(format!("{}/{}", queue_base(app), model.trim_matches('/')))
.header("Authorization", format!("Key {key}"))
.timeout(Duration::from_secs(120))
.json(body)
.send()
.await
.context("fal.ai request failed")?;
if !submit.status().is_success() {
return Err(crate::provider::http_error(submit).await);
}
let sub: Value = submit.json().await?;
let status_url = sub["status_url"].as_str().context("fal.ai: no status_url")?.to_string();
let response_url = sub["response_url"].as_str().context("fal.ai: no response_url")?.to_string();
let started = std::time::Instant::now();
loop {
if ctx.cancel.is_cancelled() {
bail!("cancelled");
}
if started.elapsed() > Duration::from_secs(20 * 60) {
bail!("fal.ai did not finish within 20 minutes");
}
tokio::time::sleep(Duration::from_secs(4)).await;
let st: Value = client.get(&status_url).header("Authorization", format!("Key {key}")).timeout(Duration::from_secs(30)).send().await?.json().await?;
match st["status"].as_str().unwrap_or("") {
"COMPLETED" => break,
"IN_QUEUE" => ctx.progress(format!("waiting in fal.ai's queue (position {}) – {} s", st["queue_position"].as_u64().unwrap_or(0), started.elapsed().as_secs())),
"IN_PROGRESS" => ctx.progress(format!("generating the 3D model – {} s", started.elapsed().as_secs())),
other => bail!("fal.ai: unexpected status {other}: {st}"),
}
}
let resp = client.get(&response_url).header("Authorization", format!("Key {key}")).timeout(Duration::from_secs(60)).send().await?;
if !resp.status().is_success() {
return Err(crate::provider::http_error(resp).await);
}
Ok(resp.json().await?)
}
/// URL of the mesh and of the preview image in a fal.ai 3D result.
fn result_urls(out: &Value) -> (Option<String>, Option<String>) {
let s = |v: &Value| v.as_str().map(String::from);
let mesh = s(&out["model_glb"]["url"])
.or_else(|| s(&out["model_mesh"]["url"]))
.or_else(|| s(&out["model_urls"]["glb"]["url"]))
.or_else(|| s(&out["model_file"]["url"]))
.or_else(|| s(&out["mesh"]["url"]));
let thumb = s(&out["thumbnail"]["url"]).or_else(|| s(&out["rendered_image"]["url"]));
(mesh, thumb)
}
/// File extension of a downloaded mesh: from the URL, else GLB (what the models return).
fn ext_of(url: &str) -> &'static str {
let path = url.split(['?', '#']).next().unwrap_or(url).to_ascii_lowercase();
["glb", "gltf", "fbx", "obj", "usdz", "stl"].into_iter().find(|e| path.ends_with(&format!(".{e}"))).unwrap_or("glb")
}
/// An image the user attached to the chat ("attached" = the first image of the latest
/// message with images, "attached:2" = its second), written to `dir` so it can be used
/// like a project file.
async fn attached_image(app: &App, session_id: &str, spec: &str, dir: &Path) -> Result<PathBuf> {
let n: usize = spec.strip_prefix("attached").map(|r| r.trim_start_matches(':').trim()).filter(|r| !r.is_empty()).map(|r| r.parse()).transpose().map_err(|_| anyhow!("use \"attached\" or \"attached:2\""))?.unwrap_or(1);
let root = app.root_session_id(session_id);
let messages = app.store.messages(&root)?;
let images: Vec<crate::types::ImageData> = messages
.iter()
.rev()
.filter(|m| m.role == crate::types::Role::User)
.map(|m| m.parts.iter().filter_map(|p| if let crate::types::Part::Image { image } = p { Some(image.clone()) } else { None }).collect::<Vec<_>>())
.find(|imgs| !imgs.is_empty())
.ok_or_else(|| anyhow!("no image attached to this chat"))?;
let img = images.get(n.saturating_sub(1)).ok_or_else(|| anyhow!("the latest message has only {} image(s)", images.len()))?;
let (mime, bytes) = match &img.blob {
Some(h) => app.store.blob(h)?.ok_or_else(|| anyhow!("attached image not found"))?,
None => (img.media_type.clone(), base64::Engine::decode(&base64::engine::general_purpose::STANDARD, img.data.as_bytes())?),
};
let ext = match mime.as_str() {
"image/jpeg" => "jpg",
"image/webp" => "webp",
_ => "png",
};
tokio::fs::create_dir_all(dir).await?;
let path = dir.join(format!("{}-attached-{n}.{ext}", chrono::Local::now().format("%Y%m%d-%H%M%S")));
tokio::fs::write(&path, &bytes).await?;
Ok(path)
}
/// An image argument: a project file, or an image attached to the chat.
async fn image_arg(ctx: &ToolCtx, spec: &str, dir: &Path) -> Result<PathBuf> {
if spec.starts_with("attached") {
attached_image(&ctx.app, &ctx.session_id, spec, dir).await
} else {
ctx.resolve(spec)
}
}
pub struct Generate3d;
#[async_trait]
impl Tool for Generate3d {
fn name(&self) -> &str {
"generate_3d"
}
fn description(&self) -> String {
"Generate a 3D model (GLB) with fal.ai and save it in the project – from an image of the \
object (a file, or image: \"attached\" for an image the user pasted into the chat; best: a \
clean front view; optional back/left/right views improve the shape), from a \
text prompt alone, or from an image steered by a prompt (the image is refined with the \
prompt first, then converted). style: \"textured\" (default), \"geometry\" (untextured – for \
3D printing), \"lowpoly\" (games). Takes one to several minutes and costs money (about \
$0.25–0.75): generate only what the user asked for. The user can open the model in the 3D \
view of the file manager."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "What the model should be (English). With an image: how to change/clean it up before the conversion."},
"image": {"type": "string", "description": "Image of the object (front view): a file path, or \"attached\" for the image the user attached to the chat (\"attached:2\" = the second of that message)"},
"back": {"type": "string", "description": "Optional back view image (path or \"attached:N\")"},
"left": {"type": "string", "description": "Optional left view image (path or \"attached:N\")"},
"right": {"type": "string", "description": "Optional right view image (path or \"attached:N\")"},
"style": {"type": "string", "enum": STYLES},
"pbr": {"type": "boolean", "description": "PBR materials (metal/roughness), textured models only; costs extra"},
"face_count": {"type": "integer", "description": "Target number of faces (optional)"},
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: <images.output_dir>/3d)"},
"model": {"type": "string", "description": "fal.ai model id – only if the user asks for a specific one"}
}
})
}
fn title(&self, input: &Value) -> String {
let what = input["prompt"].as_str().map(|p| p.chars().take(70).collect::<String>()).or_else(|| input["image"].as_str().map(String::from)).unwrap_or_default();
format!("3D model: {what}")
}
fn read_only(&self) -> bool {
false
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let app = ctx.app.clone();
let cfg = app.config();
if !cfg.images.enabled {
bail!("fal.ai is disabled – enable image generation in the settings");
}
if expand_env(&cfg.images.api_key).is_empty() {
bail!("no fal.ai API key configured");
}
let owner = app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
crate::budget::check(&app, "fal", owner.as_deref())?;
let prompt = input["prompt"].as_str().map(str::trim).filter(|p| !p.is_empty()).map(String::from);
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| format!("{}/3d", cfg.images.output_dir.trim_end_matches('/')));
let dir = ctx.resolve(&dir_arg)?;
let image = match input["image"].as_str() {
Some(spec) => Some(image_arg(ctx, spec, &dir).await?),
None => None,
};
if prompt.is_none() && image.is_none() {
bail!("give an image of the object, a prompt, or both");
}
let style = input["style"].as_str().filter(|s| STYLES.contains(s)).unwrap_or("textured");
let pbr = input["pbr"].as_bool().unwrap_or(false);
let faces = input["face_count"].as_u64();
let mut notes = Vec::new();
// Image + prompt: refine the image with the prompt first (3D models take no text).
let mut front = image.clone();
if let (Some(img), Some(p)) = (&image, &prompt) {
ctx.progress("refining the image with the prompt");
let edit = crate::images::choose_model(&app, "final", Some("illustration"), true);
if edit.reference_param.is_some() {
let records = crate::images::generate(
&app,
crate::images::GenerateRequest {
prompt: format!(
"{p}. Show exactly one object, complete and centered, as a clean product shot: plain light neutral background, soft even lighting, no text, no other objects."
),
model: Some(edit.id.clone()),
size: None,
count: 1,
dir: dir.clone(),
user_id: owner.clone(),
session_id: Some(ctx.session_id.clone()),
references: vec![img.clone()],
},
)
.await
.map_err(|e| anyhow!("refining the image failed: {e:#}"))?;
if let Some(r) = records.first() {
front = Some(PathBuf::from(&r.path));
notes.push(format!("Refined image: {}", rel(&r.path, &ctx.cwd)));
}
} else {
notes.push("No image edit model configured – the prompt was not applied to the image.".into());
}
}
let mut views = Views::default();
if let Some(f) = &front {
views.front = crate::images::reference_uris(std::slice::from_ref(f)).await?.pop();
for (field, slot) in [("back", &mut views.back), ("left", &mut views.left), ("right", &mut views.right)] {
if let Some(p) = input[field].as_str() {
*slot = crate::images::reference_uris(&[image_arg(ctx, p, &dir).await?]).await?.pop();
}
}
}
let model = input["model"].as_str().filter(|m| !m.is_empty()).map(String::from).unwrap_or_else(|| if front.is_some() { cfg.images.model_3d.clone() } else { cfg.images.model_3d_text.clone() });
if views.extra() > 0 && !model.contains("hunyuan") {
notes.push("This model takes only the front view; the other views were not used.".into());
}
let text = if front.is_none() { prompt.as_deref() } else { None };
let body = request_body(&model, text, &views, style, pbr, faces);
ctx.progress(format!("sending to {model}"));
let out = run_queued(&app, ctx, &model, &body).await?;
let (mesh_url, thumb_url) = result_urls(&out);
let mesh_url = mesh_url.ok_or_else(|| anyhow!("fal.ai returned no 3D model: {}", out.to_string().chars().take(300).collect::<String>()))?;
tokio::fs::create_dir_all(&dir).await?;
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
// Named after the prompt, else the image (without a time stamp it already carries).
let stem = image.as_ref().and_then(|p| p.file_stem()).and_then(|s| s.to_str()).unwrap_or("model");
let stem = if stem.len() > 16 && stem.as_bytes()[8] == b'-' && stem[..8].bytes().all(|b| b.is_ascii_digit()) { &stem[16..] } else { stem };
let base = crate::images::slug(prompt.as_deref().unwrap_or(stem).trim_start_matches('-'));
let (bytes, _) = crate::images::download(&mesh_url).await?;
let mesh_path = dir.join(format!("{stamp}-{base}.{}", ext_of(&mesh_url)));
tokio::fs::write(&mesh_path, &bytes).await?;
let mut thumb_path = None;
if let Some(t) = thumb_url {
if let Ok((tb, _)) = crate::images::download(&t).await {
let p = dir.join(format!("{stamp}-{base}-preview.png"));
if tokio::fs::write(&p, &tb).await.is_ok() {
thumb_path = Some(p);
}
}
}
// Cost into the statistics (provider "fal", like images).
let cost = price(&app, &model, style, pbr, views.extra() > 0 && model.contains("hunyuan"), faces.is_some());
let session = ctx.session_id.clone();
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: session.clone(),
root_session_id: app.root_session_id(&session),
provider: "fal".into(),
model: model.clone(),
agent: "3d".into(),
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
ttft_ms: 0,
duration_ms: 0,
cost,
error: false,
user_id: owner,
allowance: None,
});
let mesh_rel = rel(&mesh_path.display().to_string(), &ctx.cwd);
let mut text = format!("3D model ({model}, {style}{}): {mesh_rel} ({} KB)", if pbr && style != "geometry" { ", PBR" } else { "" }, bytes.len() / 1024);
if let Some(t) = &thumb_path {
text.push_str(&format!("\nPreview: {}", rel(&t.display().to_string(), &ctx.cwd)));
}
if cost > 0.0 {
text.push_str(&format!("\nCost: about ${cost:.2}"));
}
for n in &notes {
text.push_str(&format!("\n{n}"));
}
text.push_str("\nThe user can open the model in the 3D view of the file manager.");
let images = thumb_path.as_deref().and_then(crate::images::preview_for_model).into_iter().collect();
Ok(ToolOutput {
content: text,
images,
meta: Some(json!({
"model_file": mesh_path.display().to_string(),
"preview": thumb_path.map(|p| p.display().to_string()),
})),
..Default::default()
})
}
}
fn rel(path: &str, cwd: &Path) -> String {
Path::new(path).strip_prefix(cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bodies_per_model_family() {
let views = Views { front: Some("data:a".into()), back: Some("data:b".into()), ..Default::default() };
let h = request_body("fal-ai/hunyuan3d-v3/image-to-3d", None, &views, "geometry", true, Some(10));
assert_eq!(h["input_image_url"], "data:a");
assert_eq!(h["back_image_url"], "data:b");
assert_eq!(h["generate_type"], "Geometry");
assert_eq!(h["enable_pbr"], false);
assert_eq!(h["face_count"], 40_000);
let t = request_body("tripo3d/h3.1/image-to-3d", None, &views, "textured", true, None);
assert_eq!((t["image_url"].as_str(), t["texture"].as_bool(), t["pbr"].as_bool()), (Some("data:a"), Some(true), Some(true)));
let txt = request_body("fal-ai/hunyuan3d-v3/text-to-3d", Some("a chair"), &Views::default(), "textured", false, None);
assert_eq!((txt["prompt"].as_str(), txt["generate_type"].as_str()), (Some("a chair"), Some("Normal")));
}
#[test]
fn result_urls_of_the_families() {
let h = json!({"model_glb": {"url": "https://x/m.glb"}, "thumbnail": {"url": "https://x/t.png"}});
assert_eq!(result_urls(&h), (Some("https://x/m.glb".into()), Some("https://x/t.png".into())));
let t = json!({"model_mesh": {"url": "https://x/m.glb"}, "rendered_image": {"url": "https://x/r.webp"}});
assert_eq!(result_urls(&t).1.as_deref(), Some("https://x/r.webp"));
assert_eq!(ext_of("https://x/a.FBX?sig=1"), "fbx");
assert_eq!(ext_of("https://x/blob"), "glb");
}
}
+29 -7
View File
@@ -32,6 +32,8 @@ pub struct Agent {
pub max_parallel: Option<usize>,
/// Review loop after each turn (None/0 rounds = off).
pub refine: Option<crate::config::RefineConfig>,
/// binary_info and decompile available (besides craft).
pub reverse: bool,
/// Source file of Markdown agents.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
@@ -43,12 +45,12 @@ pub use codecraft_core::prompts::*;
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = craft_prompt(clean_room);
vec![
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
]
}
@@ -121,7 +123,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
source: "file".into(),
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }),
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
reverse: meta.get("reverse_tools").is_some_and(|v| v == "true"),
path: Some(p.display().to_string()),
});
}
@@ -141,6 +144,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
source: "config".into(),
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
reverse: a.reverse_tools.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.reverse)),
path: None,
});
}
@@ -173,6 +177,7 @@ pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) ->
source: "personal".into(),
max_parallel: None,
refine: a.refine,
reverse: a.reverse_tools.unwrap_or(false),
path: None,
});
}
@@ -216,6 +221,17 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
/// The plain chat mode: no coding instructions, project files or memory – only the
/// date, the model and what the user keeps in their personal memory.
/// Add the instructions the user set for this chat (they come last, so they win).
pub fn with_chat_instructions(mut system: String, instructions: &str) -> String {
if !instructions.trim().is_empty() {
system.push_str(&format!(
"\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n",
instructions.trim()
));
}
system
}
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
let mut s = agent.prompt.clone();
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
@@ -282,6 +298,12 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
}
}
if let Some(git) = owner.and_then(|u| crate::gitcreds::prompt_section(app, &u.id)) {
s.push_str(&git);
}
if let Some(ssh) = owner.and_then(|u| crate::sshkeys::prompt_section(app, &u.id)) {
s.push_str(&ssh);
}
match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())),
Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"),
+12
View File
@@ -61,9 +61,21 @@ impl Registry {
ProviderEntry { name: name.clone(), provider, models },
);
}
// Claude Code (the CLI's own agent) appears as a model list when it is installed.
if !providers.contains_key(crate::claude_code::PROVIDER) && crate::claude_code::binary().is_some() {
providers.insert(
crate::claude_code::PROVIDER.to_string(),
ProviderEntry { name: crate::claude_code::PROVIDER.to_string(), provider: Arc::new(crate::claude_code::Marker), models: crate::claude_code::models() },
);
}
Self { providers, limits }
}
/// First model of any provider but `skip`.
pub fn first_model_excluding(&self, skip: &str) -> Option<String> {
self.providers.values().filter(|e| e.name != skip).find_map(|e| e.models.first().map(|m| format!("{}/{}", e.name, m.id)))
}
/// Wait for a free slot on the model and its provider. Always acquired in the
/// same order (model, then provider), so concurrent requests cannot deadlock.
pub async fn acquire(&self, spec: &str) -> Slot {
+7 -1
View File
@@ -200,8 +200,12 @@ pub fn spawn_listener(app: Arc<App>) {
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
};
let mut permission_id = None;
let (session_id, kind, body) = match &ev {
Event::PermissionRequest { session_id, title, tool, .. } => (session_id.clone(), "permission", format!("{tool}: {title}")),
Event::PermissionRequest { id, session_id, title, tool, .. } => {
permission_id = Some(id.clone());
(session_id.clone(), "permission", format!("{tool}: {title}"))
}
Event::QuestionRequest { session_id, questions, .. } => {
let q = questions.first().map(|q| q.question.clone()).unwrap_or_default();
(session_id.clone(), "question", q)
@@ -228,6 +232,8 @@ pub fn spawn_listener(app: Arc<App>) {
continue;
}
let payload = json!({
// Lets the notification itself approve or reject (service worker → POST /api/permissions/<id>).
"permission_id": permission_id,
"kind": kind,
"title": title,
"body": body.chars().take(180).collect::<String>(),
+30 -4
View File
@@ -142,7 +142,10 @@ impl Tool for Decompile {
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
large programs; results are cached per file."
large programs; results are cached per file. Raw firmware images (.bin without a file \
format) need `processor` (Ghidra language id, e.g. \"ARM:LE:32:Cortex\" for Cortex-M, \
\"ARM:LE:32:v8\", \"AVR8:LE:16:atmega256\", \"Xtensa:LE:32:default\" for ESP32) and usually \
`base_address` (load address, e.g. \"0x08000000\" for STM32 flash)."
.into()
}
fn schema(&self) -> Value {
@@ -151,7 +154,9 @@ impl Tool for Decompile {
"properties": {
"path": {"type": "string"},
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
"force": {"type": "boolean", "description": "Re-run even if cached"}
"force": {"type": "boolean", "description": "Re-run even if cached"},
"processor": {"type": "string", "description": "Ghidra language id for raw images, e.g. ARM:LE:32:Cortex (default: detected from the file format)"},
"base_address": {"type": "string", "description": "Load address of a raw image, e.g. 0x08000000"}
},
"required": ["path"]
})
@@ -171,7 +176,20 @@ impl Tool for Decompile {
bail!("{} is not a file", path.display());
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}", file_hash(&path)?));
let processor = input["processor"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
let base = input["base_address"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
if processor.as_deref().is_some_and(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || ":_-.".contains(c))) {
bail!("invalid processor id (expected e.g. ARM:LE:32:Cortex)");
}
if base.as_deref().is_some_and(|b| !b.trim_start_matches("0x").chars().all(|c| c.is_ascii_hexdigit())) {
bail!("invalid base_address (expected e.g. 0x08000000)");
}
// Another processor or load address is another analysis.
let variant = match (&processor, &base) {
(None, None) => String::new(),
(p, b) => format!("-{}", crate::blobs::hash(format!("{p:?}{b:?}").as_bytes()).get(..8).unwrap_or("")),
};
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}{variant}", file_hash(&path)?));
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
}
@@ -183,7 +201,15 @@ impl Tool for Decompile {
let scripts = scripts_dir().display().to_string();
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
let args = vec![proj.as_str(), "cc", "-import", p.as_str(), "-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"];
let mut args = vec![proj.as_str(), "cc", "-import", p.as_str()];
// Raw firmware: the binary loader with the given processor and load address.
if let Some(proc_id) = processor.as_deref() {
args.extend(["-processor", proc_id]);
}
if let Some(addr) = base.as_deref() {
args.extend(["-loader", "BinaryLoader", "-loader-baseAddr", addr]);
}
args.extend(["-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]);
let java = tools.join("jdk").display().to_string();
let mut cmd = match &ctx.sandbox {
Some(spec) => {
+550 -35
View File
@@ -2,7 +2,7 @@
//! checks), feed results back, repeat until the model stops.
use crate::app::{App, PermissionReply};
use crate::permission::{Decision, Evaluator, bash_prefix, split_command};
use crate::permission::{Decision, Evaluator, bash_prefix, is_dangerous, split_command};
use crate::prompts::{self, Agent};
use crate::provider::{ChatRequest, Provider, StreamEvent};
use crate::store::UsageRecord;
@@ -18,6 +18,8 @@ use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
const MAX_STEPS: usize = 250;
/// Modes with enforced reviews work through their todo list (e.g. overnight).
const MAX_STEPS_ENFORCED: usize = 2_000;
const MAX_SUBAGENT_DEPTH: usize = 3;
/// What happened to a prompt.
@@ -71,6 +73,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
.join("\n");
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
let source = source.to_string();
let turn_started = now_ms();
tokio::spawn(async move {
let res = async {
@@ -112,6 +115,10 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
});
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
if leftover.is_empty() && !cancel.is_cancelled() {
spawn_summary(app.clone(), session_id.clone());
crate::checks::spawn_after_turn(app.clone(), session_id.clone(), turn_started);
}
if !leftover.is_empty() {
let _ = start_prompt(app, session_id, leftover);
}
@@ -143,7 +150,7 @@ pub fn abort(app: &App, session_id: &str) {
/// Messages since the last compaction summary. Tool calls left without a
/// result (run interrupted, e.g. by a server restart) get a synthetic result:
/// providers reject histories with unanswered tool calls.
fn active_history(all: Vec<Message>) -> Vec<Message> {
pub(crate) fn active_history(all: Vec<Message>) -> Vec<Message> {
let start = all
.iter()
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
@@ -213,7 +220,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
let cfg = app.config();
let mut all = tools::builtin_tools();
if !cfg.images.enabled {
all.retain(|t| t.name() != "generate_image");
all.retain(|t| t.name() != "generate_image" && t.name() != "generate_3d");
}
let (direct, deferred) = app.mcp.tools(&cfg, owner);
all.extend(direct);
@@ -224,7 +231,12 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
all.into_iter()
.filter(|t| !agent.read_only || t.read_only())
// Craft tools only where they are useful.
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some())
.filter(|t| {
agent.name == "craft"
|| agent.tools.is_some()
|| (agent.reverse && matches!(t.name(), "binary_info" | "decompile"))
|| !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase")
})
.filter(|t| match &agent.tools {
None => true,
Some(list) => {
@@ -256,11 +268,23 @@ fn run_session_boxed(
/// Run the loop for a session until the model stops. Returns the final text.
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
// Chats on a "claude-code/…" model are run by the Claude Code CLI.
if depth == 0 && crate::claude_code::is_claude_code(&app.session(session_id)?.model) {
return crate::claude_code::run(app, session_id, cancel).await;
}
let mut final_text = String::new();
let mut stop_hooks = 0;
let mut refine_done = 0u32;
let turn_start = now_ms();
for _ in 0..MAX_STEPS {
// Compacted for the current step (no second compaction before a request went through).
let mut compacted_now = false;
// Mandatory reviews of completed todos: rounds per todo, and where the last one ended.
let mut todo_reviews: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let mut review_since = turn_start;
// Reminders about open todos at the end of a turn (see MAX_IDLE_CONTINUES).
let mut last_reminder: Option<Value> = None;
let mut idle_continues = 0u32;
for step_no in 0..=MAX_STEPS_ENFORCED {
if cancel.is_cancelled() {
break;
}
@@ -275,40 +299,65 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
agent.prompt = prompts::craft_prompt(clean_room);
}
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
let step_limit = if enforced_review(&agent) > 0 { MAX_STEPS_ENFORCED } else { MAX_STEPS };
if step_no >= step_limit {
app.emit(Event::Error { session_id: session_id.into(), message: format!("Stopped after {step_limit} steps (limit of a turn) – send a message to continue.") });
break;
}
let (provider, mut model) = app.registry().resolve(&model_spec)?;
if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) {
model.effort = Some(effort);
}
// Auto-compaction when the last request used most of the context window.
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
let history = active_history(app.store.messages_for_model(session_id)?);
// Providers count input + max_tokens against the context window: the answer needs
// room too. Estimated input = last request (input + its answer) + what came since.
let est_input = estimate_input(session.last_usage.as_ref(), &history);
let want_output = model.max_output.unwrap_or(16_000);
if let Some(ctx_len) = model.context {
let threshold = app.config().server.auto_compact_threshold;
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
// Input limit: the context window, or the provider's lower input limit.
let input_limit = model.max_input.map_or(ctx_len, |m| m.min(ctx_len));
let room = ctx_len.saturating_sub(est_input);
// Most of the input limit used, or no room left for a reasonable answer.
if threshold > 0.0 && (est_input as f64 > input_limit as f64 * threshold || room < want_output.min(MIN_ANSWER_ROOM)) && !compacted_now {
compacted_now = true;
compact(app.clone(), session_id, cancel.clone(), Some((est_input, input_limit))).await?;
continue;
}
}
let max_tokens = answer_budget(want_output, model.context, est_input);
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
let tools = available_tools(&app, &agent, owner.as_ref());
let history = active_history(app.store.messages(session_id)?);
let req = ChatRequest {
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()),
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
messages: history,
tools: tools.iter().map(|t| t.def()).collect(),
max_tokens: model.max_output.unwrap_or(16_000),
max_tokens,
reasoning: true,
session_id: Some(session_id.to_string()),
model: model.clone(),
};
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
Ok(step) => step,
Ok(step) => {
compacted_now = false;
step
}
// The estimate was off and the provider says the input is too long: compact
// once and try again instead of stopping.
Err(e) if is_context_overflow(&e) && !compacted_now && !cancel.is_cancelled() => {
compacted_now = true;
app.emit(Event::Error { session_id: session_id.into(), message: format!("context window full ({}) – compacting the conversation and continuing", short_error(&e)) });
compact(app.clone(), session_id, cancel.clone(), Some((est_input, model.context.unwrap_or(0)))).await?;
continue;
}
// Host down or budget used up: switch to the model's fallback for this step.
Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => {
let fb = model.fallback.clone().unwrap_or_default();
let (fb_provider, fb_model) = app.registry().resolve(&fb)?;
app.emit(Event::Error { session_id: session_id.into(), message: format!("{model_spec} is not available ({}) – using the fallback model {fb}", short_error(&e)) });
let req = ChatRequest { model: fb_model.clone(), max_tokens: fb_model.max_output.unwrap_or(16_000), ..req };
let req = ChatRequest { model: fb_model.clone(), max_tokens: answer_budget(fb_model.max_output.unwrap_or(16_000), fb_model.context, est_input), ..req };
stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await?
}
Err(e) => return Err(e),
@@ -353,12 +402,37 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
}
}
let normal_end = matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "");
// Enforced reviews also mean: work until the todo list is done. Ending the turn with
// open todos sends the agent back (as long as it makes progress).
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() && enforced_review(&agent) > 0 {
let todos_now = app.session(session_id)?.todos;
let open = open_todos(&todos_now);
if !open.is_empty() {
let progressed = last_reminder.as_ref() != Some(&todos_now);
idle_continues = if progressed { 0 } else { idle_continues + 1 };
if idle_continues < MAX_IDLE_CONTINUES {
last_reminder = Some(todos_now);
save_user_parts(
&app,
session_id,
vec![Part::Text {
text: format!(
"<todo-reminder open=\"{}\">\n{}\n</todo-reminder>\nThese todos are still open. Continue working until all of them are completed (each completed todo is reviewed). Only if you are truly blocked and need the user, say clearly what you need and stop.",
open.len(),
open.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
),
}],
)?;
continue;
}
}
}
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() {
// Refinement loop: a reviewer checks the turn; findings send the agent back to work.
let rounds = agent.refine.as_ref().map(|r| r.rounds).unwrap_or(0);
if refine_done < rounds {
refine_done += 1;
match review_turn(&app, &session, &agent, &model_spec, turn_start, refine_done, rounds, &cancel).await {
match review_turn(&app, &session, &agent, &model_spec, turn_start, None, refine_done, rounds, &cancel).await {
Ok(Some(feedback)) => {
save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?;
continue;
@@ -396,7 +470,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
agent: agent.name.clone(),
roots: roots.clone(),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room,
// Clean room is a craft rule; other modes with reverse tools may analyse binaries.
clean_room: clean_room && agent.name == "craft",
owner_id: None,
};
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
@@ -412,6 +487,40 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await);
}
}
// Mode with a review: every todo marked completed is reviewed; with findings it is
// reopened and the agent has to fix them and complete it again (max. `rounds` per todo).
// The verdict goes into the same message as the tool results.
let rounds = enforced_review(&agent);
if rounds > 0 && depth == 0 && !cancel.is_cancelled() && calls.iter().any(|(_, n, _)| n == "todo_write") {
let todos_now = app.session(session_id)?.todos;
let due: Vec<String> = newly_completed(&session.todos, &todos_now)
.into_iter()
.filter(|t| todo_reviews.get(t).copied().unwrap_or(0) < rounds)
.collect();
if !due.is_empty() {
for t in &due {
*todo_reviews.entry(t.clone()).or_default() += 1;
}
let round = due.iter().filter_map(|t| todo_reviews.get(t)).copied().max().unwrap_or(1);
let fresh = app.session(session_id)?;
let reviewed = review_turn(&app, &fresh, &agent, &model_spec, review_since, Some(&due), round, rounds, &cancel).await;
review_since = now_ms();
match reviewed {
Ok(Some(feedback)) => {
app.set_todos(session_id, reopen_todos(&todos_now, &due))?;
results.push(Part::Text { text: feedback });
}
Ok(None) => {
let open = open_todos(&todos_now);
let next = if open.is_empty() { "All todos are done – check the whole request once more, then finish." } else { "Continue with the next open todo." };
results.push(Part::Text {
text: format!("<refine-approved reviewer=\"{}\">\n{}\n</refine-approved>\nReview passed. {next}", review_model(&agent, &model_spec), due.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")),
});
}
Err(e) => tracing::warn!("todo review of {session_id} failed: {e:#}"),
}
}
}
// Deliver messages the user queued while we were working (with attachments).
results.extend(take_queue(&app, session_id));
save_user_parts(&app, session_id, results)?;
@@ -419,6 +528,55 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
Ok(final_text)
}
/// Review rounds when the mode enforces reviews of completed todos (0 = not enforced).
fn enforced_review(agent: &prompts::Agent) -> u32 {
agent.refine.as_ref().filter(|r| r.after_todos).map(|r| r.rounds).unwrap_or(0)
}
/// The model that reviews for this mode.
fn review_model(agent: &prompts::Agent, model_spec: &str) -> String {
agent.refine.as_ref().and_then(|r| r.model.clone()).filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string())
}
/// Contents of the todos that are not completed yet.
fn open_todos(todos: &Value) -> Vec<String> {
todos.as_array().into_iter().flatten().filter(|t| t["status"] != "completed").filter_map(|t| t["content"].as_str()).map(String::from).collect()
}
/// Times in a row the agent may end its turn with open todos without making progress.
const MAX_IDLE_CONTINUES: u32 = 10;
/// Contents of the todos that are completed in `after` but were not in `before`.
fn newly_completed(before: &Value, after: &Value) -> Vec<String> {
let done_before: std::collections::HashSet<&str> = before
.as_array()
.into_iter()
.flatten()
.filter(|t| t["status"] == "completed")
.filter_map(|t| t["content"].as_str())
.collect();
after
.as_array()
.into_iter()
.flatten()
.filter(|t| t["status"] == "completed")
.filter_map(|t| t["content"].as_str())
.filter(|c| !done_before.contains(c))
.map(String::from)
.collect()
}
/// The todo list with the given items open again (in progress).
fn reopen_todos(todos: &Value, items: &[String]) -> Value {
let mut out = todos.clone();
for t in out.as_array_mut().into_iter().flatten() {
if t["content"].as_str().is_some_and(|c| items.iter().any(|i| i == c)) {
t["status"] = Value::from("in_progress");
}
}
out
}
fn tool_result(call_id: &str, name: &str, content: String, is_error: bool, meta: Option<Value>) -> Part {
Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta }
}
@@ -490,9 +648,18 @@ async fn execute_call(
// Permission check. Approvals are tracked on the top-level session.
let root = app.root_session_id(&session.id);
let cfg = app.config();
let decision = app.with_runtime(&root, |r| {
Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input)
let (mut decision, session_approved) = app.with_runtime(&root, |r| {
(Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input), r.approved.clone())
});
// What the user allowed "for this project" earlier counts as well.
if decision == Decision::Ask {
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
if !project.is_empty() {
let mut all = session_approved;
all.extend(project);
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(tool.as_ref(), &input);
}
}
match decision {
Decision::Deny => {
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
@@ -510,16 +677,27 @@ async fn execute_call(
}
return tool_result(call_id, call_name, text, true, None);
}
PermissionReply::Always => app.with_runtime(&root, |r| {
PermissionReply::Always | PermissionReply::Project => {
// (key, may be remembered for the whole project)
let mut keys: Vec<(String, bool)> = Vec::new();
if name == "bash" {
let cmd = input["command"].as_str().unwrap_or("");
for part in split_command(cmd).unwrap_or_default() {
r.approved.insert(format!("bash:{}", bash_prefix(&part)));
// Dangerous commands (rm -r, git push, docker …) are never remembered beyond this chat.
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
}
} else {
r.approved.insert(name.to_string());
keys.push((name.to_string(), true));
}
}),
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
if reply == PermissionReply::Project {
for (k, ok) in &keys {
if *ok {
let _ = app.store.add_project_approval(&session.cwd, k);
}
}
}
}
PermissionReply::Once => {}
}
if let Some(sel) = hunks {
@@ -563,7 +741,15 @@ async fn execute_call(
// Pass depth through the agent name channel: subagents run with depth+1.
run_task(app, &ctx, input, depth).await
} else {
tool.run(input, &ctx).await
let moves = name == "bash" && input["command"].as_str().is_some_and(moves_files);
let out = tool.run(input, &ctx).await;
if moves {
// Generated images the command moved or renamed keep their entries.
let a = app.clone();
let owner = session.owner_id.clone();
tokio::task::spawn_blocking(move || crate::images::relocate_missing(&a, owner.as_deref()));
}
out
};
// Diagnostics: report new errors of the changed file right away.
let res = match res {
@@ -609,6 +795,11 @@ async fn execute_call(
}
}
/// A shell command that may move or rename files (`mv`, `git mv`, `rename`).
fn moves_files(cmd: &str) -> bool {
cmd.split(|c: char| c.is_whitespace() || ";&|()`$".contains(c)).any(|w| w == "mv" || w == "rename")
}
async fn run_task(app: &Arc<App>, ctx: &ToolCtx, input: Value, depth: usize) -> Result<tools::ToolOutput> {
let prompt = tools::arg_str(&input, "prompt")?.to_string();
let description = input["description"].as_str().unwrap_or("subagent").to_string();
@@ -704,6 +895,11 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
pinned: false,
archived: false,
folder: None,
instructions: String::new(),
telegram_quiet: false,
summary: String::new(),
color: None,
icon: None,
};
let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -764,6 +960,47 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
/// Errors after which the fallback model takes over: the host is unreachable
/// (connection, timeout, gateway errors) or the provider's budget is used up.
/// Room an answer gets at least before the conversation is compacted.
const MIN_ANSWER_ROOM: u64 = 32_000;
/// Estimated input tokens of the next request: the last request (its input and answer)
/// plus the messages that came after that answer (tool results, user messages), at about
/// 3 characters per token; without usage (fresh or just compacted) the whole history.
fn estimate_input(last: Option<&crate::types::Usage>, history: &[Message]) -> u64 {
let chars_of = |m: &Message| -> u64 {
m.parts
.iter()
.map(|p| match p {
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => text.len() as u64,
Part::ToolCall { input, .. } => input.to_string().len() as u64,
Part::ToolResult { content, images, .. } => content.len() as u64 + images.len() as u64 * 4_500,
Part::Image { .. } => 4_500,
})
.sum()
};
match last {
Some(u) => {
let after = history.iter().rposition(|m| m.role == Role::Assistant).map(|i| &history[i + 1..]).unwrap_or(&[]);
u.context_tokens() + after.iter().map(chars_of).sum::<u64>() / 3
}
None => history.iter().map(chars_of).sum::<u64>() / 3 + 8_000,
}
}
/// max_tokens for a request: the model's maximum, but no more than the window leaves
/// (with a margin for the estimate), and never below a small floor.
fn answer_budget(max_output: u64, context: Option<u64>, est_input: u64) -> u64 {
let Some(ctx) = context else { return max_output };
let room = ctx.saturating_sub(est_input + est_input / 50 + 2_000);
max_output.min(room).max(4_096.min(max_output))
}
/// The provider rejected the request because input + output exceed the context window.
fn is_context_overflow(e: &anyhow::Error) -> bool {
let s = format!("{e:#}").to_lowercase();
["input too long", "context length", "context_length", "maximum context", "context window", "prompt is too long", "too many tokens", "reduce the length"].iter().any(|k| s.contains(k))
}
fn needs_fallback(e: &anyhow::Error) -> bool {
let s = format!("{e:#}").to_lowercase();
["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k))
@@ -908,7 +1145,8 @@ async fn stream_step(
let mut message = Message::new(Role::Assistant, parts);
message.id = msg_id;
message.model = Some(model_spec.to_string());
message.usage = Some(usage.clone());
// With its cost (charged, or valued at list prices) for the line under the answer.
message.usage = Some(Usage { cost: Some(req.model.cost(&usage)), ..usage.clone() });
if !message.parts.is_empty() {
app.store.add_message(&session.id, &message)?;
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
@@ -943,6 +1181,9 @@ fn small_model(app: &App, session: &Session) -> String {
let cfg = app.config();
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
cfg.server.small_model.clone()
} else if crate::claude_code::is_claude_code(&session.model) {
// Claude Code runs itself; side tasks need an ordinary model.
app.registry().first_model_excluding(crate::claude_code::PROVIDER).unwrap_or_else(|| session.model.clone())
} else {
session.model.clone()
}
@@ -970,6 +1211,174 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
});
}
/// Cut a text down to `max` bytes keeping the beginning (the goal) and the end (where it went).
pub(crate) fn fit(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let head = clip(s, max * 2 / 3);
let mut start = s.len() - max / 3;
while !s.is_char_boundary(start) {
start += 1;
}
format!("{}\n…\n{}", head.split(" […").next().unwrap_or(&head), &s[start..])
}
/// A short, tool-free job for the small model (summaries, explanations). The input is cut to the
/// model's context window, so models with 8k tokens work as well as large ones: tokens left for
/// the input = context − answer − prompt, at a conservative 2.5 bytes per token.
async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input: &str, max_out: u64) -> Result<String> {
let spec = small_model(app, session);
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("low".into());
let ctx = model.context.unwrap_or(32_000);
let room_tokens = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 400).max(600);
let input = fit(input, (room_tokens as usize * 5 / 2).min(60_000));
let req = ChatRequest {
system: system.into(),
messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])],
tools: vec![],
max_tokens: max_out,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?;
record_side_usage(app, session, &spec, &model, &usage, kind);
Ok(strip_think(&text))
}
/// A commit message for staged changes (small model; the diff is cut to its context window).
/// `recent` are the latest commit subjects, so the style matches the repository.
pub async fn suggest_commit_message(app: &App, user_id: &str, diff: &str, recent: &str) -> Result<String> {
let cfg = app.config();
let spec = if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
cfg.server.small_model.clone()
} else {
app.registry().first_model_excluding(crate::claude_code::PROVIDER).ok_or_else(|| anyhow!("no model available"))?
};
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("low".into());
let max_out = 200u64;
let system = "You write a git commit message for the staged changes. Reply with the message only: a subject line of at most 72 characters in the imperative mood, then optionally a blank line and up to 4 short bullet points on what changed and why. No quotes, no code fences. Match the style of the recent commit subjects (language, prefixes).";
let ctx = model.context.unwrap_or(32_000);
let room = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 500).max(600) as usize * 5 / 2;
let input = format!("<recent-commits>\n{}\n</recent-commits>\n<staged-diff>\n{}\n</staged-diff>", fit(recent, 600), fit(diff, room.saturating_sub(700).clamp(500, 40_000)));
let req = ChatRequest {
system: system.into(),
messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])],
tools: vec![],
max_tokens: max_out,
reasoning: false,
session_id: None,
model: model.clone(),
};
let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?;
let (p, m) = spec.split_once('/').unwrap_or(("", spec.as_str()));
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: "cli:git-commit".into(),
root_session_id: "cli:git-commit".into(),
provider: p.into(),
model: m.into(),
agent: "commit-message".into(),
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
ttft_ms: 0,
duration_ms: 0,
cost: model.cost(&usage),
error: false,
user_id: Some(user_id.to_string()),
allowance: usage.allowance,
});
let msg = strip_think(&text).trim().trim_matches('`').trim().to_string();
if msg.is_empty() {
bail!("the model returned no message");
}
Ok(msg)
}
fn message_text(m: &Message) -> String {
m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")
}
/// After a turn: one line "where things stand" for the session list (small model, ~1k tokens of input).
fn spawn_summary(app: Arc<App>, session_id: String) {
tokio::spawn(async move {
let Ok(session) = app.session(&session_id) else { return };
if session.parent_id.is_some() {
return;
}
let Ok(history) = app.store.messages(&session_id) else { return };
let history = active_history(history);
let last_user = history.iter().rev().find(|m| matches!(m.role, Role::User) && !message_text(m).trim().is_empty() && !message_text(m).trim_start().starts_with('<'));
let last_answer = history.iter().rev().find(|m| matches!(m.role, Role::Assistant) && !message_text(m).trim().is_empty());
let (Some(u), Some(a)) = (last_user, last_answer) else { return };
let input = format!(
"<previous-line>{}</previous-line>\n<user-request>\n{}\n</user-request>\n<agent-answer>\n{}\n</agent-answer>",
session.summary,
fit(&message_text(u), 1500),
fit(&message_text(a), 2500)
);
let system = "You write the status line of a coding chat for a list. One line, at most 14 words: what was done or where things stand, concrete (file/feature names). \
Use the language of the user's request. No quotes, no markdown, no trailing period. Reply with the line only.";
match side_task(&app, &session, "summary", system, &input, 120).await {
Ok(text) => {
let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ', '.']).chars().take(140).collect::<String>();
if !line.is_empty() {
let _ = app.modify_session(&session_id, false, |s| s.summary = line);
}
}
Err(e) => tracing::debug!("summary failed: {e:#}"),
}
});
}
/// "What does this do?" for a pending approval, in plain words (for phones: approve without reading code).
pub async fn explain_tool_call(app: &App, session: &Session, tool: &str, title: &str, input: &Value, diff: Option<&str>, lang: &str) -> Result<String> {
let language = if lang.starts_with("de") { "German" } else { "English" };
let system = format!(
"You explain a pending action of an AI coding agent to the user who has to approve it. Answer in {language}, plain words, at most 3 short sentences: \
what it does, and whether anything is risky (deletes or overwrites data, network access, secrets, changes outside the project, hard to undo). \
If it is harmless, say so. No markdown headings, no code blocks."
);
let mut body = format!("<tool>{tool}</tool>\n<title>{title}</title>\n<input>\n{}\n</input>", clip(&input.to_string(), 3000));
if let Some(d) = diff {
body.push_str(&format!("\n<diff>\n{}\n</diff>", clip(d, 3000)));
}
side_task(app, session, "explain", &system, &body, 300).await
}
/// Turn a chat into the instructions of a repeatable automation: (name, instructions in Markdown).
/// Works with a small model: the transcript is cut to its context window.
pub async fn draft_job(app: &App, session: &Session) -> Result<(String, String)> {
let history = active_history(app.store.messages(&session.id)?);
let transcript = transcript_of(&history);
if transcript.trim().is_empty() {
bail!("the session has no messages yet");
}
let system = "You turn a finished coding-assistant conversation into instructions for an AUTOMATION that repeats the useful work on a schedule, without a human in the loop. \
Reply in exactly this format. First line: NAME: <at most 5 words>. Then the instructions in Markdown with the headings '## Goal', '## Steps' (numbered), '## Result' and '## Limits'. \
Write them standalone: never refer to 'the conversation' or 'above'; name concrete paths, commands and checks that were used. \
Use the language of the user's messages. Keep it under 300 words.";
let text = side_task(app, session, "job-draft", system, &format!("<conversation>\n{transcript}\n</conversation>"), 1200).await?;
let mut lines = text.lines().skip_while(|l| l.trim().is_empty());
let first = lines.next().unwrap_or("");
let (name, body) = match first.trim().strip_prefix("NAME:").or_else(|| first.trim().strip_prefix("Name:")) {
Some(n) => (n.trim().trim_matches(['"', '*', '#', ' ']).to_string(), lines.collect::<Vec<_>>().join("\n")),
None => (String::new(), text.clone()),
};
let body = body.trim().to_string();
if body.is_empty() {
bail!("the model returned no instructions");
}
let name = if name.is_empty() { session.title.clone() } else { name };
let name: String = if name.trim().is_empty() { "Automation".into() } else { name.chars().take(60).collect() };
Ok((name.clone(), format!("# {name}\n\n{body}\n")))
}
fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
let Ok(session) = app.session(session_id) else { return };
if session.parent_id.is_some() || !session.title.is_empty() {
@@ -1118,21 +1527,26 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
/// Review the last turn against the user's request. Returns feedback for the
/// agent, or None when the reviewer approves.
#[allow(clippy::too_many_arguments)]
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, turn_start: i64, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, since: i64, todos: Option<&[String]>, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
let refine = agent.refine.clone().unwrap_or_default();
let spec = refine.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string());
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("medium".into());
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
let result = async {
let history = active_history(app.store.messages(&session.id)?);
let history = active_history(app.store.messages_for_model(&session.id)?);
// The user's request: the last user message with text that is not feedback/tool output.
let req_idx = history
.iter()
.rposition(|m| matches!(m.role, Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { text } if !text.starts_with("<refine-feedback") && !text.starts_with("<hook-"))))
.unwrap_or(0);
let request: String = history.get(req_idx).map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")).unwrap_or_default();
let mut work = transcript_of(&history[(req_idx + 1).min(history.len())..]);
// A todo review looks at the work since the last review, the end-of-turn review at the whole turn.
let from = match todos {
Some(_) => history.iter().position(|m| m.created_at >= since).unwrap_or(history.len()).max(req_idx + 1),
None => req_idx + 1,
};
let mut work = transcript_of(&history[from.min(history.len())..]);
if work.len() > 30_000 {
let mut start = work.len() - 30_000;
while !work.is_char_boundary(start) {
@@ -1143,7 +1557,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
// Actual file changes of this turn (checkpoint vs. current content).
let mut changes = String::new();
let mut seen = std::collections::HashSet::new();
for (path, before) in app.store.checkpoints_since(&session.id, turn_start)? {
for (path, before) in app.store.checkpoints_since(&session.id, since)? {
if !seen.insert(path.clone()) || changes.len() > 30_000 {
continue;
}
@@ -1157,10 +1571,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
let system = "You review the work of an AI coding agent for the user. Be strict about what matters for the user's request and ignore style nitpicks. \
Reply in exactly this format: the first line is APPROVED or CHANGES_NEEDED. For CHANGES_NEEDED, follow with at most 8 concrete, actionable findings as a bullet list \
(with file:line where possible). If the important work is done and correct, reply APPROVED.";
let focus = match todos {
Some(items) => format!(
"<completed-todos>\n{}\n</completed-todos>\nThe agent just marked these todo items as completed. Review only whether they are really done and correct; other parts of the request may still be in progress.\n\n",
items.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
),
None => String::new(),
};
let body = format!(
"<request>\n{request}\n</request>\n\n<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
"<request>\n{request}\n</request>\n\n{focus}<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
agent.name,
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
if changes.is_empty() { "(no files changed since the last review)" } else { changes.as_str() }
);
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) };
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
@@ -1173,10 +1594,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
}
let findings: String = lines.collect::<Vec<_>>().join("\n");
let findings = if findings.trim().is_empty() { text.clone() } else { findings };
Ok(Some(format!(
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
findings.trim()
)))
Ok(Some(match todos {
Some(items) => format!(
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\" todos=\"{}\">\n{}\n</refine-feedback>\nThe review of the todo(s) you just completed found problems, so they are open again. Fix these findings (or explain briefly why one does not apply), verify, then mark the todo(s) completed again – that triggers the next review.",
items.len(),
findings.trim()
),
None => format!(
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
findings.trim()
),
}))
}
.await;
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
@@ -1250,7 +1678,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken,
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
let session = app.session(session_id)?;
let history = active_history(app.store.messages(session_id)?);
let history = active_history(app.store.messages_for_model(session_id)?);
if history.is_empty() {
return Ok(());
}
@@ -1295,10 +1723,97 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
Ok(())
}
/// Text of the marker `/clear` puts into the history (the chat view recognises it).
pub const CLEARED_MARKER: &str = "[Context cleared by the user. Earlier messages are no longer part of this conversation – start fresh and do not refer to them.]";
/// `/clear`: the model forgets the conversation so far (the chat keeps showing it). Like a
/// compaction without a summary: history before the marker is not sent any more.
pub fn clear_context(app: &App, session_id: &str) -> Result<()> {
save_user_parts(app, session_id, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }])?;
app.update_session(session_id, |s| {
s.last_usage = None;
s.todos = Value::Null;
})?;
app.with_runtime(session_id, |r| r.read_files.clear());
// Claude Code continues its own conversation: start a new one.
if let Some(owner) = app.session(session_id).ok().and_then(|s| s.owner_id) {
let _ = app.store.set_user_setting(&owner, &format!("claude_sid:{session_id}"), &Option::<String>::None);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleared_marker_starts_a_new_history() {
let mk = |role, parts| Message::new(role, parts);
let all = vec![
mk(Role::User, vec![Part::Text { text: "old question".into() }]),
mk(Role::Assistant, vec![Part::Text { text: "old answer".into() }]),
mk(Role::User, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }]),
mk(Role::User, vec![Part::Text { text: "new question".into() }]),
];
let active = active_history(all);
assert_eq!(active.len(), 2);
assert!(!active.iter().any(|m| message_text(m).contains("old")));
}
#[test]
fn fit_keeps_head_and_tail_within_budget() {
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
let out = fit(&text, 3000);
assert!(out.len() <= 3100, "{}", out.len());
assert!(out.starts_with("AAAA") && out.ends_with("ZZZZ") && out.contains('…'));
// Multi-byte text is cut on character boundaries.
let de = "äöüß".repeat(2000);
assert!(fit(&de, 1000).is_char_boundary(0));
assert_eq!(fit("short", 100), "short");
}
#[test]
fn todo_completion_and_reopening() {
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
let after = serde_json::json!([{"content": "A", "status": "completed"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
assert_eq!(newly_completed(&before, &after), vec!["A".to_string()]);
assert_eq!(newly_completed(&Value::Null, &after), vec!["A".to_string(), "B".to_string()]);
assert_eq!(open_todos(&after), vec!["C".to_string()]);
let reopened = reopen_todos(&after, &["A".to_string()]);
assert_eq!(reopened[0]["status"], "in_progress");
assert_eq!(reopened[1]["status"], "completed");
}
#[test]
fn answer_room_and_overflow() {
// The case from production: DeepSeek V4.1 Flash, 1M window, 384K output, 720K input.
let (ctx, out) = (1_048_576u64, 393_216u64);
let b = answer_budget(out, Some(ctx), 720_000);
assert!(720_000 + b <= ctx && b > 300_000, "{b}");
assert_eq!(answer_budget(out, Some(ctx), 100_000), out);
assert_eq!(answer_budget(16_000, None, 5_000_000), 16_000);
assert_eq!(answer_budget(out, Some(ctx), 1_040_000), 4_096);
assert!(is_context_overflow(&anyhow!("HTTP 400: [invalid_request_error] Input too long: model limit 1048576 tokens, requested 1108739 tokens")));
assert!(is_context_overflow(&anyhow!("This model's maximum context length is 128000 tokens")));
assert!(!is_context_overflow(&anyhow!("HTTP 429 rate limited")));
// Estimate: last request + what came after its answer (about 3 chars per token).
let u = crate::types::Usage { input_tokens: 1_000, cache_read_tokens: 9_000, output_tokens: 500, ..Default::default() };
let mut call = Message::new(Role::Assistant, vec![call("c1")]);
call.id = "a".into();
let result = Message::new(Role::User, vec![Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "x".repeat(3_000), images: vec![], is_error: false, meta: None }]);
assert_eq!(estimate_input(Some(&u), &[call.clone(), result.clone()]), 10_500 + 1_000);
assert!(estimate_input(None, &[call, result]) >= 1_000 + 8_000);
}
#[test]
fn detects_commands_that_move_files() {
assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/"));
assert!(moves_files("git mv a.png b.png"));
assert!(moves_files("cd art;mv x y"));
assert!(!moves_files("ls -la movies && cat remove.txt"));
}
fn call(id: &str) -> Part {
Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None }
}
+35 -2
View File
@@ -18,6 +18,8 @@ pub struct SandboxSpec {
pub roots: Vec<PathBuf>,
/// Host directory mounted as the user's home.
pub home: PathBuf,
/// Extra environment (the user's git identity).
pub env: Vec<(String, String)>,
}
impl SandboxSpec {
@@ -35,6 +37,8 @@ pub async fn probe() -> Result<(), String> {
}
let res = match Command::new("bwrap")
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
// The dynamic loader lives in /lib64 on x86_64 (/lib on arm64).
.args(["--ro-bind-try", "/lib64", "/lib64", "--ro-bind-try", "/lib32", "/lib32"])
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
.env_clear()
.output()
@@ -69,7 +73,7 @@ pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
}
let home = app.data_dir().join("users").join(&user.username).join("home");
let _ = std::fs::create_dir_all(&home);
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home, env: crate::gitcreds::identity_env(app, &user.id) })
}
/// Build a command that runs `program args` inside the sandbox in `cwd`.
@@ -128,7 +132,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
if new_session {
push(&[&"--new-session"]);
}
let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin");
// SSH wrappers (user's keys); only when keys are set up.
let ssh_wrapper = spec.home.join(crate::sshkeys::SANDBOX_BIN).join("ssh");
let ssh_bin = format!("{home}/{}", crate::sshkeys::SANDBOX_BIN);
let path = format!("{}{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin", if ssh_wrapper.exists() { format!("{ssh_bin}:") } else { String::new() });
let npm = format!("{home}/.npm-global");
let pipx = format!("{home}/.local/pipx");
let pipx_bin = format!("{home}/.local/bin");
@@ -148,9 +155,35 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
] {
push(&[&"--setenv", &k, &v]);
}
if ssh_wrapper.exists() {
let cmd = format!("{ssh_bin}/ssh");
push(&[&"--setenv", &"GIT_SSH_COMMAND", &cmd]);
}
for (k, v) in &spec.env {
push(&[&"--setenv", k, v]);
}
push(&[&"--chdir", &cwd, &program]);
for a in args {
push(&[a]);
}
c
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_identity_reaches_the_sandbox() {
let spec = SandboxSpec {
username: "ann".into(),
roots: vec![],
home: PathBuf::from("/tmp/ann"),
env: vec![("GIT_AUTHOR_NAME".into(), "Ann Example".into())],
};
let args: Vec<String> = bwrap_args(&spec, Path::new("/tmp"), &[], true, "bash", &[]).into_iter().map(|a| a.to_string_lossy().into_owned()).collect();
let i = args.iter().position(|a| a == "GIT_AUTHOR_NAME").expect("identity set");
assert_eq!(args[i - 1], "--setenv");
assert_eq!(args[i + 1], "Ann Example");
}
}
+299
View File
@@ -0,0 +1,299 @@
//! Per-user SSH keys for agents, terminals and the git panel.
//!
//! The keys live in the database and are rendered into the user's home (`.ssh/`), like the git
//! credentials. OpenSSH takes `~` from the passwd entry, not from `$HOME`, so plain `ssh` would
//! not look into the sandbox home. A small `ssh`/`scp`/`sftp` wrapper directory goes first in PATH
//! and applies the managed config with `-F`; `GIT_SSH_COMMAND` points at the wrapper. Two
//! variants: one for the sandbox (paths through `${HOME}`) and one for unsandboxed runs (admins;
//! absolute host paths).
use crate::app::App;
use crate::store::User;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshKey {
pub id: String,
pub name: String,
pub public: String,
pub fingerprint: String,
/// OpenSSH private key (never sent to the UI).
#[serde(default)]
pub private: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshSettings {
#[serde(default)]
pub keys: Vec<SshKey>,
}
/// Keys without the private part, for the UI.
pub fn masked(s: &SshSettings) -> Vec<SshKey> {
s.keys.iter().map(|k| SshKey { private: String::new(), ..k.clone() }).collect()
}
fn ssh_dir(home: &Path) -> PathBuf {
home.join(".ssh")
}
/// Wrappers for unsandboxed runs.
fn bin_dir(home: &Path) -> PathBuf {
home.join(".codecraft-bin")
}
/// Wrappers inside the sandbox (see `sandbox::bwrap_args`).
pub const SANDBOX_BIN: &str = ".codecraft-sbin";
fn key_file(id: &str) -> String {
format!("codecraft_{id}")
}
/// SSH client config. `sandbox`: paths through `${HOME}` (the home is mounted at a different
/// place there); otherwise absolute paths below `home` (wrapper for unsandboxed runs).
pub fn render_config(keys: &[SshKey], home: &Path, sandbox: bool) -> String {
let mut c = String::from("# Managed by CodeCraft (Settings → Account → SSH keys). Do not edit.\nHost *\n");
for k in keys {
if sandbox {
c.push_str(&format!("\tIdentityFile ${{HOME}}/.ssh/{}\n", key_file(&k.id)));
} else {
c.push_str(&format!("\tIdentityFile {}\n", ssh_dir(home).join(key_file(&k.id)).display()));
}
}
// Trust a host on first contact and refuse changed keys afterwards.
c.push_str("\tStrictHostKeyChecking accept-new\n\tServerAliveInterval 30\n");
if sandbox {
c.push_str("\tUserKnownHostsFile ${HOME}/.ssh/known_hosts\n");
} else {
c.push_str(&format!("\tUserKnownHostsFile {}\n", ssh_dir(home).join("known_hosts").display()));
}
c
}
#[cfg(unix)]
fn chmod(p: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
Ok(())
}
#[cfg(not(unix))]
fn chmod(_: &Path, _: u32) -> Result<()> {
Ok(())
}
/// Write keys, config and wrappers into the user's home (removes files of deleted keys).
pub fn write_files(app: &App, user: &User, s: &SshSettings) -> Result<()> {
let home = crate::gitcreds::home_of(app, user);
let dir = ssh_dir(&home);
std::fs::create_dir_all(&dir)?;
chmod(&dir, 0o700)?;
// Old key files.
for e in std::fs::read_dir(&dir)?.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if n.starts_with("codecraft_") && !s.keys.iter().any(|k| n == key_file(&k.id) || n == format!("{}.pub", key_file(&k.id))) {
let _ = std::fs::remove_file(e.path());
}
}
if s.keys.is_empty() {
// No keys: no wrappers either, ssh behaves as usual.
let _ = std::fs::remove_dir_all(bin_dir(&home));
let _ = std::fs::remove_dir_all(home.join(SANDBOX_BIN));
for f in ["config-sandbox", "config-host"] {
let _ = std::fs::remove_file(dir.join(f));
}
return Ok(());
}
for k in &s.keys {
let p = dir.join(key_file(&k.id));
// The file is replaced, not appended: remove it first so the mode applies.
let _ = std::fs::remove_file(&p);
std::fs::write(&p, &k.private)?;
chmod(&p, 0o600)?;
let pubp = dir.join(format!("{}.pub", key_file(&k.id)));
std::fs::write(&pubp, format!("{}\n", k.public.trim()))?;
chmod(&pubp, 0o644)?;
}
// The user's own ~/.ssh/config (if any) is included after the managed part.
let own = dir.join("config");
let sandbox_cfg = dir.join("config-sandbox");
std::fs::write(&sandbox_cfg, format!("{}\nInclude /home/{}/.ssh/config\n", render_config(&s.keys, &home, true), user.username))?;
chmod(&sandbox_cfg, 0o600)?;
let host_cfg = dir.join("config-host");
std::fs::write(&host_cfg, format!("{}\nInclude {}\n", render_config(&s.keys, &home, false), own.display()))?;
chmod(&host_cfg, 0o600)?;
for (bin, config) in [(bin_dir(&home), format!("'{}'", host_cfg.display())), (home.join(SANDBOX_BIN), "\"$HOME/.ssh/config-sandbox\"".to_string())] {
std::fs::create_dir_all(&bin)?;
for name in ["ssh", "scp", "sftp"] {
// The image's /usr/local/bin wrappers cope with a uid without passwd entry.
let local = format!("/usr/local/bin/{name}");
let real = if Path::new(&local).exists() { local } else { format!("/usr/bin/{name}") };
let p = bin.join(name);
std::fs::write(&p, format!("#!/bin/sh\nexec {real} -F {config} \"$@\"\n"))?;
chmod(&p, 0o755)?;
}
}
Ok(())
}
/// Environment for processes that run without the sandbox: the wrappers first in PATH, git over
/// SSH through the wrapper. Empty when the user has no keys.
pub fn env(app: &App, user: &User) -> Vec<(String, String)> {
let s: SshSettings = app.store.user_setting(&user.id, "ssh").unwrap_or_default();
if s.keys.is_empty() {
return Vec::new();
}
let bin = bin_dir(&crate::gitcreds::home_of(app, user));
if !bin.join("ssh").exists() {
return Vec::new();
}
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()));
vec![("PATH".into(), path), ("GIT_SSH_COMMAND".into(), bin.join("ssh").display().to_string())]
}
/// What an agent needs to know (public keys only). None without keys.
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
render_prompt(&app.store.user_setting(user_id, "ssh").unwrap_or_default())
}
fn render_prompt(s: &SshSettings) -> Option<String> {
if s.keys.is_empty() {
return None;
}
let names: Vec<&str> = s.keys.iter().map(|k| k.name.as_str()).collect();
Some(format!(
"\n# SSH access\nThe user has SSH keys set up ({}). `ssh`, `scp`, `sftp`, `rsync -e ssh` and `git` over ssh:// or git@host use them automatically – \
no password, no `ssh-add`, no agent. A host is trusted on first contact. Do NOT generate new keys or search for keys or passwords; \
if a server refuses the connection, tell the user that their public key must be added to the server's authorized_keys. \
Commands run without a terminal, so password or passphrase prompts fail.\n",
names.join(", ")
))
}
// --- creating keys ---------------------------------------------------------------------
async fn keygen(args: &[&str], stdin: Option<&str>) -> Result<String> {
let mut cmd = Command::new("ssh-keygen");
cmd.args(args).env_clear().env("PATH", std::env::var("PATH").unwrap_or_default()).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().context("ssh-keygen is not installed on the server")?;
if let Some(mut si) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
if let Some(t) = stdin {
let _ = si.write_all(t.as_bytes()).await;
}
}
let out = tokio::time::timeout(std::time::Duration::from_secs(20), child.wait_with_output()).await.map_err(|_| anyhow!("ssh-keygen timed out"))??;
if !out.status.success() {
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn temp_dir() -> Result<PathBuf> {
let d = std::env::temp_dir().join(format!("cc-ssh-{}", crate::types::new_id()));
std::fs::create_dir_all(&d)?;
chmod(&d, 0o700)?;
Ok(d)
}
/// A new ed25519 key without passphrase.
pub async fn generate(name: &str) -> Result<SshKey> {
let d = temp_dir()?;
let f = d.join("key");
let comment = format!("codecraft-{}", name.replace(char::is_whitespace, "-"));
let res = async {
keygen(&["-q", "-t", "ed25519", "-N", "", "-C", &comment, "-f", &f.to_string_lossy()], None).await?;
let private = std::fs::read_to_string(&f)?;
let public = std::fs::read_to_string(d.join("key.pub"))?;
finish(name, private, public.trim().to_string()).await
}
.await;
let _ = std::fs::remove_dir_all(&d);
res
}
/// An existing private key (OpenSSH or PEM, without passphrase).
pub async fn import(name: &str, private: &str) -> Result<SshKey> {
let mut text = private.trim().replace("\r\n", "\n");
if !text.starts_with("-----BEGIN ") || !text.contains("PRIVATE KEY-----") {
bail!("that does not look like a private key (-----BEGIN … PRIVATE KEY-----)");
}
text.push('\n');
let d = temp_dir()?;
let f = d.join("key");
let res = async {
std::fs::write(&f, &text)?;
chmod(&f, 0o600)?;
let public = keygen(&["-y", "-P", "", "-f", &f.to_string_lossy()], None).await.map_err(|e| anyhow!("cannot read the key (keys with a passphrase are not supported): {e}"))?;
finish(name, text.clone(), public.trim().to_string()).await
}
.await;
let _ = std::fs::remove_dir_all(&d);
res
}
async fn finish(name: &str, private: String, public: String) -> Result<SshKey> {
let fp = keygen(&["-l", "-f", "-"], Some(&format!("{public}\n"))).await.unwrap_or_default();
let fingerprint = fp.split_whitespace().nth(1).unwrap_or("").to_string();
Ok(SshKey { id: crate::types::new_id().chars().take(10).collect(), name: name.to_string(), public, fingerprint, private })
}
#[cfg(test)]
mod tests {
use super::*;
fn key(id: &str) -> SshKey {
SshKey { id: id.into(), name: "laptop".into(), public: "ssh-ed25519 AAAA x".into(), fingerprint: "SHA256:x".into(), private: "SECRET".into() }
}
#[test]
fn config_has_both_path_styles() {
let home = Path::new("/data/users/ann/home");
let t = render_config(&[key("k1")], home, true);
assert!(t.contains("IdentityFile ${HOME}/.ssh/codecraft_k1") && t.contains("UserKnownHostsFile ${HOME}/.ssh/known_hosts"));
assert!(t.contains("StrictHostKeyChecking accept-new"));
let a = render_config(&[key("k1")], home, false);
assert!(a.contains("IdentityFile /data/users/ann/home/.ssh/codecraft_k1"));
assert!(a.contains("UserKnownHostsFile /data/users/ann/home/.ssh/known_hosts"));
}
#[test]
fn sandbox_config_resolves_through_home() {
if std::process::Command::new("ssh").arg("-V").output().is_err() {
return;
}
let d = std::env::temp_dir().join(format!("cc-sshcfg-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let cfg = d.join("cfg");
std::fs::write(&cfg, render_config(&[key("k1")], Path::new("/unused"), true)).unwrap();
let out = std::process::Command::new("ssh").arg("-F").arg(&cfg).args(["-G", "example.org"]).env("HOME", "/sandbox/home").output().unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
// `-G` shows identity files unexpanded (expanded when used, ssh_config(5)).
assert!(text.contains("identityfile ${home}/.ssh/codecraft_k1"), "{text}");
assert!(text.contains("userknownhostsfile /sandbox/home/.ssh/known_hosts"), "{text}");
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn prompt_and_ui_view_never_contain_the_private_key() {
let s = SshSettings { keys: vec![key("k1")] };
assert!(masked(&s).iter().all(|k| k.private.is_empty()));
let p = render_prompt(&s).unwrap();
assert!(p.contains("laptop") && !p.contains("SECRET"));
assert!(render_prompt(&SshSettings::default()).is_none());
}
#[tokio::test]
async fn generated_and_imported_keys_match() {
if std::process::Command::new("ssh-keygen").arg("-?").output().is_err() {
return; // not installed here
}
let k = generate("test key").await.unwrap();
assert!(k.public.starts_with("ssh-ed25519 ") && k.fingerprint.starts_with("SHA256:") && k.private.contains("PRIVATE KEY"));
let again = import("copy", &k.private).await.unwrap();
assert_eq!(again.fingerprint, k.fingerprint);
assert!(import("bad", "hello").await.is_err());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Statistics as CSV (one table) or XLSX (all tables, one sheet each).
use serde_json::Value;
use std::io::Write;
/// A table: header row and rows of cells.
pub struct Table {
pub id: &'static str,
pub title: String,
pub rows: Vec<Vec<Cell>>,
}
pub enum Cell {
Text(String),
Num(f64),
}
fn num(v: &Value, k: &str) -> Cell {
Cell::Num(v[k].as_f64().unwrap_or(0.0))
}
/// Column titles (English, German).
fn columns(first: &str, de: bool) -> Vec<Cell> {
let t: [&str; 10] = if de {
["Anfragen", "Input-Tokens", "Cache gelesen", "Cache geschrieben", "Output-Tokens", "Fehler", "Ø Latenz (ms)", "Output (Tok/s)", "Prefill (Tok/s)", "Kosten"]
} else {
["Requests", "Input tokens", "Cache read", "Cache write", "Output tokens", "Errors", "Avg TTFT (ms)", "Output (tok/s)", "Prefill (tok/s)", "Cost"]
};
std::iter::once(first).chain(t).map(|s| Cell::Text(s.to_string())).collect()
}
fn agg_table(id: &'static str, title: &str, first: &str, key: &str, rows: &Value, de: bool) -> Table {
let mut out = vec![columns(first, de)];
for r in rows.as_array().into_iter().flatten() {
out.push(vec![
Cell::Text(r[key].as_str().unwrap_or("").to_string()),
num(r, "requests"),
num(r, "input_tokens"),
num(r, "cache_read_tokens"),
num(r, "cache_write_tokens"),
num(r, "output_tokens"),
num(r, "errors"),
num(r, "avg_ttft_ms"),
num(r, "output_tps"),
num(r, "prefill_tps"),
num(r, "cost"),
]);
}
Table { id, title: title.to_string(), rows: out }
}
/// All tables of a statistics result (as returned by `Store::global_stats`).
pub fn tables(stats: &Value, de: bool) -> Vec<Table> {
let (day, month, model, provider, agent, user, session) = if de {
("Tag", "Monat", "Modell", "Anbieter", "Modus", "Benutzer", "Chat")
} else {
("Day", "Month", "Model", "Provider", "Agent", "User", "Chat")
};
let mut out = vec![
agg_table("day", if de { "Pro Tag" } else { "Per day" }, day, "day", &stats["by_day"], de),
agg_table("month", if de { "Pro Monat" } else { "Per month" }, month, "month", &stats["by_month"], de),
agg_table("model", model, model, "model", &stats["by_model"], de),
agg_table("provider", provider, provider, "provider", &stats["by_provider"], de),
agg_table("agent", agent, agent, "agent", &stats["by_agent"], de),
agg_table("user", user, user, "user", &stats["by_user"], de),
agg_table("session", if de { "Teuerste Chats" } else { "Top chats" }, session, "title", &stats["top_sessions"], de),
];
// The grand total first, as a one-row table.
let mut total = vec![columns(if de { "Zeitraum" } else { "Range" }, de)];
let t = &stats["total"];
total.push(vec![
Cell::Text(format!("{} – {}", date(stats["from"].as_i64().unwrap_or(0)), date(stats["to"].as_i64().unwrap_or(0)))),
num(t, "requests"),
num(t, "input_tokens"),
num(t, "cache_read_tokens"),
num(t, "cache_write_tokens"),
num(t, "output_tokens"),
num(t, "errors"),
num(t, "avg_ttft_ms"),
num(t, "output_tps"),
num(t, "prefill_tps"),
num(t, "cost"),
]);
out.insert(0, Table { id: "total", title: (if de { "Gesamt" } else { "Total" }).to_string(), rows: total });
out
}
fn date(ms: i64) -> String {
if ms <= 0 {
return "-".into();
}
chrono::DateTime::from_timestamp_millis(ms).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()
}
// --- CSV -----------------------------------------------------------------------------
/// CSV for spreadsheets: `;` and decimal comma in German, `,` and decimal point otherwise;
/// UTF-8 with BOM (Excel). Cells starting with = + - @ are prefixed against formula injection.
pub fn csv(table: &Table, de: bool) -> String {
let sep = if de { ';' } else { ',' };
let mut out = String::from('\u{feff}');
for row in &table.rows {
let line: Vec<String> = row
.iter()
.map(|c| match c {
Cell::Num(n) => {
let s = if n.fract() == 0.0 { format!("{n:.0}") } else { format!("{n:.4}") };
if de { s.replace('.', ",") } else { s }
}
Cell::Text(t) => {
let t = if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{t}") } else { t.clone() };
if t.contains([sep, '"', '\n', '\r']) { format!("\"{}\"", t.replace('"', "\"\"")) } else { t }
}
})
.collect();
out.push_str(&line.join(&sep.to_string()));
out.push_str("\r\n");
}
out
}
// --- XLSX ----------------------------------------------------------------------------
fn esc(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.map(|c| match c {
'&' => "&amp;".to_string(),
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
c => c.to_string(),
})
.collect()
}
fn col_name(mut i: usize) -> String {
let mut s = String::new();
loop {
s.insert(0, (b'A' + (i % 26) as u8) as char);
if i < 26 {
break;
}
i = i / 26 - 1;
}
s
}
fn sheet_xml(t: &Table) -> String {
let mut x = String::from(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><cols><col min="1" max="1" width="34" customWidth="1"/><col min="2" max="11" width="16" customWidth="1"/></cols><sheetData>"#);
for (r, row) in t.rows.iter().enumerate() {
x.push_str(&format!(r#"<row r="{}">"#, r + 1));
for (c, cell) in row.iter().enumerate() {
let at = format!("{}{}", col_name(c), r + 1);
let style = if r == 0 { r#" s="1""# } else { "" };
match cell {
// Text as inline strings: no shared string table needed.
Cell::Text(s) => x.push_str(&format!(r#"<c r="{at}" t="inlineStr"{style}><is><t xml:space="preserve">{}</t></is></c>"#, esc(s))),
Cell::Num(n) => x.push_str(&format!(r#"<c r="{at}"{style}><v>{n}</v></c>"#)),
}
}
x.push_str("</row>");
}
x.push_str("</sheetData></worksheet>");
x
}
/// Sheet names: at most 31 characters, none of []:*?/\
fn sheet_name(s: &str) -> String {
s.chars().filter(|c| !"[]:*?/\\".contains(*c)).take(31).collect()
}
pub fn xlsx(tables: &[Table]) -> anyhow::Result<Vec<u8>> {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut put = |name: &str, body: &str| -> anyhow::Result<()> {
z.start_file(name, opts)?;
z.write_all(body.as_bytes())?;
Ok(())
};
let mut types = String::from(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>"#);
let mut sheets = String::new();
let mut rels = String::new();
for (i, t) in tables.iter().enumerate() {
let n = i + 1;
types.push_str(&format!(r#"<Override PartName="/xl/worksheets/sheet{n}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>"#));
sheets.push_str(&format!(r#"<sheet name="{}" sheetId="{n}" r:id="rId{n}"/>"#, esc(&sheet_name(&t.title))));
rels.push_str(&format!(r#"<Relationship Id="rId{n}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{n}.xml"/>"#));
}
types.push_str("</Types>");
let styles_id = tables.len() + 1;
rels.push_str(&format!(r#"<Relationship Id="rId{styles_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#));
put("[Content_Types].xml", &types)?;
put("_rels/.rels", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#)?;
put("xl/workbook.xml", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{sheets}</sheets></workbook>"#))?;
put("xl/_rels/workbook.xml.rels", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"#))?;
// Style 1 = bold header.
put("xl/styles.xml", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs></styleSheet>"#)?;
for (i, t) in tables.iter().enumerate() {
put(&format!("xl/worksheets/sheet{}.xml", i + 1), &sheet_xml(t))?;
}
z.finish()?;
}
Ok(buf.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample() -> Value {
json!({
"from": 0, "to": 1_700_000_000_000i64,
"total": {"requests": 3, "cost": 1.5},
"by_day": [{"day": "2026-01-02", "requests": 2, "cost": 1.25, "input_tokens": 10}],
"by_month": [], "by_model": [{"model": "=evil, \"x\"", "requests": 1}],
"by_provider": [], "by_agent": [], "by_user": [], "top_sessions": []
})
}
#[test]
fn csv_uses_locale_and_blocks_formulas() {
let tabs = tables(&sample(), true);
let day = tabs.iter().find(|t| t.id == "day").unwrap();
let c = csv(day, true);
assert!(c.starts_with('\u{feff}'));
assert!(c.contains("2026-01-02;2;10;0;0;0;0;0;0;0;1,2500"), "{c}");
let model = csv(tabs.iter().find(|t| t.id == "model").unwrap(), false);
assert!(model.contains("\"'=evil, \"\"x\"\"\""), "{model}");
}
#[test]
fn xlsx_is_a_zip_with_every_sheet() {
let tabs = tables(&sample(), false);
let bytes = xlsx(&tabs).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
for i in 1..=tabs.len() {
assert!(z.by_name(&format!("xl/worksheets/sheet{i}.xml")).is_ok());
}
let mut wb = String::new();
std::io::Read::read_to_string(&mut z.by_name("xl/workbook.xml").unwrap(), &mut wb).unwrap();
assert!(wb.contains("Per day") && wb.contains("Total"));
assert_eq!(col_name(0), "A");
assert_eq!(col_name(26), "AA");
}
}
+645 -34
View File
@@ -1,6 +1,6 @@
//! SQLite persistence for sessions and messages.
use crate::types::{Message, Part, Session, now_ms};
use crate::types::{ImageData, Message, Part, Role, Session, now_ms};
use serde::Serialize;
use serde_json::{Value, json};
use anyhow::Result;
@@ -90,6 +90,13 @@ impl Store {
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
-- Images of messages, referenced by hash from the message JSON (see blobs.rs).
CREATE TABLE IF NOT EXISTS blobs (
hash TEXT PRIMARY KEY,
media_type TEXT NOT NULL,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS queue (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -150,6 +157,39 @@ impl Store {
content BLOB
);
CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts);
CREATE TABLE IF NOT EXISTS project_approvals (
cwd TEXT NOT NULL,
key TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (cwd, key)
);
CREATE TABLE IF NOT EXISTS share_links (
token TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
owner_id TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS share_links_session ON share_links(session_id);
CREATE TABLE IF NOT EXISTS project_defaults (
cwd TEXT PRIMARY KEY,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_checks (
cwd TEXT PRIMARY KEY,
command TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS turn_checks (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
message_id TEXT NOT NULL,
status TEXT NOT NULL,
exit_code INTEGER,
duration_ms INTEGER NOT NULL DEFAULT 0,
output TEXT NOT NULL DEFAULT '',
ts INTEGER NOT NULL,
PRIMARY KEY (session_id, message_id)
);
CREATE TABLE IF NOT EXISTS session_shares (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -162,6 +202,8 @@ impl Store {
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings TEXT"),
("images", "blob", "ALTER TABLE images ADD COLUMN blob TEXT"),
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
] {
let exists: bool = conn
@@ -235,28 +277,7 @@ impl Store {
let (sdata, mdata) = row?;
let session: Session = serde_json::from_str(&sdata)?;
// Snippet from the visible text of the matching message (not JSON keys).
let mut snippet = String::new();
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
for p in &m.parts {
let text = match p {
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
Part::ToolResult { content, .. } => content.as_str(),
_ => continue,
};
if let Some(i) = text.to_lowercase().find(&needle) {
let mut a = i.saturating_sub(60);
while !text.is_char_boundary(a) {
a -= 1;
}
let mut b = (i + needle.len() + 100).min(text.len());
while !text.is_char_boundary(b) {
b += 1;
}
snippet = format!("{}{}{}", if a > 0 { "…" } else { "" }, text[a..b].replace('\n', " "), if b < text.len() { "…" } else { "" });
break;
}
}
}
let snippet = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()).and_then(|m| snippet_in(&m, &needle)).unwrap_or_default();
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
continue; // matched only JSON structure (keys, ids)
}
@@ -265,6 +286,121 @@ impl Store {
Ok(out)
}
/// A part of a session's messages for the chat view (long chats are not loaded at
/// once). Returns the messages (oldest first) and whether older / newer ones exist.
pub fn messages_window(&self, session_id: &str, w: &MessageWindow) -> Result<(Vec<Message>, bool, bool)> {
let conn = self.conn.lock().unwrap();
let seq_of = |id: &str| -> Result<i64> {
conn.query_row("SELECT seq FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, id], |r| r.get(0))
.optional()?
.ok_or_else(|| anyhow::anyhow!("message {id} not found"))
};
let (sql, a, limit): (&str, i64, i64) = match w {
// From the last summary (compaction) on, at most `limit`.
MessageWindow::Tail { limit } => {
let from: i64 = conn
.query_row(
"SELECT seq FROM messages WHERE session_id = ?1 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
[session_id],
|r| r.get(0),
)
.optional()?
.unwrap_or(i64::MIN);
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq DESC LIMIT ?3", from, *limit)
}
// Scrolling up stops at a summary (compaction); the history before it on request.
MessageWindow::Before { id, limit, stop_at_summary } => {
let before = seq_of(id)?;
let summary: Option<i64> = if *stop_at_summary {
conn.query_row(
"SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
params![session_id, before],
|r| r.get(0),
)
.optional()?
} else {
None
};
match summary {
Some(from) => {
let mut stmt = conn.prepare("SELECT seq, data FROM messages WHERE session_id = ?1 AND seq >= ?2 AND seq < ?3 ORDER BY seq DESC LIMIT ?4")?;
let mut rows: Vec<(i64, Message)> = Vec::new();
for r in stmt.query_map(params![session_id, from, before, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
let (seq, data) = r?;
rows.push((seq, serde_json::from_str(&data)?));
}
drop(stmt);
return Self::finish_window(&conn, session_id, rows, false);
}
None => ("SELECT data FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3", before, *limit),
}
}
MessageWindow::After { id, limit } => ("SELECT data FROM messages WHERE session_id = ?1 AND seq > ?2 ORDER BY seq ASC LIMIT ?3", seq_of(id)?, *limit),
// The message and about half the limit before it.
MessageWindow::Around { id, limit } => {
let seq = seq_of(id)?;
let before: i64 = conn.query_row(
"SELECT COALESCE(MIN(seq), ?2) FROM (SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3)",
params![session_id, seq, limit / 2],
|r| r.get(0),
)?;
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq ASC LIMIT ?3", before, *limit)
}
};
let mut rows: Vec<(i64, Message)> = Vec::new();
let mut stmt = conn.prepare(&sql.replace("SELECT data", "SELECT seq, data"))?;
for r in stmt.query_map(params![session_id, a, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
let (seq, data) = r?;
rows.push((seq, serde_json::from_str(&data)?));
}
drop(stmt);
Self::finish_window(&conn, session_id, rows, matches!(w, MessageWindow::Tail { .. }))
}
/// Sort a window and tell whether older / newer messages exist around it.
fn finish_window(conn: &Connection, session_id: &str, mut rows: Vec<(i64, Message)>, tail: bool) -> Result<(Vec<Message>, bool, bool)> {
rows.sort_by_key(|(seq, _)| *seq);
let exists = |q: &str, seq: i64| -> Result<bool> { Ok(conn.prepare(q)?.exists(params![session_id, seq])?) };
let older = match rows.first() {
Some((first, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq < ?2", *first)?,
None => tail && exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", i64::MIN)?,
};
let newer = match rows.last() {
Some((last, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", *last)?,
None => false,
};
Ok((rows.into_iter().map(|(_, m)| m).collect(), older, newer))
}
/// Messages of one session containing `q` (case-insensitive), newest first:
/// (message id, role, created_at, snippet).
pub fn search_messages(&self, session_id: &str, q: &str, limit: usize) -> Result<Vec<(String, Role, i64, String)>> {
let needle = q.to_lowercase();
let like = format!("%{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT data FROM messages WHERE session_id = ?1 AND data LIKE ?2 ESCAPE '\\' ORDER BY seq DESC")?;
let mut out = Vec::new();
for r in stmt.query_map(params![session_id, like], |r| r.get::<_, String>(0))? {
let Ok(m) = serde_json::from_str::<Message>(&r?) else { continue };
// Only visible text counts (not JSON keys or ids).
if let Some(snippet) = snippet_in(&m, &needle) {
out.push((m.id.clone(), m.role, m.created_at, snippet));
if out.len() >= limit {
break;
}
}
}
Ok(out)
}
/// Ids of the subagent sessions started by a session.
pub fn child_sessions(&self, id: &str) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id FROM sessions WHERE parent_id = ?1")?;
let rows = stmt.query_map([id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(rows)
}
pub fn delete_session(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
// Child sessions (subagents) go with their parent.
@@ -273,13 +409,32 @@ impl Store {
Ok(())
}
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
/// (title, cwd) of all other chats (subagent sessions of this chat excluded).
pub fn other_session_cwds(&self, id: &str) -> Result<Vec<(String, String)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT COALESCE(json_extract(data, '$.title'), ''), COALESCE(json_extract(data, '$.cwd'), '') FROM sessions WHERE id != ?1 AND COALESCE(parent_id, '') != ?1",
)?;
let rows = stmt.query_map([id], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.collect::<std::result::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();
for b in &blobs {
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![b.hash, b.media_type, b.bytes, now_ms()],
)?;
}
conn.execute(
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
ON CONFLICT(id) DO UPDATE SET data = excluded.data",
params![m.id, session_id, serde_json::to_string(m)?],
params![m.id, session_id, serde_json::to_string(&m)?],
)?;
conn.execute(
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
@@ -300,6 +455,107 @@ impl Store {
Ok(out)
}
/// Messages with the bytes of their images filled in – for model requests and exports.
pub fn messages_for_model(&self, session_id: &str) -> Result<Vec<Message>> {
let mut out = self.messages(session_id)?;
let conn = self.conn.lock().unwrap();
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for m in &mut out {
crate::blobs::for_each_image(m, |img| fill_image(&conn, &mut cache, img));
}
Ok(out)
}
/// Fill in the bytes of one image reference (no-op for inline images).
pub fn resolve_image(&self, img: &ImageData) -> ImageData {
let mut img = img.clone();
let conn = self.conn.lock().unwrap();
fill_image(&conn, &mut std::collections::HashMap::new(), &mut img);
img
}
/// One stored image: (media type, bytes).
pub fn blob(&self, hash: &str) -> Result<Option<(String, Vec<u8>)>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT media_type, data FROM blobs WHERE hash = ?1", [hash], |r| Ok((r.get(0)?, r.get(1)?)))
.optional()?)
}
/// Move inline images of older messages to the blob table (once, after the update
/// that introduced it). Does not touch `updated_at` of the sessions.
pub fn migrate_inline_images(&self) -> Result<usize> {
let ids: Vec<String> = {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id FROM messages WHERE data LIKE '%\"media_type\"%\"data\":\"%'")?;
let rows = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
rows
};
let mut moved = 0;
for id in ids {
let data: Option<String> = {
let conn = self.conn.lock().unwrap();
conn.query_row("SELECT data FROM messages WHERE id = ?1", [&id], |r| r.get(0)).optional()?
};
let Some(data) = data else { continue };
let Ok(mut m) = serde_json::from_str::<Message>(&data) else { continue };
let blobs = crate::blobs::externalize(&mut m);
if blobs.is_empty() {
continue;
}
let conn = self.conn.lock().unwrap();
for b in &blobs {
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![b.hash, b.media_type, b.bytes, now_ms()],
)?;
}
conn.execute("UPDATE messages SET data = ?2 WHERE id = ?1", params![id, serde_json::to_string(&m)?])?;
moved += blobs.len();
}
Ok(moved)
}
/// Delete images no message refers to any more (deleted or truncated sessions).
pub fn gc_blobs(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let mut used = std::collections::HashSet::new();
let mut stmt = conn.prepare("SELECT data FROM messages WHERE data LIKE '%\"blob\":\"%'")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let data: String = row.get(0)?;
for (i, _) in data.match_indices("\"blob\":\"") {
if let Some(h) = data.get(i + 8..i + 72) {
used.insert(h.to_string());
}
}
}
drop(rows);
drop(stmt);
// Copies of generated images (see ImageRecord::blob).
for h in conn.prepare("SELECT blob FROM images WHERE blob IS NOT NULL")?.query_map([], |r| r.get::<_, String>(0))? {
used.insert(h?);
}
let all: Vec<String> = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
let mut removed = 0;
for h in all.iter().filter(|h| !used.contains(*h)) {
removed += conn.execute("DELETE FROM blobs WHERE hash = ?1", [h])?;
}
Ok(removed)
}
/// Give the space of deleted data back to the file system when it is worth it.
pub fn vacuum_if_worthwhile(&self) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let free: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
let page: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
if free * page < 32 * 1024 * 1024 {
return Ok(false);
}
conn.execute_batch("VACUUM")?;
Ok(true)
}
/// Remove all messages after (and including) the given one – used for "edit & resend".
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
@@ -312,6 +568,20 @@ impl Store {
}
}
/// Put the bytes of a stored image into `img.data`.
fn fill_image(conn: &Connection, cache: &mut std::collections::HashMap<String, String>, img: &mut ImageData) {
use base64::Engine;
let Some(hash) = img.blob.clone().filter(|_| img.data.is_empty()) else { return };
if !cache.contains_key(&hash) {
let Ok(bytes) = conn.query_row("SELECT data FROM blobs WHERE hash = ?1", [&hash], |r| r.get::<_, Vec<u8>>(0)) else {
tracing::warn!("image {hash} is missing in the store");
return;
};
cache.insert(hash.clone(), base64::engine::general_purpose::STANDARD.encode(bytes));
}
img.data = cache[&hash].clone();
}
/// One model request, as recorded for statistics.
#[derive(Debug, Clone, Serialize)]
pub struct UsageRecord {
@@ -426,6 +696,39 @@ impl Store {
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
}
/// Plan requests booked at 0 (opencode Go reports nothing charged, only `allowance`):
/// (row id, ts, "provider/model", usage).
pub fn plan_requests_without_cost(&self) -> Result<Vec<(i64, i64, String, crate::types::Usage)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, ts, provider || '/' || model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, allowance
FROM usage WHERE cost = 0 AND allowance > 0",
)?;
let rows = stmt
.query_map([], |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
crate::types::Usage {
input_tokens: r.get::<_, i64>(3)? as u64,
output_tokens: r.get::<_, i64>(4)? as u64,
cache_read_tokens: r.get::<_, i64>(5)? as u64,
cache_write_tokens: r.get::<_, i64>(6)? as u64,
allowance: r.get(7)?,
..Default::default()
},
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn set_usage_cost(&self, row: i64, cost: f64) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE usage SET cost = ?2 WHERE id = ?1", params![row, cost])?;
Ok(())
}
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
/// into the viewer's local time.
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
@@ -459,6 +762,9 @@ impl Store {
let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
let by_day = grouped(day_sql, "day")?;
// Long ranges are shown per month.
let by_month = grouped("strftime('%Y-%m', (ts + ?3) / 1000, 'unixepoch')", "month")?;
let first_ts: Option<i64> = conn.query_row("SELECT MIN(ts) FROM usage WHERE (?1 IS NULL OR user_id = ?1)", params![user], |r| r.get(0))?;
let by_model = grouped("provider || '/' || model", "model")?;
let by_provider = grouped("provider", "provider")?;
let by_agent = grouped("agent", "agent")?;
@@ -510,6 +816,8 @@ impl Store {
Ok(json!({
"total": total,
"by_day": by_day,
"by_month": by_month,
"first_ts": first_ts,
"by_day_model": by_day_model,
"by_model": by_model,
"by_provider": by_provider,
@@ -838,6 +1146,10 @@ pub struct ImageRecord {
pub width: i64,
pub height: i64,
pub created_at: i64,
/// Copy of the bytes in the image store: the file may be moved or deleted later
/// (agents tidy up folders), the image stays viewable.
#[serde(skip)]
pub blob: Option<String>,
}
fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
@@ -851,22 +1163,74 @@ fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
width: r.get(6)?,
height: r.get(7)?,
created_at: r.get(8)?,
blob: r.get(9)?,
})
}
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at";
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at, blob";
impl Store {
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"),
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at],
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"),
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at, img.blob],
)?;
Ok(())
}
/// Store bytes in the image store; returns their hash.
pub fn put_blob(&self, media_type: &str, bytes: &[u8]) -> Result<String> {
let hash = crate::blobs::hash(bytes);
self.conn.lock().unwrap().execute(
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![hash, media_type, bytes, now_ms()],
)?;
Ok(hash)
}
/// Generated images from before the copies in the image store (id, path).
pub fn images_without_copy(&self) -> Result<Vec<(String, String)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, path FROM images WHERE blob IS NULL")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn set_image_blob(&self, id: &str, hash: &str) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE images SET blob = ?2 WHERE id = ?1", params![id, hash])?;
Ok(())
}
/// A file or folder was moved: generated images in it follow.
pub fn move_image_paths(&self, from: &str, to: &str) -> Result<usize> {
let from = from.trim_end_matches('/');
let to = to.trim_end_matches('/');
Ok(self.conn.lock().unwrap().execute(
"UPDATE images SET path = ?2 || substr(path, length(?1) + 1)
WHERE path = ?1 OR substr(path, 1, length(?1) + 1) = ?1 || '/'",
params![from, to],
)?)
}
/// The file of a generated image was found somewhere else.
pub fn set_image_path(&self, id: &str, path: &str) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE images SET path = ?2 WHERE id = ?1", params![id, path])?;
Ok(())
}
/// Latest images; `user = None` lists everybody's (admins).
/// Generated images of these sessions.
pub fn images_of_sessions(&self, sessions: &[String]) -> Result<Vec<ImageRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!("SELECT {IMAGE_COLS} FROM images WHERE session_id = ?1"))?;
let mut out = Vec::new();
for s in sessions {
out.extend(stmt.query_map([s], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?);
}
Ok(out)
}
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!(
@@ -1008,13 +1372,14 @@ impl Store {
last_status: r.get(6)?,
created_at: r.get(7)?,
webhook: r.get::<_, Option<String>>(8)?.is_some(),
settings: r.get::<_, Option<String>>(9)?.and_then(|j| serde_json::from_str(&j).ok()),
})
}
pub fn jobs(&self, owner: Option<&str>) -> Result<Vec<crate::jobs::Job>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
)?;
let rows = stmt.query_map([owner], Self::job_row)?;
@@ -1024,7 +1389,7 @@ impl Store {
pub fn job(&self, id: &str) -> Result<Option<crate::jobs::Job>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE id = ?1", [id], Self::job_row)
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE id = ?1", [id], Self::job_row)
.optional()?)
}
@@ -1040,7 +1405,7 @@ impl Store {
}
let conn = self.conn.lock().unwrap();
Ok(conn.query_row(
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE path = ?1",
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE path = ?1",
[path],
Self::job_row,
)?)
@@ -1073,6 +1438,11 @@ impl Store {
}
/// Set (Some) or remove (None) the webhook secret of a job.
pub fn job_set_settings(&self, id: &str, settings: &crate::jobs::JobSettings) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE jobs SET settings = ?2 WHERE id = ?1", rusqlite::params![id, serde_json::to_string(settings)?])?;
Ok(())
}
pub fn job_set_webhook(&self, id: &str, secret: Option<&str>) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE jobs SET webhook_secret = ?2 WHERE id = ?1", rusqlite::params![id, secret])?;
Ok(())
@@ -1159,6 +1529,167 @@ impl Store {
}
}
// --- project overview ------------------------------------------------------------------------
impl Store {
/// Per chat: files the agent changed, the latest check result and the cost.
pub fn project_overview(&self, session_ids: &[String]) -> Result<Value> {
let conn = self.conn.lock().unwrap();
let mut out = serde_json::Map::new();
for id in session_ids {
let files: i64 = conn.query_row("SELECT COUNT(DISTINCT path) FROM checkpoints WHERE session_id = ?1", params![id], |r| r.get(0))?;
let check: Option<String> = conn
.query_row("SELECT status FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 1", params![id], |r| r.get(0))
.optional()?;
let failed: i64 = conn.query_row("SELECT COUNT(*) FROM turn_checks WHERE session_id = ?1 AND status IN ('failed','timeout')", params![id], |r| r.get(0))?;
let cost: Option<f64> = conn.query_row("SELECT SUM(cost) FROM usage WHERE root_session_id = ?1", params![id], |r| r.get(0))?;
out.insert(id.clone(), json!({"files": files, "check": check, "checks_failed": failed, "cost": cost.unwrap_or(0.0)}));
}
Ok(Value::Object(out))
}
}
// --- weekly overview ----------------------------------------------------------------------
impl Store {
/// (chats with activity, failed checks, checks run) of a user's own top-level chats since `since` (ms).
pub fn weekly_counts(&self, user_id: &str, since: i64) -> Result<(i64, i64, i64)> {
let conn = self.conn.lock().unwrap();
let chats: i64 = conn.query_row("SELECT COUNT(*) FROM sessions WHERE owner_id = ?1 AND parent_id IS NULL AND updated_at >= ?2", params![user_id, since], |r| r.get(0))?;
let (failed, total): (i64, i64) = conn.query_row(
"SELECT COALESCE(SUM(c.status IN ('failed','timeout')),0), COUNT(*) FROM turn_checks c JOIN sessions s ON s.id = c.session_id WHERE s.owner_id = ?1 AND c.ts >= ?2",
params![user_id, since],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok((chats, failed, total))
}
}
// --- read-only links ----------------------------------------------------------------------
impl Store {
pub fn add_share_link(&self, token: &str, session_id: &str, owner_id: &str, created_at: i64, expires_at: i64) -> Result<()> {
self.conn.lock().unwrap().execute(
"INSERT INTO share_links (token, session_id, owner_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![token, session_id, owner_id, created_at, expires_at],
)?;
Ok(())
}
/// (token, created_at, expires_at) of the links of a chat, newest first.
pub fn share_links(&self, session_id: &str) -> Result<Vec<(String, i64, i64)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT token, created_at, expires_at FROM share_links WHERE session_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map([session_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// The chat a link points to while it is valid.
pub fn share_link_session(&self, token: &str, now: i64) -> Result<Option<String>> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT session_id FROM share_links WHERE token = ?1 AND expires_at > ?2", params![token, now], |r| r.get(0)).optional()?)
}
pub fn delete_share_link(&self, session_id: &str, token: &str) -> Result<()> {
self.conn.lock().unwrap().execute("DELETE FROM share_links WHERE session_id = ?1 AND token = ?2", params![session_id, token])?;
Ok(())
}
/// Links are dropped with their chat and when expired.
pub fn purge_share_links(&self, now: i64) -> Result<()> {
self.conn.lock().unwrap().execute("DELETE FROM share_links WHERE expires_at <= ?1 OR session_id NOT IN (SELECT id FROM sessions)", params![now])?;
Ok(())
}
}
// --- defaults of new chats in a project -------------------------------------------------------
impl Store {
/// {model, agent, permission_mode, color, instructions, folder} new chats of the folder start with.
pub fn project_defaults(&self, cwd: &str) -> Result<Option<Value>> {
let conn = self.conn.lock().unwrap();
let v: Option<String> = conn.query_row("SELECT data FROM project_defaults WHERE cwd = ?1", params![cwd], |r| r.get(0)).optional()?;
Ok(v.and_then(|v| serde_json::from_str(&v).ok()))
}
pub fn set_project_defaults(&self, cwd: &str, data: Option<&Value>) -> Result<()> {
let conn = self.conn.lock().unwrap();
match data {
Some(d) => conn.execute("INSERT INTO project_defaults (cwd, data) VALUES (?1, ?2) ON CONFLICT(cwd) DO UPDATE SET data = ?2", params![cwd, d.to_string()])?,
None => conn.execute("DELETE FROM project_defaults WHERE cwd = ?1", params![cwd])?,
};
Ok(())
}
}
// --- checks after a turn ------------------------------------------------------------------
impl Store {
/// (command, enabled) of a project folder.
pub fn project_check(&self, cwd: &str) -> Result<Option<(String, bool)>> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT command, enabled FROM project_checks WHERE cwd = ?1", params![cwd], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? != 0))).optional()?)
}
pub fn set_project_check(&self, cwd: &str, command: &str, enabled: bool) -> Result<()> {
let conn = self.conn.lock().unwrap();
if command.trim().is_empty() {
conn.execute("DELETE FROM project_checks WHERE cwd = ?1", params![cwd])?;
} else {
conn.execute(
"INSERT INTO project_checks (cwd, command, enabled) VALUES (?1, ?2, ?3) ON CONFLICT(cwd) DO UPDATE SET command = ?2, enabled = ?3",
params![cwd, command.trim(), enabled as i64],
)?;
}
Ok(())
}
pub fn set_turn_check(&self, session_id: &str, message_id: &str, status: &str, exit_code: Option<i32>, duration_ms: u64, output: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO turn_checks (session_id, message_id, status, exit_code, duration_ms, output, ts) VALUES (?1,?2,?3,?4,?5,?6,?7)
ON CONFLICT(session_id, message_id) DO UPDATE SET status = ?3, exit_code = ?4, duration_ms = ?5, output = ?6, ts = ?7",
params![session_id, message_id, status, exit_code, duration_ms as i64, output, now_ms()],
)?;
Ok(())
}
pub fn turn_checks(&self, session_id: &str) -> Result<Vec<Value>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT message_id, status, exit_code, duration_ms, output FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 300")?;
let rows = stmt
.query_map(params![session_id], |r| {
Ok(json!({"message_id": r.get::<_, String>(0)?, "status": r.get::<_, String>(1)?, "exit_code": r.get::<_, Option<i32>>(2)?, "duration_ms": r.get::<_, i64>(3)?, "output": r.get::<_, String>(4)?}))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
}
// --- project-wide approvals ---------------------------------------------------------
impl Store {
/// Approvals ("bash:<prefix>" or a tool name) remembered for a project folder.
pub fn project_approvals(&self, cwd: &str) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT key FROM project_approvals WHERE cwd = ?1 ORDER BY key")?;
let rows = stmt.query_map(params![cwd], |r| r.get::<_, String>(0))?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn add_project_approval(&self, cwd: &str, key: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("INSERT OR IGNORE INTO project_approvals (cwd, key, created_at) VALUES (?1, ?2, ?3)", params![cwd, key, now_ms()])?;
Ok(())
}
pub fn delete_project_approval(&self, cwd: &str, key: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM project_approvals WHERE cwd = ?1 AND key = ?2", params![cwd, key])?;
Ok(())
}
}
// --- file checkpoints ------------------------------------------------------------
impl Store {
@@ -1174,13 +1705,18 @@ impl Store {
/// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore.
pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
self.checkpoints_between(session_id, since, i64::MAX)
}
/// Per file the state before its first change in the time range [since, until).
pub fn checkpoints_between(&self, session_id: &str, since: i64, until: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND path = c.path)",
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3 AND path = c.path)",
)?;
let rows = stmt
.query_map(params![session_id, since], |r| {
.query_map(params![session_id, since, until], |r| {
let existed: i64 = r.get(1)?;
Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None }))
})?
@@ -1208,3 +1744,78 @@ pub struct PushSub {
pub p256dh: String,
pub auth: String,
}
/// Which messages of a session the chat view loads (see Store::messages_window).
pub enum MessageWindow {
Tail { limit: i64 },
Before { id: String, limit: i64, stop_at_summary: bool },
After { id: String, limit: i64 },
Around { id: String, limit: i64 },
}
/// Text around the first occurrence of `needle` (lowercase) in the visible text of a
/// message: its text, reasoning, summaries, tool calls and tool output.
fn snippet_in(m: &Message, needle: &str) -> Option<String> {
for p in &m.parts {
let owned;
let text = match p {
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
Part::ToolResult { content, .. } => content.as_str(),
Part::ToolCall { input, .. } => {
owned = input.to_string();
owned.as_str()
}
_ => continue,
};
let lower = text.to_lowercase();
// Lowercasing can change byte lengths (rare): then cut the lowercase text.
let (src, i) = match lower.find(needle) {
Some(i) if lower.len() == text.len() => (text, i),
Some(i) => (lower.as_str(), i),
None => continue,
};
let mut a = i.saturating_sub(60);
while !src.is_char_boundary(a) {
a -= 1;
}
let mut b = (i + needle.len() + 100).min(src.len());
while !src.is_char_boundary(b) {
b += 1;
}
return Some(format!("{}{}{}", if a > 0 { "…" } else { "" }, src[a..b].replace('\n', " "), if b < src.len() { "…" } else { "" }));
}
None
}
#[cfg(test)]
mod link_tests {
use super::*;
#[test]
fn share_links_expire_and_can_be_revoked() {
let dir = std::env::temp_dir().join(format!("cc-links-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let store = Store::open(&dir.join("t.db")).unwrap();
store.add_share_link("tok1", "s1", "u", 1_000, 5_000).unwrap();
store.add_share_link("tok2", "s1", "u", 2_000, 9_000).unwrap();
// Valid until the expiry time, then gone.
assert_eq!(store.share_link_session("tok1", 4_999).unwrap().as_deref(), Some("s1"));
assert_eq!(store.share_link_session("tok1", 5_000).unwrap(), None);
assert_eq!(store.share_link_session("nope", 1).unwrap(), None);
// Revoking removes only the named link of that chat.
store.delete_share_link("other", "tok2").unwrap();
assert!(store.share_link_session("tok2", 3_000).unwrap().is_some());
store.delete_share_link("s1", "tok2").unwrap();
assert_eq!(store.share_link_session("tok2", 3_000).unwrap(), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn weekly_counts_of_an_empty_user_are_zero() {
let dir = std::env::temp_dir().join(format!("cc-weekly-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let store = Store::open(&dir.join("t.db")).unwrap();
assert_eq!(store.weekly_counts("nobody", 0).unwrap(), (0, 0, 0));
std::fs::remove_dir_all(&dir).unwrap();
}
}
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -157,7 +157,13 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
}
None => {
let c = pty_process::Command::new(shell[0]).args(&shell[1..]).current_dir(cwd).env("TERM", "xterm-256color").env("CODECRAFT", "1");
let c = pty_process::Command::new(shell[0])
.args(&shell[1..])
.current_dir(cwd)
.env("TERM", "xterm-256color")
.env("CODECRAFT", "1")
.envs(crate::gitcreds::identity_env(app, &user.id))
.envs(crate::sshkeys::env(app, user));
match crate::gitcreds::global_config(app, user) {
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
None => c,
+52 -3
View File
@@ -135,7 +135,7 @@ impl Tool for Bash {
/// The bash command for a script, sandboxed for non-admin users.
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
// stderr of every command (not just the last one) goes into the output.
let script = format!("exec 2>&1\n{command}");
let script = format!("exec 2>&1\n{}{command}", commit_attribution(ctx));
Ok(match &ctx.sandbox {
Some(spec) => {
if !crate::sandbox::available() {
@@ -150,14 +150,48 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
// The session owner's git credentials and identity.
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
if let Some(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) {
c.env("GIT_CONFIG_GLOBAL", cfg);
if let Some(u) = owner {
if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
c.envs(crate::sshkeys::env(&ctx.app, &u));
}
c
}
})
}
/// Directory with the git wrapper that adds the co-author trailer to commits.
fn git_wrapper_dir() -> String {
std::env::var("CODECRAFT_GIT_WRAPPER").unwrap_or_else(|_| "/usr/local/lib/codecraft/bin".into())
}
/// Shell lines that make `git commit` in this command carry the co-author trailer of
/// the session's model (see docker/git-coauthor). Set in the script itself, so it
/// works inside the sandbox (which clears the environment) too.
fn commit_attribution(ctx: &ToolCtx) -> String {
let template = ctx.app.config().server.commit_trailer.clone();
let dir = git_wrapper_dir();
if template.trim().is_empty() || !std::path::Path::new(&dir).join("git").exists() {
return String::new();
}
let Ok(session) = ctx.app.session(&ctx.session_id) else { return String::new() };
let (provider, model_id) = session.model.split_once('/').unwrap_or(("", session.model.as_str()));
let name = ctx.app.registry().resolve(&session.model).ok().and_then(|(_, m)| m.name).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| model_id.to_string());
let trailer = commit_trailer(&template, &name, provider, model_id);
format!("export PATH={}:\"$PATH\" CODECRAFT_COMMIT_TRAILER={}\n", shell_quote(&dir), shell_quote(&trailer))
}
/// The trailer text for a model ("Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft").
pub fn commit_trailer(template: &str, model: &str, provider: &str, model_id: &str) -> String {
template.replace("{model}", model).replace("{provider}", provider).replace("{model_id}", model_id).replace(['\n', '\r'], " ").trim().to_string()
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
@@ -168,3 +202,18 @@ fn tail(s: &str, max: usize) -> String {
}
s[start..].to_string()
}
#[cfg(test)]
mod commit_tests {
use super::*;
#[test]
fn commit_trailer_text() {
assert_eq!(
commit_trailer(crate::config::DEFAULT_COMMIT_TRAILER, "DeepSeek V4.1 Flash", "opencode-go", "deepseek-v4.1-flash"),
"Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft"
);
assert_eq!(commit_trailer("By {model_id}\nx", "M", "p", "m-1"), "By m-1 x");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
}
}
+1 -1
View File
@@ -186,7 +186,7 @@ impl Tool for OpenScad {
continue;
}
if let Ok(bytes) = std::fs::read(&out) {
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
files.push(out);
}
}
+2
View File
@@ -27,6 +27,8 @@ pub(crate) fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
Ok(ImageData {
blob: None,
size: None,
media_type: "image/png".into(),
data: base64::engine::general_purpose::STANDARD.encode(&buf),
})
+2
View File
@@ -107,6 +107,8 @@ impl Tool for Read {
return Ok(ToolOutput {
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
images: vec![ImageData {
blob: None,
size: None,
media_type: mt.into(),
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
}],
+1
View File
@@ -177,6 +177,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
Arc::new(craft::ImageCompare),
Arc::new(crate::prompts::SkillTool),
Arc::new(crate::images::GenerateImage),
Arc::new(crate::mesh3d::Generate3d),
Arc::new(crate::reverse::BinaryInfo),
Arc::new(crate::reverse::Decompile),
]
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<!-- interactive-widget: the on-screen keyboard shrinks the page (Android) instead of
shifting it up – the top bar stays in place. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#282828" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+34 -1
View File
@@ -7,6 +7,12 @@ self.addEventListener("push", (event) => {
data = { body: event.data ? event.data.text() : "" };
}
const title = data.title || "CodeCraft";
const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de");
// Approvals can be answered right from the notification.
const actions = data.kind === "permission" && data.permission_id ? [
{ action: "allow", title: de ? "Erlauben" : "Allow" },
{ action: "reject", title: de ? "Ablehnen" : "Reject" },
] : [];
const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || "";
event.waitUntil(
self.registration.showNotification(prefix + title, {
@@ -15,13 +21,40 @@ self.addEventListener("push", (event) => {
renotify: true,
icon: "/icon-192.png",
badge: "/icon-192.png",
data: { url: data.url || "/" },
actions,
requireInteraction: data.kind === "permission",
data: { url: data.url || "/", permission_id: data.permission_id || null },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const info = event.notification.data || {};
if ((event.action === "allow" || event.action === "reject") && info.permission_id) {
const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de");
const target = new URL(info.url || "/", self.location.origin).href;
event.waitUntil(
fetch("/api/permissions/" + encodeURIComponent(info.permission_id), {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reply: event.action === "allow" ? "once" : "reject" }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
// Short confirmation, so the tap does not feel lost.
return self.registration.showNotification(event.action === "allow" ? (de ? "Erlaubt" : "Allowed") : (de ? "Abgelehnt" : "Rejected"), {
tag: "perm-" + info.permission_id,
icon: "/icon-192.png",
silent: true,
data: { url: info.url },
}).then(() => new Promise((res) => setTimeout(res, 2500))).then(() => self.registration.getNotifications({ tag: "perm-" + info.permission_id })).then((l) => l.forEach((n) => n.close()));
})
.catch(() => self.clients.openWindow(target)), // expired, answered elsewhere or signed out: open the app
);
return;
}
const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => {
+25 -3
View File
@@ -2,8 +2,10 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { connectEvents, get, setUnauthorizedHandler } from "./api";
import { useT } from "./i18n";
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
import { applyEvent, loadSessions, resync, setState } from "./store";
import { applyEvent, loadSessions, resync, setState, useStore } from "./store";
import type { Info } from "./types";
import { ImageViewer } from "./components/ImageViewer";
import { ProjectPage } from "./components/ProjectPage";
import { Sidebar } from "./components/Sidebar";
import { Login } from "./components/Login";
import { ChatView } from "./components/ChatView";
@@ -12,6 +14,7 @@ import { Settings } from "./components/Settings";
import { Stats } from "./components/Stats";
import { Images } from "./components/Images";
import { Jobs } from "./components/Jobs";
import { JobEditor } from "./components/JobEditor";
import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs";
import { startPrefsSync } from "./prefsSync";
@@ -19,6 +22,8 @@ import { WhatsNew } from "./components/WhatsNew";
import { ShareTarget } from "./components/ShareTarget";
import { Palette } from "./components/Palette";
import { pendingShare } from "./share";
import { cacheInfo, cachedInfo, isOffline } from "./offline";
import { PublicChat } from "./components/PublicChat";
// The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -31,11 +36,21 @@ export function App() {
const [error, setError] = useState<string>();
const [menuOpen, setMenuOpen] = useState(false);
const [shared, setShared] = useState(pendingShare);
const offline = useStore((s) => s.offline);
useTitleActivity();
const refresh = async () => {
try {
const i = await get<Info>("/info");
let i: Info;
try {
i = await get<Info>("/info");
cacheInfo(i);
} catch (e) {
// No connection: open with the saved copy (chats are read-only).
const saved = isOffline(e) ? cachedInfo() : undefined;
if (!saved) throw e;
i = saved;
}
setState(() => ({ me: i.user, update: i.update ?? null }));
if (i.user) {
await loadSessions();
@@ -68,6 +83,7 @@ export function App() {
// Close the mobile menu on navigation.
useEffect(() => setMenuOpen(false), [route.join("/")]);
if (route[0] === "shared" && route[1]) return <PublicChat token={route[1]} />;
if (error) return <div className="empty">{error}</div>;
if (!info) return <div className="empty">{t("common.loading")}</div>;
if (!info.user)
@@ -93,8 +109,10 @@ export function App() {
</div>
);
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "p" && arg) content = <ProjectPage key={arg} cwd={decodeURIComponent(arg)} onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "jobs") content = <Jobs onMenu={() => setMenuOpen(true)} />;
else if (section === "terminal")
content = (
@@ -114,9 +132,13 @@ export function App() {
<div className="app">
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
<main className="main">{content}</main>
<main className="main">
{offline && <div className="offline-bar" role="status">{t("offline.banner")}</div>}
{content}
</main>
<WhatsNew version={info.version} />
<Palette />
<ImageViewer />
{shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />}
<DialogHost />
</div>
+703 -99
View File
@@ -1,18 +1,19 @@
import { takePrefill } from "../share";
import { Icon } from "./Icon";
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api";
import { useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { FileChip, FileFigure, fileType, imageSize, imageSrc, formatSize } from "./FileFigure";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { del, get, patch, post, put } from "../api";
import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtMsgTime, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { dismissError, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
import { dismissError, getState, loadAround, loadChecks, loadOlder, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode } from "../prefs";
import { useReasoningMode, useTodosCollapsed } from "../prefs";
import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog";
import { RenameDialog } from "./RenameDialog";
import { InstructionsDialog } from "./InstructionsDialog";
import { RegenerateButton } from "./Regenerate";
import { WorktreeChip } from "./WorktreeChip";
import { QueueList } from "./QueueList";
@@ -23,10 +24,19 @@ import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs";
import { choiceDialog, confirmDialog, promptDialog, toast } from "../dialogs";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
import { ColorSwatches, IconPicker } from "./ColorSwatches";
import { ProjectPermissions } from "./ProjectPermissions";
import { OutboxBar } from "./OutboxBar";
import { addToOutbox, isOffline } from "../outbox";
import { TurnCheck } from "./TurnCheck";
import { TurnChanges } from "./TurnChanges";
import { ChatCtx, MessageMenu } from "./MessageMenu";
import { MediaGallery } from "./MediaGallery";
type ToolResult = Extract<Part, { type: "tool_result" }>;
type ToolCall = Extract<Part, { type: "tool_call" }>;
const EMPTY: Message[] = [];
@@ -114,7 +124,36 @@ function BackgroundResult({ text }: { text: string }) {
);
}
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)">\n?([\s\S]*?)\n?<\/refine-feedback>/;
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)"(?: todos="\d+")?>\n?([\s\S]*?)\n?<\/refine-feedback>/;
/** Enforced reviews: a completed todo passed, or a reminder about open todos. */
const NOTE_RE = /^<(refine-approved|todo-reminder)(?: reviewer="([^"]*)")?(?: open="\d+")?>\n?([\s\S]*?)\n?<\/(?:refine-approved|todo-reminder)>/;
/** Messages from the harness shown as cards instead of user bubbles. */
const isHarnessNote = (text: string) => BG_RE.test(text) || REFINE_RE.test(text) || NOTE_RE.test(text);
/** A todo passed its review, or the agent was sent back to its open todos. */
function HarnessNote({ text }: { text: string }) {
const t = useT();
const m = text.match(NOTE_RE);
if (!m) return null;
const [, kind, reviewer, body] = m;
const items = body.split("\n").map((l) => l.replace(/^- /, "").trim()).filter(Boolean);
const approved = kind === "refine-approved";
return (
<div className={`harness-note ${approved ? "ok" : ""}`}>
<Icon name={approved ? "check" : "refresh"} />
<span className="grow">
<b>{t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })}</b> {items.join(" · ")}
</span>
{reviewer && <span className="chip">{reviewer}</span>}
</div>
);
}
function HarnessMessage({ text }: { text: string }) {
if (REFINE_RE.test(text)) return <RefineFeedback text={text} />;
if (NOTE_RE.test(text)) return <HarnessNote text={text} />;
return <BackgroundResult text={text} />;
}
/** Findings of the review loop, sent back to the agent. */
function RefineFeedback({ text }: { text: string }) {
@@ -136,10 +175,380 @@ function RefineFeedback({ text }: { text: string }) {
);
}
/** Search the whole chat on the server; a hit loads its part of the chat. */
function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) {
const t = useT();
const [q, setQ] = useState("");
const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>();
useEffect(() => {
const query = q.trim();
if (query.length < 2) return setHits(undefined);
let alive = true;
const h = setTimeout(() => {
get<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
.then((r) => alive && setHits(r ?? []))
.catch(() => alive && setHits([]));
}, 250);
return () => {
alive = false;
clearTimeout(h);
};
}, [q, sessionId]);
return (
<div className="chat-search" role="search">
<div className="row">
<Icon name="search" />
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
<Icon name="close" />
</button>
</div>
{hits && (
<div className="chat-search-hits">
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
{hits.map((h) => (
<button key={h.id} onClick={() => onPick(h.id)}>
<span className="who">
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
</span>
<span className="snippet">{h.snippet}</span>
</button>
))}
</div>
)}
</div>
);
}
/** A part of the chat: a user message, a summary, or the work of the agent after it. */
type Block = { kind: "user" | "summary" | "work"; messages: Message[] };
const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image");
/** Group the messages into user messages and the agent's work between them. */
function blocksOf(messages: Message[]): Block[] {
const out: Block[] = [];
for (const m of messages) {
const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work";
const last = out[out.length - 1];
if (kind === "work" && last?.kind === "work") last.messages.push(m);
else out.push({ kind, messages: [m] });
}
return out;
}
/**
* The messages of a chat. Memoised: typing, streaming and status changes of the
* chat view do not touch it – only new messages, tool results or the busy state.
*/
const MessageList = memo(function MessageList({
messages,
results,
busy,
canEdit,
canRegenerate,
sessionId,
model,
baseDir,
onRewind,
highlight,
currency,
}: {
messages: Message[];
currency: string;
results: Map<string, ToolResult>;
busy: boolean;
canEdit: boolean;
canRegenerate: boolean;
sessionId: string;
model: string;
baseDir: string;
onRewind: (m: Message) => void;
highlight?: string;
}) {
const blocks = useMemo(() => blocksOf(messages), [messages]);
const lastWork = blocks.map((b) => b.kind).lastIndexOf("work");
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id;
return (
<>
{blocks.map((b, i) => {
// Stable while older messages are loaded above (they may join the first block):
// the last message; the block still growing at the end keeps its first one.
const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id;
if (b.kind !== "work") return <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
const items = b.messages.map((m) => (
<AssistantOrNote
key={m.id}
m={m}
results={results}
busy={busy}
baseDir={baseDir}
highlight={highlight}
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
/>
));
// Finished turns with tool steps fold into one line (the answer stays visible).
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
// Files the agent wrote or edited in this turn: diffs and "roll back" (not while it is still working).
const changed = new Set(steps.filter((c) => (c.name === "write" || c.name === "edit") && typeof c.input?.path === "string").map((c) => c.input.path as string));
const lastAnswer = [...b.messages].reverse().find((m) => m.role === "assistant");
const checkBadge = lastAnswer && !(busy && i === lastWork) && <TurnCheck sessionId={sessionId} messageId={lastAnswer.id} canSend={canEdit} />;
const prev = blocks[i - 1];
const changes = changed.size > 0 && !(busy && i === lastWork) && (
<TurnChanges
sessionId={sessionId}
fromId={b.messages[0].id}
untilId={blocks[i + 1]?.messages[0].id}
fileCount={changed.size}
userMessage={canEdit && prev?.kind === "user" ? prev.messages[0] : undefined}
onRewind={onRewind}
/>
);
if (i === lastWork || steps.length < 2)
return (
<div key={key}>
{items}
{usage}
{checkBadge}
{changes}
</div>
);
return (
<div key={key}>
<WorkBlock messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>
{items}
</WorkBlock>
{usage}
{checkBadge}
{changes}
</div>
);
})}
</>
);
});
/** Tokens (in/out) and cost of one answer: all requests of the turn together. */
function TurnUsage({ messages, currency }: { messages: Message[]; currency: string }) {
const t = useT();
let inp = 0;
let out = 0;
let cost = 0;
let requests = 0;
for (const m of messages) {
const u = m.role === "assistant" ? m.usage : undefined;
if (!u) continue;
requests++;
inp += u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
out += u.output_tokens;
cost += u.cost ?? 0;
}
if (!requests) return null;
return (
<div className="turn-usage" title={t("chat.turnUsageHint", { n: requests })}>
{fmtCompact(inp)}
<Icon name="arrow-up" className="tiny" /> {fmtCompact(out)}
<Icon name="arrow-down" className="tiny" /> · {fmtCost(cost, currency)}
</div>
);
}
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
const t = useT();
const [open, setOpen] = useState(false);
const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string));
// The answer: text of the last assistant message of the turn.
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim()));
const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : "";
if (open || forced) {
return (
<div className="work-block open">
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
</button>
{children}
</div>
);
}
return (
<div className={`msg work-block ${answerText ? "bubble agent" : ""}`} data-mid={answer?.id}>
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
</button>
{answerText && (
<>
<Markdown text={answerText} baseDir={baseDir} />
<ImageRefs text={answerText} baseDir={baseDir} />
{answer && <MsgFoot ms={answer.created_at} menu={<MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />} />}
</>
)}
</div>
);
}
/** Foot of a bubble: time on the left, the ⋯ menu inside the bubble on the right. */
function MsgFoot({ ms, menu }: { ms: number; menu: ReactNode }) {
return (
<div className="msg-foot">
<MsgTime ms={ms} />
{menu}
</div>
);
}
/** Text parts of a message. */
const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n");
/** Small date/time line under a message. */
function MsgTime({ ms }: { ms: number }) {
if (!ms) return null;
return (
<time className="msg-time" dateTime={new Date(ms).toISOString()} title={new Date(ms).toLocaleString()}>
{fmtMsgTime(ms)}
</time>
);
}
/** An assistant message, or a user message that only carries tool results / harness notes. */
const AssistantOrNote = memo(function AssistantOrNote({
m,
results,
busy,
baseDir,
highlight,
regenerate,
}: {
m: Message;
results: Map<string, ToolResult>;
busy: boolean;
baseDir: string;
highlight?: string;
regenerate?: { sessionId: string; model: string };
}) {
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
if (m.role === "assistant") {
return (
<div className={`${cls} ${textOfMessage(m).trim() ? "bubble agent" : ""}`} data-mid={m.id}>
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />} />
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
</div>
);
}
const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text));
if (!notes.length) return null;
return (
<div className={cls} data-mid={m.id}>
{notes.map((p, i) => (
<HarnessMessage key={i} text={p.text} />
))}
</div>
);
});
/** A user message or a summary of the history before it. */
const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight }: { m: Message; canEdit: boolean; onRewind: (m: Message) => void; highlight?: string }) {
const t = useT();
const [summaryOpen, setSummaryOpen] = useState(false);
const summary = m.parts.find((p) => p.type === "summary");
if (summary && summary.type === "summary") {
return (
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
<summary>
{summary.text.startsWith("[Context cleared")
? t("chat.cleared")
: summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")}
</summary>
{summaryOpen && !summary.text.startsWith("[Context cleared") && <Markdown text={summary.text} />}
</details>
);
}
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const background = allTexts.filter((p) => isHarnessNote(p.text));
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
return (
<div className={`msg user-msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
{background.map((p, i) => (
<HarnessMessage key={i} text={p.text} />
))}
<div className="msg-user">
<UserText text={texts.map((p) => p.text).join("\n\n")} />
{imgs.length > 0 && (
<div className="user-files">
{imgs.map((p, i) => (
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
</FileFigure>
))}
</div>
)}
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />} />
</div>
</div>
);
});
/**
* Older messages: loaded automatically when scrolled to the top; the history before a
* summary only on request. Keeps the scroll position while the list grows upwards.
*/
function OlderLoader({ sessionId, afterSummary, scrollRef }: { sessionId: string; afterSummary?: boolean; scrollRef: RefObject<HTMLDivElement | null> }) {
const t = useT();
const [loading, setLoading] = useState(false);
const sentinel = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
const el = scrollRef.current;
if (loading || !el) return;
setLoading(true);
const before = { height: el.scrollHeight, top: el.scrollTop };
try {
// Scrolling stops at a summary; its button loads the history before it.
await loadOlder(sessionId, !afterSummary);
requestAnimationFrame(() => {
el.scrollTop = before.top + (el.scrollHeight - before.height);
});
} finally {
setLoading(false);
}
}, [loading, sessionId, scrollRef, afterSummary]);
useEffect(() => {
const node = sentinel.current;
if (!node || afterSummary || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver((e) => e[0]?.isIntersecting && void load(), { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" });
io.observe(node);
return () => io.disconnect();
}, [afterSummary, load, scrollRef]);
return (
<div ref={sentinel} className="older-loader">
{loading ? (
<Spinner small />
) : (
<button className="small ghost" onClick={() => void load()}>
<Icon name="arrow-up" /> {t(afterSummary ? "chat.showBeforeSummary" : "chat.loadOlder")}
</button>
)}
</div>
);
}
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
const t = useT();
const session = useStore((s) => s.sessionById[sessionId]);
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
const win = useStore((s) => s.windows[sessionId]);
// Message to show (search hit, media): scrolled into view and marked.
const [highlight, setHighlight] = useState<string>();
const [searching, setSearching] = useState(false);
const [gallery, setGallery] = useState(false);
const draft = useStore((s) => s.drafts[sessionId]);
const busy = useStore((s) => !!s.busy[sessionId]);
const errors = useStore((s) => s.errors[sessionId]);
@@ -151,6 +560,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const isAdmin = useStore((s) => s.me?.role === "admin");
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
const [sharing, setSharing] = useState(false);
// The Telegram switch in the ⋯ menu only matters with a paired bot.
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
const [editingInstructions, setEditingInstructions] = useState(false);
const [showApprovals, setShowApprovals] = useState(false);
const [planning, setPlanning] = useState(false);
const models = useModels();
const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
@@ -172,11 +586,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
};
// Scrolled away from the top: the jump buttons show up.
const [scrolled, setScrolled] = useState(false);
/** Scroll to the closest own message above the visible area (long chats on a phone). */
const jumpPrevOwn = () => {
const el = scrollRef.current;
if (!el) return;
const top = el.getBoundingClientRect().top;
const above = [...el.querySelectorAll<HTMLElement>(".msg-user")].filter((n) => n.getBoundingClientRect().top < top - 12);
const target = above[above.length - 1];
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
else el.scrollTo({ top: 0, behavior: "smooth" });
};
// Every chat starts following.
useEffect(() => follow(true), [sessionId]);
useEffect(() => {
loadSession(sessionId).catch((e) => setLoadError(e.message));
void loadChecks(sessionId);
}, [sessionId]);
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
@@ -193,11 +621,47 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
return m;
}, [messages]);
// Keep scrolled to the bottom while the user hasn't scrolled up.
// Keep scrolled to the bottom while the user hasn't scrolled up (only when the
// content changes – reading the height forces a layout).
const permCount = permissions.length + questions.length;
useLayoutEffect(() => {
const el = scrollRef.current;
if (el && stick.current) el.scrollTop = el.scrollHeight;
});
}, [messages, draft, busy, waiting, compacting, permCount, errors]);
// Stable callbacks for the (memoised) message list.
const rewindRef = useRef<(m: Message) => void>(() => undefined);
const onRewind = useCallback((m: Message) => rewindRef.current(m), []);
const showLatest = useCallback(async () => {
setHighlight(undefined);
await loadSession(sessionId);
follow(true);
}, [sessionId]);
/** Load the part of the chat around a message and scroll to it. */
const jumpTo = useCallback(
async (messageId: string) => {
follow(false);
if (!getState().messages[sessionId]?.some((m) => m.id === messageId)) await loadAround(sessionId, messageId);
setHighlight(messageId);
},
[sessionId],
);
useEffect(() => {
if (!highlight) return;
const h = setTimeout(() => document.querySelector(`[data-mid="${highlight}"]`)?.scrollIntoView({ block: "center" }), 50);
return () => clearTimeout(h);
}, [highlight, messages]);
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
const hasSession = !!session;
useEffect(() => {
const el = scrollRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
if (stick.current) el.scrollTop = el.scrollHeight;
});
ro.observe(el);
return () => ro.disconnect();
}, [hasSession, sessionId]);
if (loadError) return <div className="empty">{loadError}</div>;
if (!session) return <div className="empty">{t("common.loading")}</div>;
@@ -232,13 +696,19 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
setText("");
follow(true);
} catch (e: any) {
toast(e.message);
// No connection: keep the text on this device and send it when the server is reachable again.
if (isOffline(e) && txt && images.length === 0) {
addToOutbox(sessionId, txt, effort || undefined);
setText("");
toast(t("outbox.saved"));
} else toast(e.message);
}
};
const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1);
const [rewinding, setRewinding] = useState<Message>();
const rewind = (m: Message) => setRewinding(m);
rewindRef.current = rewind;
const rewindDone = async () => {
const m = rewinding!;
setRewinding(undefined);
@@ -246,9 +716,70 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
};
/** New chats in this project start like this one (model, mode, colour, instructions, folder). */
const saveDefaults = async (remove: boolean) => {
try {
if (remove) await del(`/sessions/${sessionId}/project-defaults`);
else await put(`/sessions/${sessionId}/project-defaults`, {});
toast(t(remove ? "projectDefaults.removed" : "projectDefaults.saved"), "success");
} catch (e: any) {
toast(e.message);
}
};
/** The project's check command (tests, build …), run after every turn that changed files. */
const editCheck = async () => {
try {
const cur = await get<{ command: string; enabled: boolean }>(`/sessions/${sessionId}/check`);
const cmd = await promptDialog({ title: t("check.title"), message: t("check.hint", { project: session.cwd }), defaultValue: cur.command, placeholder: "cargo test", confirmLabel: t("common.save") });
if (cmd === null) return;
await put(`/sessions/${sessionId}/check`, { command: cmd.trim(), enabled: cmd.trim() !== "" });
toast(cmd.trim() ? t("check.saved") : t("check.removed"), "success");
} catch (e: any) {
toast(e.message);
}
};
/** Draft an automation from this chat (small model), create it switched off and open its editor. */
const planJob = async () => {
if (planning) return;
setPlanning(true);
toast(t("job.drafting"));
try {
const d = await post<{ name: string; prompt: string; cwd: string; agent: string }>(`/sessions/${sessionId}/job-draft`, {});
const job = await post<{ id: string }>("/jobs/create", { cwd: d.cwd, name: d.name, prompt: d.prompt, agent: d.agent === "plan" ? undefined : d.agent });
navigate(`/jobs/${job.id}`);
} catch (e: any) {
toast(e.message);
} finally {
setPlanning(false);
}
};
const remove = async () => {
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
await del(`/sessions/${sessionId}`);
// Does the chat have a folder of its own? Then ask whether it goes too.
const folder = session.parent_id ? undefined : await get<{ own: boolean; path?: string; files?: number; bytes?: number; git?: boolean; truncated?: boolean }>(`/sessions/${sessionId}/folder`).catch(() => undefined);
let withFolder = false;
if (folder?.own) {
const size = `${folder.truncated ? "> " : ""}${t("chat.folderFiles", { n: folder.files ?? 0 })}, ${formatSize(folder.bytes ?? 0)}`;
const choice = await choiceDialog({
title: t("chat.deleteTitle"),
message: `${t("chat.deleteWithFolder", { path: folder.path ?? "", size })}${folder.git ? `\n\n${t("chat.folderHasGit")}` : ""}`,
choices: [
{ value: "chat", label: t("chat.deleteOnlyChat") },
{ value: "both", label: t("chat.deleteChatAndFolder"), danger: true },
],
});
if (!choice) return;
withFolder = choice === "both";
} else if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try {
const r = await del<{ folder_deleted?: boolean; folder_error?: string }>(`/sessions/${sessionId}${withFolder ? "?delete_folder=true" : ""}`);
if (r?.folder_error) toast(r.folder_error);
else if (r?.folder_deleted) toast(t("chat.folderDeleted"), "success");
} catch (e: any) {
return void toast(e.message);
}
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
};
@@ -268,8 +799,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
return (
<>
<div className="topbar">
<ChatCtx.Provider value={{ sessionId, canFork: !session.parent_id && !readOnly && !busy, canReply: !readOnly }}>
<div className="topbar" data-color={session.color}>
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
@@ -280,6 +811,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="undo" />
</a>
)}
{session.icon && <Icon name={session.icon} className="chat-icon" />}
{session.title || t("session.untitled")}
{!readOnly && (
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
@@ -307,11 +839,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<ShellsChip sessionId={sessionId} busy={busy} />
{isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
{!!session.instructions && (
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
<Icon name="edit" /> {t("instructions.chip")}
</button>
)}
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
{!readOnly && (
<>
{!session.parent_id && (
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
<button className="small ghost hide-mobile" onClick={() => setSharing(true)} title={t("share.title")}>
<Icon name="share" />
</button>
)}
@@ -322,11 +859,14 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
>
<Icon name="terminal" />
</button>
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
<Icon name="search" />
</button>
<button className="small ghost hide-mobile" onClick={tabs.openFiles} title={t("files.newTab")}>
<Icon name="folder" />
</button>
{!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
<button className="small ghost hide-mobile" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
<Icon name="fork" />
</button>
)}
@@ -335,6 +875,79 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="more" />
</summary>
<div className="menu-pop-body">
{/* The buttons that do not fit into the narrow bar. */}
{!session.parent_id && (
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, setSharing(true))}>
<Icon name="share" /> {t("share.title")}
</a>
)}
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, tabs.openFiles())}>
<Icon name="folder" /> {t("files.newTab")}
</a>
{!session.parent_id && !busy && (
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, forkSession())}>
<Icon name="fork" /> {t("chat.fork")}
</a>
)}
{!busy && (
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, post(`/sessions/${sessionId}/compact`).catch((err) => toast(err.message)))}>
<Icon name="compact" /> {t("command.compact")}
</a>
)}
<a href="#" className="mobile-only danger" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, remove())}>
<Icon name="trash" /> {t("chat.delete")}
</a>
<a
href="#"
onClick={(e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
setEditingInstructions(true);
}}
>
<Icon name="edit" /> {t("instructions.title")}
</a>
<ColorSwatches value={session.color} onChange={(color) => void update({ color })} />
<IconPicker value={session.icon} color={session.color} onChange={(icon) => void update({ icon })} />
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}>
<Icon name="clock" /> {t("job.plan")}
</a>
<a href="#" title={t("projectDefaults.hint")} onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(false))}>
<Icon name="template" /> {t("projectDefaults.save")}
</a>
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(true))}>
<Icon name="undo" /> {t("projectDefaults.remove")}
</a>
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void editCheck())}>
<Icon name="check" /> {t("check.menu")}
</a>
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), setShowApprovals(true))}>
<Icon name="lock" /> {t("permProject.menu")}
</a>
<a
href="#"
onClick={(e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
setGallery(true);
}}
>
<Icon name="image" /> {t("media.title")}
</a>
{telegramPaired && !session.parent_id && (
<a
href="#"
role="menuitemcheckbox"
aria-checked={!session.telegram_quiet}
title={t("chat.telegramHint")}
onClick={(e) => {
e.preventDefault();
void update({ telegram_quiet: !session.telegram_quiet });
}}
>
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
</a>
)}
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
<Icon name="download" /> {t("chat.exportMd")}
</a>
@@ -360,10 +973,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
)}
</div>
</details>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
<button className="small ghost hide-mobile" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
<Icon name="compact" />
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
<button className="small ghost danger hide-mobile" onClick={remove} title={t("chat.delete")}>
<Icon name="trash" />
</button>
</>
@@ -374,9 +987,20 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</button>
)}
</div>
{gallery && (
<MediaGallery
sessionId={sessionId}
onClose={() => setGallery(false)}
onJump={(id) => {
setGallery(false);
void jumpTo(id);
}}
/>
)}
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
<ChatTabBar state={tabs} />
<ChatTabPanes state={tabs} />
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
<div className="chat-pane" data-color={session.color} data-session-id={sessionId} hidden={tabs.tab !== "chat"}>
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
<div
@@ -391,6 +1015,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
// … scrolling back to the very end resumes it.
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
setScrolled(el.scrollTop > 240);
}}
onWheel={(e) => {
if (e.deltaY < 0 && stick.current) follow(false);
@@ -399,73 +1024,27 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<div className="chat">
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
{messages.map((m, idx) => {
if (m.role === "assistant") {
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
return (
<div key={m.id} className="msg">
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
</div>
);
}
const summary = m.parts.find((p) => p.type === "summary");
if (summary && summary.type === "summary") {
return (
<details key={m.id} className="summary-divider">
<summary>
{summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")}
</summary>
<Markdown text={summary.text} />
</details>
);
}
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const background = allTexts.filter((p) => BG_RE.test(p.text) || REFINE_RE.test(p.text));
const texts = allTexts.filter((p) => !BG_RE.test(p.text) && !REFINE_RE.test(p.text));
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
if (texts.length === 0 && imgs.length === 0) {
return background.length ? (
<div key={m.id} className="msg">
{background.map((p, i) => (
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
))}
</div>
) : null;
}
return (
<div key={m.id} className="msg">
{background.map((p, i) => (
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
))}
<div className="msg-user">
{!busy && !readOnly && (
<div className="msg-actions">
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
<Icon name="edit" />
</button>
</div>
)}
<UserText text={texts.map((p) => p.text).join("\n\n")} />
{imgs.length > 0 && (
<div className="user-files">
{imgs.map((p, i) => (
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
</FileFigure>
))}
</div>
)}
</div>
</div>
);
})}
{win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
<MessageList
messages={messages}
results={results}
busy={busy}
canEdit={!busy && !readOnly}
canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
sessionId={sessionId}
model={session.model}
baseDir={session.cwd}
onRewind={onRewind}
highlight={highlight}
currency={stats?.currency ?? "USD"}
/>
{win?.newer && (
<button className="back-to-latest" onClick={() => void showLatest()}>
<Icon name="arrow-down" /> {t("chat.backToLatest")}
</button>
)}
{draft && (
<div className="msg">
<div className={`msg ${draft.parts.some((p) => p.type === "text" && p.text.trim()) ? "bubble agent" : ""}`}>
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div>
)}
@@ -486,10 +1065,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<div className="todos">
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
<TodoList todos={todos} />
</div>
<TodoPanel todos={todos} />
)}
{permissions.map((p) => (
<PermissionCard key={p.id} req={p} currentId={sessionId} />
@@ -519,17 +1095,23 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</div>
)}
</div>
{!following && (
{(!following || scrolled) && (
<div className="jump-latest">
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
<Icon name="arrow-down" />
<button onClick={jumpPrevOwn} title={t("chat.jumpPrevOwn")} aria-label={t("chat.jumpPrevOwn")}>
<Icon name="arrow-up" />
</button>
{!following && <button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
<Icon name="arrow-down" />
</button>}
</div>
)}
</div>
{showApprovals && <ProjectPermissions sessionId={sessionId} cwd={session.cwd} onClose={() => setShowApprovals(false)} />}
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && <OutboxBar sessionId={sessionId} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? (
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
@@ -545,7 +1127,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
/>
)}
</div>
</>
</ChatCtx.Provider>
);
}
@@ -566,9 +1148,10 @@ function UserText({ text }: { text: string }) {
const last = rest.pop()!;
if (last.trim().endsWith(":")) break;
}
const body = rest.join("\n").trim();
return (
<>
{rest.join("\n").trimEnd()}
{body && <Markdown text={body} breaks />}
{files.length > 0 && (
<div className="user-files">
{files.map((f) => (
@@ -615,3 +1198,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
</div>
);
}
/** Tasks of the model above the composer; folds to one line (current task, progress). */
function TodoPanel({ todos }: { todos: Todo[] }) {
const t = useT();
const [collapsed, setCollapsed] = useTodosCollapsed();
const done = todos.filter((x) => x.status === "completed").length;
const current = todos.find((x) => x.status === "in_progress");
return (
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
<span>{t("tool.todos")}</span>
<span className="todos-count">
{done}/{todos.length}
</span>
{collapsed && current && <span className="todos-current">{current.content}</span>}
</button>
{!collapsed && <TodoList todos={todos} />}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useT, type Key } from "../i18n";
import { Icon } from "./Icon";
/** Colour tags of chats: names, drawn in the colours of the active theme (see `[data-color]` in styles.css). */
export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"] as const;
/** Icons a chat can get (must match SESSION_ICONS on the server). */
export const SESSION_ICONS = [
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
"users", "toolbox", "plug", "scroll", "eye", "template",
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
] as const;
/** Row of colour dots; the selected one is ringed, a second click removes the tag. */
export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) {
const t = useT();
return (
<div className="color-swatches" role="radiogroup" aria-label={t("color.label")}>
{SESSION_COLORS.map((c) => (
<button
key={c}
type="button"
role="radio"
aria-checked={value === c}
className={`swatch ${value === c ? "on" : ""}`}
data-color={c}
title={t(`color.${c}` as Key)}
aria-label={t(`color.${c}` as Key)}
onClick={(e) => {
e.stopPropagation();
onChange(value === c ? "" : c);
}}
/>
))}
</div>
);
}
/** Grid of icons for a chat (in the colour of the chat); a second click removes the icon. */
export function IconPicker({ value, color, onChange }: { value?: string; color?: string; onChange: (icon: string) => void }) {
const t = useT();
return (
<div className="icon-picker" role="radiogroup" aria-label={t("icon.label")} data-color={color}>
{SESSION_ICONS.map((i) => (
<button
key={i}
type="button"
role="radio"
aria-checked={value === i}
className={`icon-choice ${value === i ? "on" : ""}`}
title={i}
aria-label={i}
onClick={(e) => {
e.stopPropagation();
onChange(value === i ? "" : i);
}}
>
<Icon name={i} />
</button>
))}
</div>
);
}
+57 -5
View File
@@ -1,6 +1,6 @@
import { TemplatePicker } from "./TemplatePicker";
import { Icon } from "./Icon";
import { formatSize } from "./FileFigure";
import { formatSize, imageSrc } from "./FileFigure";
import { uploadFile } from "../api";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch, useModels } from "../hooks";
@@ -9,6 +9,8 @@ import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect";
import { EffortSelect } from "./Regenerate";
import { VoiceButton } from "./Voice";
import { Markdown } from "./Markdown";
import { COMPOSE_EVENT, type ComposeDetail } from "../compose";
import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion {
@@ -18,6 +20,7 @@ interface Suggestion {
const BUILTIN: { name: string; key: Key }[] = [
{ name: "compact", key: "command.compact" },
{ name: "clear", key: "command.clear" },
{ name: "new", key: "command.new" },
];
@@ -67,6 +70,8 @@ export function Composer({
const [effort, setEffort] = useState("");
const [interim, setInterim] = useState("");
const [picker, setPicker] = useState(false);
// Rendered Markdown instead of the text field (to check formatting before sending).
const [preview, setPreview] = useState(false);
const models = useModels();
const [prov, ...mid] = session.model.split("/");
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
@@ -74,6 +79,7 @@ export function Composer({
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
const snippets = useFetch<{ id: string; name: string; text: string }[]>("/account/snippets").data;
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
// Auto-grow.
@@ -130,11 +136,14 @@ export function Composer({
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
...(cmdData?.commands ?? []),
];
return all
const mine = (snippets ?? [])
.filter((s) => s.name.toLowerCase().startsWith(m[1].toLowerCase()))
.map((s) => ({ key: `snip:${s.id}`, label: `/${s.name}`, description: s.text.replace(/\s+/g, " ").slice(0, 80), apply: () => (setText(s.text), requestAnimationFrame(() => ref.current?.focus())) }));
const cmds = all
.filter((c) => c.name.startsWith(m[1]))
.slice(0, 12)
.map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) }));
}, [text, cmdData, t, mention, mentionHits]);
return [...mine, ...cmds].slice(0, 12);
}, [text, cmdData, snippets, t, mention, mentionHits]);
const send = () => {
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
@@ -204,6 +213,27 @@ export function Composer({
}
};
// Content sent from elsewhere (quote a message, image viewer): latest closures via refs.
const live = useRef({ text, addFiles, setText });
live.current = { text, addFiles, setText };
useEffect(() => {
const on = (e: Event) => {
const d = (e as CustomEvent<ComposeDetail>).detail;
if (d.sessionId !== session.id) return;
if (d.text) live.current.setText(live.current.text.trim() ? `${live.current.text.replace(/\s+$/, "")}\n\n${d.text}` : d.text);
if (d.files?.length) void live.current.addFiles(d.files);
requestAnimationFrame(() => {
const el = ref.current;
if (!el) return;
setPreview(false);
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
});
};
window.addEventListener(COMPOSE_EVENT, on);
return () => window.removeEventListener(COMPOSE_EVENT, on);
}, [session.id]);
const removeFile = (path: string) => {
setFiles((cur) => cur.filter((f) => f.path !== path));
void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" });
@@ -257,7 +287,7 @@ export function Composer({
)}
{images.map((img, i) => (
<div className="att" key={i}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<img src={imageSrc(img)} alt="" />
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
@@ -265,7 +295,20 @@ export function Composer({
))}
</div>
)}
{preview && (
<div
className="composer-preview"
role="button"
tabIndex={0}
title={t("composer.previewEdit")}
onClick={() => (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
onKeyDown={(e) => e.key === "Enter" && (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
>
{text.trim() ? <Markdown text={text} breaks /> : <span className="hint">{t("composer.previewEmpty")}</span>}
</div>
)}
<textarea
style={preview ? { display: "none" } : undefined}
ref={ref}
rows={1}
value={text}
@@ -337,6 +380,15 @@ export function Composer({
}}
/>
)}
<button
className={`ghost ${preview ? "active" : ""}`}
style={{ padding: "3px 6px" }}
onClick={() => setPreview(!preview)}
aria-pressed={preview}
title={t(preview ? "composer.previewEdit" : "composer.preview")}
>
<Icon name={preview ? "edit" : "eye"} />
</button>
<VoiceButton onText={(d) => setText(text ? `${text.replace(/\s+$/, "")} ${d}` : d)} onInterim={setInterim} />
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
<Icon name="clip" />
+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 { useEffect, useState, type ReactNode } from "react";
import type { ImageData } from "../types";
/** Human-readable file size. */
export function formatSize(n: number) {
@@ -32,6 +33,16 @@ export function basename(p: string) {
}
/** Bytes of a base64 payload. */
/** URL of an image of a message: inline (data URL) or from the image store. */
export function imageSrc(img: ImageData) {
return img.data ? `data:${img.media_type};base64,${img.data}` : `/api/blobs/${img.blob}`;
}
/** Size in bytes of an image of a message. */
export function imageSize(img: ImageData) {
return img.data ? base64Size(img.data) : img.size;
}
export function base64Size(b64: string) {
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
return Math.floor((b64.length * 3) / 4) - pad;
+7
View File
@@ -207,6 +207,13 @@ export function GitPanel({
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")}
</label>
<span className="spacer" />
<button
disabled={!!busy || staged.length === 0}
title={t("git.suggestHint")}
onClick={() => run("suggest", async () => setMessage((await call<{ message: string }>("suggest")).message))}
>
{busy === "suggest" ? <Spinner small /> : t("git.suggest")}
</button>
<button
className="primary"
disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))}
+98
View File
@@ -34,6 +34,98 @@ const P: Record<string, ReactNode> = {
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
gamepad: (
<>
<path d="M7 8h10a4 4 0 0 1 4 4.2l-.6 3.8a2.4 2.4 0 0 1-4.1 1.3L14 15h-4l-2.3 2.3a2.4 2.4 0 0 1-4.1-1.3L3 12.2A4 4 0 0 1 7 8Z" />
<path d="M8 10.5v3M6.5 12h3M15.5 11h.01M17.5 13h.01" />
</>
),
computer: (
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
</>
),
food: (
<>
<path d="M5 3v6a2 2 0 0 0 4 0V3M7 11v10" />
<path d="M17 21V3c-2.6 1.4-3.5 4.2-3.5 7.5H17" />
</>
),
home: (
<>
<path d="M4 11.5 12 4l8 7.5V20H4Z" />
<path d="M10 20v-5h4v5" />
</>
),
book: (
<>
<path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3Z" />
<path d="M5 17a3 3 0 0 1 3-3h11" />
</>
),
code: <path d="m8 7-5 5 5 5M16 7l5 5-5 5M14 5l-4 14" />,
camera: (
<>
<path d="M4 8h3l1.5-2h7L17 8h3v11H4Z" />
<circle cx="12" cy="13" r="3.5" />
</>
),
cart: (
<>
<path d="M3 4h2.5l2 11h10l2-8H7" />
<circle cx="9" cy="19" r="1.4" />
<circle cx="17" cy="19" r="1.4" />
</>
),
heart: <path d="M12 20s-8-4.6-8-10.5A4.5 4.5 0 0 1 12 7a4.5 4.5 0 0 1 8 2.5C20 15.4 12 20 12 20Z" />,
star: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9L12 16.9l-5.2 2.8 1-5.9-4.3-4.1 5.9-.8Z" />,
globe: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M3.5 12h17M12 3.5c3 3 3 14 0 17M12 3.5c-3 3-3 14 0 17" />
</>
),
calendar: (
<>
<rect x="4" y="5" width="16" height="15" rx="2" />
<path d="M4 10h16M8 3v4M16 3v4" />
</>
),
coffee: (
<>
<path d="M5 9h11v5a5 5 0 0 1-5 5H10a5 5 0 0 1-5-5Z" />
<path d="M16 10h1.5a2.5 2.5 0 0 1 0 5H16M8 3v3M12 3v3" />
</>
),
car: (
<>
<path d="M3.5 16v-4l2-5h13l2 5v4Z" />
<path d="M3.5 12h17" />
<circle cx="7.5" cy="16" r="1.5" />
<circle cx="16.5" cy="16" r="1.5" />
</>
),
plane: (
<>
<path d="M21 3 3 10.5l6.5 2.5L12 20Z" />
<path d="M9.5 13 21 3" />
</>
),
bulb: <path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.3 1 2.1h5c0-.8.4-1.6 1-2.1A6 6 0 0 0 12 3Z" />,
money: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 1.9 0 2.7 5 1.2 5 4 0 1.1-1 2-2.5 2-1.1 0-2.1-.5-2.6-1.4M12 6.5V8M12 16v1.5" />
</>
),
palette: (
<>
<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.3 0 1.8-1 1.3-2-.6-1.2.2-2.5 1.6-2.5H17a3.5 3.5 0 0 0 3.5-3.5C20.5 7 16.8 3.5 12 3.5Z" />
<path d="M7.5 11h.01M9.5 7.5h.01M14 7.5h.01" />
</>
),
reply: <path d="M9 7 4 12l5 5M4 12h9a7 7 0 0 1 7 7" />,
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
clip: <path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />,
@@ -125,6 +217,12 @@ const P: Record<string, ReactNode> = {
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" />
</>
),
eye: (
<>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../i18n";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
type Tool = "pen" | "arrow" | "rect";
interface Shape {
tool: Tool;
color: string;
/** pen: all points; arrow/rect: start and end. */
pts: { x: number; y: number }[];
}
const COLORS = ["#ff3b30", "#ffcc00", "#34c759", "#0a84ff", "#ffffff", "#000000"];
const MAX_SIDE = 2000;
function paint(ctx: CanvasRenderingContext2D, shapes: Shape[], w: number) {
const lw = Math.max(3, Math.round(w / 280));
for (const s of shapes) {
ctx.strokeStyle = s.color;
ctx.fillStyle = s.color;
ctx.lineWidth = lw;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const a = s.pts[0];
const b = s.pts[s.pts.length - 1];
ctx.beginPath();
if (s.tool === "pen") {
ctx.moveTo(a.x, a.y);
for (const p of s.pts.slice(1)) ctx.lineTo(p.x, p.y);
if (s.pts.length === 1) ctx.lineTo(a.x + 0.1, a.y);
ctx.stroke();
} else if (s.tool === "rect") {
ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
} else {
// Arrow: line with a filled head at the end.
const ang = Math.atan2(b.y - a.y, b.x - a.x);
const head = lw * 5;
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x - Math.cos(ang) * head * 0.6, b.y - Math.sin(ang) * head * 0.6);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x - head * Math.cos(ang - 0.45), b.y - head * Math.sin(ang - 0.45));
ctx.lineTo(b.x - head * Math.cos(ang + 0.45), b.y - head * Math.sin(ang + 0.45));
ctx.closePath();
ctx.fill();
}
}
}
/** Draw on an image (pen, arrow, rectangle) and hand the result back as a PNG file. */
export function ImageAnnotator({ src, name, onDone, onCancel }: { src: string; name: string; onDone: (file: File) => void; onCancel: () => void }) {
const t = useT();
const canvas = useRef<HTMLCanvasElement>(null);
const bitmap = useRef<HTMLImageElement | undefined>(undefined);
const [shapes, setShapes] = useState<Shape[]>([]);
const [tool, setTool] = useState<Tool>("pen");
const [color, setColor] = useState(COLORS[0]);
const [ready, setReady] = useState(false);
const drawing = useRef<Shape | null>(null);
const redraw = (extra?: Shape | null) => {
const c = canvas.current;
const img = bitmap.current;
if (!c || !img) return;
const ctx = c.getContext("2d")!;
ctx.clearRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0, c.width, c.height);
paint(ctx, extra ? [...shapes, extra] : shapes, c.width);
};
useEffect(() => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const c = canvas.current;
if (!c) return;
const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
c.width = Math.max(1, Math.round((img.naturalWidth || 300) * scale));
c.height = Math.max(1, Math.round((img.naturalHeight || 200) * scale));
bitmap.current = img;
setReady(true);
};
img.onerror = () => {
toast(t("viewer.annotateFailed"));
onCancel();
};
img.src = src;
}, [src]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => redraw(), [shapes, ready]); // eslint-disable-line react-hooks/exhaustive-deps
const point = (e: React.PointerEvent) => {
const c = canvas.current!;
const r = c.getBoundingClientRect();
return { x: ((e.clientX - r.left) / r.width) * c.width, y: ((e.clientY - r.top) / r.height) * c.height };
};
const finish = () => {
const c = canvas.current;
if (!c) return;
c.toBlob((blob) => {
if (!blob) return toast(t("viewer.annotateFailed"));
const base = name.replace(/\.[a-z0-9]{1,5}$/i, "") || "image";
onDone(new File([blob], `${base}-marked.png`, { type: "image/png" }));
}, "image/png");
};
return (
<div className="annotator" onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
<canvas
ref={canvas}
style={{ visibility: ready ? "visible" : "hidden" }}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drawing.current = { tool, color, pts: [point(e)] };
}}
onPointerMove={(e) => {
const d = drawing.current;
if (!d) return;
const p = point(e);
d.pts = d.tool === "pen" ? [...d.pts, p] : [d.pts[0], p];
redraw(d);
}}
onPointerUp={() => {
const d = drawing.current;
drawing.current = null;
if (d) setShapes((s) => [...s, d]);
}}
onPointerCancel={() => {
drawing.current = null;
redraw();
}}
/>
<div className="annotator-tools" role="toolbar" aria-label={t("viewer.annotate")}>
{(["pen", "arrow", "rect"] as Tool[]).map((k) => (
<button key={k} className={`small ${tool === k ? "primary" : "ghost"}`} onClick={() => setTool(k)} aria-pressed={tool === k} title={t(`viewer.tool.${k}` as const)}>
<Icon name={k === "pen" ? "edit" : k === "arrow" ? "arrow-right" : "box-off"} />
</button>
))}
<span className="annotator-colors">
{COLORS.map((c) => (
<button key={c} className={`swatch ${c === color ? "on" : ""}`} style={{ background: c }} onClick={() => setColor(c)} aria-label={c} aria-pressed={c === color} />
))}
</span>
<button className="small ghost" onClick={() => setShapes((s) => s.slice(0, -1))} disabled={!shapes.length} title={t("viewer.undo")} aria-label={t("viewer.undo")}>
<Icon name="undo" />
</button>
<span className="grow" />
<button className="small" onClick={onCancel}>
{t("common.cancel")}
</button>
<button className="small primary" onClick={finish} disabled={!ready}>
<Icon name="clip" /> {t("viewer.attach")}
</button>
</div>
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Icon } from "./Icon";
import { ImageAnnotator } from "./ImageAnnotator";
import { compose } from "../compose";
import { toast } from "../dialogs";
import { useT } from "../i18n";
/** Images that open in the viewer: everything in the app, except UI chrome. */
const SKIP = ".viewer-overlay, .sidebar, .topbar, button, .modal-backdrop .viewer, .model-viewer, .media-stage, .composer, .brand, .login";
interface Item {
src: string;
alt: string;
}
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
/**
* Popup image viewer: a click on any image of the app opens it fullscreen.
* Pinch / wheel / double tap to zoom, drag to pan, arrows or swipe for the other
* images of the same chat, Esc to close. Mounted once; listens for clicks globally.
*/
export function ImageViewer() {
const t = useT();
const [list, setList] = useState<Item[]>([]);
const [idx, setIdx] = useState(0);
const [open, setOpen] = useState(false);
// Chat the image belongs to (for "use in message"); drawing mode.
const [sessionId, setSessionId] = useState<string>();
const [marking, setMarking] = useState(false);
const [view, setView] = useState({ x: 0, y: 0, s: 1 });
const pointers = useRef(new Map<number, { x: number; y: number }>());
const gesture = useRef<{ d: number; s: number } | null>(null);
const moved = useRef(false);
const lastTap = useRef(0);
const swipe = useRef<{ x: number; y: number } | null>(null);
const reset = useCallback(() => setView({ x: 0, y: 0, s: 1 }), []);
const close = useCallback(() => setOpen(false), []);
const go = useCallback(
(d: number) => {
setIdx((i) => clamp(i + d, 0, list.length - 1));
reset();
},
[list.length, reset],
);
useEffect(() => {
const onClick = (e: MouseEvent) => {
const img = (e.target as HTMLElement | null)?.closest?.("img");
if (!img || img.closest(SKIP) || e.defaultPrevented || e.button !== 0) return;
if (img.naturalWidth && img.naturalWidth < 24) return; // icons
e.preventDefault(); // the surrounding link would open a new tab
e.stopPropagation();
const scope = img.closest(".chat, .files-main, .gallery, .main") ?? document;
const all = [...scope.querySelectorAll("img")].filter((i) => !i.closest(SKIP) && i.src && (i.naturalWidth === 0 || i.naturalWidth >= 24));
const seen = new Set<string>();
const items: Item[] = [];
let start = 0;
for (const i of all) {
if (seen.has(i.currentSrc || i.src)) {
if (i === img) start = items.findIndex((x) => x.src === (i.currentSrc || i.src));
continue;
}
seen.add(i.currentSrc || i.src);
if (i === img) start = items.length;
items.push({ src: i.currentSrc || i.src, alt: i.alt });
}
setSessionId((img.closest("[data-session-id]") as HTMLElement | null)?.dataset.sessionId);
setMarking(false);
setList(items.length ? items : [{ src: img.currentSrc || img.src, alt: img.alt }]);
setIdx(Math.max(0, start));
reset();
setOpen(true);
};
// Capture phase: before the link around a thumbnail navigates.
document.addEventListener("click", onClick, true);
return () => document.removeEventListener("click", onClick, true);
}, [reset]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (marking) return;
if (e.key === "Escape") close();
else if (e.key === "ArrowLeft") go(-1);
else if (e.key === "ArrowRight") go(1);
else if (e.key === "+" || e.key === "=") setView((v) => ({ ...v, s: clamp(v.s * 1.25, 1, 8) }));
else if (e.key === "-") setView((v) => ({ ...v, s: clamp(v.s / 1.25, 1, 8), ...(v.s / 1.25 <= 1 ? { x: 0, y: 0 } : {}) }));
else return;
e.preventDefault();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, close, go, marking]);
if (!open || !list[idx]) return null;
const cur = list[idx];
/** The image as a file for the composer (variants, questions about it, …). */
async function useInChat() {
try {
const blob = await (await fetch(cur.src, { credentials: "same-origin" })).blob();
const ext = blob.type.split("/")[1]?.split("+")[0] || "png";
compose({ sessionId: sessionId!, files: [new File([blob], `image.${ext}`, { type: blob.type || "image/png" })] });
toast(t("viewer.attached"), "success");
close();
} catch {
toast(t("viewer.annotateFailed"));
}
}
const dist = () => {
const [a, b] = [...pointers.current.values()];
return Math.hypot(a.x - b.x, a.y - b.y);
};
return (
<div
className="viewer-overlay"
role="dialog"
aria-modal="true"
aria-label={t("viewer.title")}
onClick={() => {
if (!moved.current) close();
}}
onWheel={(e) => setView((v) => {
const s = clamp(v.s * (e.deltaY < 0 ? 1.15 : 1 / 1.15), 1, 8);
return s === 1 ? { x: 0, y: 0, s } : { ...v, s };
})}
>
<div className="viewer-bar" onClick={(e) => e.stopPropagation()}>
<span className="viewer-count">{list.length > 1 ? `${idx + 1} / ${list.length}` : ""}</span>
<span className="viewer-name">{cur.alt && !/^(image|SVG)$/i.test(cur.alt) ? cur.alt : ""}</span>
{sessionId && (
<>
<button className="small ghost" onClick={() => setMarking(true)} title={t("viewer.annotate")} aria-label={t("viewer.annotate")}>
<Icon name="edit" />
</button>
<button className="small ghost" onClick={() => void useInChat()} title={t("viewer.useInChat")} aria-label={t("viewer.useInChat")}>
<Icon name="reply" />
</button>
</>
)}
<a className="button small ghost" href={cur.src} target="_blank" rel="noreferrer" title={t("viewer.open")} aria-label={t("viewer.open")}>
<Icon name="share" />
</a>
<a className="button small ghost" href={cur.src} download title={t("viewer.download")} aria-label={t("viewer.download")}>
<Icon name="download" />
</a>
<button className="small ghost" onClick={close} aria-label={t("common.close")}>
<Icon name="close" />
</button>
</div>
{marking && (
<ImageAnnotator
src={cur.src}
name={cur.alt || "image"}
onCancel={() => setMarking(false)}
onDone={(file) => {
compose({ sessionId: sessionId!, files: [file] });
toast(t("viewer.attached"), "success");
close();
}}
/>
)}
{!marking && idx > 0 && (
<button className="viewer-nav prev" onClick={(e) => (e.stopPropagation(), go(-1))} aria-label={t("media.prev")}>
<Icon name="arrow-left" />
</button>
)}
{!marking && idx < list.length - 1 && (
<button className="viewer-nav next" onClick={(e) => (e.stopPropagation(), go(1))} aria-label={t("media.next")}>
<Icon name="arrow-right" />
</button>
)}
{!marking && <img
key={cur.src}
className="viewer-img"
src={cur.src}
alt={cur.alt}
draggable={false}
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`, cursor: view.s > 1 ? "grab" : "zoom-in" }}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
moved.current = false;
if (pointers.current.size === 2) gesture.current = { d: dist(), s: view.s };
else swipe.current = { x: e.clientX, y: e.clientY };
}}
onPointerMove={(e) => {
const p = pointers.current.get(e.pointerId);
if (!p) return;
const dx = e.clientX - p.x;
const dy = e.clientY - p.y;
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.current.size === 2 && gesture.current) {
moved.current = true;
const s = clamp((gesture.current.s * dist()) / gesture.current.d, 1, 8);
setView((v) => (s === 1 ? { x: 0, y: 0, s } : { ...v, s }));
} else if (view.s > 1) {
if (Math.abs(dx) + Math.abs(dy) > 1) moved.current = true;
setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
}
}}
onPointerUp={(e) => {
pointers.current.delete(e.pointerId);
gesture.current = null;
const s0 = swipe.current;
swipe.current = null;
if (s0 && view.s === 1) {
const dx = e.clientX - s0.x;
const dy = e.clientY - s0.y;
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
moved.current = true;
go(dx < 0 ? 1 : -1);
} else if (dy > 90 && Math.abs(dy) > Math.abs(dx) * 1.5) {
moved.current = true;
close();
}
}
// Double tap / double click: zoom in or back.
if (!moved.current) {
const now = Date.now();
if (now - lastTap.current < 300) setView((v) => (v.s > 1 ? { x: 0, y: 0, s: 1 } : { ...v, s: 2.5 }));
lastTap.current = now;
}
}}
onPointerCancel={(e) => {
pointers.current.delete(e.pointerId);
gesture.current = null;
}}
/>}
</div>
);
}
+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>
);
}
+38 -2
View File
@@ -1,7 +1,7 @@
import { Icon } from "./Icon";
import { useState } from "react";
import { post } from "../api";
import { useT } from "../i18n";
import { getLang, useT } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import type { PermissionRequest, QuestionRequest } from "../types";
@@ -31,7 +31,24 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
const chosen = selected.filter(Boolean).length;
const partial = hunkCount > 1 && chosen < hunkCount;
const reply = async (r: "once" | "always" | "reject") => {
const [explaining, setExplaining] = useState(false);
const [explanation, setExplanation] = useState<string>();
const [explainError, setExplainError] = useState<string>();
/** The small model explains in plain words what the action does (approving on a phone without reading code). */
const explain = async () => {
setExplaining(true);
setExplainError(undefined);
try {
const r = await post<{ text: string }>(`/permissions/${req.id}/explain`, { lang: getLang() });
setExplanation(r.text);
} catch (e: any) {
setExplainError(e.message);
} finally {
setExplaining(false);
}
};
const reply = async (r: "once" | "always" | "project" | "reject") => {
setBusy(true);
try {
const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined;
@@ -47,6 +64,10 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
: t("permission.alwaysTool", { tool: req.tool });
// Dangerous commands are only ever allowed for the session (the server decides; this hides the button).
const dangerous = !!command && /\b(rm\s+-[a-zA-Z]*[rRf]|sudo|su|git\s+(push|reset|clean|rebase|checkout|restore)|docker|docker-compose|systemctl|chmod\s+-R|chown\s+-R|dd|mkfs|shutdown|reboot|kill|pkill|killall|crontab)\b|\|\s*(ba)?sh\b|publish/.test(command);
const projectLabel = req.tool === "bash" ? t("permission.projectCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") }) : t("permission.projectTool", { tool: req.tool });
return (
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
<h3>
@@ -72,6 +93,16 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
</details>
)
)}
{explanation ? (
<div className="explain" role="note">
<Icon name="sparkle" className="tiny" /> {explanation}
</div>
) : (
<button className="small ghost explain-btn" disabled={explaining} onClick={explain}>
<Icon name="sparkle" /> {explaining ? t("permission.explaining") : t("permission.explain")}
</button>
)}
{explainError && <div className="hint">{explainError}</div>}
<input
style={{ width: "100%", marginTop: 8 }}
placeholder={t("permission.feedback")}
@@ -86,6 +117,11 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
<button disabled={busy || partial} onClick={() => reply("always")}>
{alwaysLabel}
</button>
{!dangerous && (
<button disabled={busy || partial} onClick={() => reply("project")} title={t("permission.projectHint")}>
{projectLabel}
</button>
)}
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
</button>
+304
View File
@@ -0,0 +1,304 @@
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;
notify: boolean;
}
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,
notify: j.spec?.notify ?? true,
});
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>
<label className="row" style={{ gap: 6, alignItems: "center", marginTop: 6 }}>
<input type="checkbox" checked={settings.notify} onChange={(e) => up({ notify: e.target.checked })} />
{t("jobEditor.notify")}
</label>
{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 { Icon } from "./Icon";
import { Spinner } from "./Activity";
import { JobTemplateDialog } from "./JobTemplateDialog";
import { NewJobDialog } from "./NewJobDialog";
interface JobInfo {
export interface JobInfo {
id: string;
path: string;
enabled: boolean;
@@ -17,12 +17,12 @@ interface JobInfo {
next_run?: number | null;
error?: string;
webhook?: boolean;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; notify?: boolean; prompt: string; assistant_session?: string | null };
}
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
/** Automations: agents started on a schedule from a Markdown job file. */
/** Automations: agents started on a schedule with the instructions from a Markdown file. */
export function Jobs({ onMenu }: { onMenu: () => void }) {
const t = useT();
const [jobs, setJobs] = useState<JobInfo[]>();
@@ -68,10 +68,10 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="notice">{t("jobs.intro")}</div>
<div className="row" style={{ margin: "12px 0" }}>
<button className="primary" onClick={() => setTemplates(true)}>
<Icon name="plus" /> {t("jobs.fromTemplate")}
<Icon name="plus" /> {t("jobs.new")}
</button>
</div>
{templates && <JobTemplateDialog onClose={() => setTemplates(false)} onCreated={() => void reload()} />}
{templates && <NewJobDialog onClose={() => setTemplates(false)} />}
{!jobs && <div className="empty"><Spinner /></div>}
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
{jobs?.map((j) => (
@@ -79,7 +79,11 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="job-head">
<Icon name="clock" className="job-icon" />
<div style={{ flex: 1, minWidth: 0 }}>
<h3>{j.spec?.name ?? j.path.split("/").pop()}</h3>
<h3>
<button className="link-btn job-title" onClick={() => navigate(`/jobs/${j.id}`)}>
{j.spec?.name ?? j.path.split("/").pop()}
</button>
</h3>
<div className="hint mono">{j.path}</div>
</div>
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
@@ -107,7 +111,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
</span>
<span>{t("jobs.mode")}</span>
<span>
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {t(`permissionMode.${j.spec?.permission_mode ?? "auto"}` as any)} · max {j.spec?.timeout_minutes} min
</span>
</div>
)}
@@ -163,14 +167,8 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
})}>
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
</button>
<button
className="small ghost"
onClick={() => {
sessionStorage.setItem("codecraft.openFile", j.path);
navigate("/files");
}}
>
<Icon name="edit" /> {t("jobs.editFile")}
<button className="small" onClick={() => navigate(`/jobs/${j.id}`)}>
<Icon name="edit" /> {t("jobs.edit")}
</button>
<span className="grow" />
<button
@@ -190,20 +188,13 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="row" style={{ gap: 8 }}>
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
<button disabled={!path.trim()} onClick={() => act("new", async () => {
await post("/jobs", { path: path.trim() });
const j = await post<JobInfo>("/jobs", { path: path.trim() });
setPath("");
navigate(`/jobs/${j.id}`);
})}>
{t("jobs.registerBtn")}
</button>
</div>
<pre className="job-example">{`---
name: "Nightly dependency check"
schedule: "0 3 * * *"
agent: build
permission_mode: auto
timeout_minutes: 60
---
Check the project for outdated dependencies …`}</pre>
</div>
</div>
</div>
+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 (
<div className="markdown">
<ReactMarkdown
@@ -81,7 +104,7 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
},
}}
>
{text}
{breaks ? keepLineBreaks(text) : text}
</ReactMarkdown>
</div>
);
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useState } from "react";
import { get } from "../api";
import { useT, type Key } from "../i18n";
import { Spinner } from "./Activity";
import { Icon } from "./Icon";
import { navigate } from "../router";
/** Open a 3D model in the 3D view of the file manager. */
const open3d = (path: string) => {
sessionStorage.setItem("codecraft.openFile", path);
navigate("/files");
};
interface MediaItem {
kind: "image" | "video" | "audio" | "model";
url: string;
name: string;
source: "generated" | "tool" | "written" | "answer" | "upload";
created_at: number;
message_id?: string;
/** 3D models: file (opened in the 3D view) and preview image. */
path?: string;
preview?: string;
}
type Filter = "all" | MediaItem["kind"];
/**
* All images, videos and audio files of a chat (with its subagents), newest first.
* An item opens large; "show in chat" jumps to the message it came with.
*/
export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string; onClose: () => void; onJump: (messageId: string) => void }) {
const t = useT();
const [items, setItems] = useState<MediaItem[]>();
const [error, setError] = useState<string>();
const [filter, setFilter] = useState<Filter>("all");
const [open, setOpen] = useState<number>();
useEffect(() => {
get<MediaItem[]>(`/sessions/${sessionId}/media`)
.then(setItems)
.catch((e) => setError(e.message));
}, [sessionId]);
const shown = (items ?? []).filter((i) => filter === "all" || i.kind === filter);
const count = (k: Filter) => (items ?? []).filter((i) => k === "all" || i.kind === k).length;
const current = open !== undefined ? shown[open] : undefined;
// Keyboard: Esc closes, arrows step through the open item.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") return current ? setOpen(undefined) : onClose();
if (current && e.key === "ArrowRight") setOpen((o) => Math.min((o ?? 0) + 1, shown.length - 1));
if (current && e.key === "ArrowLeft") setOpen((o) => Math.max((o ?? 0) - 1, 0));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [current, shown.length, onClose]);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal media-gallery" role="dialog" aria-label={t("media.title")} onClick={(e) => e.stopPropagation()}>
<div className="media-head">
<h3>
<Icon name="image" /> {t("media.title")}
</h3>
<div className="media-filters" role="radiogroup">
{(["all", "image", "video", "audio", "model"] as Filter[]).map((k) => (
<button key={k} role="radio" aria-checked={filter === k} className={`chip-btn ${filter === k ? "active" : ""}`} onClick={() => (setFilter(k), setOpen(undefined))} disabled={!!items && count(k) === 0 && k !== "all"}>
{t(`media.filter.${k}` as Key)} {items && <small>{count(k)}</small>}
</button>
))}
</div>
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
<Icon name="close" />
</button>
</div>
{error && <div className="notice err">{error}</div>}
{!items && !error && (
<div className="empty">
<Spinner />
</div>
)}
{items && shown.length === 0 && <div className="empty">{t("media.none")}</div>}
{current ? (
<div className="media-viewer">
<div className="media-stage">
{current.kind === "image" && <img src={current.url} alt={current.name} />}
{current.kind === "video" && <video src={current.url} controls autoPlay />}
{current.kind === "audio" && <audio src={current.url} controls autoPlay />}
{current.kind === "model" && (
<div className="media-model">
{current.preview ? <img src={current.preview} alt={current.name} /> : <Icon name="cube" />}
{current.path && (
<button className="primary" onClick={() => open3d(current.path!)}>
<Icon name="cube" /> {t("tool.open3d")}
</button>
)}
</div>
)}
</div>
<div className="media-bar">
<button className="small ghost" disabled={open === 0} onClick={() => setOpen((o) => (o ?? 1) - 1)} aria-label={t("media.prev")}>
<Icon name="arrow-left" />
</button>
<span className="grow">
{current.name} · {t(`media.source.${current.source}` as Key)} · {new Date(current.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
</span>
{current.message_id && (
<button className="small" onClick={() => onJump(current.message_id!)}>
{t("media.showInChat")}
</button>
)}
<a className="small button" href={current.url} target="_blank" rel="noreferrer">
<Icon name="external" /> {t("media.open")}
</a>
<button className="small ghost" disabled={open === shown.length - 1} onClick={() => setOpen((o) => (o ?? 0) + 1)} aria-label={t("media.next")}>
<Icon name="arrow-right" />
</button>
<button className="small ghost" onClick={() => setOpen(undefined)} aria-label={t("media.back")}>
<Icon name="close" />
</button>
</div>
</div>
) : (
<div className="media-grid">
{shown.map((m, i) => (
<button key={m.url} className={`media-tile ${m.kind}`} onClick={() => setOpen(i)} title={`${m.name} · ${t(`media.source.${m.source}` as Key)}`}>
{m.kind === "image" && <img src={m.url} alt="" loading="lazy" decoding="async" />}
{m.kind === "video" && <video src={`${m.url}#t=0.1`} preload="metadata" muted />}
{m.kind === "audio" && (
<span className="media-audio">
<Icon name="music" />
<span>{m.name}</span>
</span>
)}
{m.kind === "video" && <Icon name="play" className="media-play" />}
{m.kind === "model" &&
(m.preview ? (
<img src={m.preview} alt="" loading="lazy" decoding="async" />
) : (
<span className="media-audio">
<Icon name="cube" />
<span>{m.name}</span>
</span>
))}
{m.kind === "model" && <span className="media-badge">3D</span>}
</button>
))}
</div>
)}
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { createContext, useContext } from "react";
import { post } from "../api";
import { asQuote, compose } from "../compose";
import { useT } from "../i18n";
import { navigate } from "../router";
import { loadSessions } from "../store";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
/** What the message menus of one chat need to know. */
export const ChatCtx = createContext<{ sessionId: string; canFork: boolean; canReply: boolean }>({ sessionId: "", canFork: false, canReply: false });
/**
* Actions of one message: copy, quote into the reply, fork the chat from here and
* (for own messages) edit and resend. Hover on desktop, always faintly visible on touch.
*/
export function MessageMenu({ messageId, text, forkable, onRewind }: { messageId: string; text: string; forkable: boolean; onRewind?: () => void }) {
const t = useT();
const { sessionId, canFork, canReply } = useContext(ChatCtx);
const close = (e: React.MouseEvent) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
};
if (!text.trim() && !onRewind) return null;
return (
<details className="menu-pop msg-menu">
<summary className="small ghost" title={t("msg.actions")} aria-label={t("msg.actions")}>
<Icon name="more" />
</summary>
<div className="menu-pop-body">
{text.trim() && (
<a
href="#"
onClick={(e) => {
close(e);
const done = () => toast(t("msg.copied"), "success");
if (navigator.clipboard?.writeText) void navigator.clipboard.writeText(text).then(done, () => toast(t("msg.copyFailed")));
else toast(t("msg.copyFailed"));
}}
>
<Icon name="copy" /> {t("msg.copy")}
</a>
)}
{text.trim() && canReply && (
<a href="#" onClick={(e) => (close(e), compose({ sessionId, text: asQuote(text) }))}>
<Icon name="reply" /> {t("msg.quote")}
</a>
)}
{onRewind && (
<a href="#" onClick={(e) => (close(e), onRewind())}>
<Icon name="edit" /> {t("chat.rewind")}
</a>
)}
{forkable && canFork && (
<a
href="#"
onClick={async (e) => {
close(e);
try {
const fork = await post<{ id: string }>(`/sessions/${sessionId}/fork`, { message_id: messageId });
await loadSessions();
toast(t("chat.forked"));
navigate(`/s/${fork.id}`);
} catch (err: any) {
toast(err.message);
}
}}
>
<Icon name="fork" /> {t("msg.forkHere")}
</a>
)}
</div>
</details>
);
}
+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>
);
}
+24 -3
View File
@@ -1,11 +1,12 @@
import { Icon } from "./Icon";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { post, uploadFile } from "../api";
import { promptDialog, toast } from "../dialogs";
import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData } from "../types";
import { readImage } from "./Composer";
import { formatSize } from "./FileFigure";
import { formatSize, imageSrc } from "./FileFigure";
import { ModelSelect } from "./ModelSelect";
import { takePrefill } from "../share";
@@ -327,7 +328,8 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const isCraft = task === "craft";
const s = await post<{ id: string }>("/sessions", {
cwd,
model: model || undefined,
// The server default is not sent, so the project's own default model can apply.
model: model && model !== config?.default_model ? model : undefined,
agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined,
});
@@ -478,7 +480,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<div className="attachments">
{images.map((img, i) => (
<div className="att" key={`i${i}`}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<img src={imageSrc(img)} alt="" />
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
@@ -602,6 +604,20 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listing?.path]);
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
/** Create a subfolder of the shown folder and select it. */
const newFolder = async () => {
if (!listing) return;
const name = (await promptDialog({ title: t("files.newFolder"), message: t("files.newFolderName") }))?.trim();
if (!name) return;
if (name.includes("/") || name === "." || name === "..") return toast(t("new.invalidFolderName"));
const target = `${listing.path.replace(/\/$/, "")}/${name}`;
try {
await post("/files/create", { path: target, dir: true });
setBrowse(target);
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="wiz-picker">
{roots.length > 1 && (
@@ -622,6 +638,11 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
<Icon name="arrow-up" /> {t("new.up")}
</button>
)}
{listing && (
<button className="small" onClick={() => void newFolder()}>
<Icon name="plus" /> {t("files.newFolder")}
</button>
)}
</div>
<div className="dir-list">
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect } from "react";
import { useT } from "../i18n";
import { flushOutbox, removeFromOutbox, useOutbox } from "../outbox";
import { useStore } from "../store";
import { Icon } from "./Icon";
/** Messages waiting for a connection: sent automatically once the server is reachable again. */
export function OutboxBar({ sessionId }: { sessionId: string }) {
const t = useT();
const items = useOutbox(sessionId);
const connected = useStore((s) => s.connected);
useEffect(() => {
if (!items.length) return;
const go = () => void flushOutbox(sessionId);
if (connected && navigator.onLine) go();
window.addEventListener("online", go);
return () => window.removeEventListener("online", go);
}, [sessionId, connected, items.length]);
if (!items.length) return null;
return (
<div className="outbox" role="status">
<div className="outbox-head">
<Icon name="clock" className="tiny" /> {t("outbox.waiting", { n: items.length })}
<span className="grow" />
<button className="small" onClick={() => void flushOutbox(sessionId)}>
{t("outbox.sendNow")}
</button>
</div>
{items.map((i) => (
<div key={i.id} className="outbox-item">
<span>{i.text.length > 120 ? `${i.text.slice(0, 120)}…` : i.text}</span>
<button className="small ghost" onClick={() => removeFromOutbox(i.id)} aria-label={t("outbox.discard")} title={t("outbox.discard")}>
<Icon name="close" />
</button>
</div>
))}
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useMemo } from "react";
import { useFetch } from "../hooks";
import { fmtCost, fmtDate, fmtRelative, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import type { Session } from "../types";
import { Icon } from "./Icon";
import { basename } from "./FileFigure";
interface Overview {
sessions: Record<string, { files: number; check: string | null; checks_failed: number; cost: number }>;
check: { command: string; enabled: boolean } | null;
defaults: { model?: string; agent?: string; permission_mode?: string; color?: string; instructions?: string; folder?: string } | null;
currency: string;
}
const day = (ms: number) => new Date(ms).toDateString();
/** One project folder: its chats as a timeline (newest first) with what each one did. */
export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }) {
const t = useT();
const all = useStore((s) => s.sessions);
const { data } = useFetch<Overview>(`/projects/overview?cwd=${encodeURIComponent(cwd)}`);
const sessions = useMemo(() => all.filter((s) => s.cwd === cwd && !s.archived).sort((a, b) => b.updated_at - a.updated_at), [all, cwd]);
const archived = all.filter((s) => s.cwd === cwd && s.archived).length;
// Day groups: "Today", "Yesterday", date.
const groups = useMemo(() => {
const out: { label: string; items: Session[] }[] = [];
const today = day(Date.now());
const yesterday = day(Date.now() - 86_400_000);
for (const s of sessions) {
const d = day(s.updated_at);
const label = d === today ? t("project.today") : d === yesterday ? t("project.yesterday") : fmtDate(s.updated_at, false);
const last = out[out.length - 1];
if (last?.label === label) last.items.push(s);
else out.push({ label, items: [s] });
}
return out;
}, [sessions, t]);
const totalCost = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.cost ?? 0), 0);
const totalFiles = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.files ?? 0), 0);
const newChat = () => {
try {
localStorage.setItem("codecraft.lastDir", cwd);
} catch {
// Without storage the wizard starts at its usual folder.
}
navigate("/new");
};
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>
<Icon name="folder" /> {basename(cwd) || "/"}
</h1>
<div className="path mono">{cwd}</div>
</div>
<button className="small primary" onClick={newChat}>
<Icon name="plus" /> {t("project.newChat")}
</button>
</div>
<div className="chat-scroll">
<div className="chat project-page">
<div className="project-summary">
<span className="chip">{t("project.chats", { n: sessions.length })}</span>
{totalFiles > 0 && <span className="chip">{t("project.files", { n: totalFiles })}</span>}
{data && totalCost > 0 && <span className="chip">{fmtCost(totalCost, data.currency)}</span>}
{data?.check?.enabled && (
<span className="chip mono" title={t("check.title")}>
<Icon name="check" className="tiny" /> {data.check.command}
</span>
)}
{data?.defaults?.model && (
<span className="chip" title={t("projectDefaults.hint")}>
<Icon name="template" className="tiny" /> {data.defaults.model.split("/").pop()}
</span>
)}
{archived > 0 && <span className="hint">{t("project.archived", { n: archived })}</span>}
</div>
{sessions.length === 0 && <div className="empty">{t("project.empty")}</div>}
{groups.map((g) => (
<section key={g.label} className="timeline-day">
<h3>{g.label}</h3>
<ol className="timeline">
{g.items.map((s) => {
const info = data?.sessions[s.id];
return (
<li key={s.id} data-color={s.color} className={s.color ? "tagged" : ""}>
<a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}>
<div className="tl-title">
<span className={s.color ? "color-badge" : undefined}>
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
{s.title || t("session.untitled")}
</span>
<span className="tl-when" title={fmtDate(s.updated_at)}>
{fmtRelative(s.updated_at)}
</span>
</div>
{s.summary && <div className="tl-summary">{s.summary}</div>}
<div className="tl-meta">
<span>{s.agent}</span>
<span>{s.model.split("/").pop()}</span>
{!!info?.files && <span>{t("project.files", { n: info.files })}</span>}
{info?.check && (
<span className={`check-${info.check}`}>
<Icon name={info.check === "passed" ? "check" : info.check === "running" ? "clock" : "x-mark"} className="tiny" /> {t(`check.${info.check}` as Key)}
</span>
)}
{!!info?.checks_failed && <span className="check-failed">{t("project.checksFailed", { n: info.checks_failed })}</span>}
{!!info?.cost && data && <span>{fmtCost(info.cost, data.currency)}</span>}
</div>
</a>
</li>
);
})}
</ol>
</section>
))}
</div>
</div>
</>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { api, get } from "../api";
import { useT } from "../i18n";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
/** "bash:git status" → "git status"; tool names stay. */
const label = (key: string) => (key.startsWith("bash:") ? `$ ${key.slice(5)}` : key);
/** What the user allowed "for this project" (all chats of the folder) – with a way to revoke it. */
export function ProjectPermissions({ sessionId, cwd, onClose }: { sessionId: string; cwd: string; onClose: () => void }) {
const t = useT();
const [keys, setKeys] = useState<string[]>();
useEffect(() => {
get<string[]>(`/sessions/${sessionId}/approvals`).then(setKeys, (e) => (toast(e.message), setKeys([])));
}, [sessionId]);
const revoke = async (key: string) => {
try {
await api(`/sessions/${sessionId}/approvals`, { method: "DELETE", json: { key } });
setKeys((k) => k?.filter((x) => x !== key));
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" role="dialog" aria-label={t("permProject.title")} onClick={(e) => e.stopPropagation()}>
<h3>
<Icon name="lock" /> {t("permProject.title")}
</h3>
<p style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 4 }}>{t("permProject.intro")}</p>
<div className="path mono" style={{ marginBottom: 8 }}>{cwd}</div>
{keys === undefined ? (
<div className="empty">…</div>
) : keys.length === 0 ? (
<p style={{ fontSize: 13 }}>{t("permProject.empty")}</p>
) : (
<ul className="rewind-files">
{keys.map((k) => (
<li key={k} className="row" style={{ justifyContent: "space-between" }}>
<code>{label(k)}</code>
<button className="small danger" onClick={() => void revoke(k)}>
{t("permProject.revoke")}
</button>
</li>
))}
</ul>
)}
<div className="row" style={{ justifyContent: "flex-end", marginTop: 10 }}>
<button onClick={onClose}>{t("common.close")}</button>
</div>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { get } from "../api";
import { fmtMsgTime, useT } from "../i18n";
import { Markdown } from "./Markdown";
interface Shared {
title: string;
messages: { id: string; role: "user" | "assistant"; text: string; created_at: number }[];
}
/** A chat opened through a read-only link (no account needed). */
export function PublicChat({ token }: { token: string }) {
const t = useT();
const [chat, setChat] = useState<Shared>();
const [error, setError] = useState<string>();
useEffect(() => {
get<Shared>(`/public/chat/${encodeURIComponent(token)}`)
.then((c) => (setChat(c), (document.title = `${c.title || "Chat"} – CodeCraft`)))
.catch((e) => setError(e.message));
}, [token]);
if (error) return <div className="empty">{error}</div>;
if (!chat) return <div className="empty">{t("common.loading")}</div>;
return (
<div className="public-chat">
<header>
<h1>{chat.title || "Chat"}</h1>
<div className="hint">{t("link.readOnly")}</div>
</header>
<div className="chat-scroll">
{chat.messages.map((m) => (
<div key={m.id} className={m.role === "user" ? "bubble user-msg" : "bubble agent"}>
<Markdown text={m.text} />
<div className="msg-foot">
<span className="hint">{fmtMsgTime(m.created_at)}</span>
</div>
</div>
))}
</div>
</div>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { del, post } from "../api";
import { useT } from "../i18n";
import { useStore } from "../store";
import type { QueueItem } from "../types";
import { imageSrc } from "./FileFigure";
import { confirmDialog, promptDialog, toast } from "../dialogs";
const EMPTY: QueueItem[] = [];
@@ -30,7 +31,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
{it.source === "background" && <span className="chip">{t("tool.background")}</span>}
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
{images.map((p, i) => (
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
<img key={i} src={imageSrc(p.image)} alt="" />
))}
{it.source !== "background" && (
<button
+49 -1
View File
@@ -146,8 +146,11 @@ function VersionInfo() {
function DigestToggle() {
const t = useT();
const [on, setOn] = useState<boolean>();
const [weekly, setWeekly] = useState(false);
useEffect(() => {
get<{ enabled: boolean }>("/push/digest").then((r) => setOn(r.enabled)).catch(() => {});
get<{ enabled: boolean; weekly?: boolean }>("/push/digest")
.then((r) => (setOn(r.enabled), setWeekly(!!r.weekly)))
.catch(() => {});
}, []);
if (on === undefined) return null;
return (
@@ -164,6 +167,30 @@ function DigestToggle() {
/>
{t("settings.digest")}
</label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input
type="checkbox"
checked={weekly}
onChange={(e) => {
const v = e.target.checked;
setWeekly(v);
put("/push/digest", { weekly: v }).catch((err) => (setWeekly(!v), toast(err.message)));
}}
/>
{t("settings.weeklyDigest")}
</label>
{weekly && (
<button
className="small ghost"
onClick={() =>
post<{ sent: number; errors: string[] }>("/push/digest?weekly=true")
.then((r) => toast(r.sent ? t("settings.pushTestSent") : r.errors.join("; ") || t("settings.pushTestNone"), r.sent ? "success" : "error"))
.catch((e) => toast(e.message))
}
>
{t("settings.digestNow")}
</button>
)}
{on && (
<button
className="small ghost"
@@ -246,8 +273,11 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const [form, setForm] = useState({
default_model: s.default_model ?? "",
small_model: s.small_model ?? "",
plan_model: ((cfg.config as any).agents?.plan?.model ?? "") as string,
transcribe_model: ((s as any).transcribe_model ?? "") as string,
currency: s.currency ?? "USD",
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
max_parallel_subagents: s.max_parallel_subagents ?? 4,
monthly_budget: s.monthly_budget ?? 0,
budget_warning: s.budget_warning ?? 0.8,
@@ -269,8 +299,11 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
savePatches([
{ path: ["server", "default_model"], value: form.default_model },
{ path: ["server", "small_model"], value: form.small_model },
{ path: ["agents", "plan", "model"], value: form.plan_model || null },
{ path: ["server", "transcribe_model"], value: form.transcribe_model.trim() },
{ path: ["server", "currency"], value: form.currency },
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
@@ -300,11 +333,20 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<label>{t("settings.smallModel")}</label>
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
<div className="hint">{t("settings.smallModelHint")}</div>
<label>{t("settings.planModel")}</label>
<ModelSelect value={form.plan_model} emptyLabel={t("settings.sameAsChat")} onChange={(v) => setForm({ ...form, plan_model: v })} />
<div className="hint">{t("settings.planModelHint")}</div>
<label>{t("settings.transcribeModel")}</label>
<input value={form.transcribe_model} placeholder="openai/whisper-1" onChange={(e) => setForm({ ...form, transcribe_model: e.target.value })} />
<div className="hint">{t("settings.transcribeHint")}</div>
<label>{t("settings.currency")}</label>
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
<label>{t("settings.autoCompact")}</label>
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
<div className="hint">{t("settings.autoCompactHint")}</div>
<label>{t("settings.commitTrailer")}</label>
<input className="mono" value={form.commit_trailer} placeholder={t("settings.commitTrailerOff")} onChange={(e) => setForm({ ...form, commit_trailer: e.target.value })} />
<div className="hint">{t("settings.commitTrailerHint")}</div>
<label>{t("settings.monthlyBudgetTotal")}</label>
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
@@ -376,6 +418,7 @@ interface ModelRow {
name?: string;
context?: number;
max_output?: number;
max_input?: number;
reasoning?: boolean;
vision?: boolean;
effort?: string;
@@ -454,6 +497,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
price_multipliers: f.price_multipliers ?? m.price_multipliers,
context: m.context ?? f.context,
max_output: m.max_output ?? f.max_output,
max_input: m.max_input ?? f.max_input ?? undefined,
vision: m.vision || !!f.vision,
reasoning: m.reasoning || !!f.reasoning,
};
@@ -616,6 +660,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<th>ID</th>
<th>{t("settings.context")}</th>
<th>{t("settings.maxOutput")}</th>
<th title={t("settings.maxInputHint")}>{t("settings.maxInput")}</th>
<th title={t("settings.reasoning")}><Icon name="brain" /></th>
<th title={t("settings.vision")}><Icon name="eye" /></th>
<th>{t("settings.effort")}</th>
@@ -640,6 +685,9 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<td>
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
</td>
<td>
<input type="number" value={m.max_input ?? ""} placeholder="–" onChange={(e) => setModel(i, { max_input: num(e.target.value) })} />
</td>
<td>
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
</td>
+52 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { get, put } from "../api";
import { del, get, post, put } from "../api";
import { useT } from "../i18n";
import { useStore } from "../store";
import { toast } from "../dialogs";
import { fmtDate } from "../i18n";
interface DirUser {
id: string;
@@ -9,6 +11,54 @@ interface DirUser {
display_name: string;
}
interface Link {
token: string;
url: string;
expires_at: number;
}
/** Read-only links for people without an account. */
function Links({ sessionId }: { sessionId: string }) {
const t = useT();
const [links, setLinks] = useState<Link[]>([]);
const [days, setDays] = useState(7);
const load = () => get<Link[]>(`/sessions/${sessionId}/links`).then(setLinks).catch(() => undefined);
useEffect(() => void load(), [sessionId]);
const create = async () => {
try {
const l = await post<Link>(`/sessions/${sessionId}/links`, { days });
await navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"), () => undefined);
void load();
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="share-links">
<h4>{t("link.title")}</h4>
<div className="hint">{t("link.hint")}</div>
{links.map((l) => (
<div key={l.token} className="row share-link">
<input readOnly value={l.url} onFocus={(e) => e.currentTarget.select()} aria-label={t("link.title")} />
<span className="hint">{t("link.until", { date: fmtDate(l.expires_at, false) })}</span>
<button onClick={() => navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"))}>{t("link.copy")}</button>
<button onClick={() => del(`/sessions/${sessionId}/links/${l.token}`).then(load).catch((e) => toast(e.message))}>{t("link.revoke")}</button>
</div>
))}
<div className="row" style={{ marginTop: 6 }}>
<select value={days} onChange={(e) => setDays(Number(e.target.value))} aria-label={t("link.validity")}>
{[1, 7, 30, 90].map((d) => (
<option key={d} value={d}>
{t("link.days", { n: d })}
</option>
))}
</select>
<button onClick={create}>{t("link.create")}</button>
</div>
</div>
);
}
/** Choose users a session is shared with (read-only for them). */
export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
const t = useT();
@@ -59,6 +109,7 @@ export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose
</label>
))}
</div>
<Links sessionId={sessionId} />
{error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button>
+212 -14
View File
@@ -1,8 +1,9 @@
import { ColorSwatches, IconPicker } from "./ColorSwatches";
import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, patch, post } from "../api";
import { get, patch, post, put } from "../api";
import { promptDialog, toast } from "../dialogs";
import { fmtRelative, useT, type Key } from "../i18n";
import { navigate, openBeside } from "../router";
@@ -25,7 +26,7 @@ function groupKey(ms: number): Key {
const GROUP_BY = "codecraft.sidebarGroupBy";
const COLLAPSED = "codecraft.sidebarCollapsed";
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string; color?: string; icon?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
/** One session in the list, with a menu to pin, file or archive it. */
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
@@ -53,11 +54,15 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
};
return (
<div className={`session-row ${menu ? "menu-open" : ""}`}>
<button className={`session-item ${active ? "active" : ""}`} onClick={() => navigate(`/s/${s.id}`)}>
<button className={`session-item ${active ? "active" : ""}`} data-color={s.color} onClick={() => navigate(`/s/${s.id}`)}>
<div className="title">
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
<span>{s.title || t("session.untitled")}</span>
<span className={s.color ? "color-badge" : undefined}>
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
{s.title || t("session.untitled")}
</span>
</div>
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
<div className="sub">
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
@@ -93,6 +98,8 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
</button>
)}
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button>
@@ -102,6 +109,118 @@ 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()}>
{g.id.startsWith("project:") && (
<button role="menuitem" onClick={() => (setMenu(false), navigate(`/p/${encodeURIComponent(g.id.slice("project:".length))}`))}>
<Icon name="clock" /> {t("project.open")}
</button>
)}
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
</button>
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(-1))}>
<Icon name="arrow-up" /> {t("sidebar.groupUp")}
</button>
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(1))}>
<Icon name="arrow-down" /> {t("sidebar.groupDown")}
</button>
</div>
)}
</div>
);
}
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
const t = useT();
const sessions = useStore((s) => s.sessions);
@@ -164,14 +283,15 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
const live = sessions.filter((s) => !s.archived);
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
const rest = live.filter((s) => !s.pinned);
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])];
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
const loose = rest.filter((s) => !s.folder);
if (groupBy === "project") {
const byProject = new Map<string, Session[]>();
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
// Projects with the most recent activity first.
for (const [cwd, items] of [...byProject].sort((a, b) => b[1][0].updated_at - a[1][0].updated_at)) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
// Order of the projects: see `ordered` below (not by activity – a new message
// moves the chat to the top of its project, not the project).
for (const [cwd, items] of byProject) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
} else {
for (const s of loose) {
const key = groupKey(s.updated_at);
@@ -181,6 +301,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");
return out;
}, [sessions, groupBy, t]);
// Folders and projects: pinned first, then in the user's order. Groups not ordered yet
// (new folder or project) start at the top and are then kept where they are.
const [layout, setLayout] = useState<Layout>();
useEffect(() => {
get<Layout>("/account/sidebar")
.then((l) => setLayout({ order: l.order ?? [], pinned: l.pinned ?? [] }))
.catch(() => setLayout({ order: [], pinned: [] }));
}, []);
const ordered = useMemo(() => {
const l = layout ?? { order: [], pinned: [] };
const head = groups.filter((g) => g.id === "pinned");
const tail = groups.filter((g) => g.id !== "pinned" && !movable(g.id));
const rank = (id: string) => l.order.indexOf(id);
const mid = groups
.filter((g) => movable(g.id))
.sort((a, b) => {
const pa = l.pinned.includes(a.id);
const pb = l.pinned.includes(b.id);
if (pa !== pb) return pa ? -1 : 1;
const ra = rank(a.id);
const rb = rank(b.id);
if (ra < 0 && rb < 0) return b.items[0].updated_at - a.items[0].updated_at;
if (ra < 0 || rb < 0) return ra < 0 ? -1 : 1;
return ra - rb;
});
return { head, mid, tail };
}, [groups, layout]);
const saveLayout = (next: Layout) => {
// Forget groups that no longer exist (no chat in that folder or project).
const known = new Set(sessions.flatMap((s) => [`project:${s.cwd}`, ...(s.folder ? [`folder:${s.folder}`] : [])]));
const clean = { order: next.order.filter((id) => known.has(id)), pinned: next.pinned.filter((id) => known.has(id)) };
setLayout(clean);
void put("/account/sidebar", clean).catch((e) => toast(e.message));
};
// Freeze the position of groups that appeared since the last visit.
useEffect(() => {
if (!layout) return;
const fresh = ordered.mid.map((g) => g.id).filter((id) => !layout.order.includes(id));
if (fresh.length) saveLayout({ ...layout, order: [...fresh, ...layout.order] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ordered, layout]);
/** Order of the shown groups with `id` moved: by a step, or next to another group. */
const reorder = (ids: string[]) => {
if (!layout) return;
const shown = new Set(ids);
saveLayout({ ...layout, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
};
const moveGroup = (id: string, dir: -1 | 1) => {
if (!layout) return;
const ids = ordered.mid.map((g) => g.id);
const i = ids.indexOf(id);
const j = i + dir;
// Only within the pinned or the unpinned part.
if (j < 0 || j >= ids.length || layout.pinned.includes(ids[j]) !== layout.pinned.includes(id)) return;
[ids[i], ids[j]] = [ids[j], ids[i]];
reorder(ids);
};
const dropGroup = (dragged: string, target: string, after: boolean) => {
if (!layout || !movable(dragged)) return;
const ids = ordered.mid.map((g) => g.id).filter((id) => id !== dragged);
const at = ids.indexOf(target);
if (at < 0) return;
ids.splice(after ? at + 1 : at, 0, dragged);
// Dropped among pinned groups: pinned too (and the other way round).
const pinTarget = layout.pinned.includes(target);
const pinned = pinTarget ? [...new Set([...layout.pinned, dragged])] : layout.pinned.filter((p) => p !== dragged);
const shown = new Set(ids);
saveLayout({ pinned, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
};
const pinGroup = (id: string) => {
if (!layout) return;
const pinned = layout.pinned.includes(id) ? layout.pinned.filter((p) => p !== id) : [...layout.pinned, id];
saveLayout({ ...layout, pinned });
};
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
return (
@@ -235,14 +430,17 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
</div>
)}
{!hits &&
groups.map((g) => (
[...ordered.head, ...ordered.mid, ...ordered.tail].map((g) => (
<div key={g.id}>
<button className="session-group" onClick={() => toggle(g.id)} aria-expanded={!collapsed.has(g.id)}>
<Icon name={collapsed.has(g.id) ? "chevron-right" : "chevron-down"} className="tiny" />
{g.icon && <Icon name={g.icon} className="tiny" />}
<span className="grow">{g.label}</span>
{collapsed.has(g.id) && <span className="count">{g.items.length}</span>}
</button>
<GroupHeader
g={g}
collapsed={collapsed.has(g.id)}
pinned={!!layout?.pinned.includes(g.id)}
onToggle={() => toggle(g.id)}
onPin={movable(g.id) ? () => pinGroup(g.id) : undefined}
onMove={movable(g.id) ? (dir) => moveGroup(g.id, dir) : undefined}
onDrop={movable(g.id) ? (dragged, after) => dropGroup(dragged, g.id, after) : undefined}
/>
{!collapsed.has(g.id) &&
g.items.map((s) => (
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
+186 -56
View File
@@ -1,5 +1,5 @@
import { Icon } from "./Icon";
import { useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { useFetch } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
@@ -15,6 +15,9 @@ interface StatsData {
total: Aggregate;
by_day: Row<"day">[];
by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[];
by_month: Row<"month">[];
/** First recorded request (any range): the years to choose from. */
first_ts: number | null;
by_model: Row<"model">[];
by_provider: Row<"provider">[];
by_agent: Row<"agent">[];
@@ -37,6 +40,16 @@ const RANGES: { key: string; days: number; label: Key }[] = [
type HeatMetric = "requests" | "tokens" | "cost";
/** All months (YYYY-MM, local time) from the month of `fromMs` to the month of `toMs`. */
function monthRange(fromMs: number, toMs: number): string[] {
const out: string[] = [];
const d = new Date(fromMs);
d.setDate(1);
d.setHours(0, 0, 0, 0);
for (; d.getTime() <= toMs && out.length < 600; d.setMonth(d.getMonth() + 1)) out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
return out;
}
function dayKey(ms: number) {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
@@ -51,6 +64,44 @@ function dayRange(fromMs: number, toMs: number, firstDataDay?: string): string[]
return out;
}
const FOLD_KEY = "codecraft.statsFolded";
function foldedSet(): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(FOLD_KEY) ?? "[]"));
} catch {
return new Set();
}
}
/** Panel with a title that folds the content; the state is remembered per device. */
function Fold({ id, title, children }: { id: string; title: React.ReactNode; children: React.ReactNode }) {
const [closed, setClosed] = useState(() => foldedSet().has(id));
const toggle = () => {
const next = !closed;
setClosed(next);
try {
const set = foldedSet();
if (next) set.add(id);
else set.delete(id);
localStorage.setItem(FOLD_KEY, JSON.stringify([...set]));
} catch {
/* storage unavailable */
}
};
return (
<div className={`panel fold${closed ? " closed" : ""}`}>
<h3>
<button className="fold-toggle" aria-expanded={!closed} onClick={toggle}>
<span className="fold-caret" aria-hidden="true" />
{title}
</button>
</h3>
{!closed && children}
</div>
);
}
function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="kpi">
@@ -67,8 +118,39 @@ function perMillion(r: Aggregate, currency: string) {
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–";
}
function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & Record<string, any>)[]; keyName: string; label: string; currency: string; link?: (r: any) => void }) {
function AggTable({
rows,
keyName,
label,
currency,
link,
group,
}: {
rows: (Aggregate & Record<string, any>)[];
keyName: string;
label: string;
currency: string;
link?: (r: any) => void;
/** Group key of a row (e.g. the provider of a model): groups fold. */
group?: (r: any) => string;
}) {
const t = useT();
const [shut, setShut] = useState<Set<string>>(new Set());
const groups = group ? [...new Set(rows.map(group))] : [];
const row = (r: (typeof rows)[number]) => (
<tr key={r[keyName]}>
<td className={group ? "fold-indent" : undefined}>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
<td className="num">{fmtNumber(r.prefill_tps)}</td>
<td className="num">{fmtCost(r.cost, currency)}</td>
<td className="num">{perMillion(r, currency)}</td>
</tr>
);
return (
<div style={{ overflowX: "auto" }}>
<table className="grid">
@@ -87,20 +169,33 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r[keyName]}>
<td>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
<td className="num">{fmtNumber(r.prefill_tps)}</td>
<td className="num">{fmtCost(r.cost, currency)}</td>
<td className="num">{perMillion(r, currency)}</td>
</tr>
))}
{group
? groups.map((g) => {
const members = rows.filter((r) => group(r) === g);
const open = !shut.has(g);
return (
<Fragment key={g}>
<tr className="fold-group">
<td>
<button
className="fold-toggle"
aria-expanded={open}
onClick={() => setShut((x) => (x.has(g) ? new Set([...x].filter((k) => k !== g)) : new Set(x).add(g)))}
>
<span className="fold-caret" aria-hidden="true" />
{g} <span className="hint">({members.length})</span>
</button>
</td>
<td className="num">{fmtNumber(members.reduce((a, r) => a + r.requests, 0))}</td>
<td colSpan={6} />
<td className="num">{fmtCost(members.reduce((a, r) => a + r.cost, 0), currency)}</td>
<td />
</tr>
{open && members.map(row)}
</Fragment>
);
})
: rows.map(row)}
</tbody>
</table>
</div>
@@ -117,16 +212,40 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
const isAdmin = useStore((s) => s.me?.role === "admin");
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
const [userFilter, setUserFilter] = useState("");
const days = RANGES.find((r) => r.key === range)!.days;
const tz = -new Date().getTimezoneOffset();
// Stable per range: rounded to the minute so the URL doesn't change on every render.
const now = Math.floor(Date.now() / 60000) * 60000 + 60000;
const from = days ? now - days * 86400000 : 0;
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
// "y2025": that calendar year (local time); otherwise a rolling window.
const year = /^y(\d{4})$/.test(range) ? Number(range.slice(1)) : 0;
const days = RANGES.find((r) => r.key === range)?.days ?? 0;
const from = year ? new Date(year, 0, 1).getTime() : days ? now - days * 86400000 : 0;
const to = year ? Math.min(now, new Date(year + 1, 0, 1).getTime()) : now;
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${to}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
/** Download of what is shown: all tables as XLSX, or one table as CSV. */
const exportAs = (what: string) => {
if (!what) return;
const q = `from=${from}&to=${to}&tz=${tz}&lang=${getLang()}${userFilter ? `&user=${userFilter}` : ""}`;
const a = document.createElement("a");
a.href = `/api/stats/export?${q}&format=${what === "xlsx" ? "xlsx" : "csv&table=" + what.slice(4)}`;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
};
// Years since the first recorded request (the data survives indefinitely).
const [firstYear, setFirstYear] = useState(new Date().getFullYear());
useEffect(() => {
if (data?.first_ts) setFirstYear(new Date(data.first_ts).getFullYear());
}, [data?.first_ts]);
const years = Array.from({ length: new Date().getFullYear() - firstYear + 1 }, (_, i) => new Date().getFullYear() - i);
const spanDays = ((data?.to ?? now) - (data?.from || data?.first_ts || now)) / 86400000;
// Long ranges: one bar per month instead of one per day.
const monthly = spanDays > 120;
const view = useMemo(() => {
if (!data) return undefined;
const cats = dayRange(data.from, data.to, data.by_day[0]?.day);
const bucket = (day: string) => (monthly ? day.slice(0, 7) : day);
const cats = monthly ? monthRange(data.from || data.first_ts || data.to, data.to) : dayRange(data.from, data.to, data.by_day[0]?.day);
// Up to 7 models by cost get their own color; the rest fold into "Other".
const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests);
const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model);
@@ -135,7 +254,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
const costByDay = cats.map(() => ({}) as Record<string, number>);
const idx = new Map(cats.map((c, i) => [c, i]));
for (const r of data.by_day_model) {
const i = idx.get(r.day);
const i = idx.get(bucket(r.day));
if (i === undefined) continue;
const key = named.includes(r.model) ? r.model : other;
costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost;
@@ -146,7 +265,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
{ key: "output", label: t("stats.output"), color: SERIES[2] },
];
const tokensByDay = cats.map((c): Record<string, number> => {
const d = data.by_day.find((x) => x.day === c);
const d = monthly ? data.by_month.find((x) => x.month === c) : data.by_day.find((x) => x.day === c);
return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {};
});
// Rolling window: the last 14 days, today at the bottom.
@@ -174,10 +293,12 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) };
});
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows };
}, [data, heat, t]);
}, [data, heat, t, monthly]);
const dayLabel = (d: string) =>
new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`));
monthly
? new Intl.DateTimeFormat(getLang(), { month: "short", year: "2-digit" }).format(new Date(`${d}-01T00:00:00`))
: new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`));
const cur = data?.currency ?? "USD";
const tot = data?.total;
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
@@ -203,6 +324,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
</button>
))}
</div>
{years.length > 1 && (
<select value={year ? range : ""} onChange={(e) => e.target.value && setRange(e.target.value)} aria-label={t("stats.year")} className={year ? "active" : ""}>
<option value="">{t("stats.year")}</option>
{years.map((y) => (
<option key={y} value={`y${y}`}>
{y}
</option>
))}
</select>
)}
{isAdmin && (
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
<option value="">{t("stats.allUsers")}</option>
@@ -213,6 +344,15 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
))}
</select>
)}
<select value="" onChange={(e) => exportAs(e.target.value)} aria-label={t("stats.export")} title={t("stats.exportHint")}>
<option value="">{t("stats.export")}</option>
<option value="xlsx">{t("stats.exportXlsx")}</option>
{(["day", "month", "model", "provider", "agent", "user", "session"] as const).map((k) => (
<option key={k} value={`csv:${k}`}>
{t("stats.exportCsv", { table: t(`stats.table.${k}` as Key) })}
</option>
))}
</select>
<button className="small" onClick={() => setTableView(!tableView)}>
{tableView ? t("stats.chartView") : t("stats.tableView")}
</button>
@@ -238,61 +378,51 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
{tot.requests === 0 ? (
<div className="empty">{t("stats.noData")}</div>
) : tableView ? (
<div className="panel">
<h3>{t("stats.perDay")}</h3>
<Fold id="perDay" title={t("stats.perDay")}>
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
</div>
</Fold>
) : (
<>
<div className="panel">
<h3>{t("stats.costPerDay")}</h3>
<Fold id="costPerDay" title={t(monthly ? "stats.costPerMonth" : "stats.costPerDay")}>
<Legend series={view.modelSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
</div>
<div className="panel">
<h3>{t("stats.tokensPerDay")}</h3>
</Fold>
<Fold id="tokensPerDay" title={t(monthly ? "stats.tokensPerMonth" : "stats.tokensPerDay")}>
<Legend series={view.tokenSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
</div>
<div className="panel">
<h3>
{t("stats.heatmap")}
<span className="spacer" />
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)}>
</Fold>
<Fold id="heatmap" title={t("stats.heatmap")}>
<div className="row" style={{ justifyContent: "flex-end", marginBottom: 6 }}>
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)} aria-label={t("stats.heatmap")}>
<option value="requests">{t("stats.requests")}</option>
<option value="tokens">{t("stats.tokens")}</option>
<option value="cost">{t("stats.cost")}</option>
</select>
</h3>
</div>
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
</div>
</Fold>
</>
)}
{tot.requests > 0 && (
<>
<div className="panel">
<h3>{t("stats.byModel")}</h3>
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
</div>
<div className="panel">
<h3>{t("stats.byProvider")}</h3>
<Fold id="byModel" title={t("stats.byModel")}>
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} group={(r) => String(r.model).split("/")[0]} />
</Fold>
<Fold id="byProvider" title={t("stats.byProvider")}>
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
</div>
<div className="panel">
<h3>{t("stats.byAgent")}</h3>
</Fold>
<Fold id="byAgent" title={t("stats.byAgent")}>
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
</div>
</Fold>
{isAdmin && !userFilter && data.by_user.length > 1 && (
<div className="panel">
<h3>{t("stats.byUser")}</h3>
<Fold id="byUser" title={t("stats.byUser")}>
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
</div>
</Fold>
)}
<div className="panel">
<h3>{t("stats.topSessions")}</h3>
<Fold id="topSessions" title={t("stats.topSessions")}>
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
</div>
</Fold>
</>
)}
</>
+45 -10
View File
@@ -1,5 +1,6 @@
import { Icon } from "./Icon";
import { base64Size, FileFigure } from "./FileFigure";
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
import { useState } from "react";
import { useT } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
@@ -68,6 +69,8 @@ export function TodoList({ todos }: { todos: Todo[] }) {
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
const t = useT();
// The body (diff, output) is only built while the card is open – long chats have thousands.
const [toggled, setToggled] = useState<boolean | null>(null);
const progress = useStore((s) => s.progress[call.id]);
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
@@ -118,10 +121,14 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
const openByDefault =
(status === "err" && !!result) ||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || (result.images?.length ?? 0) > 0 || call.name === "generate_image" || call.name === "generate_3d"));
const open = toggled ?? openByDefault;
return (
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}>
<details className={`tool ${status === "run" ? "running" : ""}`} open={open} onToggle={(e) => {
const now = (e.currentTarget as HTMLDetailsElement).open;
if (now !== open) setToggled(now);
}}>
<summary>
<span className="tool-name">{call.name}</span>
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
@@ -130,6 +137,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
</summary>
{open && (
<div className="tool-body">
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
@@ -156,22 +164,49 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
<pre className="mono">{result.content}</pre>
</>
)}
{typeof meta?.model_file === "string" && (
<button
className="small"
style={{ marginBottom: 8 }}
onClick={() => {
sessionStorage.setItem("codecraft.openFile", meta.model_file);
navigate("/files");
}}
>
<Icon name="cube" /> {t("tool.open3d")}
</button>
)}
{Array.isArray(meta?.image_ids) ? (
<div className="gallery small">
{meta.image_ids.map((id: string) => (
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
<img src={`/api/images/${id}/file`} alt="" />
</FileFigure>
))}
{meta.image_ids.map((id: string, i: number) => {
// The file may be gone (moved or deleted later): fall back to the preview the model saw.
const preview = result?.images?.[i];
return (
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
<img
src={`/api/images/${id}/file`}
alt=""
onError={(e) => {
const img = e.currentTarget;
if (preview && !img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = imageSrc(preview);
}
}}
/>
</FileFigure>
);
})}
</div>
) : (
result?.images?.map((img, i) => (
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
<img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
</FileFigure>
))
)}
</div>
)}
</details>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useState } from "react";
import { get } from "../api";
import { useT } from "../i18n";
import type { Message } from "../types";
import { Diff } from "./Diff";
import { Icon } from "./Icon";
import { basename } from "./FileFigure";
interface Change {
path: string;
created: boolean;
deleted?: boolean;
diff?: string;
truncated?: boolean;
}
const count = (diff: string | undefined, sign: "+" | "-") => (diff ?? "").split("\n").filter((l) => l.startsWith(sign) && !l.startsWith(sign.repeat(3))).length;
/**
* What one turn changed: files with their diffs (from the checkpoints the agent leaves before
* every write/edit) and a button to roll the chat and the files back to before the turn.
*/
export function TurnChanges({
sessionId,
fromId,
untilId,
fileCount,
userMessage,
onRewind,
}: {
sessionId: string;
fromId: string;
untilId?: string;
fileCount: number;
userMessage?: Message;
onRewind?: (m: Message) => void;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const [changes, setChanges] = useState<Change[]>();
const [error, setError] = useState<string>();
const toggle = () => {
const next = !open;
setOpen(next);
if (next && !changes) {
const q = `since_message=${encodeURIComponent(fromId)}&diff=true${untilId ? `&until_message=${encodeURIComponent(untilId)}` : ""}`;
get<Change[]>(`/sessions/${sessionId}/changes?${q}`).then(setChanges, (e) => setError(e.message));
}
};
return (
<div className="turn-changes">
<button className="small ghost" onClick={toggle} aria-expanded={open}>
<Icon name={open ? "chevron-down" : "chevron-right"} className="tiny" /> {t("turn.changes", { n: fileCount })}
</button>
{open && (
<div className="turn-changes-body">
{error && <div className="notice err">{error}</div>}
{!changes && !error && <div className="hint">…</div>}
{changes?.length === 0 && <div className="hint">{t("turn.noChanges")}</div>}
{changes?.map((c) => (
<details key={c.path} className="turn-file">
<summary title={c.path}>
<span className="mono">{basename(c.path)}</span>
{c.created && <span className="chip">{t("turn.new")}</span>}
{c.deleted && <span className="chip warn">{t("turn.deleted")}</span>}
<span className="turn-stat">
<span className="add">+{count(c.diff, "+")}</span> <span className="del">−{count(c.diff, "-")}</span>
</span>
</summary>
{c.diff ? <Diff diff={c.diff} /> : <div className="hint">{t("turn.noDiff")}</div>}
{c.truncated && <div className="hint">{t("turn.truncated")}</div>}
</details>
))}
{userMessage && onRewind && changes && changes.length > 0 && (
<button className="small" onClick={() => onRewind(userMessage)}>
<Icon name="undo" /> {t("turn.rollback")}
</button>
)}
</div>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { useState } from "react";
import { post } from "../api";
import { toast } from "../dialogs";
import { useT } from "../i18n";
import { useStore } from "../store";
import { Spinner } from "./Activity";
import { Icon } from "./Icon";
/** Green/red badge of the project's check (tests, build …) for one turn; output and "let the agent fix it" on click. */
export function TurnCheck({ sessionId, messageId, canSend }: { sessionId: string; messageId: string; canSend: boolean }) {
const t = useT();
const check = useStore((s) => s.checks[sessionId]?.[messageId]);
const [open, setOpen] = useState(false);
if (!check) return null;
const seconds = check.duration_ms >= 1000 ? ` · ${(check.duration_ms / 1000).toFixed(check.duration_ms >= 10_000 ? 0 : 1)} s` : "";
const label =
check.status === "running" ? t("check.running") : check.status === "passed" ? t("check.passed") : check.status === "timeout" ? t("check.timeout") : t("check.failed");
const fix = async () => {
try {
await post(`/sessions/${sessionId}/prompt`, { text: `${t("check.fixPrompt")}\n\n\`\`\`\n${check.output}\n\`\`\`` });
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="turn-check">
<button className={`chip check-${check.status}`} onClick={() => setOpen(!open)} aria-expanded={open} disabled={check.status === "running"}>
{check.status === "running" ? <Spinner small /> : <Icon name={check.status === "passed" ? "check" : "x-mark"} className="tiny" />} {label}
{seconds}
</button>
{open && check.status !== "running" && (
<div className="turn-check-body">
<pre className="mono">{check.output || t("check.noOutput")}</pre>
{check.status !== "passed" && canSend && (
<button className="small primary" onClick={fix}>
<Icon name="sparkle" /> {t("check.fix")}
</button>
)}
</div>
)}
</div>
);
}
+78 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { getLang, useT } from "../i18n";
import { toast } from "../dialogs";
import { get } from "../api";
import { Icon } from "./Icon";
/* Web Speech API (Chrome/Edge/Safari; Firefox has none). */
@@ -18,10 +19,86 @@ const Impl: (new () => Recognition) | undefined = (window as any).SpeechRecognit
export const voiceSupported = () => !!Impl;
/** Server-side transcription is on (server.transcribe_model) – for browsers without dictation. */
function useServerSpeech(): boolean {
const [on, setOn] = useState(false);
useEffect(() => {
if (Impl) return;
get<{ transcribe?: boolean }>("/config")
.then((c) => setOn(!!c.transcribe))
.catch(() => undefined);
}, []);
return on;
}
/** Record with the microphone; the audio is transcribed by the server. */
function RecorderButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const t = useT();
const [state, setState] = useState<"idle" | "recording" | "sending">("idle");
const rec = useRef<MediaRecorder | null>(null);
useEffect(() => () => rec.current?.stream.getTracks().forEach((x) => x.stop()), []);
const toggle = async () => {
if (state === "recording") {
rec.current?.stop();
return;
}
if (state === "sending") return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const r = new MediaRecorder(stream);
const chunks: Blob[] = [];
r.ondataavailable = (e) => e.data.size && chunks.push(e.data);
r.onstop = async () => {
stream.getTracks().forEach((x) => x.stop());
setState("sending");
onInterim(t("voice.transcribing"));
try {
const blob = new Blob(chunks, { type: r.mimeType || "audio/webm" });
const res = await fetch(`/api/transcribe?lang=${getLang()}`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": blob.type }, body: blob });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? data.message ?? `HTTP ${res.status}`);
if (data.text) onText(String(data.text));
} catch (e: any) {
toast(`${t("voice.error")}: ${e.message}`);
} finally {
onInterim("");
setState("idle");
}
};
rec.current = r;
r.start();
setState("recording");
} catch {
toast(t("voice.denied"));
}
};
return (
<button
type="button"
className={`ghost small voice-btn ${state === "recording" ? "on" : ""}`}
disabled={state === "sending"}
onClick={() => void toggle()}
title={t(state === "recording" ? "voice.stop" : "voice.start")}
aria-label={t(state === "recording" ? "voice.stop" : "voice.start")}
aria-pressed={state === "recording"}
>
<Icon name="mic" />
</button>
);
}
const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" };
/** Microphone button: dictated text is appended to the message. */
export function VoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
export function VoiceButton(props: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const server = useServerSpeech();
if (!Impl) return server ? <RecorderButton {...props} /> : null;
return <BrowserVoiceButton {...props} />;
}
function BrowserVoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const t = useT();
const [listening, setListening] = useState(false);
const rec = useRef<Recognition | null>(null);
+8
View File
@@ -3,9 +3,13 @@ import { post } from "../../api";
import { useT } from "../../i18n";
import { useStore } from "../../store";
import { GitAccount } from "./GitAccount";
import { SshKeys } from "./SshKeys";
import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory";
import { Snippets } from "./Snippets";
import { ClaudeCode } from "./ClaudeCode";
import { Telegram } from "./Telegram";
export function Account() {
const t = useT();
@@ -49,8 +53,12 @@ export function Account() {
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div>
<Memory />
<Snippets />
<GitAccount />
<SshKeys />
<CliInstall />
<ClaudeCode />
<Telegram />
<ApiTokens />
</>
);
+18 -1
View File
@@ -61,6 +61,8 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
refine_model: raw?.refine?.model ?? "",
refine_criteria: raw?.refine?.criteria ?? "",
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
reverse_tools: !!(raw?.reverse_tools ?? a.reverse),
});
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
@@ -74,9 +76,10 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
patches.push({
path: [...base, "refine"],
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null } : null,
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
});
}
if (a.name !== "craft") patches.push({ path: [...base, "reverse_tools"], value: form.reverse_tools || null });
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
if (!builtin) {
patches.push({ path: [...base, "mode"], value: form.mode });
@@ -158,6 +161,20 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
<label>{t("agents.refineCriteria")}</label>
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
<div className="hint">{t("agents.refineCriteriaHint")}</div>
<label className="check">
<input type="checkbox" checked={form.refine_after_todos} onChange={(e) => setForm({ ...form, refine_after_todos: e.target.checked })} />
{t("agents.refineAfterTodos")}
</label>
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
</>
)}
{a.name !== "craft" && (
<>
<label className="check">
<input type="checkbox" checked={form.reverse_tools} onChange={(e) => setForm({ ...form, reverse_tools: e.target.checked })} />
{t("agents.reverseTools")}
</label>
<div className="hint">{t("agents.reverseToolsHint")}</div>
</>
)}
<label>{t("agents.description")}</label>
+191
View File
@@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { api, get, post } from "../../api";
import { confirmDialog, toast } from "../../dialogs";
import { useT } from "../../i18n";
import { Icon } from "../Icon";
import { Spinner } from "../Activity";
import { useStore } from "../../store";
interface Status {
installed: boolean;
logged_in?: boolean;
method?: string;
email?: string;
org?: string;
expires?: string;
login_in_progress?: boolean;
error?: string;
version?: string | null;
latest?: string | null;
update_available?: boolean;
output?: string;
}
/**
* Claude Code: sign in from the browser. The CLI prints a sign-in URL; the Anthropic page shows
* a code after login that is pasted here and handed back to the CLI on the server.
*/
export function ClaudeCode() {
const t = useT();
const [status, setStatus] = useState<Status>();
const [url, setUrl] = useState<string>();
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [console_, setConsole] = useState(false);
const isAdmin = useStore((s) => s.me?.role === "admin");
const [updating, setUpdating] = useState(false);
const [checking, setChecking] = useState(false);
const [updateLog, setUpdateLog] = useState<string>();
const check = async () => {
setChecking(true);
try {
setStatus(await get<Status>("/claude/status?refresh=true"));
} catch (e: any) {
toast(e.message);
} finally {
setChecking(false);
}
};
const update = async () => {
setUpdating(true);
setUpdateLog(undefined);
try {
const r = await post<Status>("/claude/update", {});
setStatus(r);
setUpdateLog(r.output);
toast(t("claude.updated", { version: r.version ?? "" }), "success");
} catch (e: any) {
setUpdateLog(e.message);
toast(t("claude.updateFailed"));
} finally {
setUpdating(false);
}
};
const load = () => get<Status>("/claude/status").then(setStatus, (e) => setStatus({ installed: false, error: e.message }));
useEffect(() => void load(), []);
const start = async () => {
setBusy(true);
try {
const r = await post<{ url: string }>("/claude/login", { console: console_ });
setUrl(r.url);
setCode("");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const submit = async () => {
setBusy(true);
try {
setStatus(await post<Status>("/claude/login/code", { code }));
setUrl(undefined);
setCode("");
toast(t("claude.signedIn"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const cancel = async () => {
await api("/claude/login", { method: "DELETE" }).catch(() => undefined);
setUrl(undefined);
setCode("");
};
const logout = async () => {
if (!(await confirmDialog({ message: t("claude.logoutConfirm"), danger: true, confirmLabel: t("claude.logout") }))) return;
try {
setStatus(await post<Status>("/claude/logout", {}));
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="panel">
<h3>
<Icon name="robot" /> {t("claude.title")}
<span className="spacer" />
{status?.logged_in && (
<button className="small ghost danger" onClick={logout}>
{t("claude.logout")}
</button>
)}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>{t("claude.intro")}</div>
{!status && <Spinner />}
{status && !status.installed && <div className="notice err">{status.error ?? t("claude.notInstalled")}</div>}
{status?.installed && (
<div className="row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
<span className="chip mono">{t("claude.version", { version: status.version ?? "?" })}</span>
{status.update_available ? (
<>
<span className="chip warn">{t("claude.updateAvailable", { version: status.latest ?? "" })}</span>
{isAdmin ? (
<button className="small primary" disabled={updating} onClick={update}>
{updating ? <Spinner small /> : <Icon name="download" />} {updating ? t("claude.updating") : t("claude.update")}
</button>
) : (
<span className="hint">{t("claude.updateAdmin")}</span>
)}
</>
) : (
status.latest && <span className="chip">{t("claude.upToDate")}</span>
)}
<button className="small ghost" disabled={checking || updating} onClick={check} title={t("claude.checkNow")} aria-label={t("claude.checkNow")}>
{checking ? <Spinner small /> : <Icon name="refresh" />}
</button>
</div>
)}
{updateLog && (
<details style={{ marginBottom: 10 }}>
<summary className="hint" style={{ cursor: "pointer" }}>{t("claude.updateLog")}</summary>
<pre className="mono" style={{ whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>{updateLog}</pre>
</details>
)}
{status?.installed && status.logged_in && (
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<span className="chip">
<Icon name="check" className="tiny" /> {t("claude.signedInAs", { who: status.email || status.org || status.method || "" })}
</span>
{status.method && <span className="chip">{status.method}</span>}
{status.expires && <span className="hint">{t("claude.expires", { date: new Date(status.expires).toLocaleDateString() })}</span>}
</div>
)}
{status?.installed && !status.logged_in && !url && (
<>
<label className="row" style={{ marginBottom: 8 }}>
<input type="checkbox" checked={console_} onChange={(e) => setConsole(e.target.checked)} /> {t("claude.console")}
</label>
<button className="primary" disabled={busy} onClick={start}>
{busy ? <Spinner small /> : <Icon name="lock" />} {t("claude.signIn")}
</button>
</>
)}
{url && (
<ol className="cli-steps">
<li>
<a className="button" href={url} target="_blank" rel="noreferrer">
<Icon name="external" /> {t("claude.openLogin")}
</a>
<div className="hint">{t("claude.step1")}</div>
</li>
<li>
{t("claude.step2")}
<div className="row" style={{ marginTop: 6 }}>
<input style={{ flex: 1 }} value={code} placeholder={t("claude.codePlaceholder")} autoComplete="off" autoCapitalize="off" spellCheck={false} onChange={(e) => setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && code.trim() && void submit()} />
<button className="primary" disabled={busy || !code.trim()} onClick={submit}>
{busy ? <Spinner small /> : <Icon name="check" />} {t("claude.confirm")}
</button>
<button onClick={cancel}>{t("common.cancel")}</button>
</div>
</li>
</ol>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
export interface Snippet {
id?: string;
name: string;
text: string;
}
/** Personal prompt snippets: typed as `/name` in the composer. */
export function Snippets() {
const t = useT();
const [items, setItems] = useState<Snippet[]>();
useEffect(() => {
get<Snippet[]>("/account/snippets")
.then(setItems)
.catch((e) => toast(e.message));
}, []);
if (!items) return null;
const set = (i: number, patch: Partial<Snippet>) => setItems(items.map((s, j) => (j === i ? { ...s, ...patch } : s)));
const save = () => {
const clean = items.filter((s) => s.name.trim() || s.text.trim()).map((s) => ({ ...s, name: s.name.trim().replace(/^\/+/, "").replace(/\s+/g, "-") }));
put<Snippet[]>("/account/snippets", clean)
.then((r) => (setItems(r), toast(t("settings.saved"), "success")))
.catch((e) => toast(e.message));
};
return (
<div className="panel">
<h3>
<Icon name="sparkle" /> {t("snippets.title")}
</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("snippets.hint")}
</div>
{items.map((s, i) => (
<div key={s.id ?? i} className="snippet-row">
<div className="row">
<span className="snippet-slash">/</span>
<input value={s.name} placeholder={t("snippets.name")} maxLength={40} onChange={(e) => set(i, { name: e.target.value })} aria-label={t("snippets.name")} />
<button title={t("common.delete")} aria-label={t("common.delete")} onClick={() => setItems(items.filter((_, j) => j !== i))}>
<Icon name="trash" />
</button>
</div>
<textarea rows={2} value={s.text} placeholder={t("snippets.text")} onChange={(e) => set(i, { text: e.target.value })} aria-label={t("snippets.text")} />
</div>
))}
<div className="row" style={{ marginTop: 8 }}>
<button onClick={() => setItems([...items, { name: "", text: "" }])}>
<Icon name="plus" /> {t("snippets.add")}
</button>
<span className="grow" />
<button className="primary" onClick={save}>
{t("common.save")}
</button>
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import { del, get, post } from "../../api";
import { useT } from "../../i18n";
import { confirmDialog, toast } from "../../dialogs";
import { Icon } from "../Icon";
interface Key {
id: string;
name: string;
public: string;
fingerprint: string;
}
/** SSH keys of the user: used by agents, the terminal and the git panel to reach other servers. */
export function SshKeys() {
const t = useT();
const [keys, setKeys] = useState<Key[]>();
const [name, setName] = useState("");
const [priv, setPriv] = useState("");
const [importing, setImporting] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
get<Key[]>("/account/ssh")
.then(setKeys)
.catch((e) => toast(e.message));
}, []);
if (!keys) return null;
const add = async () => {
setBusy(true);
try {
setKeys(await post<Key[]>("/account/ssh", { name, private: importing ? priv : undefined }));
setName("");
setPriv("");
toast(t("ssh.added"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const copy = (text: string) => navigator.clipboard?.writeText(text).then(() => toast(t("ssh.copied"), "success"), () => toast(t("ssh.copyFailed")));
return (
<div className="panel">
<h3>
<Icon name="lock" /> {t("ssh.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("ssh.hint")}
</div>
{keys.length === 0 && <div className="hint">{t("ssh.none")}</div>}
{keys.map((k) => (
<div key={k.id} className="ssh-key">
<div className="row">
<b>{k.name}</b>
<span className="hint mono">{k.fingerprint}</span>
<span className="spacer" />
<button className="small" onClick={() => copy(k.public)}>
<Icon name="copy" /> {t("ssh.copyPublic")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("ssh.deleteConfirm", { name: k.name }), danger: true })) {
del<Key[]>(`/account/ssh/${k.id}`).then(setKeys).catch((e) => toast(e.message));
}
}}
>
<Icon name="trash" />
</button>
</div>
<input readOnly value={k.public} onFocus={(e) => e.currentTarget.select()} aria-label={t("ssh.public")} className="mono" />
</div>
))}
<div className="row" style={{ marginTop: 10, flexWrap: "wrap" }}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("ssh.name")} maxLength={60} aria-label={t("ssh.name")} style={{ flex: "1 1 160px" }} />
<button className="primary" disabled={busy || !name.trim() || (importing && !priv.trim())} onClick={add}>
{importing ? t("ssh.import") : t("ssh.generate")}
</button>
<label className="row" style={{ gap: 6 }}>
<input type="checkbox" checked={importing} onChange={(e) => setImporting(e.target.checked)} />
{t("ssh.importExisting")}
</label>
</div>
{importing && (
<>
<textarea rows={5} className="mono" value={priv} onChange={(e) => setPriv(e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" aria-label={t("ssh.private")} style={{ width: "100%", marginTop: 8 }} spellCheck={false} autoComplete="off" />
<div className="hint">{t("ssh.importHint")}</div>
</>
)}
</div>
);
}
+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>
);
}
+20
View File
@@ -0,0 +1,20 @@
/** Put content into the composer of a chat from anywhere in the app (quote, image viewer, …). */
export interface ComposeDetail {
sessionId: string;
/** Appended to the text field (a blank line before it if there is text already). */
text?: string;
/** Attached like dropped files (images stay images). */
files?: File[];
}
export const COMPOSE_EVENT = "codecraft:compose";
export function compose(detail: ComposeDetail) {
window.dispatchEvent(new CustomEvent<ComposeDetail>(COMPOSE_EVENT, { detail }));
}
/** "> quoted\n> lines\n\n" for the composer. */
export function asQuote(text: string) {
const lines = text.trim().split("\n").map((l) => `> ${l}`.trimEnd());
return `${lines.join("\n")}\n\n`;
}
+36 -8
View File
@@ -20,8 +20,15 @@ interface PromptOpts {
confirmLabel?: string;
}
interface ChoiceOpts {
title?: string;
message: string;
choices: { value: string; label: string; danger?: boolean }[];
}
type Dialog =
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
| ({ kind: "choice"; id: number; resolve: (v: string | null) => void } & ChoiceOpts)
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
interface Toast {
@@ -45,6 +52,14 @@ export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
});
}
/** Several actions to choose from (plus Cancel): resolves to the chosen value, or null. */
export function choiceDialog(opts: ChoiceOpts): Promise<string | null> {
return new Promise((resolve) => {
dialogs = [...dialogs, { kind: "choice", id: ++seq, resolve, ...opts }];
emit();
});
}
export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
const o = typeof opts === "string" ? { message: opts } : opts;
return new Promise((resolve) => {
@@ -108,14 +123,27 @@ function DialogView({ d }: { d: Dialog }) {
style={{ width: "100%" }}
/>
)}
<div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")}
</button>
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
{d.confirmLabel ?? t("common.ok")}
</button>
</div>
{d.kind === "choice" ? (
<div className="row dialog-actions dialog-choices">
<button type="button" onClick={() => close(d, null)}>
{t("common.cancel")}
</button>
{d.choices.map((c, i) => (
<button key={c.value} ref={i === d.choices.length - 1 ? ok : undefined} type="button" className={c.danger ? "danger-solid" : i === d.choices.length - 1 ? "primary" : undefined} onClick={() => close(d, c.value)}>
{c.label}
</button>
))}
</div>
) : (
<div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")}
</button>
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
{d.confirmLabel ?? t("common.ok")}
</button>
</div>
)}
</form>
</div>
);
+291 -10
View File
@@ -189,7 +189,7 @@ export const de: Messages = {
"tools.install": "Ghidra installieren",
"tools.reinstall": "Installation prüfen / reparieren",
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…",
"tools.ghidraHint": "Mit Ghidra können Agenten im Craft-Deep-Modus Programme dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren. Wird samt eigenem JDK ins Daten-Volume installiert.",
"tools.ghidraHint": "Mit Ghidra können Agenten Programme und Firmware dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren – im Craft-Deep-Modus und in Modi mit Reverse-Engineering-Werkzeugen (Einstellungen → Agenten & Befehle). Wird samt eigenem JDK ins Daten-Volume installiert.",
"rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.",
"rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
@@ -334,6 +334,135 @@ export const de: Messages = {
"wizard.idea.cadBox": "Eine Box mit Schnappdeckel, Innenmaß 80 × 50 × 30 mm",
"wizard.idea.cadBracket": "Eine Wandhalterung für ein 25-mm-Rohr mit zwei M4-Schraublöchern",
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
"chat.reviewPassed": "Review bestanden:",
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
"agents.reverseTools": "Reverse-Engineering-Werkzeuge (binary_info, decompile mit Ghidra)",
"agents.reverseToolsHint": "Der Agent kann in diesem Modus Programme und Firmware untersuchen und dekompilieren (Ghidra muss unter Einstellungen → Werkzeuge installiert sein). Craft hat sie im Deep-Modus ohnehin.",
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
"chat.workSteps": "{n} Schritte",
"chat.workFiles": "{n} Dateien",
"chat.loadOlder": "Ältere Nachrichten laden",
"chat.showBeforeSummary": "Verlauf vor der Zusammenfassung anzeigen",
"chat.backToLatest": "Zu den neuesten Nachrichten",
"chat.search": "Im Chat suchen",
"chat.searchPlaceholder": "Nachrichten, Tool-Ausgaben … durchsuchen",
"chat.searchNone": "Keine Treffer",
"chat.searchHits": "{n} Treffer",
"chat.you": "Du",
"chat.agent": "Agent",
"media.title": "Medien",
"media.none": "Noch keine Bilder, Videos oder Audiodateien in diesem Chat.",
"media.filter.all": "Alle",
"media.filter.image": "Bilder",
"media.filter.video": "Videos",
"media.filter.model": "3D",
"media.filter.audio": "Audio",
"media.source.generated": "generiert",
"media.source.tool": "Tool-Ausgabe",
"media.source.written": "vom Agenten geschrieben",
"media.source.answer": "in einer Antwort",
"media.source.upload": "dein Anhang",
"media.showInChat": "Im Chat zeigen",
"media.open": "Öffnen",
"media.prev": "Vorheriges",
"msg.actions": "Nachrichtenaktionen",
"msg.copy": "Text kopieren",
"msg.copied": "Kopiert",
"msg.copyFailed": "Kopieren ist hier nicht möglich",
"msg.quote": "In Antwort zitieren",
"msg.forkHere": "Ab hier abzweigen",
"chat.jumpPrevOwn": "Vorherige eigene Nachricht",
"viewer.annotate": "Markieren",
"viewer.annotateFailed": "Dieses Bild lässt sich nicht bearbeiten",
"viewer.attach": "An Nachricht anhängen",
"viewer.undo": "Rückgängig",
"viewer.tool.pen": "Stift",
"viewer.tool.arrow": "Pfeil",
"viewer.tool.rect": "Rechteck",
"viewer.useInChat": "In Nachricht verwenden",
"viewer.attached": "Bild an die Nachricht angehängt",
"turn.changes": "{n} Datei(en) geändert",
"turn.noChanges": "Für diesen Schritt wurden keine Dateiänderungen gefunden.",
"turn.noDiff": "Kein Text-Diff verfügbar.",
"turn.truncated": "Der Diff ist gekürzt.",
"turn.new": "neu",
"turn.deleted": "gelöscht",
"turn.rollback": "Auf den Stand vor diesem Schritt zurücksetzen",
"job.plan": "Als Automatisierung planen",
"job.planHint": "Macht aus diesem Chat Anweisungen für eine wiederkehrende Automatisierung (aus, bis du den Zeitplan setzt)",
"job.drafting": "Automatisierung wird aus diesem Chat entworfen …",
"color.label": "Farbe dieses Chats",
"color.red": "Rot",
"color.orange": "Orange",
"color.yellow": "Gelb",
"color.green": "Grün",
"color.aqua": "Türkis",
"color.blue": "Blau",
"color.purple": "Violett",
"color.pink": "Pink",
"icon.label": "Symbol dieses Chats",
"claude.title": "Claude Code",
"claude.intro": "Chats mit einem „Claude Code“-Modell führt die Claude-Code-CLI auf dem Server aus – mit deinem Claude-Abo oder Anthropic-Console-Konto. Einmal hier anmelden; Freigaben, Push und Sandbox funktionieren wie gewohnt.",
"claude.notInstalled": "Claude Code ist auf dem Server nicht installiert.",
"claude.console": "Anthropic Console (API-Abrechnung) statt Claude-Abo",
"claude.signIn": "Anmelden",
"claude.openLogin": "Anmeldeseite öffnen",
"claude.step1": "Bei Anthropic in einem beliebigen Browser (auch auf einem anderen Gerät) anmelden und den Zugriff erlauben.",
"claude.step2": "Den Code einfügen, den die Seite danach anzeigt:",
"claude.codePlaceholder": "Code von der Anmeldeseite",
"claude.confirm": "Bestätigen",
"claude.signedIn": "Bei Claude Code angemeldet",
"claude.signedInAs": "Angemeldet: {who}",
"claude.expires": "gültig bis {date}",
"claude.logout": "Abmelden",
"claude.logoutConfirm": "Auf dem Server von Claude Code abmelden?",
"claude.version": "CLI {version}",
"claude.updateAvailable": "Update verfügbar: {version}",
"claude.upToDate": "aktuell",
"claude.update": "Update installieren",
"claude.updating": "Installiere …",
"claude.updated": "Claude Code auf {version} aktualisiert",
"claude.updateFailed": "Das Update ist fehlgeschlagen (Details unten)",
"claude.updateAdmin": "Ein Administrator kann es installieren.",
"claude.checkNow": "Nach Updates suchen",
"claude.updateLog": "Ausgabe des Updates",
"check.menu": "Prüfung nach jedem Schritt …",
"check.title": "Prüfung nach jedem Schritt",
"check.hint": "Befehl, der in {project} nach jedem Schritt mit Dateiänderungen läuft (z. B. cargo test, npm test). Das Ergebnis erscheint als Badge am Schritt. Leer = aus.",
"check.saved": "Prüfung gespeichert",
"check.removed": "Prüfung entfernt",
"check.running": "Prüfung läuft …",
"check.passed": "Prüfung bestanden",
"check.failed": "Prüfung fehlgeschlagen",
"check.timeout": "Prüfung: Zeitüberschreitung",
"check.noOutput": "(keine Ausgabe)",
"check.fix": "Vom Agenten beheben lassen",
"check.fixPrompt": "Die Prüfung des Projekts ist nach deiner letzten Änderung fehlgeschlagen. Bitte beheben und erneut ausführen. Ausgabe:",
"projectDefaults.save": "Als Projekt-Standard verwenden",
"projectDefaults.remove": "Projekt-Standard entfernen",
"projectDefaults.hint": "Neue Chats in diesem Projektordner starten mit Modell, Modus, Farbe, Anweisungen und Ordner dieses Chats",
"projectDefaults.saved": "Neue Chats in diesem Projekt starten wie dieser",
"projectDefaults.removed": "Projekt-Standard entfernt",
"outbox.waiting": "{n} Nachricht(en) warten auf eine Verbindung",
"outbox.sendNow": "Jetzt senden",
"outbox.discard": "Verwerfen",
"outbox.saved": "Keine Verbindung – die Nachricht ist gespeichert und wird gesendet, sobald du wieder online bist",
"project.open": "Projektübersicht",
"project.newChat": "Neuer Chat",
"project.today": "Heute",
"project.yesterday": "Gestern",
"project.chats": "{n} Chat(s)",
"project.files": "{n} Datei(en) geändert",
"project.archived": "{n} archiviert",
"project.checksFailed": "{n} Prüfung(en) fehlgeschlagen",
"project.empty": "In diesem Ordner gibt es noch keine Chats.",
"viewer.title": "Bildbetrachter",
"viewer.open": "In neuem Tab öffnen",
"viewer.download": "Herunterladen",
"media.next": "Nächstes",
"media.back": "Zurück zur Übersicht",
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
"chat.reviewRound": "Review {round}: Änderungen nötig",
"agents.refine": "Review-Schleife",
"agents.refineRounds": "Review-Runden pro Aufgabe",
@@ -344,7 +473,7 @@ export const de: Messages = {
"agents.refineShort": "Review ×{n}",
"agents.deleteFile": "Agenten-Datei {path} löschen?",
"nav.jobs": "Automationen",
"jobs.intro": "Agenten, die nach Zeitplan starten. Jede Automation ist eine Markdown-Datei (Auftrag + Zeitplan im Frontmatter) – lass sie dir in einem Chat von einem Agenten anlegen (Tool „schedule“) oder registriere unten eine Datei. Jeder Lauf erscheint als normaler Chat; braucht er eine Freigabe, bekommst du eine Push-Benachrichtigung.",
"jobs.intro": "Agenten, die nach Zeitplan starten. Die Anweisungen jeder Automatisierung stehen in einer Markdown-Datei; Name, Zeitplan und Einstellungen legst du hier fest. Mit „Neue Automatisierung“ anlegen und die Anweisungen mit dem Assistenten erarbeiten – oder einen Agenten in einem Chat darum bitten (Tool „schedule“). Jeder Lauf erscheint als normaler Chat.",
"jobs.none": "Noch keine Automationen.",
"jobs.schedule": "Zeitplan",
"jobs.next": "Nächster Lauf",
@@ -355,13 +484,12 @@ export const de: Messages = {
"jobs.enable": "Aktivieren",
"jobs.disable": "Deaktivieren",
"jobs.runNow": "Jetzt starten",
"jobs.editFile": "Datei bearbeiten",
"jobs.remove": "Entfernen",
"jobs.deleteConfirm": "Automation „{name}“ entfernen? Die Job-Datei bleibt erhalten.",
"jobs.openRun": "Lauf öffnen",
"jobs.prompt": "Auftrag",
"jobs.register": "Job-Datei registrieren",
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Zeitplan im Frontmatter:",
"jobs.register": "Vorhandene Datei registrieren",
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Anweisungen (ältere Dateien mit Zeitplan im Frontmatter werden übernommen):",
"jobs.registerBtn": "Registrieren",
"jobs.status.running": "läuft",
"jobs.status.done": "fertig",
@@ -405,6 +533,19 @@ export const de: Messages = {
"tokens.title": "API-Tokens",
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
"telegram.title": "Telegram",
"telegram.intro": "CodeCraft über Telegram mit deinem eigenen Bot nutzen: Nachrichten gehen an deine aktuelle Sitzung, Befehle wechseln Sitzungen, Modi und Modelle, Freigaben und Rückfragen kommen mit Knöpfen. Nur dein Telegram-Konto kann den Bot nutzen.",
"telegram.step1": "In Telegram @BotFather öffnen, /newbot senden und den Schritten folgen.",
"telegram.step2": "Das Token von BotFather hier einfügen.",
"telegram.connect": "Verbinden",
"telegram.checking": "Prüfe…",
"telegram.running": "läuft",
"telegram.stopped": "läuft nicht",
"telegram.paired": "gekoppelt",
"telegram.pairHint": "Letzter Schritt: deinen Bot öffnen und den Code senden (oder den Link antippen) – danach bedient er nur noch dein Telegram-Konto:",
"telegram.commands": "In Telegram: /help zeigt die Befehle – /sessions, /new, /mode, /model, /stop, /status, /costs …",
"telegram.disconnect": "Trennen",
"telegram.disconnectConfirm": "Telegram-Bot trennen? Er antwortet dann nicht mehr; mit seinem Token kannst du ihn wieder verbinden.",
"tokens.copy": "Kopieren",
"tokens.copied": "Kopiert",
"tokens.name": "Name",
@@ -496,6 +637,11 @@ export const de: Messages = {
"sidebar.unpin": "Nicht mehr anpinnen",
"sidebar.openBeside": "Daneben öffnen",
"chat.closePane": "Spalte schließen",
"sidebar.groupMenu": "Ordner-Optionen",
"sidebar.groupPin": "Oben fixieren",
"sidebar.groupUnpin": "Nicht mehr fixieren",
"sidebar.groupUp": "Nach oben",
"sidebar.groupDown": "Nach unten",
"sidebar.moveTo": "In Ordner verschieben…",
"sidebar.removeFromFolder": "Aus Ordner entfernen",
"sidebar.archiveOne": "Archivieren",
@@ -532,16 +678,12 @@ export const de: Messages = {
"backup.s3Keep": "Im Bucket behalten (0 = alle)",
"backup.last": "Letztes Backup",
"backup.s3Ok": "hochgeladen",
"jobs.fromTemplate": "Neue Automation aus Vorlage",
"jobs.tplName": "Name",
"jobs.tplProject": "Projektordner",
"jobs.tplSchedule": "Wann",
"jobs.tplCronHint": "Cron: Minute Stunde Tag Monat Wochentag (Zeitzone des Servers).",
"jobs.tplMode": "Modus",
"jobs.tplPrompt": "Aufgabe für den Agenten",
"jobs.tplCreate": "Automation anlegen",
"new.title": "Neue Session",
"new.directory": "Projektverzeichnis",
"new.invalidFolderName": "Ordnernamen dürfen kein „/“ enthalten.",
"new.up": "Hoch",
"new.noSubdirs": "Keine Unterverzeichnisse",
"new.model": "Modell",
@@ -580,6 +722,59 @@ export const de: Messages = {
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
"chat.compactingManual": "Unterhaltung wird komprimiert",
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
"chat.telegram": "Fragen an Telegram senden",
"chat.telegramHint": "Freigaben und Rückfragen dieses Chats auch per Telegram schicken (der gerade in Telegram geöffnete Chat meldet sich immer)",
"cron.label": "Zeitplan",
"cron.mode.minutes": "Alle paar Minuten",
"cron.mode.hourly": "Stündlich",
"cron.mode.daily": "Täglich",
"cron.mode.weekly": "An Wochentagen",
"cron.mode.monthly": "Monatlich",
"cron.mode.custom": "Cron-Ausdruck",
"cron.every": "Alle",
"cron.minutes": "Minuten",
"cron.atMinute": "Zur Minute",
"cron.days": "Wochentage",
"cron.workdays": "Mo–Fr",
"cron.onDay": "Am",
"cron.at": "um",
"cron.customHint": "Minute Stunde Tag Monat Wochentag, z. B. „30 6 * * 1-5“ = Mo–Fr 06:30.",
"cron.nextRuns": "Nächste Läufe:",
"cron.shortMonths": "Monate ohne diesen Tag werden übersprungen.",
"cron.serverTz": "Uhrzeiten in Serverzeit ({tz})",
"jobEditor.saved": "Gespeichert",
"jobEditor.conflict": "Die Anweisungen wurden inzwischen geändert (z. B. vom Assistenten). Mit deiner Fassung überschreiben?",
"jobEditor.overwrite": "Überschreiben",
"jobEditor.runUnsaved": "Es gibt ungespeicherte Änderungen – der Lauf verwendet die gespeicherte Fassung. Trotzdem starten?",
"jobEditor.discard": "Ungespeicherte Änderungen verwerfen?",
"jobEditor.back": "Zurück zu den Automatisierungen",
"jobEditor.tab.prompt": "Anweisungen",
"jobEditor.tab.settings": "Zeitplan",
"jobEditor.tab.assistant": "Assistent",
"jobEditor.timeout": "Zeitlimit (Min.)",
"jobEditor.bypassHint": "Läuft nachts: Mit „Alles erlauben“ wartet der Lauf nie auf eine Freigabe.",
"jobEditor.instructions": "Anweisungen",
"jobEditor.instructionsHint": "Der Agent sieht nur diesen Text – keinen Chat, niemanden zum Nachfragen.",
"jobEditor.changedOnDisk": "Der Assistent hat die Datei geändert, während du bearbeitet hast.",
"jobEditor.reload": "Seine Fassung laden",
"jobEditor.assistantIntro": "Erarbeite die Anweisungen zusammen mit einem Modell: Beschreib, was die Automatisierung tun soll – es fragt nach und schreibt die Datei nebenan mit.",
"jobEditor.assistantStart": "Assistent starten",
"jobEditor.defaultModel": "Server-Standard",
"jobs.new": "Neue Automatisierung",
"jobs.namePlaceholder": "z. B. Nächtlicher Abhängigkeits-Check",
"jobs.fileHint": "Die Anweisungen landen in .codecraft/jobs/<name>.md in diesem Ordner.",
"jobs.startWith": "Beginnen mit",
"jobs.blank": "Leer",
"jobs.blankDesc": "Beispielgliederung zum Ausfüllen mit dem Assistenten",
"jobs.createAndEdit": "Anlegen und bearbeiten",
"jobs.edit": "Bearbeiten",
"jobs.permissionMode": "Freigabemodus",
"jobs.permissionModeHint": "Gilt ab dem nächsten Lauf. „Alles erlauben“: der Lauf fragt nie nach und wartet nicht auf Freigaben.",
"instructions.title": "Anweisungen für diesen Chat",
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
"instructions.clear": "Entfernen",
"instructions.chip": "Anweisungen",
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
"chat.context": "Kontext",
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
@@ -597,6 +792,9 @@ export const de: Messages = {
"composer.mode": "Modus",
"composer.model": "Modell",
"composer.permissionMode": "Berechtigungsmodus",
"composer.preview": "Vorschau (Markdown)",
"composer.previewEdit": "Zurück zum Bearbeiten",
"composer.previewEmpty": "Noch nichts zum Anzeigen.",
"composer.attach": "Dateien anhängen",
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
@@ -610,6 +808,7 @@ export const de: Messages = {
"plan.implementPrompt": "Setze den Plan um.",
"tool.input": "Eingabe",
"tool.open3d": "In 3D-Ansicht öffnen",
"tool.output": "Ausgabe",
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
"tool.todos": "Aufgaben",
@@ -619,6 +818,16 @@ export const de: Messages = {
"permission.title": "Freigabe erforderlich",
"permission.allowOnce": "Einmal erlauben",
"permission.projectCommand": "„{prefix}“ in diesem Projekt immer erlauben",
"permission.projectTool": "{tool} in diesem Projekt immer erlauben",
"permission.projectHint": "Wird für diesen Projektordner gemerkt: in allen Chats, auch nach einem Neustart. Widerrufen im ⋯-Menü.",
"permission.explain": "Was macht das?",
"permission.explaining": "Erkläre …",
"permProject.title": "Projekt-Freigaben",
"permProject.intro": "In diesem Projektordner ohne Rückfrage erlaubt (alle Chats):",
"permProject.empty": "Für dieses Projekt ist noch nichts freigegeben. Der Knopf „… in diesem Projekt immer erlauben“ bei einer Freigabe-Anfrage legt hier Einträge an.",
"permProject.revoke": "Widerrufen",
"permProject.menu": "Projekt-Freigaben",
"permission.alwaysTool": "{tool} in dieser Session immer erlauben",
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
"permission.reject": "Ablehnen",
@@ -668,6 +877,9 @@ export const de: Messages = {
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
"settings.currency": "Währung",
"settings.autoCompact": "Auto-Komprimierung bei",
"settings.commitTrailer": "Commit-Vermerk der Agenten",
"settings.commitTrailerOff": "aus",
"settings.commitTrailerHint": "Wird als Trailer an jeden Git-Commit eines Agenten angehängt. {model} = Modellname, {provider} = Anbieter, {model_id} = Modell-ID. Leer = aus. Deine eigenen Commits bleiben unverändert.",
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
"settings.craft": "Craft-Modus",
"settings.cleanRoom": "Clean Room",
@@ -688,6 +900,8 @@ export const de: Messages = {
"settings.pricesHint": "Preise pro 1 Mio. Tokens in der eingestellten Währung. Cache-Preise fallen auf den Input-Preis zurück.",
"settings.modelsEmptyHint": "Keine Modelle eingetragen: Sie werden automatisch beim Provider abgefragt.",
"settings.context": "Kontext",
"settings.maxInput": "Max. Eingabe",
"settings.maxInputHint": "Eingabe-Limit, falls der Anbieter eines unterhalb des Kontextfensters setzt (leer: das Kontextfenster). Der Chat wird zusammengefasst, bevor die Eingabe es erreicht.",
"settings.maxOutput": "Max. Ausgabe",
"settings.reasoning": "Reasoning",
"settings.vision": "Bilder",
@@ -887,4 +1101,71 @@ export const de: Messages = {
"stats.user": "Benutzer",
"stats.allUsers": "Alle Benutzer",
"stats.byUser": "Nach Benutzer",
"snippets.title": "Prompt-Bausteine",
"snippets.hint": "Eigene Textbausteine. Tippe / im Nachrichtenfeld und wähle einen Baustein, um ihn einzufügen (z. B. /tests → \"Schreibe Tests zu dieser Änderung\").",
"snippets.name": "Name",
"snippets.text": "Einzufügender Text",
"snippets.add": "Baustein hinzufügen",
"snippets.insert": "Baustein",
"link.title": "Nur-Lese-Link",
"link.hint": "Wer den Link hat, kann den Gesprächstext lesen (keine Tool-Ausgaben, keine Dateien) – ohne Konto.",
"link.create": "Link erstellen",
"link.copy": "Kopieren",
"link.copied": "Link kopiert",
"link.revoke": "Widerrufen",
"link.validity": "Gültigkeit",
"link.days": "{n} Tage",
"link.until": "bis {date}",
"link.readOnly": "Schreibgeschützt geteilt",
"git.suggest": "Nachricht vorschlagen",
"git.suggestHint": "Das kleine Modell schreibt eine Commit-Nachricht für die vorgemerkten Änderungen",
"settings.weeklyDigest": "Wochenrückblick montags um 8:00 (Chats, Checks, Kosten)",
"settings.transcribeModel": "Spracherkennungs-Modell",
"settings.transcribeHint": "provider/modell eines OpenAI-kompatiblen Transkriptions-Endpunkts (z. B. openai/whisper-1). Schaltet den Mikrofon-Knopf in Browsern ohne eigene Diktierfunktion frei, etwa Firefox.",
"voice.transcribing": "Wird transkribiert …",
"offline.banner": "Keine Verbindung – gespeicherte Kopien. Geschriebene Nachrichten warten im Postausgang.",
"settings.planModel": "Planungsmodell",
"settings.planModelHint": "Der Plan-Modus nutzt immer dieses Modell (z. B. ein starkes); alle anderen Modi das im Chat gewählte (z. B. ein günstigeres). Leer = Modell des Chats.",
"settings.sameAsChat": "(Modell des Chats)",
"jobEditor.notify": "Bericht als Push-Nachricht senden, wenn ein Lauf endet",
"stats.year": "Jahr",
"stats.month": "Monat",
"stats.perMonth": "Pro Monat",
"stats.costPerMonth": "Kosten pro Monat",
"stats.tokensPerMonth": "Tokens pro Monat",
"stats.export": "Export",
"stats.exportHint": "Die Werte des gewählten Zeitraums herunterladen",
"stats.exportXlsx": "Excel (.xlsx) – alle Tabellen",
"stats.exportCsv": "CSV – {table}",
"stats.table.day": "pro Tag",
"stats.table.month": "pro Monat",
"stats.table.model": "nach Modell",
"stats.table.provider": "nach Anbieter",
"stats.table.agent": "nach Modus",
"stats.table.user": "nach Benutzer",
"stats.table.session": "teuerste Chats",
"command.clear": "Bisheriges Gespräch vergessen (der Chat bleibt sichtbar)",
"chat.cleared": "Kontext geleert – das Modell sieht die Nachrichten darüber nicht mehr",
"ssh.title": "SSH-Schlüssel",
"ssh.hint": "Schlüssel, mit denen Agenten, Terminal und Git-Panel andere Server erreichen (ssh, scp, rsync, Git über ssh). Sie werden automatisch genutzt; Hosts werden beim ersten Kontakt akzeptiert. Den öffentlichen Schlüssel trägst du in die authorized_keys des Servers ein.",
"ssh.none": "Noch keine Schlüssel.",
"ssh.name": "Name (z. B. Laptop, Build-Server)",
"ssh.generate": "Neuen Schlüssel erzeugen",
"ssh.import": "Schlüssel importieren",
"ssh.importExisting": "Vorhandenen Schlüssel importieren",
"ssh.importHint": "Nur private Schlüssel ohne Passphrase. Der Schlüssel wird für dein Konto auf dem Server gespeichert.",
"ssh.private": "Privater Schlüssel",
"ssh.public": "Öffentlicher Schlüssel",
"ssh.copyPublic": "Öffentlichen Schlüssel kopieren",
"ssh.copied": "Öffentlicher Schlüssel kopiert",
"ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren",
"ssh.added": "Schlüssel hinzugefügt",
"ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.",
"chat.deleteTitle": "Chat löschen",
"chat.deleteWithFolder": "Dieser Chat hat einen eigenen Ordner:\n{path}\n({size})\n\nSoll der Ordner mit dem Chat gelöscht werden?",
"chat.deleteOnlyChat": "Nur Chat löschen",
"chat.deleteChatAndFolder": "Chat und Ordner löschen",
"chat.folderFiles": "{n} Dateien",
"chat.folderHasGit": "Der Ordner enthält ein Git-Repository – nicht gepushte Commits und Branches gehen verloren.",
"chat.folderDeleted": "Chat und Ordner gelöscht",
};
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more