Compare commits

..
Author SHA1 Message Date
Richard Zink 81d9df4e6e Release v202611-00039 2026-11-15 14:59:12 +01:00
Richard ZinkandClaude Sonnet 5.5 ead75e78e0 Neutral chat background for colour tags; answer Claude Code's AskUserQuestion with the question card
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 14:59:10 +01:00
Richard Zink 77f9d4b0e0 Release v202611-00038 2026-11-15 14:37:31 +01:00
Richard ZinkandClaude Sonnet 5.5 103132a359 Sidebar chat menu: add Delete, remove colour and icon pickers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 14:37:29 +01:00
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
89 changed files with 8176 additions and 216 deletions

No files matched your search

+221
View File
@@ -3,6 +3,227 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [v202611-00039] – 2026-11-15
### Changed
- A chat's colour no longer tints the chat background (pane, message field, agent bubbles, top bar
fill); it still marks the top bar line, the list entry, the badge and your own bubbles.
### Fixed
- **Claude Code can ask questions again**: its `AskUserQuestion` was always rejected; now the question
appears as a question card in the chat (single and multiple choice) and the answers go back to it.
## [v202611-00038] – 2026-11-15
### Changed
- The chat menu in the sidebar has a **Delete** entry (with the question about the chat's own folder);
colour and icon selection moved out of it – they remain in the ⋯ menu inside the chat.
## [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 ## [v202611-00019] – 2026-11-11
### Added ### Added
+11 -2
View File
@@ -85,17 +85,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \ build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \ chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
openscad xvfb xauth \ openscad xvfb xauth \
openssh-client rsync mc \ openssh-client libnss-wrapper rsync mc \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be # Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
# installed by agents: rustup component add rust-analyzer, go install …). # 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=server /codecraft /usr/local/bin/codecraft
COPY --from=web /web/dist /app/web COPY --from=web /web/dist /app/web
COPY --from=cli /out /app/cli COPY --from=cli /out /app/cli
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock). # Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
COPY --from=dockercli /docker /usr/local/bin/docker COPY --from=dockercli /docker /usr/local/bin/docker
COPY server/ghidra /app/ghidra 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 COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space # The agent's home lives on the data volume: tools it installs in user space
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds. # (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
+6 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00019" src="https://img.shields.io/badge/version-v202611--00019-d69e2e?style=flat-square"> <img alt="Version v202611-00039" src="https://img.shields.io/badge/version-v202611--00039-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square"> <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust"> <img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react"> <img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
@@ -176,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 - **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 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. 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 ## Terminal client
@@ -296,6 +300,7 @@ in turn are based on:
| Coffee | Dark, Latte | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) | MIT | | 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 | | 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 | | 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): Further standard themes (palettes re-created from the published colour specifications):
+54
View File
@@ -701,6 +701,60 @@
"syntax-type": "#b58900", "syntax-type": "#b58900",
"syntax-variable": "#586e75" "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": { "tokyonight-dark": {
"accent": "#7aa2f7", "accent": "#7aa2f7",
"accent2": "#ff9e64", "accent2": "#ff9e64",
+22 -1
View File
@@ -159,7 +159,13 @@ impl ModelConfig {
/// Cost of one request now. Cache prices default to the input price. /// Cost of one request now. Cache prices default to the input price.
/// The provider's own figure wins when it reports one (opencode). /// The provider's own figure wins when it reports one (opencode).
pub fn cost(&self, u: &crate::types::Usage) -> f64 { 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. /// Cost of one request at a given time: context tiers and time factors applied.
@@ -187,6 +193,21 @@ mod price_tests {
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() } 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] #[test]
fn tiers_and_multipliers() { fn tiers_and_multipliers() {
let m = ModelConfig { let m = ModelConfig {
+43 -1
View File
@@ -187,8 +187,27 @@ pub struct Session {
/// unless it is the chat currently open in Telegram. /// unless it is the chat currently open in Telegram.
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub telegram_quiet: bool, 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 { impl Session {
pub fn clean_room(&self, default: bool) -> bool { pub fn clean_room(&self, default: bool) -> bool {
self.clean_room.unwrap_or(default) self.clean_room.unwrap_or(default)
@@ -243,6 +262,8 @@ pub enum Event {
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by /// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
/// a nearly full context (tokens of limit). /// a nearly full context (tokens of limit).
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> }, 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 { impl Event {
@@ -265,7 +286,8 @@ impl Event {
| Waiting { session_id, .. } | Waiting { session_id, .. }
| QueueUpdated { session_id, .. } | QueueUpdated { session_id, .. }
| StepStarted { session_id, .. } | StepStarted { session_id, .. }
| Compaction { session_id, .. } => Some(session_id), | Compaction { session_id, .. }
| CheckResult { session_id, .. } => Some(session_id),
McpStatus { .. } => None, McpStatus { .. } => None,
} }
} }
@@ -278,3 +300,23 @@ pub fn new_id() -> String {
pub fn now_ms() -> i64 { pub fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis() 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")" "$@"
@@ -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.
+626 -18
View File
@@ -7,7 +7,7 @@ use crate::config::{self, ConfigPatch};
use crate::prompts; use crate::prompts;
use crate::runner; use crate::runner;
use crate::store::User; use crate::store::User;
use crate::types::{Event, ImageData, Part, Session, new_id, now_ms}; use crate::types::{Event, ImageData, Part, Role, Session, new_id, now_ms};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
@@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router {
.route("/agents/tools", get(agent_tools)) .route("/agents/tools", get(agent_tools))
.route("/agents/{name}", delete(delete_agent)) .route("/agents/{name}", delete(delete_agent))
.route("/commands", get(commands)) .route("/commands", get(commands))
.route("/transcribe", post(transcribe))
.route("/sessions", get(list_sessions).post(create_session)) .route("/sessions", get(list_sessions).post(create_session))
.route("/sessions/search", get(search_sessions)) .route("/sessions/search", get(search_sessions))
.route("/sessions/{id}/fork", post(fork_session)) .route("/sessions/{id}/fork", post(fork_session))
@@ -85,19 +86,37 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/search", get(search_in_session)) .route("/sessions/{id}/search", get(search_in_session))
.route("/sessions/{id}/media", get(session_media)) .route("/sessions/{id}/media", get(session_media))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/folder", get(session_folder))
.route("/sessions/{id}/prompt", post(prompt)) .route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort)) .route("/sessions/{id}/abort", post(abort))
.route("/sessions/{id}/compact", post(compact)) .route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/rewind", post(rewind)) .route("/sessions/{id}/rewind", post(rewind))
.route("/sessions/{id}/job-draft", post(job_draft))
.route("/projects/overview", get(project_overview))
.route("/sessions/{id}/project-defaults", get(get_project_defaults).put(put_project_defaults).delete(delete_project_defaults))
.route("/sessions/{id}/check", get(get_check).put(put_check))
.route("/sessions/{id}/checks", get(list_checks))
.route("/sessions/{id}/check/run", post(run_check))
.route("/sessions/{id}/changes", get(changed_files)) .route("/sessions/{id}/changes", get(changed_files))
.route("/sessions/{id}/shells", get(list_shells)) .route("/sessions/{id}/shells", get(list_shells))
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell)) .route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
.route("/sessions/{id}/stats", get(session_stats)) .route("/sessions/{id}/stats", get(session_stats))
.route("/sessions/{id}/links", get(list_links).post(create_link))
.route("/sessions/{id}/links/{token}", delete(delete_link))
.route("/public/chat/{token}", get(public_chat))
.route("/mcp-bridge/{token}", post(crate::mcp_bridge::handle))
.route("/sessions/{id}/shares", get(get_shares).put(put_shares)) .route("/sessions/{id}/shares", get(get_shares).put(put_shares))
.route("/sessions/{id}/queue", get(get_queue)) .route("/sessions/{id}/queue", get(get_queue))
.route("/sessions/{id}/queue/flush", post(flush_queue)) .route("/sessions/{id}/queue/flush", post(flush_queue))
.route("/sessions/{id}/queue/{item}", delete(delete_queue_item)) .route("/sessions/{id}/queue/{item}", delete(delete_queue_item))
.route("/permissions/{id}", post(reply_permission)) .route("/permissions/{id}", post(reply_permission))
.route("/permissions/{id}/explain", post(explain_permission))
.route("/claude/status", get(claude_status))
.route("/claude/login", post(claude_login_start).delete(claude_login_cancel))
.route("/claude/login/code", post(claude_login_code))
.route("/claude/logout", post(claude_logout))
.route("/claude/update", post(claude_update))
.route("/sessions/{id}/approvals", get(project_approvals).delete(delete_project_approval))
.route("/questions/{id}", post(answer_question)) .route("/questions/{id}", post(answer_question))
.route("/pending", get(pending)) .route("/pending", get(pending))
.route("/mcp", get(mcp_status)) .route("/mcp", get(mcp_status))
@@ -120,6 +139,7 @@ pub fn router(app: AppState) -> Router {
.route("/git", post(crate::git::handle)) .route("/git", post(crate::git::handle))
.route("/fs/dirs", get(list_dirs)) .route("/fs/dirs", get(list_dirs))
.route("/stats", get(stats)) .route("/stats", get(stats))
.route("/stats/export", get(stats_export))
.route("/budget", get(budget)) .route("/budget", get(budget))
.route("/audit", get(audit_log)) .route("/audit", get(audit_log))
.route("/backup", get(backup)) .route("/backup", get(backup))
@@ -139,6 +159,9 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}", delete(delete_image)) .route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file)) .route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings)) .route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/ssh", get(get_ssh_keys).post(add_ssh_key))
.route("/account/ssh/{id}", delete(delete_ssh_key))
.route("/account/snippets", get(crate::cli_api::get_snippets).put(crate::cli_api::put_snippets))
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs)) .route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
.route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar)) .route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory)) .route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
@@ -308,7 +331,7 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) }; let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
if !user.is_admin() { if !user.is_admin() {
// Users only need defaults, not the configuration. // Users only need defaults, not the configuration.
return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency}))); return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency, "transcribe": !cfg.server.transcribe_model.is_empty()})));
} }
Ok(Json(json!({ Ok(Json(json!({
"config": config::redacted_json(&cfg), "config": config::redacted_json(&cfg),
@@ -316,6 +339,7 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
"default_model": default_model, "default_model": default_model,
"small_model": cfg.server.small_model, "small_model": cfg.server.small_model,
"currency": cfg.server.currency, "currency": cfg.server.currency,
"transcribe": !cfg.server.transcribe_model.is_empty(),
}))) })))
} }
@@ -595,13 +619,17 @@ fn validate_cwd(app: &App, user: &User, cwd: Option<String>) -> Result<PathBuf,
async fn create_session(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult { async fn create_session(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
let cwd = validate_cwd(&app, &user, body.cwd)?; let cwd = validate_cwd(&app, &user, body.cwd)?;
let cfg = app.config(); let cfg = app.config();
// What the project's chats start with (⋯ menu "use as project default").
let defaults = app.store.project_defaults(&cwd.display().to_string()).ok().flatten().unwrap_or(Value::Null);
let dstr = |k: &str| defaults[k].as_str().filter(|v| !v.is_empty()).map(String::from);
let model = body let model = body
.model .model
.filter(|m| !m.is_empty()) .filter(|m| !m.is_empty())
.or_else(|| dstr("model").filter(|m| app.registry().resolve(m).is_ok()))
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())) .or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
.or_else(|| app.registry().first_model()) .or_else(|| app.registry().first_model())
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?; .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
let mode = body.permission_mode.unwrap_or_else(|| "auto".into()); let mode = body.permission_mode.or_else(|| dstr("permission_mode")).unwrap_or_else(|| "auto".into());
if !crate::permission::MODES.contains(&mode.as_str()) { if !crate::permission::MODES.contains(&mode.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
} }
@@ -624,10 +652,13 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
worktree: None, worktree: None,
pinned: false, pinned: false,
archived: false, archived: false,
folder: None, folder: dstr("folder"),
instructions: String::new(), instructions: dstr("instructions").unwrap_or_default(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on). // New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true, telegram_quiet: true,
summary: String::new(),
color: dstr("color").filter(|c| codecraft_core::types::SESSION_COLORS.contains(&c.as_str())),
icon: dstr("icon").filter(|c| codecraft_core::types::SESSION_ICONS.contains(&c.as_str())),
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
@@ -843,6 +874,19 @@ impl WindowQ {
} }
} }
/// The cost of each answer for the chat view (usage.cost: charged, or valued at list prices).
fn with_costs(app: &App, mut messages: Vec<crate::types::Message>) -> Vec<crate::types::Message> {
let registry = app.registry();
for m in &mut messages {
if let (Some(u), Some(spec)) = (m.usage.as_mut(), m.model.as_deref()) {
if let Ok((_, model)) = registry.resolve(spec) {
u.cost = Some(model.cost(u));
}
}
}
messages
}
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult { async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
let s = readable(&app, &user, &id)?; let s = readable(&app, &user, &id)?;
let busy = app.is_busy(&id); let busy = app.is_busy(&id);
@@ -853,7 +897,7 @@ async fn get_session(Extension(user): Extension<User>, State(app): State<AppStat
match q.window() { match q.window() {
Some(w) => { Some(w) => {
let (messages, older, newer) = app.store.messages_window(&id, &w)?; let (messages, older, newer) = app.store.messages_window(&id, &w)?;
out["messages"] = json!(messages); out["messages"] = json!(with_costs(&app, messages));
out["has_older"] = json!(older); out["has_older"] = json!(older);
out["has_newer"] = json!(newer); out["has_newer"] = json!(newer);
} }
@@ -867,7 +911,7 @@ async fn session_messages(Extension(user): Extension<User>, State(app): State<Ap
readable(&app, &user, &id)?; readable(&app, &user, &id)?;
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?; let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?; let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?;
Ok(Json(json!({"messages": messages, "has_older": older, "has_newer": newer}))) Ok(Json(json!({"messages": with_costs(&app, messages), "has_older": older, "has_newer": newer})))
} }
/// Images, videos and audio files shown in a chat (all of it, with subagents). /// Images, videos and audio files shown in a chat (all of it, with subagents).
@@ -908,6 +952,10 @@ struct UpdateSession {
instructions: Option<String>, instructions: Option<String>,
/// Approvals and questions of this chat are not sent to Telegram. /// Approvals and questions of this chat are not sent to Telegram.
telegram_quiet: Option<bool>, telegram_quiet: Option<bool>,
/// Colour tag ("" removes it).
color: Option<String>,
/// Icon ("" removes it).
icon: Option<String>,
} }
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult { async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
@@ -920,6 +968,12 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(m) = &body.model { if let Some(m) = &body.model {
app.registry().resolve(m)?; app.registry().resolve(m)?;
} }
if body.color.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_COLORS.contains(&c)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid colour".into()));
}
if body.icon.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_ICONS.contains(&c)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid icon".into()));
}
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) { if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
} }
@@ -959,6 +1013,12 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(q) = body.telegram_quiet { if let Some(q) = body.telegram_quiet {
s.telegram_quiet = q; s.telegram_quiet = q;
} }
if let Some(c) = body.color {
s.color = (!c.is_empty()).then_some(c);
}
if let Some(i) = body.icon {
s.icon = (!i.is_empty()).then_some(i);
}
})?; })?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
} }
@@ -997,8 +1057,83 @@ async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult { /// The folder a chat owns: inside the owner's folders (never one of them), not a link, and no
writable(&app, &user, &id)?; /// other chat works in it, below it or above it (then it belongs to a project, not to this chat).
/// Returns the folder and the titles of chats that prevent it.
fn own_folder(app: &App, session: &Session) -> (Option<PathBuf>, Vec<String>) {
let Ok(dir) = std::fs::canonicalize(&session.cwd) else { return (None, vec![]) };
if std::fs::symlink_metadata(&session.cwd).map(|m| m.file_type().is_symlink()).unwrap_or(true) || !dir.is_dir() {
return (None, vec![]);
}
let roots: Vec<PathBuf> = app.user_roots(session.owner_id.as_deref()).iter().filter_map(|r| std::fs::canonicalize(r).ok()).collect();
if !roots.iter().any(|r| dir.starts_with(r) && dir != *r) {
return (None, vec![]);
}
let mut blockers = Vec::new();
for (title, cwd) in app.store.other_session_cwds(&session.id).unwrap_or_default() {
let Ok(other) = std::fs::canonicalize(&cwd) else { continue };
if other.starts_with(&dir) || dir.starts_with(&other) {
blockers.push(if title.is_empty() { "(untitled)".to_string() } else { title });
}
}
if blockers.is_empty() { (Some(dir), blockers) } else { (None, blockers) }
}
/// Facts for the delete dialog: does the chat have a folder of its own, and what is in it?
async fn session_folder(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let session = writable(&app, &user, &id)?;
let (dir, blockers) = own_folder(&app, &session);
let Some(dir) = dir else { return Ok(Json(json!({"own": false, "shared_with": blockers}))) };
let (files, bytes, git, truncated) = tokio::task::spawn_blocking({
let dir = dir.clone();
move || {
let (mut files, mut bytes, mut seen) = (0u64, 0u64, 0u64);
let mut stack = vec![dir.clone()];
let mut truncated = false;
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
// Git's own files are not "the chat's files".
if e.file_name() == ".git" {
continue;
}
seen += 1;
if seen > 50_000 {
truncated = true;
stack.clear();
break;
}
let Ok(m) = e.metadata() else { continue };
if m.is_dir() && !e.file_type().map(|t| t.is_symlink()).unwrap_or(true) {
stack.push(e.path());
} else if m.is_file() {
files += 1;
bytes += m.len();
}
}
}
(files, bytes, dir.join(".git").exists(), truncated)
}
})
.await
.unwrap_or((0, 0, false, false));
Ok(Json(json!({"own": true, "path": dir, "files": files, "bytes": bytes, "git": git, "truncated": truncated})))
}
#[derive(Deserialize)]
struct DeleteQ {
/// Also delete the chat's own folder (only if it really is one – checked again here).
#[serde(default)]
delete_folder: bool,
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<DeleteQ>) -> ApiResult {
let session = writable(&app, &user, &id)?;
// Decide about the folder while the chat and all the others still exist.
let folder = if q.delete_folder { own_folder(&app, &session).0 } else { None };
if q.delete_folder && folder.is_none() {
return Err(ApiError(StatusCode::CONFLICT, "this chat has no folder of its own (other chats use it, or it is a project folder)".into()));
}
// Notify before deleting: visibility is checked against the stored session. // Notify before deleting: visibility is checked against the stored session.
app.emit(Event::SessionDeleted { session_id: id.clone() }); app.emit(Event::SessionDeleted { session_id: id.clone() });
runner::abort(&app, &id); runner::abort(&app, &id);
@@ -1012,7 +1147,20 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
tracing::warn!("image cleanup: {e:#}"); tracing::warn!("image cleanup: {e:#}");
} }
}); });
Ok(Json(json!({"ok": true}))) let mut folder_error = None;
let mut folder_deleted = false;
if let Some(dir) = folder {
let shown = dir.display().to_string();
match tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir)).await {
Ok(Ok(())) => {
folder_deleted = true;
app.audit(Some(&user), "session.delete_folder", &shown, "");
}
Ok(Err(e)) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
Err(e) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
}
}
Ok(Json(json!({"ok": true, "folder_deleted": folder_deleted, "folder_error": folder_error})))
} }
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes, /// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
@@ -1072,6 +1220,13 @@ pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text:
tokio::spawn(do_compact(app.clone(), id.clone())); tokio::spawn(do_compact(app.clone(), id.clone()));
return Ok(json!({"ok": true, "command": "compact"})); return Ok(json!({"ok": true, "command": "compact"}));
} }
if text == "/clear" {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
runner::clear_context(app, &id).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
return Ok(json!({"ok": true, "command": "clear"}));
}
let cwd = PathBuf::from(&session.cwd); let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd); let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut prompt_text = text.clone(); let mut prompt_text = text.clone();
@@ -1218,13 +1373,47 @@ async fn kill_shell(Extension(user): Extension<User>, State(app): State<AppState
#[derive(Deserialize)] #[derive(Deserialize)]
struct ChangesQuery { struct ChangesQuery {
since_message: String, since_message: String,
/// Include a unified diff per file.
#[serde(default)]
diff: bool,
/// Only changes before this message (one turn instead of everything since).
until_message: Option<String>,
} }
/// Files the agent changed since a message (for the rewind dialog). /// Files the agent changed since a message (for the rewind dialog).
async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult { async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult {
readable(&app, &user, &id)?; readable(&app, &user, &id)?;
let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX); let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX);
let files: Vec<Value> = app.store.checkpoints_since(&id, since)?.into_iter().map(|(p, c)| json!({"path": p, "created": c.is_none()})).collect(); let until = match &q.until_message {
Some(m) => app.store.message_created_at(&id, m)?.unwrap_or(i64::MAX),
None => i64::MAX,
};
let files: Vec<Value> = app
.store
.checkpoints_between(&id, since, until)?
.into_iter()
.map(|(p, c)| {
let mut v = json!({"path": p, "created": c.is_none()});
if q.diff {
// Checkpoint (state before the agent's first change) against the file now.
let old = c.map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default();
let new = std::fs::read_to_string(&p);
v["deleted"] = json!(new.is_err());
let new = new.unwrap_or_default();
let mut diff = similar::TextDiff::from_lines(&old, &new).unified_diff().context_radius(3).header(&p, &p).to_string();
if diff.len() > 60_000 {
let mut end = 60_000;
while !diff.is_char_boundary(end) {
end -= 1;
}
diff.truncate(end);
v["truncated"] = json!(true);
}
v["diff"] = json!(diff);
}
v
})
.collect();
Ok(Json(json!(files))) Ok(Json(json!(files)))
} }
@@ -1235,6 +1424,117 @@ async fn session_stats(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(v)) Ok(Json(v))
} }
// --- speech to text -----------------------------------------------------------
#[derive(Deserialize)]
struct TranscribeQ {
lang: Option<String>,
}
/// Dictation for browsers without speech recognition: the recorded audio (request body) goes
/// to the configured OpenAI-compatible transcription endpoint.
async fn transcribe(State(app): State<AppState>, Query(q): Query<TranscribeQ>, headers: axum::http::HeaderMap, body: axum::body::Bytes) -> ApiResult {
let cfg = app.config();
let spec = cfg.server.transcribe_model.clone();
let (prov, model) = spec.split_once('/').ok_or_else(|| ApiError(StatusCode::NOT_IMPLEMENTED, "speech-to-text is not configured (server.transcribe_model)".into()))?;
let p = cfg.providers.get(prov).filter(|p| p.enabled).ok_or_else(|| ApiError(StatusCode::NOT_IMPLEMENTED, format!("transcription provider '{prov}' is not available")))?;
if body.is_empty() || body.len() > 25 * 1024 * 1024 {
return Err(ApiError(StatusCode::BAD_REQUEST, "audio missing or larger than 25 MB".into()));
}
let mime = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("audio/webm").split(';').next().unwrap_or("audio/webm").to_string();
let ext = if mime.contains("mp4") || mime.contains("aac") { "m4a" } else if mime.contains("ogg") { "ogg" } else if mime.contains("wav") { "wav" } else { "webm" };
let part = reqwest::multipart::Part::bytes(body.to_vec()).file_name(format!("speech.{ext}")).mime_str(&mime).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e.to_string()))?;
let mut form = reqwest::multipart::Form::new().part("file", part).text("model", model.to_string());
if let Some(l) = q.lang.as_deref().filter(|l| l.len() == 2 && l.chars().all(|c| c.is_ascii_lowercase())) {
form = form.text("language", l.to_string());
}
let url = format!("{}/audio/transcriptions", p.base_url.trim_end_matches('/'));
let mut req = reqwest::Client::new().post(&url).multipart(form).timeout(std::time::Duration::from_secs(120));
if !p.api_key.is_empty() {
req = match &p.auth_header {
Some(h) => req.header(h.as_str(), format!("{}{}", p.auth_prefix.clone().unwrap_or_default(), p.api_key)),
None => req.bearer_auth(&p.api_key),
};
}
for (k, v) in &p.headers {
req = req.header(k.as_str(), v.as_str());
}
let resp = req.send().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("transcription request failed: {e}")))?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(ApiError(StatusCode::BAD_GATEWAY, format!("transcription failed ({status}): {}", text.chars().take(300).collect::<String>())));
}
let v: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({"text": text.trim()}));
Ok(Json(json!({"text": v["text"].as_str().unwrap_or("").trim()})))
}
// --- read-only links ----------------------------------------------------------
#[derive(Deserialize)]
struct LinkBody {
/// Validity in days (1–90, default 7).
days: Option<i64>,
}
fn link_json(base: &str, token: &str, created: i64, expires: i64) -> Value {
json!({"token": token, "url": format!("{base}/#/shared/{token}"), "created_at": created, "expires_at": expires})
}
async fn list_links(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
let base = public_base(&app, &headers);
let now = now_ms();
let links: Vec<Value> = app.store.share_links(&id)?.into_iter().filter(|(_, _, exp)| *exp > now).map(|(t, c, e)| link_json(&base, &t, c, e)).collect();
Ok(Json(json!(links)))
}
async fn create_link(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, body: Option<Json<LinkBody>>) -> ApiResult {
writable(&app, &user, &id)?;
let days = body.and_then(|b| b.0.days).unwrap_or(7).clamp(1, 90);
let now = now_ms();
let token = format!("{}{}", new_id(), new_id()).replace('-', "");
let expires = now + days * 86_400_000;
app.store.purge_share_links(now)?;
app.store.add_share_link(&token, &id, &user.id, now, expires)?;
app.audit(Some(&user), "session.link", &format!("{id}: {days} day(s)"), "");
Ok(Json(link_json(&public_base(&app, &headers), &token, now, expires)))
}
async fn delete_link(Extension(user): Extension<User>, State(app): State<AppState>, Path((id, token)): Path<(String, String)>) -> ApiResult {
writable(&app, &user, &id)?;
app.store.delete_share_link(&id, &token)?;
app.audit(Some(&user), "session.link.revoke", &id, "");
Ok(Json(json!({"ok": true})))
}
/// A chat for people without an account: only the conversation text (no tool output,
/// reasoning or attachments), readable as long as the link is valid.
async fn public_chat(State(app): State<AppState>, Path(token): Path<String>) -> ApiResult {
let gone = || ApiError(StatusCode::NOT_FOUND, "this link is invalid or has expired".into());
let id = app.store.share_link_session(&token, now_ms())?.ok_or_else(gone)?;
let session = app.session(&id).map_err(|_| gone())?;
let messages: Vec<Value> = app
.store
.messages(&id)?
.into_iter()
.filter(|m| matches!(m.role, Role::User | Role::Assistant))
.filter_map(|m| {
let text = m
.parts
.iter()
.filter_map(|p| match p {
Part::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
(!text.trim().is_empty()).then(|| json!({"id": m.id, "role": m.role, "text": text, "created_at": m.created_at}))
})
.collect();
Ok(Json(json!({"title": session.title, "updated_at": session.updated_at, "messages": messages})))
}
async fn get_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult { async fn get_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?; writable(&app, &user, &id)?;
Ok(Json(json!(app.store.shares(&id)?))) Ok(Json(json!(app.store.shares(&id)?)))
@@ -1278,6 +1578,201 @@ async fn reply_permission(Extension(user): Extension<User>, State(app): State<Ap
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
/// Instructions for an automation drafted from this chat (small model); the client creates the job.
async fn job_draft(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
let (name, prompt) = runner::draft_job(&app, &s).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"name": name, "prompt": prompt, "cwd": s.cwd, "agent": s.agent})))
}
// --- Claude Code: sign-in from the web UI ---------------------------------------------------
#[derive(Deserialize, Default)]
struct ClaudeStatusQuery {
#[serde(default)]
refresh: bool,
}
async fn claude_status(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<ClaudeStatusQuery>) -> Json<Value> {
if q.refresh {
crate::claude_code::refresh_latest().await;
}
Json(crate::claude_code::status(&app, &user.id).await)
}
#[derive(Deserialize, Default)]
struct ClaudeLoginBody {
/// Anthropic Console (API billing) instead of the Claude subscription.
#[serde(default)]
console: bool,
}
async fn claude_login_start(Extension(user): Extension<User>, State(app): State<AppState>, body: Option<Json<ClaudeLoginBody>>) -> ApiResult {
let console = body.map(|b| b.0.console).unwrap_or(false);
let url = crate::claude_code::login_start(&app, &user.id, console).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.audit(Some(&user), "claude.login_start", "", "");
Ok(Json(json!({"url": url})))
}
#[derive(Deserialize)]
struct ClaudeCodeBody {
code: String,
}
async fn claude_login_code(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ClaudeCodeBody>) -> ApiResult {
crate::claude_code::login_code(&user.id, &b.code).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "claude.login", "", "");
// New models may have appeared (the CLI is signed in now): clients reload them.
Ok(Json(crate::claude_code::status(&app, &user.id).await))
}
async fn claude_login_cancel(Extension(user): Extension<User>) -> Json<Value> {
crate::claude_code::login_cancel(&user.id);
Json(json!({"ok": true}))
}
/// Install the latest Claude Code CLI on the server (admins: it is shared by all accounts).
async fn claude_update(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let output = crate::claude_code::update().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.audit(Some(&user), "claude.update", "", "");
let mut v = crate::claude_code::status(&app, &user.id).await;
v["output"] = json!(output);
Ok(Json(v))
}
async fn claude_logout(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
crate::claude_code::logout(&app, &user.id).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.audit(Some(&user), "claude.logout", "", "");
Ok(Json(crate::claude_code::status(&app, &user.id).await))
}
// --- project page ---------------------------------------------------------------------------------
#[derive(Deserialize)]
struct OverviewQuery {
cwd: String,
}
/// Chats of a project folder with what they did (files, checks, cost) plus the folder's settings.
async fn project_overview(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<OverviewQuery>) -> ApiResult {
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
let sessions: Vec<Session> = app.store.list_sessions(filter)?.into_iter().filter(|s| s.cwd == q.cwd).collect();
let ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
let check = app.store.project_check(&q.cwd)?;
Ok(Json(json!({
"sessions": app.store.project_overview(&ids)?,
"check": check.map(|c| json!({"command": c.0, "enabled": c.1})),
"defaults": app.store.project_defaults(&q.cwd)?,
"currency": app.config().server.currency,
})))
}
// --- defaults of new chats per project ---------------------------------------------------------
async fn get_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
Ok(Json(app.store.project_defaults(&s.cwd)?.unwrap_or(Value::Null)))
}
/// Remember this chat's colour, model, mode, instructions and folder for new chats in the project folder.
async fn put_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
let data = json!({
"model": s.model, "agent": s.agent, "permission_mode": s.permission_mode,
"color": s.color, "icon": s.icon, "instructions": s.instructions, "folder": s.folder,
});
app.store.set_project_defaults(&s.cwd, Some(&data))?;
Ok(Json(data))
}
async fn delete_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
app.store.set_project_defaults(&s.cwd, None)?;
Ok(Json(json!({"ok": true})))
}
// --- check command per project ------------------------------------------------------------------
async fn get_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
let c = app.store.project_check(&s.cwd)?;
Ok(Json(json!({"command": c.as_ref().map(|c| c.0.clone()).unwrap_or_default(), "enabled": c.map(|c| c.1).unwrap_or(false)})))
}
#[derive(Deserialize)]
struct CheckBody {
command: String,
#[serde(default = "yes")]
enabled: bool,
}
fn yes() -> bool {
true
}
async fn put_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<CheckBody>) -> ApiResult {
let s = writable(&app, &user, &id)?;
if b.command.len() > 2000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "command too long".into()));
}
app.store.set_project_check(&s.cwd, &b.command, b.enabled)?;
app.audit(Some(&user), "project.check", &s.cwd, &b.command);
Ok(Json(json!({"ok": true})))
}
async fn list_checks(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
Ok(Json(json!(app.store.turn_checks(&id)?)))
}
/// Run the check now for the latest answer.
async fn run_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
let last = app.store.messages(&id)?.into_iter().rev().find(|m| m.role == crate::types::Role::Assistant).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no answer yet".into()))?;
let app2 = app.clone();
tokio::spawn(async move {
let _ = crate::checks::run(&app2, &s, &last.id).await;
});
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize, Default)]
struct ExplainBody {
lang: Option<String>,
}
/// Plain-words explanation of a pending approval (small model).
async fn explain_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<ExplainBody>>) -> ApiResult {
check_pending(&app, &user, &id)?;
let ev = app.pending_events().into_iter().find(|e| matches!(e, Event::PermissionRequest { id: i, .. } if *i == id)).ok_or_else(|| not_found("request"))?;
let Event::PermissionRequest { session_id, tool, title, input, diff, .. } = ev else { return Err(not_found("request")) };
let session = app.session(&session_id)?;
let lang = body.and_then(|b| b.0.lang).unwrap_or_default();
let text = crate::runner::explain_tool_call(&app, &session, &tool, &title, &input, diff.as_deref(), &lang)
.await
.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"text": text})))
}
/// What is allowed "for this project" (remembered across chats).
async fn project_approvals(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
Ok(Json(json!(app.store.project_approvals(&s.cwd)?)))
}
#[derive(Deserialize)]
struct ApprovalKey {
key: String,
}
async fn delete_project_approval(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<ApprovalKey>) -> ApiResult {
let s = writable(&app, &user, &id)?;
app.store.delete_project_approval(&s.cwd, &body.key)?;
app.audit(Some(&user), "permission.project_revoked", body.key, "");
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct QuestionBody { struct QuestionBody {
/// question text → answer (string or array of strings); null = dismissed /// question text → answer (string or array of strings); null = dismissed
@@ -1690,6 +2185,45 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
Ok(Json(v)) Ok(Json(v))
} }
#[derive(Deserialize)]
struct StatsExportQuery {
from: Option<i64>,
to: Option<i64>,
tz: Option<i64>,
user: Option<String>,
/// "xlsx" (all tables) or "csv" (one table: see `table`).
format: Option<String>,
/// csv: total, day, month, model, provider, agent, user, session (default: day).
table: Option<String>,
lang: Option<String>,
}
/// The statistics of a range as a download.
async fn stats_export(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<StatsExportQuery>) -> Result<Response, ApiError> {
let to = q.to.unwrap_or_else(|| now_ms() + 1);
let from = q.from.unwrap_or(to - 30 * 86_400_000);
let filter = if user.is_admin() { q.user.filter(|u| !u.is_empty()) } else { Some(user.id.clone()) };
let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0), filter.as_deref())?;
v["from"] = json!(from);
v["to"] = json!(to);
let de = q.lang.as_deref().is_some_and(|l| l.starts_with("de"));
let tables = crate::stats_export::tables(&v, de);
let stamp = chrono::Local::now().format("%Y-%m-%d");
let (body, ctype, name): (Vec<u8>, &str, String) = if q.format.as_deref() == Some("csv") {
let id = q.table.as_deref().unwrap_or("day");
let t = tables.iter().find(|t| t.id == id).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "unknown table".into()))?;
(crate::stats_export::csv(t, de).into_bytes(), "text/csv; charset=utf-8", format!("codecraft-stats-{id}-{stamp}.csv"))
} else {
let bytes = crate::stats_export::xlsx(&tables).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", format!("codecraft-stats-{stamp}.xlsx"))
};
Ok((
[(axum::http::header::CONTENT_TYPE, ctype.to_string()), (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\""))],
body,
)
.into_response())
}
// --- websocket -------------------------------------------------------------- // --- websocket --------------------------------------------------------------
// --- automations (scheduled agents) ------------------------------------------------ // --- automations (scheduled agents) ------------------------------------------------
@@ -1779,12 +2313,13 @@ struct UpdateJob {
model: Option<String>, model: Option<String>,
permission_mode: Option<String>, permission_mode: Option<String>,
timeout_minutes: Option<u64>, timeout_minutes: Option<u64>,
notify: Option<bool>,
} }
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult { async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
let job = own_job(&app, &user, &id)?; let job = own_job(&app, &user, &id)?;
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some(); let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some() || b.notify.is_some();
if changes { if changes {
let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation")); let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
if let Some(x) = b.name { if let Some(x) = b.name {
@@ -1808,6 +2343,9 @@ async fn update_job(Extension(user): Extension<User>, State(app): State<AppState
if let Some(x) = b.timeout_minutes { if let Some(x) = b.timeout_minutes {
st.timeout_minutes = x; st.timeout_minutes = x;
} }
if let Some(x) = b.notify {
st.notify = x;
}
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.store.job_set_settings(&job.id, &st)?; app.store.job_set_settings(&job.id, &st)?;
app.audit(Some(&user), "job.settings", &job.path, &st.schedule); app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
@@ -1874,6 +2412,9 @@ async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppSt
folder: None, folder: None,
instructions, instructions,
telegram_quiet: true, telegram_quiet: true,
summary: String::new(),
color: None,
icon: None,
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
st.assistant_session = Some(s.id.clone()); st.assistant_session = Some(s.id.clone());
@@ -1977,6 +2518,57 @@ async fn run_job(Extension(user): Extension<User>, State(app): State<AppState>,
// --- git credentials --------------------------------------------------------------- // --- git credentials ---------------------------------------------------------------
// --- SSH keys -----------------------------------------------------------------
async fn get_ssh_keys(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
#[derive(Deserialize)]
struct NewSshKey {
name: String,
/// An existing private key to import; a new key is generated when missing.
private: Option<String>,
}
async fn add_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<NewSshKey>) -> ApiResult {
let name = b.name.trim().to_string();
if name.is_empty() || name.chars().count() > 60 {
return Err(ApiError(StatusCode::BAD_REQUEST, "give the key a name (up to 60 characters)".into()));
}
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
if s.keys.len() >= 10 {
return Err(ApiError(StatusCode::BAD_REQUEST, "at most 10 keys".into()));
}
let key = match b.private.as_deref().filter(|p| !p.trim().is_empty()) {
Some(p) => crate::sshkeys::import(&name, p).await,
None => crate::sshkeys::generate(&name).await,
}
.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
if s.keys.iter().any(|k| k.fingerprint == key.fingerprint && !key.fingerprint.is_empty()) {
return Err(ApiError(StatusCode::CONFLICT, "this key is already stored".into()));
}
s.keys.push(key);
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "ssh", &s)?;
app.audit(Some(&user), "ssh.key.add", &name, "");
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
async fn delete_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
let before = s.keys.len();
s.keys.retain(|k| k.id != id);
if s.keys.len() == before {
return Err(not_found("key"));
}
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "ssh", &s)?;
app.audit(Some(&user), "ssh.key.delete", &id, "");
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
async fn get_git_settings(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn get_git_settings(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?; let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?;
Ok(Json(json!(crate::gitcreds::masked(&s)))) Ok(Json(json!(crate::gitcreds::masked(&s))))
@@ -2079,22 +2671,38 @@ async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<Ap
async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let on: bool = app.store.user_setting(&user.id, "cost_push")?; let on: bool = app.store.user_setting(&user.id, "cost_push")?;
Ok(Json(json!({"enabled": on}))) let weekly: bool = app.store.user_setting(&user.id, "weekly_push")?;
Ok(Json(json!({"enabled": on, "weekly": weekly})))
}
#[derive(Deserialize)]
struct DigestQ {
#[serde(default)]
weekly: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct DigestBody { struct DigestBody {
enabled: bool, enabled: Option<bool>,
weekly: Option<bool>,
} }
async fn put_digest(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<DigestBody>) -> ApiResult { async fn put_digest(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<DigestBody>) -> ApiResult {
app.store.set_user_setting(&user.id, "cost_push", &b.enabled)?; if let Some(v) = b.enabled {
Ok(Json(json!({"enabled": b.enabled}))) app.store.set_user_setting(&user.id, "cost_push", &v)?;
}
if let Some(v) = b.weekly {
app.store.set_user_setting(&user.id, "weekly_push", &v)?;
}
let on: bool = app.store.user_setting(&user.id, "cost_push")?;
let weekly: bool = app.store.user_setting(&user.id, "weekly_push")?;
Ok(Json(json!({"enabled": on, "weekly": weekly})))
} }
/// Send today's summary right away (preview). /// Send today's summary right away (preview).
async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<DigestQ>) -> ApiResult {
let payload = crate::digest::summary(&app, &user.id).unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"})); let found = if q.weekly { crate::digest::weekly(&app, &user.id) } else { crate::digest::summary(&app, &user.id) };
let payload = found.unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"}));
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await; let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]}))) Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]})))
} }
+7
View File
@@ -20,6 +20,8 @@ pub enum PermissionReply {
Once, Once,
/// Allow this tool (or bash command prefix) for the rest of the session. /// Allow this tool (or bash command prefix) for the rest of the session.
Always, Always,
/// Like `Always`, and remembered for the project folder (all chats, survives restarts).
Project,
Reject, Reject,
} }
@@ -144,6 +146,11 @@ impl App {
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"), 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:#}"), 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) { match crate::images::backfill_copies(&self) {
Ok(0) => {} Ok(0) => {}
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"), Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
+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"] ["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter() .iter()
.any(|p| path.ends_with(p)) .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 { pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
+72 -10
View File
@@ -361,6 +361,49 @@ fn opencode_when(w: &Value) -> Option<When> {
Some((days, hours, from, until)) 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). /// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
fn opencode_model(m: &Value) -> Value { fn opencode_model(m: &Value) -> Value {
let cost = &m["cost"]["text"]; 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() { for o in side["overrides"].as_array().into_iter().flatten() {
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue }; let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
let t = tiers.entry(gt).or_default(); 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 field == "input_price" {
if o["cache"]["read"].is_number() { for (key, k) in [("read", "cache_read_price"), ("write", "cache_write_price")] {
t.insert("cache_read_price".into(), o["cache"]["read"].clone()); let p = price_of(&o["cache"][key]);
} if !p.is_null() {
if o["cache"]["write"].is_number() { t.insert(k.into(), p);
t.insert("cache_write_price".into(), o["cache"]["write"].clone()); }
} }
} }
} }
@@ -412,10 +455,10 @@ fn opencode_model(m: &Value) -> Value {
"max_input": m["limit"]["input"], "max_input": m["limit"]["input"],
"vision": images, "vision": images,
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false), "reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
"input_price": input["price"], "input_price": price_of(&input["price"]),
"output_price": output["price"], "output_price": price_of(&output["price"]),
"cache_read_price": input["cache"]["read"], "cache_read_price": price_of(&input["cache"]["read"]),
"cache_write_price": input["cache"]["write"], "cache_write_price": price_of(&input["cache"]["write"]),
"price_tiers": price_tiers, "price_tiers": price_tiers,
"price_multipliers": price_multipliers, "price_multipliers": price_multipliers,
"notes": notes, "notes": notes,
@@ -481,6 +524,25 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn opencode_cost_conversion() { fn opencode_cost_conversion() {
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000}, 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:#}");
}
});
}
File diff suppressed because it is too large. Load diff
+23
View File
@@ -231,6 +231,29 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(b)) 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 /// 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]}. /// 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 { pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
+29
View File
@@ -177,6 +177,19 @@ pub struct ImagesConfig {
/// Optional fal.ai Admin key to query the account balance. /// Optional fal.ai Admin key to query the account balance.
#[serde(default, skip_serializing_if = "String::is_empty")] #[serde(default, skip_serializing_if = "String::is_empty")]
pub admin_key: String, 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)] #[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() }], models: vec![ImageModel { id: "fal-ai/flux/schnell".into(), name: Some("FLUX.1 schnell".into()), price: Some(0.003), ..Default::default() }],
monthly_budget: None, monthly_budget: None,
admin_key: String::new(), 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 data_dir: String,
pub default_model: String, pub default_model: String,
pub small_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, pub auto_compact_threshold: f64,
/// Currency label for cost statistics (display only). /// Currency label for cost statistics (display only).
pub currency: String, pub currency: String,
@@ -307,8 +325,13 @@ pub struct ServerConfig {
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled. /// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
#[serde(skip_serializing_if = "String::is_empty")] #[serde(skip_serializing_if = "String::is_empty")]
pub metrics_token: String, 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 { impl Default for ServerConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -317,6 +340,7 @@ impl Default for ServerConfig {
data_dir: "./data".into(), data_dir: "./data".into(),
default_model: String::new(), default_model: String::new(),
small_model: String::new(), small_model: String::new(),
transcribe_model: String::new(),
auto_compact_threshold: 0.85, auto_compact_threshold: 0.85,
currency: "USD".into(), currency: "USD".into(),
max_parallel_subagents: 4, max_parallel_subagents: 4,
@@ -327,6 +351,7 @@ impl Default for ServerConfig {
max_upload_mb: 500, max_upload_mb: 500,
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(), update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
metrics_token: String::new(), 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. /// Review loop after each turn of this mode.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub refine: Option<RefineConfig>, 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 /// Refinement loop: after the agent finishes, a reviewer checks the result
+49 -1
View File
@@ -2,7 +2,7 @@
//! user via the user setting "cost_push"). //! user via the user setting "cost_push").
use crate::app::App; use crate::app::App;
use chrono::{Local, Timelike}; use chrono::{Datelike, Local, Timelike};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; 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>) { pub fn spawn(app: Arc<App>) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
tokio::time::sleep(Duration::from_secs(60)).await; tokio::time::sleep(Duration::from_secs(60)).await;
let now = Local::now(); 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 { if now.hour() != HOUR {
continue; continue;
} }
+13 -2
View File
@@ -174,7 +174,8 @@ fn parse_status(raw: &str) -> Value {
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult { pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
let spec = crate::sandbox::spec_for(&app, Some(&user.id)); let spec = crate::sandbox::spec_for(&app, Some(&user.id));
let cfg = crate::gitcreds::global_config(&app, &user); let cfg = crate::gitcreds::global_config(&app, &user);
let env = crate::gitcreds::identity_env(&app, &user.id); 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 SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
} }
@@ -254,6 +255,15 @@ async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
git_ok(&repo, &args, 60).await?; git_ok(&repo, &args, 60).await?;
json!({"ok": true}) 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" => { "commit" => {
let msg = b.message.as_deref().unwrap_or("").trim().to_string(); let msg = b.message.as_deref().unwrap_or("").trim().to_string();
if msg.is_empty() && !b.amend { if msg.is_empty() && !b.amend {
@@ -392,7 +402,8 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> { pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
let spec = crate::sandbox::spec_for(app, Some(&user.id)); let spec = crate::sandbox::spec_for(app, Some(&user.id));
let cfg = crate::gitcreds::global_config(app, user); let cfg = crate::gitcreds::global_config(app, user);
let env = crate::gitcreds::identity_env(app, &user.id); let mut env = crate::gitcreds::identity_env(app, &user.id);
env.extend(crate::sshkeys::env(app, user));
let s = session.clone(); let s = session.clone();
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?; let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) { if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
+42
View File
@@ -53,6 +53,35 @@ pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
env 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 { pub fn home_of(app: &App, user: &User) -> PathBuf {
app.data_dir().join("users").join(&user.username).join("home") app.data_dir().join("users").join(&user.username).join("home")
} }
@@ -159,6 +188,19 @@ pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
mod tests { mod tests {
use super::*; 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] #[test]
fn keeps_masked_tokens() { 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() }; 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), Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
Err(_) => (Some("timed out".to_string()), 0), Err(_) => (Some("timed out".to_string()), 0),
}; };
// The host answered but has no model list: reachable. // The host answered but has no model list: reachable. (fal.ai's router answers 403
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405"))); // "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 { let state = match &error {
None => "ok", None => "ok",
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth", 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());
}
}
+4 -4
View File
@@ -38,7 +38,7 @@ pub struct GenerateRequest {
pub references: Vec<PathBuf>, pub references: Vec<PathBuf>,
} }
fn slug(s: &str) -> String { pub(crate) fn slug(s: &str) -> String {
let mut out: String = s let mut out: String = s
.chars() .chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) .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). /// 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(); let mut out = Vec::new();
for p in paths { for p in paths {
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?; 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) 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:") { if let Some(rest) = url.strip_prefix("data:") {
let (meta, data) = rest.split_once(',').context("malformed data URI")?; let (meta, data) = rest.split_once(',').context("malformed data URI")?;
let ctype = meta.split(';').next().unwrap_or("image/png").to_string(); let ctype = meta.split(';').next().unwrap_or("image/png").to_string();
@@ -348,7 +348,7 @@ pub fn backfill_copies(app: &App) -> Result<usize> {
} }
/// Downscaled copy for the model's context. /// 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 = image::open(path).ok()?;
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle); let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut buf = Vec::new(); let mut buf = Vec::new();
+49
View File
@@ -68,6 +68,13 @@ pub struct JobSettings {
/// Chat in which the user works out the prompt with a model. /// Chat in which the user works out the prompt with a model.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>, 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 { impl JobSettings {
@@ -81,6 +88,7 @@ impl JobSettings {
timeout_minutes: default_timeout(), timeout_minutes: default_timeout(),
cwd: None, cwd: None,
assistant_session: None, assistant_session: None,
notify: true,
} }
} }
@@ -121,6 +129,7 @@ pub struct JobSpec {
pub cwd: Option<String>, pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>, pub assistant_session: Option<String>,
pub notify: bool,
pub prompt: String, pub prompt: String,
} }
@@ -135,6 +144,7 @@ impl JobSpec {
timeout_minutes: s.timeout_minutes, timeout_minutes: s.timeout_minutes,
cwd: s.cwd.clone(), cwd: s.cwd.clone(),
assistant_session: s.assistant_session.clone(), assistant_session: s.assistant_session.clone(),
notify: s.notify,
prompt, prompt,
} }
} }
@@ -149,6 +159,7 @@ impl JobSpec {
timeout_minutes: self.timeout_minutes, timeout_minutes: self.timeout_minutes,
cwd: self.cwd.clone(), cwd: self.cwd.clone(),
assistant_session: self.assistant_session.clone(), assistant_session: self.assistant_session.clone(),
notify: self.notify,
} }
} }
} }
@@ -218,6 +229,7 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60), timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()), cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
assistant_session: None, assistant_session: None,
notify: meta.get("notify").map(|v| unquote(v) != "false").unwrap_or(true),
prompt: body.trim().to_string(), prompt: body.trim().to_string(),
}) })
} }
@@ -321,6 +333,9 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
instructions: String::new(), instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on). // New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true, telegram_quiet: true,
summary: String::new(),
color: None,
icon: None,
}; };
let session = app.save_session(session)?; let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?; app.store.job_started(&job.id, &session.id, now)?;
@@ -339,6 +354,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
let job_id = job.id.clone(); let job_id = job.id.clone();
let sid = session.id.clone(); let sid = session.id.clone();
let timeout = Duration::from_secs(spec.timeout_minutes * 60); let timeout = Duration::from_secs(spec.timeout_minutes * 60);
let (spec_name, spec_notify) = (spec.name.clone(), spec.notify);
tokio::spawn(async move { tokio::spawn(async move {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await; tokio::time::sleep(Duration::from_secs(2)).await;
@@ -346,15 +362,48 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
if started.elapsed() > timeout { if started.elapsed() > timeout {
crate::runner::abort(&app2, &sid); crate::runner::abort(&app2, &sid);
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout"); let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
report(&app2, &sid, &spec_name, spec_notify, "timeout").await;
return; return;
} }
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
} }
let _ = app2.store.job_finished(&job_id, Some(&sid), "done"); let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
report(&app2, &sid, &spec_name, spec_notify, "done").await;
}); });
Ok(session.id) 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. /// Background scheduler: checks every 30 s which jobs are due.
pub fn spawn_scheduler(app: Arc<App>) { pub fn spawn_scheduler(app: Arc<App>) {
tokio::spawn(async move { tokio::spawn(async move {
+6
View File
@@ -5,6 +5,8 @@ mod app;
mod auth; mod auth;
mod backup; mod backup;
mod blobs; mod blobs;
mod checks;
mod claude_code;
mod cli_api; mod cli_api;
mod budget; mod budget;
mod config; mod config;
@@ -22,7 +24,9 @@ mod lsp;
mod mentions; mod mentions;
mod metrics; mod metrics;
mod mcp; mod mcp;
mod mcp_bridge;
mod media; mod media;
mod mesh3d;
mod permission; mod permission;
mod prompts; mod prompts;
mod push; mod push;
@@ -31,6 +35,8 @@ mod reverse;
mod runner; mod runner;
mod sandbox; mod sandbox;
mod shells; mod shells;
mod sshkeys;
mod stats_export;
mod store; mod store;
mod telegram; mod telegram;
mod terminal; mod terminal;
+41 -2
View File
@@ -558,6 +558,42 @@ impl McpManager {
/// Search the on-demand MCP tools by keywords. /// Search the on-demand MCP tools by keywords.
pub struct McpSearch; 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] #[async_trait]
impl Tool for McpSearch { impl Tool for McpSearch {
fn name(&self) -> &str { fn name(&self) -> &str {
@@ -602,10 +638,13 @@ impl Tool for McpSearch {
.filter(|(s, _)| *s > 0 || words.is_empty()) .filter(|(s, _)| *s > 0 || words.is_empty())
.collect(); .collect();
scored.sort_by(|a, b| b.0.cmp(&a.0)); 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() { 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) { for (_, t) in scored.iter().take(8) {
let desc: String = t.info.description.chars().take(300).collect(); 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)); 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);
}
}
+26 -6
View File
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, PartialEq)] #[derive(Debug, Serialize, PartialEq)]
pub struct MediaItem { pub struct MediaItem {
/// "image", "video" or "audio". /// "image", "video", "audio" or "model" (3D).
pub kind: &'static str, pub kind: &'static str,
pub url: String, pub url: String,
pub name: String, pub name: String,
@@ -21,11 +21,17 @@ pub struct MediaItem {
/// Message to jump to (only for messages of the chat itself, not of subagents). /// Message to jump to (only for messages of the chat itself, not of subagents).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>, 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 IMAGE: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"];
const VIDEO: &[&str] = &["mp4", "webm", "mov", "m4v", "ogv"]; const VIDEO: &[&str] = &["mp4", "webm", "mov", "m4v", "ogv"];
const AUDIO: &[&str] = &["mp3", "wav", "ogg", "oga", "m4a", "flac", "opus", "aac"]; 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. /// Kind of a media file by its extension.
pub fn kind_of(path: &str) -> Option<&'static str> { pub fn kind_of(path: &str) -> Option<&'static str> {
@@ -36,6 +42,8 @@ pub fn kind_of(path: &str) -> Option<&'static str> {
Some("video") Some("video")
} else if AUDIO.contains(&ext.as_str()) { } else if AUDIO.contains(&ext.as_str()) {
Some("audio") Some("audio")
} else if MODEL.contains(&ext.as_str()) {
Some("model")
} else { } else {
None None
} }
@@ -118,7 +126,7 @@ pub fn collect(app: &App, session_id: &str) -> anyhow::Result<Vec<MediaItem>> {
// Generated images whose tool result was not found (e.g. removed by a rewind). // Generated images whose tool result was not found (e.g. removed by a rewind).
for img in app.store.images_of_sessions(&sessions)? { for img in app.store.images_of_sessions(&sessions)? {
push( 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 }, 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, &mut out,
); );
} }
@@ -133,6 +141,10 @@ pub fn collect(app: &App, session_id: &str) -> anyhow::Result<Vec<MediaItem>> {
Ok(out) Ok(out)
} }
fn raw_url(p: &Path) -> String {
format!("/api/files/raw?path={}", encode(&p.display().to_string()))
}
fn file_name(p: &str) -> String { fn file_name(p: &str) -> String {
Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p).to_string() Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p).to_string()
} }
@@ -144,25 +156,33 @@ fn collect_message(m: &Message, cwd: &Path, own: bool, call_msg: Option<&str>, a
let file = |p: &str, source: &'static str| -> Option<MediaItem> { let file = |p: &str, source: &'static str| -> Option<MediaItem> {
let kind = kind_of(p)?; let kind = kind_of(p)?;
let abs = resolve(p, cwd); let abs = resolve(p, cwd);
abs.is_file().then(|| MediaItem { kind, url: format!("/api/files/raw?path={}", encode(&abs.display().to_string())), name: file_name(p), source, created_at: at, message_id: mid.clone() }) 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 { for p in &m.parts {
match p { match p {
Part::Image { image } if m.role == Role::User => { Part::Image { image } if m.role == Role::User => {
if let Some(h) = &image.blob { 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() }); 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, .. } => { 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(); 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() { if !ids.is_empty() {
for id in ids { 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() }); 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 { } else {
for img in images { for img in images {
if let Some(h) = &img.blob { 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() }); 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 });
} }
} }
} }
+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");
}
}
+17 -6
View File
@@ -32,6 +32,8 @@ pub struct Agent {
pub max_parallel: Option<usize>, pub max_parallel: Option<usize>,
/// Review loop after each turn (None/0 rounds = off). /// Review loop after each turn (None/0 rounds = off).
pub refine: Option<crate::config::RefineConfig>, pub refine: Option<crate::config::RefineConfig>,
/// binary_info and decompile available (besides craft).
pub reverse: bool,
/// Source file of Markdown agents. /// Source file of Markdown agents.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>, pub path: Option<String>,
@@ -43,12 +45,12 @@ pub use codecraft_core::prompts::*;
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> { pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = craft_prompt(clean_room); let craft = craft_prompt(clean_room);
vec![ 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: "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, 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, 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, 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, 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, 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 },
] ]
} }
@@ -122,6 +124,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
source: "file".into(), source: "file".into(),
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()), 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, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }), 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()), path: Some(p.display().to_string()),
}); });
} }
@@ -141,6 +144,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
source: "config".into(), source: "config".into(),
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)), 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())), 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, path: None,
}); });
} }
@@ -173,6 +177,7 @@ pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) ->
source: "personal".into(), source: "personal".into(),
max_parallel: None, max_parallel: None,
refine: a.refine, refine: a.refine,
reverse: a.reverse_tools.unwrap_or(false),
path: None, path: None,
}); });
} }
@@ -293,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())); 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")) { 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>())), 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"), 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 }, 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 } 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 /// 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. /// same order (model, then provider), so concurrent requests cannot deadlock.
pub async fn acquire(&self, spec: &str) -> Slot { 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(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break, Err(_) => break,
}; };
let mut permission_id = None;
let (session_id, kind, body) = match &ev { 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, .. } => { Event::QuestionRequest { session_id, questions, .. } => {
let q = questions.first().map(|q| q.question.clone()).unwrap_or_default(); let q = questions.first().map(|q| q.question.clone()).unwrap_or_default();
(session_id.clone(), "question", q) (session_id.clone(), "question", q)
@@ -228,6 +232,8 @@ pub fn spawn_listener(app: Arc<App>) {
continue; continue;
} }
let payload = json!({ let payload = json!({
// Lets the notification itself approve or reject (service worker → POST /api/permissions/<id>).
"permission_id": permission_id,
"kind": kind, "kind": kind,
"title": title, "title": title,
"body": body.chars().take(180).collect::<String>(), "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, \ "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 \ 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 \ 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() .into()
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
@@ -151,7 +154,9 @@ impl Tool for Decompile {
"properties": { "properties": {
"path": {"type": "string"}, "path": {"type": "string"},
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"}, "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"] "required": ["path"]
}) })
@@ -171,7 +176,20 @@ impl Tool for Decompile {
bail!("{} is not a file", path.display()); 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 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) { 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)?))); 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 scripts = scripts_dir().display().to_string();
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into()); 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 (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 java = tools.join("jdk").display().to_string();
let mut cmd = match &ctx.sandbox { let mut cmd = match &ctx.sandbox {
Some(spec) => { Some(spec) => {
+268 -12
View File
@@ -2,7 +2,7 @@
//! checks), feed results back, repeat until the model stops. //! checks), feed results back, repeat until the model stops.
use crate::app::{App, PermissionReply}; 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::prompts::{self, Agent};
use crate::provider::{ChatRequest, Provider, StreamEvent}; use crate::provider::{ChatRequest, Provider, StreamEvent};
use crate::store::UsageRecord; use crate::store::UsageRecord;
@@ -73,6 +73,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
.join("\n"); .join("\n");
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true }); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
let source = source.to_string(); let source = source.to_string();
let turn_started = now_ms();
tokio::spawn(async move { tokio::spawn(async move {
let res = async { let res = async {
@@ -114,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) } if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
}); });
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false }); 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() { if !leftover.is_empty() {
let _ = start_prompt(app, session_id, leftover); let _ = start_prompt(app, session_id, leftover);
} }
@@ -145,7 +150,7 @@ pub fn abort(app: &App, session_id: &str) {
/// Messages since the last compaction summary. Tool calls left without a /// Messages since the last compaction summary. Tool calls left without a
/// result (run interrupted, e.g. by a server restart) get a synthetic result: /// result (run interrupted, e.g. by a server restart) get a synthetic result:
/// providers reject histories with unanswered tool calls. /// 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 let start = all
.iter() .iter()
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. }))) .rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
@@ -215,7 +220,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
let cfg = app.config(); let cfg = app.config();
let mut all = tools::builtin_tools(); let mut all = tools::builtin_tools();
if !cfg.images.enabled { 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); let (direct, deferred) = app.mcp.tools(&cfg, owner);
all.extend(direct); all.extend(direct);
@@ -226,7 +231,12 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
all.into_iter() all.into_iter()
.filter(|t| !agent.read_only || t.read_only()) .filter(|t| !agent.read_only || t.read_only())
// Craft tools only where they are useful. // 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 { .filter(|t| match &agent.tools {
None => true, None => true,
Some(list) => { Some(list) => {
@@ -258,6 +268,10 @@ fn run_session_boxed(
/// Run the loop for a session until the model stops. Returns the final text. /// 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> { 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 final_text = String::new();
let mut stop_hooks = 0; let mut stop_hooks = 0;
let mut refine_done = 0u32; let mut refine_done = 0u32;
@@ -456,7 +470,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
agent: agent.name.clone(), agent: agent.name.clone(),
roots: roots.clone(), roots: roots.clone(),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()), 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, owner_id: None,
}; };
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned(); let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
@@ -633,9 +648,18 @@ async fn execute_call(
// Permission check. Approvals are tracked on the top-level session. // Permission check. Approvals are tracked on the top-level session.
let root = app.root_session_id(&session.id); let root = app.root_session_id(&session.id);
let cfg = app.config(); let cfg = app.config();
let decision = app.with_runtime(&root, |r| { 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) (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 { match decision {
Decision::Deny => { Decision::Deny => {
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None); return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
@@ -653,16 +677,27 @@ async fn execute_call(
} }
return tool_result(call_id, call_name, text, true, None); 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" { if name == "bash" {
let cmd = input["command"].as_str().unwrap_or(""); let cmd = input["command"].as_str().unwrap_or("");
for part in split_command(cmd).unwrap_or_default() { 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 { } 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 => {} PermissionReply::Once => {}
} }
if let Some(sel) = hunks { if let Some(sel) = hunks {
@@ -862,6 +897,9 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
folder: None, folder: None,
instructions: String::new(), instructions: String::new(),
telegram_quiet: false, telegram_quiet: false,
summary: String::new(),
color: None,
icon: None,
}; };
let child = app.save_session(child)?; let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -1107,7 +1145,8 @@ async fn stream_step(
let mut message = Message::new(Role::Assistant, parts); let mut message = Message::new(Role::Assistant, parts);
message.id = msg_id; message.id = msg_id;
message.model = Some(model_spec.to_string()); 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() { if !message.parts.is_empty() {
app.store.add_message(&session.id, &message)?; app.store.add_message(&session.id, &message)?;
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() }); app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
@@ -1142,6 +1181,9 @@ fn small_model(app: &App, session: &Session) -> String {
let cfg = app.config(); let cfg = app.config();
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() { if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
cfg.server.small_model.clone() 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 { } else {
session.model.clone() session.model.clone()
} }
@@ -1169,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) { fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
let Ok(session) = app.session(session_id) else { return }; let Ok(session) = app.session(session_id) else { return };
if session.parent_id.is_some() || !session.title.is_empty() { if session.parent_id.is_some() || !session.title.is_empty() {
@@ -1513,10 +1723,56 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn todo_completion_and_reopening() { fn todo_completion_and_reopening() {
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]); let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
+8 -1
View File
@@ -132,7 +132,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
if new_session { if new_session {
push(&[&"--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 npm = format!("{home}/.npm-global");
let pipx = format!("{home}/.local/pipx"); let pipx = format!("{home}/.local/pipx");
let pipx_bin = format!("{home}/.local/bin"); let pipx_bin = format!("{home}/.local/bin");
@@ -152,6 +155,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
] { ] {
push(&[&"--setenv", &k, &v]); 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 { for (k, v) in &spec.env {
push(&[&"--setenv", k, v]); push(&[&"--setenv", k, v]);
} }
+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");
}
}
+283 -3
View File
@@ -157,6 +157,39 @@ impl Store {
content BLOB content BLOB
); );
CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts); 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 ( CREATE TABLE IF NOT EXISTS session_shares (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -376,6 +409,16 @@ impl Store {
Ok(()) Ok(())
} }
/// (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<()> { pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
// Images go to the blob table (encoded before taking the lock). // Images go to the blob table (encoded before taking the lock).
let mut m = m.clone(); let mut m = m.clone();
@@ -653,6 +696,39 @@ impl Store {
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last})) 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 /// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
/// into the viewer's local time. /// into the viewer's local time.
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> { pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
@@ -686,6 +762,9 @@ impl Store {
let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')"; let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
let by_day = grouped(day_sql, "day")?; 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_model = grouped("provider || '/' || model", "model")?;
let by_provider = grouped("provider", "provider")?; let by_provider = grouped("provider", "provider")?;
let by_agent = grouped("agent", "agent")?; let by_agent = grouped("agent", "agent")?;
@@ -737,6 +816,8 @@ impl Store {
Ok(json!({ Ok(json!({
"total": total, "total": total,
"by_day": by_day, "by_day": by_day,
"by_month": by_month,
"first_ts": first_ts,
"by_day_model": by_day_model, "by_day_model": by_day_model,
"by_model": by_model, "by_model": by_model,
"by_provider": by_provider, "by_provider": by_provider,
@@ -1448,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 ------------------------------------------------------------ // --- file checkpoints ------------------------------------------------------------
impl Store { impl Store {
@@ -1463,13 +1705,18 @@ impl Store {
/// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore. /// 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>>)>> { 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 conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2 "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 path = c.path)", AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3 AND path = c.path)",
)?; )?;
let rows = stmt let rows = stmt
.query_map(params![session_id, since], |r| { .query_map(params![session_id, since, until], |r| {
let existed: i64 = r.get(1)?; let existed: i64 = r.get(1)?;
Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None })) Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None }))
})? })?
@@ -1539,3 +1786,36 @@ fn snippet_in(m: &Message, needle: &str) -> Option<String> {
} }
None 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();
}
}
+3
View File
@@ -541,6 +541,9 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
instructions: String::new(), instructions: String::new(),
// Started from Telegram: keeps reporting there after switching to another chat. // Started from Telegram: keeps reporting there after switching to another chat.
telegram_quiet: false, telegram_quiet: false,
summary: String::new(),
color: None,
icon: None,
})?; })?;
update(app, &u.id, |st| st.session = Some(s.id.clone())); update(app, &u.id, |st| st.session = Some(s.id.clone()));
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?; bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
+2 -1
View File
@@ -162,7 +162,8 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
.current_dir(cwd) .current_dir(cwd)
.env("TERM", "xterm-256color") .env("TERM", "xterm-256color")
.env("CODECRAFT", "1") .env("CODECRAFT", "1")
.envs(crate::gitcreds::identity_env(app, &user.id)); .envs(crate::gitcreds::identity_env(app, &user.id))
.envs(crate::sshkeys::env(app, user));
match crate::gitcreds::global_config(app, user) { match crate::gitcreds::global_config(app, user) {
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg), Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
None => c, None => c,
+47 -1
View File
@@ -135,7 +135,7 @@ impl Tool for Bash {
/// The bash command for a script, sandboxed for non-admin users. /// The bash command for a script, sandboxed for non-admin users.
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> { 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. // 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 { Ok(match &ctx.sandbox {
Some(spec) => { Some(spec) => {
if !crate::sandbox::available() { if !crate::sandbox::available() {
@@ -155,12 +155,43 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
c.env("GIT_CONFIG_GLOBAL", cfg); c.env("GIT_CONFIG_GLOBAL", cfg);
} }
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id)); c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
c.envs(crate::sshkeys::env(&ctx.app, &u));
} }
c 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 { fn tail(s: &str, max: usize) -> String {
if s.len() <= max { if s.len() <= max {
return s.to_string(); return s.to_string();
@@ -171,3 +202,18 @@ fn tail(s: &str, max: usize) -> String {
} }
s[start..].to_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
View File
@@ -177,6 +177,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
Arc::new(craft::ImageCompare), Arc::new(craft::ImageCompare),
Arc::new(crate::prompts::SkillTool), Arc::new(crate::prompts::SkillTool),
Arc::new(crate::images::GenerateImage), Arc::new(crate::images::GenerateImage),
Arc::new(crate::mesh3d::Generate3d),
Arc::new(crate::reverse::BinaryInfo), Arc::new(crate::reverse::BinaryInfo),
Arc::new(crate::reverse::Decompile), Arc::new(crate::reverse::Decompile),
] ]
+34 -1
View File
@@ -7,6 +7,12 @@ self.addEventListener("push", (event) => {
data = { body: event.data ? event.data.text() : "" }; data = { body: event.data ? event.data.text() : "" };
} }
const title = data.title || "CodeCraft"; 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] || ""; const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || "";
event.waitUntil( event.waitUntil(
self.registration.showNotification(prefix + title, { self.registration.showNotification(prefix + title, {
@@ -15,13 +21,40 @@ self.addEventListener("push", (event) => {
renotify: true, renotify: true,
icon: "/icon-192.png", icon: "/icon-192.png",
badge: "/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) => { self.addEventListener("notificationclick", (event) => {
event.notification.close(); 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; const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
event.waitUntil( event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => { self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => {
+23 -3
View File
@@ -2,8 +2,10 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { connectEvents, get, setUnauthorizedHandler } from "./api"; import { connectEvents, get, setUnauthorizedHandler } from "./api";
import { useT } from "./i18n"; import { useT } from "./i18n";
import { navigate, useRoute, closePane, MAX_PANES } from "./router"; 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 type { Info } from "./types";
import { ImageViewer } from "./components/ImageViewer";
import { ProjectPage } from "./components/ProjectPage";
import { Sidebar } from "./components/Sidebar"; import { Sidebar } from "./components/Sidebar";
import { Login } from "./components/Login"; import { Login } from "./components/Login";
import { ChatView } from "./components/ChatView"; import { ChatView } from "./components/ChatView";
@@ -20,6 +22,8 @@ import { WhatsNew } from "./components/WhatsNew";
import { ShareTarget } from "./components/ShareTarget"; import { ShareTarget } from "./components/ShareTarget";
import { Palette } from "./components/Palette"; import { Palette } from "./components/Palette";
import { pendingShare } from "./share"; 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. // 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 }))); const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -32,11 +36,21 @@ export function App() {
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [shared, setShared] = useState(pendingShare); const [shared, setShared] = useState(pendingShare);
const offline = useStore((s) => s.offline);
useTitleActivity(); useTitleActivity();
const refresh = async () => { const refresh = async () => {
try { 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 })); setState(() => ({ me: i.user, update: i.update ?? null }));
if (i.user) { if (i.user) {
await loadSessions(); await loadSessions();
@@ -69,6 +83,7 @@ export function App() {
// Close the mobile menu on navigation. // Close the mobile menu on navigation.
useEffect(() => setMenuOpen(false), [route.join("/")]); 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 (error) return <div className="empty">{error}</div>;
if (!info) return <div className="empty">{t("common.loading")}</div>; if (!info) return <div className="empty">{t("common.loading")}</div>;
if (!info.user) if (!info.user)
@@ -94,6 +109,7 @@ export function App() {
</div> </div>
); );
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />; else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "p" && arg) content = <ProjectPage key={arg} cwd={decodeURIComponent(arg)} onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />; else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />; else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />; else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
@@ -116,9 +132,13 @@ export function App() {
<div className="app"> <div className="app">
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />} {menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} /> <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} /> <WhatsNew version={info.version} />
<Palette /> <Palette />
<ImageViewer />
{shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />} {shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />}
<DialogHost /> <DialogHost />
</div> </div>
+46
View File
@@ -0,0 +1,46 @@
import { del, get } from "./api";
import { choiceDialog, confirmDialog, toast } from "./dialogs";
import { t } from "./i18n";
import { formatSize } from "./components/FileFigure";
interface FolderInfo {
own: boolean;
path?: string;
files?: number;
bytes?: number;
git?: boolean;
truncated?: boolean;
}
/**
* Delete a chat after asking. A chat with a folder of its own (not shared with other chats, not a
* project) gets the question whether the folder goes with it. Resolves to true when it was deleted.
*/
export async function deleteChat(session: { id: string; parent_id?: string | null }): Promise<boolean> {
const folder = session.parent_id ? undefined : await get<FolderInfo>(`/sessions/${session.id}/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 false;
withFolder = choice === "both";
} else if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) {
return false;
}
try {
const r = await del<{ folder_deleted?: boolean; folder_error?: string }>(`/sessions/${session.id}${withFolder ? "?delete_folder=true" : ""}`);
if (r?.folder_error) toast(r.folder_error);
else if (r?.folder_deleted) toast(t("chat.folderDeleted"), "success");
return true;
} catch (e: any) {
toast(e.message);
return false;
}
}
+230 -35
View File
@@ -2,11 +2,11 @@ import { takePrefill } from "../share";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure"; import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { del, get, patch, post } from "../api"; import { del, get, patch, post, put } from "../api";
import { useFetch, useModels } from "../hooks"; import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { fmtCompact, fmtCost, fmtMsgTime, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store"; import { dismissError, getState, loadAround, loadChecks, loadOlder, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types"; import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity"; import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode, useTodosCollapsed } from "../prefs"; import { useReasoningMode, useTodosCollapsed } from "../prefs";
@@ -24,8 +24,16 @@ import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog"; import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel"; import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard"; import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { promptDialog, toast } from "../dialogs";
import { deleteChat } from "../chatActions";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; 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"; import { MediaGallery } from "./MediaGallery";
type ToolResult = Extract<Part, { type: "tool_result" }>; type ToolResult = Extract<Part, { type: "tool_result" }>;
@@ -245,8 +253,10 @@ const MessageList = memo(function MessageList({
baseDir, baseDir,
onRewind, onRewind,
highlight, highlight,
currency,
}: { }: {
messages: Message[]; messages: Message[];
currency: string;
results: Map<string, ToolResult>; results: Map<string, ToolResult>;
busy: boolean; busy: boolean;
canEdit: boolean; canEdit: boolean;
@@ -280,13 +290,71 @@ const MessageList = memo(function MessageList({
)); ));
// Finished turns with tool steps fold into one line (the answer stays visible). // 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 steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
if (i === lastWork || steps.length < 2) return <div key={key}>{items}</div>; const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
return <WorkBlock key={key} messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>{items}</WorkBlock>; // 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. */ /** 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 }) { function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
const t = useT(); const t = useT();
@@ -307,7 +375,7 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa
); );
} }
return ( return (
<div className="msg work-block" data-mid={answer?.id}> <div className={`msg work-block ${answerText ? "bubble agent" : ""}`} data-mid={answer?.id}>
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}> <button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })} <Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>} {files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
@@ -316,12 +384,36 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa
<> <>
<Markdown text={answerText} baseDir={baseDir} /> <Markdown text={answerText} baseDir={baseDir} />
<ImageRefs 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> </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. */ /** An assistant message, or a user message that only carries tool results / harness notes. */
const AssistantOrNote = memo(function AssistantOrNote({ const AssistantOrNote = memo(function AssistantOrNote({
m, m,
@@ -341,8 +433,9 @@ const AssistantOrNote = memo(function AssistantOrNote({
const cls = `msg ${highlight === m.id ? "highlight" : ""}`; const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
if (m.role === "assistant") { if (m.role === "assistant") {
return ( return (
<div className={cls} data-mid={m.id}> <div className={`${cls} ${textOfMessage(m).trim() ? "bubble agent" : ""}`} data-mid={m.id}>
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} /> <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} />} {regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
</div> </div>
); );
@@ -367,13 +460,15 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
return ( return (
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}> <details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
<summary> <summary>
{summary.auto {summary.text.startsWith("[Context cleared")
? t("chat.cleared")
: summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" }) ? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens : summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) }) ? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")} : t("chat.compacted")}
</summary> </summary>
{summaryOpen && <Markdown text={summary.text} />} {summaryOpen && !summary.text.startsWith("[Context cleared") && <Markdown text={summary.text} />}
</details> </details>
); );
} }
@@ -382,18 +477,11 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
const texts = 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 }[]; const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
return ( return (
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}> <div className={`msg user-msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
{background.map((p, i) => ( {background.map((p, i) => (
<HarnessMessage key={i} text={p.text} /> <HarnessMessage key={i} text={p.text} />
))} ))}
<div className="msg-user"> <div className="msg-user">
{canEdit && (
<div className="msg-actions">
<button className="small ghost" onClick={() => onRewind(m)} title={t("chat.rewind")}>
<Icon name="edit" />
</button>
</div>
)}
<UserText text={texts.map((p) => p.text).join("\n\n")} /> <UserText text={texts.map((p) => p.text).join("\n\n")} />
{imgs.length > 0 && ( {imgs.length > 0 && (
<div className="user-files"> <div className="user-files">
@@ -404,6 +492,7 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
))} ))}
</div> </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>
</div> </div>
); );
@@ -475,6 +564,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
// The Telegram switch in the ⋯ menu only matters with a paired bot. // The Telegram switch in the ⋯ menu only matters with a paired bot.
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false; const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
const [editingInstructions, setEditingInstructions] = useState(false); const [editingInstructions, setEditingInstructions] = useState(false);
const [showApprovals, setShowApprovals] = useState(false);
const [planning, setPlanning] = useState(false);
const models = useModels(); const models = useModels();
const [loadError, setLoadError] = useState<string>(); const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState(() => takePrefill(sessionId) ?? ""); const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
@@ -496,11 +587,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); 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. // Every chat starts following.
useEffect(() => follow(true), [sessionId]); useEffect(() => follow(true), [sessionId]);
useEffect(() => { useEffect(() => {
loadSession(sessionId).catch((e) => setLoadError(e.message)); loadSession(sessionId).catch((e) => setLoadError(e.message));
void loadChecks(sessionId);
}, [sessionId]); }, [sessionId]);
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId); const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
@@ -592,7 +697,12 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
setText(""); setText("");
follow(true); follow(true);
} catch (e: any) { } 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);
} }
}; };
@@ -607,10 +717,48 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")); 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 () => { const remove = async () => {
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; if (await deleteChat(session)) navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
await del(`/sessions/${sessionId}`);
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
}; };
// Context usage of the last request relative to the model's window. // Context usage of the last request relative to the model's window.
@@ -629,8 +777,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined; const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
return ( return (
<> <ChatCtx.Provider value={{ sessionId, canFork: !session.parent_id && !readOnly && !busy, canReply: !readOnly }}>
<div className="topbar"> <div className="topbar" data-color={session.color}>
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}> <button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" /> <Icon name="menu" />
</button> </button>
@@ -641,6 +789,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="undo" /> <Icon name="undo" />
</a> </a>
)} )}
{session.icon && <Icon name={session.icon} className="chat-icon" />}
{session.title || t("session.untitled")} {session.title || t("session.untitled")}
{!readOnly && ( {!readOnly && (
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}> <button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
@@ -677,7 +826,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{!readOnly && ( {!readOnly && (
<> <>
{!session.parent_id && ( {!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" /> <Icon name="share" />
</button> </button>
)} )}
@@ -691,11 +840,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}> <button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
<Icon name="search" /> <Icon name="search" />
</button> </button>
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}> <button className="small ghost hide-mobile" onClick={tabs.openFiles} title={t("files.newTab")}>
<Icon name="folder" /> <Icon name="folder" />
</button> </button>
{!session.parent_id && ( {!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" /> <Icon name="fork" />
</button> </button>
)} )}
@@ -704,6 +853,28 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="more" /> <Icon name="more" />
</summary> </summary>
<div className="menu-pop-body"> <div className="menu-pop-body">
{/* 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 <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
@@ -714,6 +885,23 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
> >
<Icon name="edit" /> {t("instructions.title")} <Icon name="edit" /> {t("instructions.title")}
</a> </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 <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
@@ -763,10 +951,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
)} )}
</div> </div>
</details> </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" /> <Icon name="compact" />
</button> </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" /> <Icon name="trash" />
</button> </button>
</> </>
@@ -790,7 +978,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />} {searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
<ChatTabBar state={tabs} /> <ChatTabBar state={tabs} />
<ChatTabPanes 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} />} {session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} /> <WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
<div <div
@@ -805,6 +993,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
// … scrolling back to the very end resumes it. // … scrolling back to the very end resumes it.
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true); else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight }; lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
setScrolled(el.scrollTop > 240);
}} }}
onWheel={(e) => { onWheel={(e) => {
if (e.deltaY < 0 && stick.current) follow(false); if (e.deltaY < 0 && stick.current) follow(false);
@@ -825,6 +1014,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
baseDir={session.cwd} baseDir={session.cwd}
onRewind={onRewind} onRewind={onRewind}
highlight={highlight} highlight={highlight}
currency={stats?.currency ?? "USD"}
/> />
{win?.newer && ( {win?.newer && (
<button className="back-to-latest" onClick={() => void showLatest()}> <button className="back-to-latest" onClick={() => void showLatest()}>
@@ -832,7 +1022,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</button> </button>
)} )}
{draft && ( {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} /> <AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div> </div>
)} )}
@@ -883,18 +1073,23 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</div> </div>
)} )}
</div> </div>
{!following && ( {(!following || scrolled) && (
<div className="jump-latest"> <div className="jump-latest">
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}> <button onClick={jumpPrevOwn} title={t("chat.jumpPrevOwn")} aria-label={t("chat.jumpPrevOwn")}>
<Icon name="arrow-down" /> <Icon name="arrow-up" />
</button> </button>
{!following && <button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
<Icon name="arrow-down" />
</button>}
</div> </div>
)} )}
</div> </div>
{showApprovals && <ProjectPermissions sessionId={sessionId} cwd={session.cwd} onClose={() => setShowApprovals(false)} />}
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />} {editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />} {editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />} {sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />} {rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && <OutboxBar sessionId={sessionId} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />} {!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? ( {readOnly ? (
<div className="readonly-bar">{t("chat.readOnlyHint")}</div> <div className="readonly-bar">{t("chat.readOnlyHint")}</div>
@@ -910,7 +1105,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
/> />
)} )}
</div> </div>
</> </ChatCtx.Provider>
); );
} }
+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>
);
}
+30 -3
View File
@@ -10,6 +10,7 @@ import { ModelSelect } from "./ModelSelect";
import { EffortSelect } from "./Regenerate"; import { EffortSelect } from "./Regenerate";
import { VoiceButton } from "./Voice"; import { VoiceButton } from "./Voice";
import { Markdown } from "./Markdown"; import { Markdown } from "./Markdown";
import { COMPOSE_EVENT, type ComposeDetail } from "../compose";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion { interface Suggestion {
@@ -19,6 +20,7 @@ interface Suggestion {
const BUILTIN: { name: string; key: Key }[] = [ const BUILTIN: { name: string; key: Key }[] = [
{ name: "compact", key: "command.compact" }, { name: "compact", key: "command.compact" },
{ name: "clear", key: "command.clear" },
{ name: "new", key: "command.new" }, { name: "new", key: "command.new" },
]; ];
@@ -77,6 +79,7 @@ export function Composer({
const [mention, setMention] = useState<{ start: number; end: number; q: string }>(); const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]); const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary"); 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; const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
// Auto-grow. // Auto-grow.
@@ -133,11 +136,14 @@ export function Composer({
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })), ...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
...(cmdData?.commands ?? []), ...(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])) .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} `) })); .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 = () => { const send = () => {
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return; if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
@@ -207,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) => { const removeFile = (path: string) => {
setFiles((cur) => cur.filter((f) => f.path !== path)); setFiles((cur) => cur.filter((f) => f.path !== path));
void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" }); void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" });
+7
View File
@@ -207,6 +207,13 @@ export function GitPanel({
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")} <input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")}
</label> </label>
<span className="spacer" /> <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 <button
className="primary" className="primary"
disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))} disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))}
+92
View File
@@ -34,6 +34,98 @@ const P: Record<string, ReactNode> = {
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />, download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />, compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />, 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" />, 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" />, 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" />, 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" />,
+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>
);
}
+38 -2
View File
@@ -1,7 +1,7 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { useState } from "react"; import { useState } from "react";
import { post } from "../api"; import { post } from "../api";
import { useT } from "../i18n"; import { getLang, useT } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { useStore } from "../store"; import { useStore } from "../store";
import type { PermissionRequest, QuestionRequest } from "../types"; 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 chosen = selected.filter(Boolean).length;
const partial = hunkCount > 1 && chosen < hunkCount; 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); setBusy(true);
try { try {
const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined; 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.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
: t("permission.alwaysTool", { tool: req.tool }); : 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 ( return (
<div className="card" role="alertdialog" aria-label={t("permission.title")}> <div className="card" role="alertdialog" aria-label={t("permission.title")}>
<h3> <h3>
@@ -72,6 +93,16 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
</details> </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 <input
style={{ width: "100%", marginTop: 8 }} style={{ width: "100%", marginTop: 8 }}
placeholder={t("permission.feedback")} placeholder={t("permission.feedback")}
@@ -86,6 +117,11 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
<button disabled={busy || partial} onClick={() => reply("always")}> <button disabled={busy || partial} onClick={() => reply("always")}>
{alwaysLabel} {alwaysLabel}
</button> </button>
{!dangerous && (
<button disabled={busy || partial} onClick={() => reply("project")} title={t("permission.projectHint")}>
{projectLabel}
</button>
)}
<button className="danger" disabled={busy} onClick={() => reply("reject")}> <button className="danger" disabled={busy} onClick={() => reply("reject")}>
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")} {feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
</button> </button>
+6
View File
@@ -20,6 +20,7 @@ interface Settings {
model: string; model: string;
permission_mode: string; permission_mode: string;
timeout_minutes: number; timeout_minutes: number;
notify: boolean;
} }
const settingsOf = (j: JobInfo): Settings => ({ const settingsOf = (j: JobInfo): Settings => ({
@@ -29,6 +30,7 @@ const settingsOf = (j: JobInfo): Settings => ({
model: j.spec?.model ?? "", model: j.spec?.model ?? "",
permission_mode: j.spec?.permission_mode ?? "auto", permission_mode: j.spec?.permission_mode ?? "auto",
timeout_minutes: j.spec?.timeout_minutes ?? 60, timeout_minutes: j.spec?.timeout_minutes ?? 60,
notify: j.spec?.notify ?? true,
}); });
type Tab = "prompt" | "settings" | "assistant"; type Tab = "prompt" | "settings" | "assistant";
@@ -254,6 +256,10 @@ export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) {
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} /> <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> </label>
</div> </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>} {settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
</section> </section>
<section className="job-editor-prompt"> <section className="job-editor-prompt">
+1 -1
View File
@@ -17,7 +17,7 @@ export interface JobInfo {
next_run?: number | null; next_run?: number | null;
error?: string; error?: string;
webhook?: boolean; webhook?: boolean;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null }; 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" }) : "–"); const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
+32 -2
View File
@@ -3,14 +3,24 @@ import { get } from "../api";
import { useT, type Key } from "../i18n"; import { useT, type Key } from "../i18n";
import { Spinner } from "./Activity"; import { Spinner } from "./Activity";
import { Icon } from "./Icon"; 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 { interface MediaItem {
kind: "image" | "video" | "audio"; kind: "image" | "video" | "audio" | "model";
url: string; url: string;
name: string; name: string;
source: "generated" | "tool" | "written" | "answer" | "upload"; source: "generated" | "tool" | "written" | "answer" | "upload";
created_at: number; created_at: number;
message_id?: string; message_id?: string;
/** 3D models: file (opened in the 3D view) and preview image. */
path?: string;
preview?: string;
} }
type Filter = "all" | MediaItem["kind"]; type Filter = "all" | MediaItem["kind"];
@@ -55,7 +65,7 @@ export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string
<Icon name="image" /> {t("media.title")} <Icon name="image" /> {t("media.title")}
</h3> </h3>
<div className="media-filters" role="radiogroup"> <div className="media-filters" role="radiogroup">
{(["all", "image", "video", "audio"] as Filter[]).map((k) => ( {(["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"}> <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>} {t(`media.filter.${k}` as Key)} {items && <small>{count(k)}</small>}
</button> </button>
@@ -78,6 +88,16 @@ export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string
{current.kind === "image" && <img src={current.url} alt={current.name} />} {current.kind === "image" && <img src={current.url} alt={current.name} />}
{current.kind === "video" && <video src={current.url} controls autoPlay />} {current.kind === "video" && <video src={current.url} controls autoPlay />}
{current.kind === "audio" && <audio 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>
<div className="media-bar"> <div className="media-bar">
<button className="small ghost" disabled={open === 0} onClick={() => setOpen((o) => (o ?? 1) - 1)} aria-label={t("media.prev")}> <button className="small ghost" disabled={open === 0} onClick={() => setOpen((o) => (o ?? 1) - 1)} aria-label={t("media.prev")}>
@@ -115,6 +135,16 @@ export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string
</span> </span>
)} )}
{m.kind === "video" && <Icon name="play" className="media-play" />} {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> </button>
))} ))}
</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>
);
}
+2 -1
View File
@@ -328,7 +328,8 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const isCraft = task === "craft"; const isCraft = task === "craft";
const s = await post<{ id: string }>("/sessions", { const s = await post<{ id: string }>("/sessions", {
cwd, 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, agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined, clean_room: isCraft ? !deep : undefined,
}); });
+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>
);
}
+43 -1
View File
@@ -146,8 +146,11 @@ function VersionInfo() {
function DigestToggle() { function DigestToggle() {
const t = useT(); const t = useT();
const [on, setOn] = useState<boolean>(); const [on, setOn] = useState<boolean>();
const [weekly, setWeekly] = useState(false);
useEffect(() => { 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; if (on === undefined) return null;
return ( return (
@@ -164,6 +167,30 @@ function DigestToggle() {
/> />
{t("settings.digest")} {t("settings.digest")}
</label> </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 && ( {on && (
<button <button
className="small ghost" className="small ghost"
@@ -246,8 +273,11 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
default_model: s.default_model ?? "", default_model: s.default_model ?? "",
small_model: s.small_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", currency: s.currency ?? "USD",
auto_compact_threshold: s.auto_compact_threshold ?? 0.85, 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, max_parallel_subagents: s.max_parallel_subagents ?? 4,
monthly_budget: s.monthly_budget ?? 0, monthly_budget: s.monthly_budget ?? 0,
budget_warning: s.budget_warning ?? 0.8, budget_warning: s.budget_warning ?? 0.8,
@@ -269,8 +299,11 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
savePatches([ savePatches([
{ path: ["server", "default_model"], value: form.default_model }, { path: ["server", "default_model"], value: form.default_model },
{ path: ["server", "small_model"], value: form.small_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", "currency"], value: form.currency },
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) }, { 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", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 }, { path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 }, { 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> <label>{t("settings.smallModel")}</label>
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} /> <ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
<div className="hint">{t("settings.smallModelHint")}</div> <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> <label>{t("settings.currency")}</label>
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} /> <input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
<label>{t("settings.autoCompact")}</label> <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 })} /> <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> <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> <label>{t("settings.monthlyBudgetTotal")}</label>
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} /> <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> <div className="hint">{t("settings.monthlyBudgetHint")}</div>
+52 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { get, put } from "../api"; import { del, get, post, put } from "../api";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { useStore } from "../store"; import { useStore } from "../store";
import { toast } from "../dialogs";
import { fmtDate } from "../i18n";
interface DirUser { interface DirUser {
id: string; id: string;
@@ -9,6 +11,54 @@ interface DirUser {
display_name: string; 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). */ /** Choose users a session is shared with (read-only for them). */
export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) { export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
const t = useT(); const t = useT();
@@ -59,6 +109,7 @@ export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose
</label> </label>
))} ))}
</div> </div>
<Links sessionId={sessionId} />
{error && <div className="notice err">{error}</div>} {error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}> <div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button> <button onClick={onClose}>{t("common.cancel")}</button>
+25 -3
View File
@@ -1,3 +1,4 @@
import { deleteChat } from "../chatActions";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds"; import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
@@ -25,7 +26,7 @@ function groupKey(ms: number): Key {
const GROUP_BY = "codecraft.sidebarGroupBy"; const GROUP_BY = "codecraft.sidebarGroupBy";
const COLLAPSED = "codecraft.sidebarCollapsed"; 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. */ /** 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[] }) { 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 ( return (
<div className={`session-row ${menu ? "menu-open" : ""}`}> <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"> <div className="title">
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />} {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> </div>
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
<div className="sub"> <div className="sub">
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>} {s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)} {basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
@@ -96,6 +101,18 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}> <button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")} <Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button> </button>
{!s.shared && (
<button
role="menuitem"
className="danger"
onClick={async () => {
setMenu(false);
if ((await deleteChat(s)) && active) navigate("/new");
}}
>
<Icon name="trash" /> {t("chat.delete")}
</button>
)}
</div> </div>
)} )}
</div> </div>
@@ -194,6 +211,11 @@ function GroupHeader({
)} )}
{menu && ( {menu && (
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}> <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?.())}> <button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")} <Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
</button> </button>
+186 -56
View File
@@ -1,5 +1,5 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { useMemo, useState } from "react"; import { Fragment, useEffect, useMemo, useState } from "react";
import { useFetch } from "../hooks"; import { useFetch } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n"; import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
@@ -15,6 +15,9 @@ interface StatsData {
total: Aggregate; total: Aggregate;
by_day: Row<"day">[]; by_day: Row<"day">[];
by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[]; 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_model: Row<"model">[];
by_provider: Row<"provider">[]; by_provider: Row<"provider">[];
by_agent: Row<"agent">[]; by_agent: Row<"agent">[];
@@ -37,6 +40,16 @@ const RANGES: { key: string; days: number; label: Key }[] = [
type HeatMetric = "requests" | "tokens" | "cost"; 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) { function dayKey(ms: number) {
const d = new Date(ms); const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; 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; 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 }) { function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
return ( return (
<div className="kpi"> <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) : "–"; 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 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 ( return (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<table className="grid"> <table className="grid">
@@ -87,20 +169,33 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r) => ( {group
<tr key={r[keyName]}> ? groups.map((g) => {
<td>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td> const members = rows.filter((r) => group(r) === g);
<td className="num">{fmtNumber(r.requests)}</td> const open = !shut.has(g);
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td> return (
<td className="num">{fmtCompact(r.cache_read_tokens)}</td> <Fragment key={g}>
<td className="num">{fmtCompact(r.output_tokens)}</td> <tr className="fold-group">
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td> <td>
<td className="num">{fmtNumber(r.output_tps, 1)}</td> <button
<td className="num">{fmtNumber(r.prefill_tps)}</td> className="fold-toggle"
<td className="num">{fmtCost(r.cost, currency)}</td> aria-expanded={open}
<td className="num">{perMillion(r, currency)}</td> onClick={() => setShut((x) => (x.has(g) ? new Set([...x].filter((k) => k !== g)) : new Set(x).add(g)))}
</tr> >
))} <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> </tbody>
</table> </table>
</div> </div>
@@ -117,16 +212,40 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
const isAdmin = useStore((s) => s.me?.role === "admin"); const isAdmin = useStore((s) => s.me?.role === "admin");
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? []; const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
const [userFilter, setUserFilter] = useState(""); const [userFilter, setUserFilter] = useState("");
const days = RANGES.find((r) => r.key === range)!.days;
const tz = -new Date().getTimezoneOffset(); const tz = -new Date().getTimezoneOffset();
// Stable per range: rounded to the minute so the URL doesn't change on every render. // 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 now = Math.floor(Date.now() / 60000) * 60000 + 60000;
const from = days ? now - days * 86400000 : 0; // "y2025": that calendar year (local time); otherwise a rolling window.
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`); 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(() => { const view = useMemo(() => {
if (!data) return undefined; 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". // 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 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); 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 costByDay = cats.map(() => ({}) as Record<string, number>);
const idx = new Map(cats.map((c, i) => [c, i])); const idx = new Map(cats.map((c, i) => [c, i]));
for (const r of data.by_day_model) { 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; if (i === undefined) continue;
const key = named.includes(r.model) ? r.model : other; const key = named.includes(r.model) ? r.model : other;
costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost; 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] }, { key: "output", label: t("stats.output"), color: SERIES[2] },
]; ];
const tokensByDay = cats.map((c): Record<string, number> => { 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 } : {}; 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. // 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 { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) };
}); });
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows }; return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows };
}, [data, heat, t]); }, [data, heat, t, monthly]);
const dayLabel = (d: string) => 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 cur = data?.currency ?? "USD";
const tot = data?.total; const tot = data?.total;
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v)); const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
@@ -203,6 +324,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
</button> </button>
))} ))}
</div> </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 && ( {isAdmin && (
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}> <select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
<option value="">{t("stats.allUsers")}</option> <option value="">{t("stats.allUsers")}</option>
@@ -213,6 +344,15 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
))} ))}
</select> </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)}> <button className="small" onClick={() => setTableView(!tableView)}>
{tableView ? t("stats.chartView") : t("stats.tableView")} {tableView ? t("stats.chartView") : t("stats.tableView")}
</button> </button>
@@ -238,61 +378,51 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
{tot.requests === 0 ? ( {tot.requests === 0 ? (
<div className="empty">{t("stats.noData")}</div> <div className="empty">{t("stats.noData")}</div>
) : tableView ? ( ) : tableView ? (
<div className="panel"> <Fold id="perDay" title={t("stats.perDay")}>
<h3>{t("stats.perDay")}</h3>
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} /> <AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
</div> </Fold>
) : ( ) : (
<> <>
<div className="panel"> <Fold id="costPerDay" title={t(monthly ? "stats.costPerMonth" : "stats.costPerDay")}>
<h3>{t("stats.costPerDay")}</h3>
<Legend series={view.modelSeries} /> <Legend series={view.modelSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} /> <StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
</div> </Fold>
<div className="panel"> <Fold id="tokensPerDay" title={t(monthly ? "stats.tokensPerMonth" : "stats.tokensPerDay")}>
<h3>{t("stats.tokensPerDay")}</h3>
<Legend series={view.tokenSeries} /> <Legend series={view.tokenSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} /> <StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
</div> </Fold>
<div className="panel"> <Fold id="heatmap" title={t("stats.heatmap")}>
<h3> <div className="row" style={{ justifyContent: "flex-end", marginBottom: 6 }}>
{t("stats.heatmap")} <select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)} aria-label={t("stats.heatmap")}>
<span className="spacer" />
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)}>
<option value="requests">{t("stats.requests")}</option> <option value="requests">{t("stats.requests")}</option>
<option value="tokens">{t("stats.tokens")}</option> <option value="tokens">{t("stats.tokens")}</option>
<option value="cost">{t("stats.cost")}</option> <option value="cost">{t("stats.cost")}</option>
</select> </select>
</h3> </div>
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} /> <Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
</div> </Fold>
</> </>
)} )}
{tot.requests > 0 && ( {tot.requests > 0 && (
<> <>
<div className="panel"> <Fold id="byModel" title={t("stats.byModel")}>
<h3>{t("stats.byModel")}</h3> <AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} group={(r) => String(r.model).split("/")[0]} />
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} /> </Fold>
</div> <Fold id="byProvider" title={t("stats.byProvider")}>
<div className="panel">
<h3>{t("stats.byProvider")}</h3>
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} /> <AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
</div> </Fold>
<div className="panel"> <Fold id="byAgent" title={t("stats.byAgent")}>
<h3>{t("stats.byAgent")}</h3>
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} /> <AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
</div> </Fold>
{isAdmin && !userFilter && data.by_user.length > 1 && ( {isAdmin && !userFilter && data.by_user.length > 1 && (
<div className="panel"> <Fold id="byUser" title={t("stats.byUser")}>
<h3>{t("stats.byUser")}</h3>
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} /> <AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
</div> </Fold>
)} )}
<div className="panel"> <Fold id="topSessions" title={t("stats.topSessions")}>
<h3>{t("stats.topSessions")}</h3>
<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}`)} /> <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>
</> </>
)} )}
</> </>
+13 -1
View File
@@ -121,7 +121,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0; const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
const openByDefault = const openByDefault =
(status === "err" && !!result) || (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; const open = toggled ?? openByDefault;
return ( return (
@@ -164,6 +164,18 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
<pre className="mono">{result.content}</pre> <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) ? ( {Array.isArray(meta?.image_ids) ? (
<div className="gallery small"> <div className="gallery small">
{meta.image_ids.map((id: string, i: number) => { {meta.image_ids.map((id: string, i: number) => {
+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 { useEffect, useRef, useState } from "react";
import { getLang, useT } from "../i18n"; import { getLang, useT } from "../i18n";
import { toast } from "../dialogs"; import { toast } from "../dialogs";
import { get } from "../api";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
/* Web Speech API (Chrome/Edge/Safari; Firefox has none). */ /* 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; 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" }; const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" };
/** Microphone button: dictated text is appended to the message. */ /** 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 t = useT();
const [listening, setListening] = useState(false); const [listening, setListening] = useState(false);
const rec = useRef<Recognition | null>(null); const rec = useRef<Recognition | null>(null);
+6
View File
@@ -3,9 +3,12 @@ import { post } from "../../api";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { GitAccount } from "./GitAccount"; import { GitAccount } from "./GitAccount";
import { SshKeys } from "./SshKeys";
import { ApiTokens } from "./ApiTokens"; import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall"; import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory"; import { Memory } from "./Memory";
import { Snippets } from "./Snippets";
import { ClaudeCode } from "./ClaudeCode";
import { Telegram } from "./Telegram"; import { Telegram } from "./Telegram";
export function Account() { export function Account() {
@@ -50,8 +53,11 @@ export function Account() {
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div> <div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div> </div>
<Memory /> <Memory />
<Snippets />
<GitAccount /> <GitAccount />
<SshKeys />
<CliInstall /> <CliInstall />
<ClaudeCode />
<Telegram /> <Telegram />
<ApiTokens /> <ApiTokens />
</> </>
@@ -62,6 +62,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
refine_model: raw?.refine?.model ?? "", refine_model: raw?.refine?.model ?? "",
refine_criteria: raw?.refine?.criteria ?? "", refine_criteria: raw?.refine?.criteria ?? "",
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean, 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)); const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
@@ -78,6 +79,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
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, 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 || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
if (!builtin) { if (!builtin) {
patches.push({ path: [...base, "mode"], value: form.mode }); patches.push({ path: [...base, "mode"], value: form.mode });
@@ -166,6 +168,15 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
<div className="hint">{t("agents.refineAfterTodosHint")}</div> <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> <label>{t("agents.description")}</label>
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /> <input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>} {a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
+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>
);
}
+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; confirmLabel?: string;
} }
interface ChoiceOpts {
title?: string;
message: string;
choices: { value: string; label: string; danger?: boolean }[];
}
type Dialog = type Dialog =
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts) | ({ 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); | ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
interface Toast { 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> { export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
const o = typeof opts === "string" ? { message: opts } : opts; const o = typeof opts === "string" ? { message: opts } : opts;
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -108,14 +123,27 @@ function DialogView({ d }: { d: Dialog }) {
style={{ width: "100%" }} style={{ width: "100%" }}
/> />
)} )}
<div className="row dialog-actions"> {d.kind === "choice" ? (
<button type="button" onClick={() => close(d, cancelValue)}> <div className="row dialog-actions dialog-choices">
{t("common.cancel")} <button type="button" onClick={() => close(d, null)}>
</button> {t("common.cancel")}
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}> </button>
{d.confirmLabel ?? t("common.ok")} {d.choices.map((c, i) => (
</button> <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)}>
</div> {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> </form>
</div> </div>
); );
+180 -1
View File
@@ -189,7 +189,7 @@ export const de: Messages = {
"tools.install": "Ghidra installieren", "tools.install": "Ghidra installieren",
"tools.reinstall": "Installation prüfen / reparieren", "tools.reinstall": "Installation prüfen / reparieren",
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…", "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.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.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen", "rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
@@ -336,6 +336,8 @@ export const de: Messages = {
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…", "activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
"chat.reviewPassed": "Review bestanden:", "chat.reviewPassed": "Review bestanden:",
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:", "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.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.", "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.workSteps": "{n} Schritte",
@@ -354,6 +356,7 @@ export const de: Messages = {
"media.filter.all": "Alle", "media.filter.all": "Alle",
"media.filter.image": "Bilder", "media.filter.image": "Bilder",
"media.filter.video": "Videos", "media.filter.video": "Videos",
"media.filter.model": "3D",
"media.filter.audio": "Audio", "media.filter.audio": "Audio",
"media.source.generated": "generiert", "media.source.generated": "generiert",
"media.source.tool": "Tool-Ausgabe", "media.source.tool": "Tool-Ausgabe",
@@ -363,8 +366,103 @@ export const de: Messages = {
"media.showInChat": "Im Chat zeigen", "media.showInChat": "Im Chat zeigen",
"media.open": "Öffnen", "media.open": "Öffnen",
"media.prev": "Vorheriges", "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.next": "Nächstes",
"media.back": "Zurück zur Übersicht", "media.back": "Zurück zur Übersicht",
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
"chat.reviewRound": "Review {round}: Änderungen nötig", "chat.reviewRound": "Review {round}: Änderungen nötig",
"agents.refine": "Review-Schleife", "agents.refine": "Review-Schleife",
"agents.refineRounds": "Review-Runden pro Aufgabe", "agents.refineRounds": "Review-Runden pro Aufgabe",
@@ -710,6 +808,7 @@ export const de: Messages = {
"plan.implementPrompt": "Setze den Plan um.", "plan.implementPrompt": "Setze den Plan um.",
"tool.input": "Eingabe", "tool.input": "Eingabe",
"tool.open3d": "In 3D-Ansicht öffnen",
"tool.output": "Ausgabe", "tool.output": "Ausgabe",
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)", "tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
"tool.todos": "Aufgaben", "tool.todos": "Aufgaben",
@@ -719,6 +818,16 @@ export const de: Messages = {
"permission.title": "Freigabe erforderlich", "permission.title": "Freigabe erforderlich",
"permission.allowOnce": "Einmal erlauben", "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.alwaysTool": "{tool} in dieser Session immer erlauben",
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben", "permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
"permission.reject": "Ablehnen", "permission.reject": "Ablehnen",
@@ -768,6 +877,9 @@ export const de: Messages = {
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.", "settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
"settings.currency": "Währung", "settings.currency": "Währung",
"settings.autoCompact": "Auto-Komprimierung bei", "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.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
"settings.craft": "Craft-Modus", "settings.craft": "Craft-Modus",
"settings.cleanRoom": "Clean Room", "settings.cleanRoom": "Clean Room",
@@ -989,4 +1101,71 @@ export const de: Messages = {
"stats.user": "Benutzer", "stats.user": "Benutzer",
"stats.allUsers": "Alle Benutzer", "stats.allUsers": "Alle Benutzer",
"stats.byUser": "Nach 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",
}; };
+180 -1
View File
@@ -188,7 +188,7 @@ export const en = {
"tools.install": "Install Ghidra", "tools.install": "Install Ghidra",
"tools.reinstall": "Check / repair installation", "tools.reinstall": "Check / repair installation",
"tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…", "tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…",
"tools.ghidraHint": "Ghidra lets agents in craft Deep mode decompile programs (Windows .exe/.dll, Linux, DOS, …) to study how they work. Installed into the data volume together with its own JDK.", "tools.ghidraHint": "Ghidra lets agents decompile programs and firmware (Windows .exe/.dll, Linux, DOS, …) to study how they work – in craft Deep mode and in modes with reverse engineering tools (Settings → Agents & commands). Installed into the data volume together with its own JDK.",
"rewind.intro": "The conversation is reset to before this message; you can edit and resend it.", "rewind.intro": "The conversation is reset to before this message; you can edit and resend it.",
"rewind.noFiles": "The agent did not change files since then.", "rewind.noFiles": "The agent did not change files since then.",
"rewind.restore": "Also restore the {n} file(s) the agent changed", "rewind.restore": "Also restore the {n} file(s) the agent changed",
@@ -335,6 +335,8 @@ export const en = {
"activity.reviewing": "Reviewing the result (round {round})…", "activity.reviewing": "Reviewing the result (round {round})…",
"chat.reviewPassed": "Review passed:", "chat.reviewPassed": "Review passed:",
"chat.todoReminder": "Back to work – {n} open todos:", "chat.todoReminder": "Back to work – {n} open todos:",
"agents.reverseTools": "Reverse engineering tools (binary_info, decompile with Ghidra)",
"agents.reverseToolsHint": "The agent can inspect and decompile programs and firmware in this mode (Ghidra must be installed under Settings → Tools). Craft has them anyway in deep mode.",
"agents.refineAfterTodos": "Review every completed todo and work until the list is done", "agents.refineAfterTodos": "Review every completed todo and work until the list is done",
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.", "agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
"chat.workSteps": "{n} steps", "chat.workSteps": "{n} steps",
@@ -353,6 +355,7 @@ export const en = {
"media.filter.all": "All", "media.filter.all": "All",
"media.filter.image": "Images", "media.filter.image": "Images",
"media.filter.video": "Videos", "media.filter.video": "Videos",
"media.filter.model": "3D",
"media.filter.audio": "Audio", "media.filter.audio": "Audio",
"media.source.generated": "generated", "media.source.generated": "generated",
"media.source.tool": "tool output", "media.source.tool": "tool output",
@@ -363,7 +366,102 @@ export const en = {
"media.open": "Open", "media.open": "Open",
"media.prev": "Previous", "media.prev": "Previous",
"media.next": "Next", "media.next": "Next",
"msg.actions": "Message actions",
"msg.copy": "Copy text",
"msg.copied": "Copied",
"msg.copyFailed": "Copying is not possible here",
"msg.quote": "Quote in reply",
"msg.forkHere": "Fork from here",
"chat.jumpPrevOwn": "Previous own message",
"viewer.annotate": "Mark up",
"viewer.annotateFailed": "This image cannot be edited",
"viewer.attach": "Attach to message",
"viewer.undo": "Undo",
"viewer.tool.pen": "Pen",
"viewer.tool.arrow": "Arrow",
"viewer.tool.rect": "Rectangle",
"viewer.useInChat": "Use in message",
"viewer.attached": "Image attached to the message",
"turn.changes": "{n} file(s) changed",
"turn.noChanges": "No file changes found for this turn.",
"turn.noDiff": "No text diff available.",
"turn.truncated": "The diff is shortened.",
"turn.new": "new",
"turn.deleted": "deleted",
"turn.rollback": "Roll back to before this turn",
"job.plan": "Plan as automation",
"job.planHint": "Turns this chat into instructions for a recurring automation (switched off until you set the schedule)",
"job.drafting": "Drafting the automation from this chat …",
"color.label": "Colour of this chat",
"color.red": "Red",
"color.orange": "Orange",
"color.yellow": "Yellow",
"color.green": "Green",
"color.aqua": "Teal",
"color.blue": "Blue",
"color.purple": "Purple",
"color.pink": "Pink",
"icon.label": "Icon of this chat",
"claude.title": "Claude Code",
"claude.intro": "Chats with a \"Claude Code\" model are run by the Claude Code CLI on the server – with your Claude subscription or Anthropic Console account. Sign in here once; approvals, push and the sandbox work as usual.",
"claude.notInstalled": "Claude Code is not installed on the server.",
"claude.console": "Anthropic Console (API billing) instead of the Claude subscription",
"claude.signIn": "Sign in",
"claude.openLogin": "Open the sign-in page",
"claude.step1": "Sign in at Anthropic in any browser (also on another device) and allow access.",
"claude.step2": "Paste the code the page shows afterwards:",
"claude.codePlaceholder": "Code from the sign-in page",
"claude.confirm": "Confirm",
"claude.signedIn": "Signed in to Claude Code",
"claude.signedInAs": "Signed in: {who}",
"claude.expires": "valid until {date}",
"claude.logout": "Sign out",
"claude.logoutConfirm": "Sign out of Claude Code on the server?",
"claude.version": "CLI {version}",
"claude.updateAvailable": "Update available: {version}",
"claude.upToDate": "up to date",
"claude.update": "Install update",
"claude.updating": "Installing …",
"claude.updated": "Claude Code updated to {version}",
"claude.updateFailed": "The update failed (details below)",
"claude.updateAdmin": "An administrator can install it.",
"claude.checkNow": "Check for updates",
"claude.updateLog": "Output of the update",
"check.menu": "Check after each turn …",
"check.title": "Check after each turn",
"check.hint": "Command that runs in {project} after every turn that changed files (e.g. cargo test, npm test). The result appears as a badge on the turn. Empty = off.",
"check.saved": "Check saved",
"check.removed": "Check removed",
"check.running": "Check running …",
"check.passed": "Check passed",
"check.failed": "Check failed",
"check.timeout": "Check timed out",
"check.noOutput": "(no output)",
"check.fix": "Let the agent fix it",
"check.fixPrompt": "The check of the project failed after your last change. Please fix the cause and run it again. Output:",
"projectDefaults.save": "Use as project default",
"projectDefaults.remove": "Remove project default",
"projectDefaults.hint": "New chats in this project folder start with this chat's model, mode, colour, instructions and folder",
"projectDefaults.saved": "New chats in this project will start like this one",
"projectDefaults.removed": "Project default removed",
"outbox.waiting": "{n} message(s) waiting for a connection",
"outbox.sendNow": "Send now",
"outbox.discard": "Discard",
"outbox.saved": "No connection – the message is saved and sent when you are back online",
"project.open": "Project overview",
"project.newChat": "New chat",
"project.today": "Today",
"project.yesterday": "Yesterday",
"project.chats": "{n} chat(s)",
"project.files": "{n} file(s) changed",
"project.archived": "{n} archived",
"project.checksFailed": "{n} failed check(s)",
"project.empty": "No chats in this folder yet.",
"viewer.title": "Image viewer",
"viewer.open": "Open in new tab",
"viewer.download": "Download",
"media.back": "Back to the overview", "media.back": "Back to the overview",
"chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)",
"chat.reviewRound": "Review {round}: changes requested", "chat.reviewRound": "Review {round}: changes requested",
"agents.refine": "Review loop", "agents.refine": "Review loop",
"agents.refineRounds": "Review rounds per task", "agents.refineRounds": "Review rounds per task",
@@ -709,6 +807,7 @@ export const en = {
"plan.implementPrompt": "Implement the plan.", "plan.implementPrompt": "Implement the plan.",
"tool.input": "Input", "tool.input": "Input",
"tool.open3d": "Open in the 3D view",
"tool.output": "Output", "tool.output": "Output",
"tool.todosUpdated": "Tasks updated ({done}/{total} done)", "tool.todosUpdated": "Tasks updated ({done}/{total} done)",
"tool.todos": "Tasks", "tool.todos": "Tasks",
@@ -718,6 +817,16 @@ export const en = {
"permission.title": "Permission required", "permission.title": "Permission required",
"permission.allowOnce": "Allow once", "permission.allowOnce": "Allow once",
"permission.projectCommand": "Always allow \"{prefix}\" in this project",
"permission.projectTool": "Always allow {tool} in this project",
"permission.projectHint": "Remembered for this project folder, in all chats, also after a restart. Revoke it in the ⋯ menu.",
"permission.explain": "What does this do?",
"permission.explaining": "Explaining …",
"permProject.title": "Project permissions",
"permProject.intro": "Allowed without asking in this project folder (all chats):",
"permProject.empty": "Nothing is allowed for this project yet. The button \"Always allow … in this project\" on an approval adds entries here.",
"permProject.revoke": "Revoke",
"permProject.menu": "Project permissions",
"permission.alwaysTool": "Always allow {tool} in this session", "permission.alwaysTool": "Always allow {tool} in this session",
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session", "permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
"permission.reject": "Reject", "permission.reject": "Reject",
@@ -767,6 +876,9 @@ export const en = {
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.", "settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
"settings.currency": "Currency", "settings.currency": "Currency",
"settings.autoCompact": "Auto-compact at", "settings.autoCompact": "Auto-compact at",
"settings.commitTrailer": "Commit note of agents",
"settings.commitTrailerOff": "off",
"settings.commitTrailerHint": "Added as a trailer to every git commit an agent makes. {model} = model name, {provider} = provider, {model_id} = model id. Empty = off. Your own commits stay unchanged.",
"settings.autoCompactHint": "Fraction of the context window (0 = off).", "settings.autoCompactHint": "Fraction of the context window (0 = off).",
"settings.craft": "Craft mode", "settings.craft": "Craft mode",
"settings.cleanRoom": "Clean room", "settings.cleanRoom": "Clean room",
@@ -988,6 +1100,73 @@ export const en = {
"stats.user": "User", "stats.user": "User",
"stats.allUsers": "All users", "stats.allUsers": "All users",
"stats.byUser": "By user", "stats.byUser": "By user",
"snippets.title": "Prompt snippets",
"snippets.hint": "Your own text blocks. Type / in the message field and pick a snippet to insert it (e.g. /tests → \"Add tests for this change\").",
"snippets.name": "name",
"snippets.text": "Text to insert",
"snippets.add": "Add snippet",
"snippets.insert": "Snippet",
"link.title": "Read-only link",
"link.hint": "Anyone with the link can read the conversation text (no tool output, no files) – without an account.",
"link.create": "Create link",
"link.copy": "Copy",
"link.copied": "Link copied",
"link.revoke": "Revoke",
"link.validity": "Validity",
"link.days": "{n} days",
"link.until": "until {date}",
"link.readOnly": "Shared read-only",
"git.suggest": "Suggest message",
"git.suggestHint": "Let the small model write a commit message for the staged changes",
"settings.weeklyDigest": "Weekly review on Mondays at 8:00 (chats, checks, costs)",
"settings.transcribeModel": "Speech-to-text model",
"settings.transcribeHint": "provider/model of an OpenAI-compatible transcription endpoint (e.g. openai/whisper-1). Adds the microphone button in browsers without built-in dictation, such as Firefox.",
"voice.transcribing": "Transcribing …",
"offline.banner": "No connection – showing saved copies. Messages you write wait in the outbox.",
"settings.planModel": "Planning model",
"settings.planModelHint": "Plan mode always uses this model (e.g. a strong one); all other modes use the model chosen for the chat (e.g. a cheaper one). Empty = the chat's model.",
"settings.sameAsChat": "(model of the chat)",
"jobEditor.notify": "Send the report as a push notification when a run ends",
"stats.year": "Year",
"stats.month": "Month",
"stats.perMonth": "Per month",
"stats.costPerMonth": "Cost per month",
"stats.tokensPerMonth": "Tokens per month",
"stats.export": "Export",
"stats.exportHint": "Download the figures of the selected range",
"stats.exportXlsx": "Excel (.xlsx) – all tables",
"stats.exportCsv": "CSV – {table}",
"stats.table.day": "per day",
"stats.table.month": "per month",
"stats.table.model": "by model",
"stats.table.provider": "by provider",
"stats.table.agent": "by agent",
"stats.table.user": "by user",
"stats.table.session": "top chats",
"command.clear": "Forget the conversation so far (the chat stays visible)",
"chat.cleared": "Context cleared – the model no longer sees the messages above",
"ssh.title": "SSH keys",
"ssh.hint": "Keys that agents, the terminal and the git panel use to reach other servers (ssh, scp, rsync, git over ssh). They are used automatically; hosts are trusted on first contact. Add the public key to the server's authorized_keys.",
"ssh.none": "No keys yet.",
"ssh.name": "Name (e.g. laptop, build server)",
"ssh.generate": "Generate new key",
"ssh.import": "Import key",
"ssh.importExisting": "Import an existing key",
"ssh.importHint": "Private keys without a passphrase only. The key is stored on the server for your account.",
"ssh.private": "Private key",
"ssh.public": "Public key",
"ssh.copyPublic": "Copy public key",
"ssh.copied": "Public key copied",
"ssh.copyFailed": "Could not copy – select the text manually",
"ssh.added": "Key added",
"ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.",
"chat.deleteTitle": "Delete chat",
"chat.deleteWithFolder": "This chat has a folder of its own:\n{path}\n({size})\n\nDelete the folder with the chat?",
"chat.deleteOnlyChat": "Delete chat only",
"chat.deleteChatAndFolder": "Delete chat and folder",
"chat.folderFiles": "{n} files",
"chat.folderHasGit": "The folder contains a git repository – commits and branches that were not pushed are lost.",
"chat.folderDeleted": "Chat and folder deleted",
}; };
export type Messages = Record<keyof typeof en, string>; export type Messages = Record<keyof typeof en, string>;
+7
View File
@@ -72,6 +72,13 @@ export function fmtCompact(n: number) {
return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n); return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n);
} }
/** Short date and time of a message: only the time for today. */
export function fmtMsgTime(ms: number) {
const d = new Date(ms);
const today = d.toDateString() === new Date().toDateString();
return new Intl.DateTimeFormat(current, today ? { timeStyle: "short" } : { dateStyle: "short", timeStyle: "short" }).format(d);
}
export function fmtCost(n: number, currency: string) { export function fmtCost(n: number, currency: string) {
const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2; const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2;
try { try {
+86
View File
@@ -0,0 +1,86 @@
import type { Info, Message, Part, Session } from "./types";
/**
* Read-only copy of the app's last state for the offline case (train, tunnel): the chat
* list and the most recently opened chats. Kept small: attachments are dropped and long
* tool output is cut. Everything is best-effort – storage may be full or blocked.
*/
const KEY = "codecraft.offline.v1";
const MAX_CHATS = 6;
const MAX_MESSAGES = 80;
const MAX_BYTES = 2_500_000;
interface Cache {
info?: Info;
sessions?: Session[];
chats: Record<string, { at: number; session: Session; messages: Message[] }>;
}
function read(): Cache {
try {
const c = JSON.parse(localStorage.getItem(KEY) ?? "null");
if (c && typeof c === "object") return { chats: {}, ...c };
} catch {
/* empty or corrupt */
}
return { chats: {} };
}
function write(c: Cache) {
try {
// Oldest chats go first when the copy gets too big.
let text = JSON.stringify(c);
while (text.length > MAX_BYTES && Object.keys(c.chats).length > 0) {
const oldest = Object.entries(c.chats).sort((a, b) => a[1].at - b[1].at)[0][0];
delete c.chats[oldest];
text = JSON.stringify(c);
}
localStorage.setItem(KEY, text);
} catch {
/* storage full or unavailable */
}
}
function slim(part: Part): Part {
if (part.type === "image") return { type: "text", text: "[image]" } as Part;
if (part.type === "tool_result") return { ...part, content: part.content.slice(0, 1500), images: [], meta: undefined } as Part;
return part;
}
export function cacheInfo(info: Info) {
if (!info.user) return;
write({ ...read(), info });
}
export function cachedInfo(): Info | undefined {
return read().info;
}
export function cacheSessions(sessions: Session[]) {
write({ ...read(), sessions });
}
export function cachedSessions(): Session[] {
return read().sessions ?? [];
}
export function cacheChat(session: Session, messages: Message[]) {
const c = read();
c.chats[session.id] = {
at: Date.now(),
session,
messages: messages.slice(-MAX_MESSAGES).map((m) => ({ ...m, parts: m.parts.map(slim) })),
};
const ids = Object.entries(c.chats).sort((a, b) => b[1].at - a[1].at);
for (const [id] of ids.slice(MAX_CHATS)) delete c.chats[id];
write(c);
}
export function cachedChat(id: string) {
return read().chats[id];
}
/** A request failed because there is no connection (as opposed to an answer from the server). */
export function isOffline(e: unknown): boolean {
return e instanceof TypeError || !navigator.onLine;
}
+85
View File
@@ -0,0 +1,85 @@
import { useSyncExternalStore } from "react";
import { ApiError, post } from "./api";
/** Messages written without a connection: kept on this device and sent when it is back. */
export interface OutboxItem {
id: string;
sessionId: string;
text: string;
effort?: string;
createdAt: number;
}
const KEY = "codecraft.outbox";
const listeners = new Set<() => void>();
let cache: OutboxItem[] | undefined;
function load(): OutboxItem[] {
if (cache) return cache;
try {
const v = JSON.parse(localStorage.getItem(KEY) ?? "[]");
cache = Array.isArray(v) ? v : [];
} catch {
cache = [];
}
return cache!;
}
function save(items: OutboxItem[]) {
cache = items;
try {
localStorage.setItem(KEY, JSON.stringify(items));
} catch {
// Storage blocked: the list still works until the page is closed.
}
listeners.forEach((l) => l());
}
export function addToOutbox(sessionId: string, text: string, effort?: string) {
save([...load(), { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, sessionId, text, effort, createdAt: Date.now() }]);
}
export function removeFromOutbox(id: string) {
save(load().filter((i) => i.id !== id));
}
export function useOutbox(sessionId: string): OutboxItem[] {
const all = useSyncExternalStore(
(l) => {
listeners.add(l);
return () => listeners.delete(l);
},
() => load(),
);
return all.filter((i) => i.sessionId === sessionId);
}
/** True for failures of the connection itself (not answers of the server). */
export function isOffline(e: unknown) {
return !(e instanceof ApiError) && (!navigator.onLine || e instanceof TypeError);
}
let flushing = false;
/** Send the waiting messages of a chat in order; stops at the first connection failure. Returns how many were sent. */
export async function flushOutbox(sessionId: string): Promise<number> {
if (flushing) return 0;
flushing = true;
let sent = 0;
try {
for (const item of load().filter((i) => i.sessionId === sessionId)) {
try {
await post(`/sessions/${item.sessionId}/prompt`, { text: item.text, effort: item.effort });
removeFromOutbox(item.id);
sent++;
} catch (e) {
if (isOffline(e)) break;
// The server refused it (deleted chat, read-only …): it would never go through.
removeFromOutbox(item.id);
}
}
} finally {
flushing = false;
}
return sent;
}
+43 -4
View File
@@ -1,3 +1,4 @@
import { cacheChat, cacheSessions, cachedChat, cachedSessions, isOffline } from "./offline";
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { get } from "./api"; import { get } from "./api";
import type { import type {
@@ -9,6 +10,7 @@ import type {
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
ServerEvent, ServerEvent,
TurnCheckInfo,
Session, Session,
} from "./types"; } from "./types";
@@ -28,6 +30,8 @@ export interface State {
/** Newer release (admins). */ /** Newer release (admins). */
update: { version: string; url: string; notes: string } | null; update: { version: string; url: string; notes: string } | null;
connected: boolean; connected: boolean;
/** Showing a saved copy: the server cannot be reached. */
offline: boolean;
sessions: Session[]; sessions: Session[];
/** Includes subagent sessions loaded on demand. */ /** Includes subagent sessions loaded on demand. */
sessionById: Record<string, Session>; sessionById: Record<string, Session>;
@@ -51,12 +55,15 @@ export interface State {
waiting: Record<string, string | undefined>; waiting: Record<string, string | undefined>;
/** Compaction running in a session (auto = context nearly full). */ /** Compaction running in a session (auto = context nearly full). */
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>; compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
/** Check results per session, by the id of the turn's last answer. */
checks: Record<string, Record<string, TurnCheckInfo> | undefined>;
} }
let state: State = { let state: State = {
me: null, me: null,
update: null, update: null,
connected: false, connected: false,
offline: false,
sessions: [], sessions: [],
sessionById: {}, sessionById: {},
messages: {}, messages: {},
@@ -73,6 +80,7 @@ let state: State = {
steps: {}, steps: {},
waiting: {}, waiting: {},
compacting: {}, compacting: {},
checks: {},
}; };
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@@ -203,6 +211,11 @@ export function applyEvent(ev: ServerEvent) {
}, },
})); }));
break; break;
case "check_result": {
const info: TurnCheckInfo = { message_id: ev.message_id, status: ev.status, exit_code: ev.exit_code, duration_ms: ev.duration_ms, output: ev.output };
setState((s) => ({ checks: { ...s.checks, [ev.session_id]: { ...s.checks[ev.session_id], [ev.message_id]: info } } }));
break;
}
case "mcp_status": case "mcp_status":
setState(() => ({ mcp: ev.servers })); setState(() => ({ mcp: ev.servers }));
break; break;
@@ -227,7 +240,17 @@ export function dismissError(sessionId: string, id: number) {
} }
export async function loadSessions() { export async function loadSessions() {
const list = await get<Session[]>("/sessions"); let list: Session[];
try {
list = await get<Session[]>("/sessions");
cacheSessions(list);
if (getState().offline) setState(() => ({ offline: false }));
} catch (e) {
if (!isOffline(e)) throw e;
list = cachedSessions().map((x) => ({ ...x, busy: false }));
setState(() => ({ offline: true }));
if (list.length === 0) throw e;
}
setState((s) => ({ setState((s) => ({
sessions: list, sessions: list,
sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) }, sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) },
@@ -241,9 +264,19 @@ export const PAGE = 200;
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean }; type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
export async function loadSession(id: string) { export async function loadSession(id: string) {
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>( type Loaded = { session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean };
`/sessions/${id}?limit=${PAGE}`, let data: Loaded;
); try {
data = await get<Loaded>(`/sessions/${id}?limit=${PAGE}`);
cacheChat(data.session, data.messages);
if (getState().offline) setState(() => ({ offline: false }));
} catch (e) {
const copy = isOffline(e) ? cachedChat(id) : undefined;
if (!copy) throw e;
// No connection: show the saved copy; new messages wait in the outbox.
data = { session: copy.session, messages: copy.messages, busy: false, can_write: true, queue: [], has_older: true };
setState(() => ({ offline: true }));
}
setState((s) => ({ setState((s) => ({
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } }, windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
...upsertSession(s, data.session), ...upsertSession(s, data.session),
@@ -259,6 +292,12 @@ export async function loadSession(id: string) {
})); }));
} }
/** Check results of a chat (badges on its turns). */
export async function loadChecks(id: string) {
const list = await get<TurnCheckInfo[]>(`/sessions/${id}/checks`).catch(() => [] as TurnCheckInfo[]);
setState((s) => ({ checks: { ...s.checks, [id]: { ...Object.fromEntries(list.map((c) => [c.message_id, c])), ...s.checks[id] } } }));
}
/** Older messages before the loaded ones (scrolling up). Returns how many came. */ /** Older messages before the loaded ones (scrolling up). Returns how many came. */
export async function loadOlder(id: string, stopAtSummary = true): Promise<number> { export async function loadOlder(id: string, stopAtSummary = true): Promise<number> {
const first = getState().messages[id]?.[0]; const first = getState().messages[id]?.[0];
+634 -12
View File
@@ -439,6 +439,9 @@ button.session-group:hover {
.main { .main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
/* Nothing in the bars may widen the page (and push it sideways on phones). */
overflow-x: clip;
max-width: 100vw;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@@ -456,7 +459,8 @@ button.session-group:hover {
} }
.topbar .grow { .topbar .grow {
flex: 1; flex: 1;
min-width: 0; /* The title stays readable: with many chips they wrap into a second row instead. */
min-width: min(160px, 40vw);
} }
.topbar h1 { .topbar h1 {
font-size: 15px; font-size: 15px;
@@ -517,16 +521,90 @@ button.session-group:hover {
.msg { .msg {
margin-bottom: 14px; margin-bottom: 14px;
position: relative;
} }
/* Message actions: hover on desktop, faintly visible on touch. */
/* The ⋯ menu sits in the foot of the bubble (next to the time); its popup opens upwards inside
* the bubble and never gets wider than the bubble. Faint on touch, appears on hover on desktop. */
.msg-foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.msg-foot .msg-time {
margin-top: 0;
flex: 1;
}
.msg-menu.menu-pop {
position: static; /* the popup is placed against the bubble */
flex-shrink: 0;
opacity: 0.45;
transition: opacity 0.12s;
}
.msg:hover .msg-menu,
.msg-menu:focus-within,
.msg-menu[open] {
opacity: 1;
}
.msg-menu.menu-pop > summary {
padding: 0 4px;
border-radius: 6px;
line-height: 0;
background: transparent;
border: none;
}
.msg-menu.menu-pop > summary:hover {
background: color-mix(in srgb, var(--fg1) 14%, transparent);
}
.msg-menu.menu-pop .menu-pop-body {
top: auto;
bottom: 30px;
right: 8px;
z-index: 30;
min-width: 0;
width: max-content;
max-width: max(calc(100% - 16px), 11rem);
}
.msg-menu.menu-pop .menu-pop-body a {
white-space: normal;
}
/* Chat bubbles: the agent's answers on the left, your messages on the right in another colour. */
.msg-user { .msg-user {
background: var(--bg1); background: color-mix(in srgb, var(--accent) 26%, var(--bg1));
border: 1px solid var(--bg2); border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
border-radius: var(--radius); border-radius: 18px 4px 18px 18px;
padding: 8px 12px; color: var(--fg0);
padding: 9px 14px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
position: relative; position: relative;
} }
.msg.user-msg {
width: fit-content;
max-width: min(88%, 780px);
margin-left: auto;
}
.msg.bubble.agent {
width: fit-content;
max-width: 100%;
padding: 10px 14px;
background: var(--bg1);
border: 1px solid var(--bg2);
border-radius: 4px 18px 18px 18px;
}
.msg.bubble.agent > :last-child {
margin-bottom: 0;
}
.chat-pane[data-color] .msg-user {
background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
border-color: color-mix(in srgb, var(--tag) 70%, transparent);
}
@media (max-width: 700px) {
.msg.user-msg {
max-width: 94%;
}
}
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */ /* User messages are Markdown too; the line breaks come from keepLineBreaks. */
.msg-user .markdown { .msg-user .markdown {
white-space: normal; white-space: normal;
@@ -541,14 +619,16 @@ button.session-group:hover {
background: var(--bg1); background: var(--bg1);
cursor: text; cursor: text;
} }
.msg-user .msg-actions { .msg-time {
position: absolute;
top: 4px;
right: 4px;
display: none;
}
.msg-user:hover .msg-actions {
display: block; display: block;
margin-top: 4px;
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.msg-user .msg-foot .msg-time {
text-align: right;
} }
.msg-user img { .msg-user img {
max-height: 160px; max-height: 160px;
@@ -1356,6 +1436,23 @@ table.grid input[type="number"] {
.topbar .path { .topbar .path {
display: none; display: none;
} }
.topbar .grow {
min-width: 0;
}
.topbar .chip {
flex-shrink: 1;
min-width: 0;
max-width: 30vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-pop-body {
max-width: calc(100vw - 16px);
}
.menu-pop-body .mobile-only {
display: block;
}
.topbar button.small { .topbar button.small {
padding: 2px 5px; padding: 2px 5px;
} }
@@ -2525,6 +2622,7 @@ button.danger-solid {
height: 0; height: 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 10px;
pointer-events: none; pointer-events: none;
z-index: 4; z-index: 4;
} }
@@ -4398,3 +4496,527 @@ span.chat-tab > button {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* Tokens and cost under each answer. */
.turn-usage {
display: flex;
align-items: center;
gap: 2px;
margin: -6px 0 12px;
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.media-badge {
position: absolute;
top: 6px;
left: 6px;
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
background: var(--bg0);
color: var(--accent);
}
.media-model {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-height: 100%;
}
.media-model img {
max-height: calc(100% - 50px);
max-width: 100%;
object-fit: contain;
}
.media-model > .icon {
width: 64px;
height: 64px;
color: var(--accent);
}
/* --- image viewer ------------------------------------------------------------ */
.viewer-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
}
.viewer-img {
max-width: 100vw;
max-height: 100dvh;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
touch-action: none;
transition: transform 0.08s ease-out;
background: repeating-conic-gradient(#222 0% 25%, #111 0% 50%) 50% / 16px 16px;
}
.viewer-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
padding-top: max(8px, env(safe-area-inset-top));
background: linear-gradient(rgba(0, 0, 0, 0.6), transparent);
color: #eee;
font-size: 13px;
}
.viewer-bar .button,
.viewer-bar button {
color: #eee;
}
.viewer-count {
font-variant-numeric: tabular-nums;
min-width: 3.5em;
}
.viewer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
}
.viewer-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 1;
width: 40px;
height: 56px;
border: none;
border-radius: 8px;
background: rgba(0, 0, 0, 0.45);
color: #eee;
}
.viewer-nav.prev {
left: 6px;
}
.viewer-nav.next {
right: 6px;
}
@media (pointer: coarse) {
.viewer-nav {
display: none;
}
}
/* Thumbnails invite a tap. */
.chat img.inline-image,
.image-refs img,
.user-files img,
.image-result img,
.svg-block img {
cursor: zoom-in;
}
/* --- image markup ------------------------------------------------------------ */
.annotator {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 52px 8px 64px;
}
.annotator canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
touch-action: none;
cursor: crosshair;
background: #111;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.annotator-tools {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 8px 10px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.75);
}
.annotator-colors {
display: inline-flex;
gap: 6px;
margin: 0 6px;
}
.annotator-colors .swatch {
width: 22px;
height: 22px;
padding: 0;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.35);
}
.annotator-colors .swatch.on {
border-color: #fff;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35);
}
/* --- approvals: explanation, session summary ------------------------------------ */
.explain {
margin: 8px 0 0;
padding: 6px 10px;
border-left: 3px solid var(--purple);
border-radius: 4px;
background: var(--bg1);
font-size: 12.5px;
color: var(--fg2);
}
.explain .icon {
color: var(--purple);
}
.explain-btn {
margin-top: 6px;
}
.session-item .summary {
font-size: 12px;
color: var(--fg3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- changes of one turn ------------------------------------------------------- */
.turn-changes {
margin: -6px 0 12px;
}
.turn-changes-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
padding: 6px 8px;
border: 1px solid var(--bg2);
border-radius: var(--radius);
background: var(--bg1);
}
.turn-changes-body > button {
align-self: flex-start;
}
.turn-file > summary {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
}
.turn-file .turn-stat {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.turn-file .turn-stat .add {
color: var(--green);
}
.turn-file .turn-stat .del {
color: var(--red);
}
.turn-file .diff {
max-height: 50vh;
overflow: auto;
}
/* --- colour tags of chats ------------------------------------------------------
* The tag is a name. Eight clearly separated hues, drawn with a lightness that fits the theme
* (light or dark). Browsers without relative colours use the theme's own colours.
* Backgrounds are mixed into --bg0, so text stays readable. */
[data-color="red"] { --tag: var(--red); --tag-h: 18; --tag-c: 0.14; }
[data-color="orange"] { --tag: var(--accent2, var(--red)); --tag-h: 57; --tag-c: 0.14; }
[data-color="yellow"] { --tag: var(--accent, var(--green)); --tag-h: 104; --tag-c: 0.12; }
[data-color="green"] { --tag: var(--green); --tag-h: 147; --tag-c: 0.17; }
[data-color="aqua"] { --tag: var(--aqua); --tag-h: 190; --tag-c: 0.1; }
[data-color="blue"] { --tag: var(--blue); --tag-h: 250; --tag-c: 0.12; }
[data-color="purple"] { --tag: var(--purple); --tag-h: 297; --tag-c: 0.135; }
[data-color="pink"] { --tag: var(--red); --tag-h: 338; --tag-c: 0.17; }
@supports (color: oklch(from red l c h)) {
/* Lightness 0.76 on dark and 0.60 on light themes (from the theme's background), chroma per hue
* as high as sRGB allows at both – vivid and still clearly different from each other. */
[data-color] {
--tag: oklch(from var(--bg0) calc(0.6 + 0.16 * clamp(0, (0.5 - l) * 20, 1)) var(--tag-c) var(--tag-h));
}
}
/* The chat's colour marks the top bar (line), the list entry, the badge and your own bubbles –
* the chat background itself stays neutral. */
.topbar[data-color] {
border-bottom: 3px solid var(--tag);
}
.session-item[data-color] {
box-shadow: inset 4px 0 0 var(--tag);
}
.color-badge {
padding: 0 8px;
border-radius: 999px;
color: var(--fg0);
background: color-mix(in srgb, var(--tag) 42%, var(--bg1));
border: 1px solid color-mix(in srgb, var(--tag) 75%, transparent);
}
.chat-icon {
margin-right: 5px;
vertical-align: -2px;
}
[data-color] .chat-icon {
color: var(--tag);
}
.color-badge .chat-icon {
color: var(--fg0);
}
.color-swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 6px 10px;
}
.color-swatches .swatch {
width: 22px;
height: 22px;
padding: 0;
border-radius: 50%;
background: var(--tag);
border: 2px solid transparent;
}
.color-swatches .swatch.on {
border-color: var(--fg0);
box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag);
}
.icon-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
gap: 4px;
padding: 4px 10px 8px;
max-width: 280px;
}
.icon-choice {
padding: 5px;
line-height: 0;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--fg2);
}
.icon-choice:hover {
background: var(--bg2);
}
.icon-choice.on {
color: var(--tag, var(--accent));
border-color: var(--tag, var(--accent));
background: var(--bg2);
}
/* --- check badge of a turn ------------------------------------------------------ */
.turn-check {
margin: -6px 0 12px;
}
.turn-check .chip {
border: none;
cursor: pointer;
}
.turn-check .check-passed {
color: var(--green);
}
.turn-check .check-failed,
.turn-check .check-timeout {
color: var(--red);
}
.turn-check-body {
margin-top: 4px;
padding: 6px 8px;
border: 1px solid var(--bg2);
border-radius: var(--radius);
background: var(--bg1);
}
.turn-check-body pre {
margin: 0 0 6px;
max-height: 40vh;
overflow: auto;
white-space: pre-wrap;
font-size: 12px;
}
/* --- messages waiting for a connection ------------------------------------------ */
.outbox {
margin: 0 12px 6px;
padding: 6px 10px;
border: 1px dashed var(--accent2, var(--accent));
border-radius: var(--radius);
background: var(--bg1);
font-size: 12.5px;
}
.outbox-head,
.outbox-item {
display: flex;
align-items: center;
gap: 8px;
}
.outbox-item span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg3);
}
/* --- project page: timeline -------------------------------------------------------- */
.project-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.timeline-day h3 {
margin: 14px 0 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.timeline {
list-style: none;
margin: 0;
padding: 0 0 0 14px;
border-left: 2px solid var(--bg3);
}
.timeline li {
position: relative;
margin: 0 0 8px;
}
.timeline li::before {
content: "";
position: absolute;
left: -20px;
top: 12px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--tag, var(--bg3));
border: 2px solid var(--bg0);
}
.timeline li > a {
display: block;
padding: 8px 12px;
border: 1px solid var(--bg2);
border-radius: var(--radius);
background: var(--bg1);
color: var(--fg1);
text-decoration: none;
}
.timeline li > a:hover {
border-color: var(--bg3);
}
.tl-title {
display: flex;
align-items: baseline;
gap: 8px;
color: var(--fg0);
font-weight: 600;
}
.tl-when {
margin-left: auto;
font-weight: 400;
font-size: 11.5px;
color: var(--muted);
}
.tl-summary {
margin-top: 2px;
color: var(--fg2);
font-size: 13px;
}
.tl-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin-top: 4px;
font-size: 11.5px;
color: var(--muted);
}
.tl-meta .check-passed {
color: var(--green);
}
.tl-meta .check-failed,
.tl-meta .check-timeout {
color: var(--red);
}
/* Foldable statistics sections */
.fold-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.fold-caret {
width: 0;
height: 0;
border-left: 5px solid currentColor;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.15s;
opacity: 0.7;
}
.fold-toggle[aria-expanded="true"] .fold-caret {
transform: rotate(90deg);
}
.panel.fold.closed h3 {
margin-bottom: 0;
}
.fold-group td {
background: var(--bg2, color-mix(in srgb, var(--fg, #888) 6%, transparent));
font-weight: 600;
}
td.fold-indent {
padding-left: 1.6em;
}
.snippet-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.snippet-slash {
color: var(--muted);
font-family: var(--mono, monospace);
}
.public-chat { max-width: 820px; margin: 0 auto; padding: 16px; }
.public-chat header { margin-bottom: 12px; }
.public-chat h1 { font-size: 1.2rem; margin: 0 0 4px; }
.public-chat .chat-scroll { display: flex; flex-direction: column; gap: 10px; }
.share-links { margin-top: 14px; border-top: 1px solid var(--border, #8884); padding-top: 10px; }
.share-links h4 { margin: 0 0 4px; }
.share-link { margin-top: 6px; flex-wrap: wrap; }
.share-link input { flex: 1 1 200px; min-width: 0; }
.offline-bar { background: color-mix(in srgb, var(--warn, #d79921) 25%, var(--bg0, transparent)); color: var(--fg, inherit); padding: 6px 12px; font-size: 0.85rem; text-align: center; }
.ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.ssh-key .row { align-items: center; flex-wrap: wrap; }
.ssh-key input.mono { width: 100%; font-size: 12px; }
.dialog-message { overflow-wrap: anywhere; }
.dialog-choices { flex-wrap: wrap; }
+1
View File
@@ -23,6 +23,7 @@ export const THEMES = [
{ id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" }, { id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" },
{ id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" }, { id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" },
{ id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" }, { id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" },
{ id: "tintenblau", name: "Tintenblau", source: "zink/tintenblau", license: "private" },
] as const; ] as const;
export type Theme = (typeof THEMES)[number]["id"]; export type Theme = (typeof THEMES)[number]["id"];
+2
View File
@@ -30,3 +30,5 @@ import "./everforest-dark.css";
import "./everforest-light.css"; import "./everforest-light.css";
import "./kanagawa-dark.css"; import "./kanagawa-dark.css";
import "./kanagawa-light.css"; import "./kanagawa-light.css";
import "./tintenblau-dark.css";
import "./tintenblau-light.css";
+40
View File
@@ -0,0 +1,40 @@
/*
* Tintenblau Dark
* Palette: zink/tintenblau (colors.toml, neovim.lua, zed.json of the desktop theme).
*/
:root[data-theme="tintenblau-dark"] {
color-scheme: dark;
--theme-color: #232a44;
--bg0: #232a44;
--bg1: #2b3352;
--bg2: #353e60;
--bg3: #4a5478;
--fg0: #f5efe3;
--fg1: #eae4d6;
--fg2: #d6d0c2;
--fg3: #bdcfd4;
--muted: #8e96b4;
--accent: #7aa0cc;
--accent2: #d4b46a;
--on-accent: #232a44;
--red: #d47a6a;
--green: #96b4bc;
--aqua: #96b4bc;
--blue: #7aa0cc;
--purple: #c896b2;
--diff-add: rgba(150, 180, 188, 0.16);
--diff-del: rgba(212, 122, 106, 0.16);
--shadow: 0 6px 24px rgba(10, 14, 30, 0.45);
--syntax-keyword: #c896b2;
--syntax-string: #d47a6a;
--syntax-number: #96b4bc;
--syntax-comment: #8e96b4;
--syntax-function: #7aa0cc;
--syntax-type: #d4b46a;
--syntax-variable: #eae4d6;
--syntax-meta: #baa0c4;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Tintenblau (light)
* Palette: zink/tintenblau (colors.toml, neovim.lua, zed.json of the desktop theme).
*/
:root[data-theme="tintenblau-light"] {
color-scheme: light;
--theme-color: #f0ead9;
--bg0: #f0ead9;
--bg1: #e6dfd0;
--bg2: #dbd3c1;
--bg3: #c9c0ac;
--fg0: #0f1628;
--fg1: #1e2a4a;
--fg2: #3a3f5a;
--fg3: #5e5a6a;
--muted: #8a8694;
--accent: #2a3f6f;
--accent2: #6a4a2c;
--on-accent: #f0ead9;
--red: #8a3028;
--green: #4a5a3a;
--aqua: #3a5a6a;
--blue: #3a5a8a;
--purple: #5a4a72;
--diff-add: rgba(74, 90, 58, 0.14);
--diff-del: rgba(138, 48, 40, 0.12);
--shadow: 0 6px 24px rgba(30, 42, 74, 0.16);
--syntax-keyword: #2a3f6f;
--syntax-string: #8a3028;
--syntax-number: #5a4a72;
--syntax-comment: #8a8694;
--syntax-function: #3a5a6a;
--syntax-type: #6a4a2c;
--syntax-variable: #1e2a4a;
--syntax-meta: #4a5a3a;
}
+21
View File
@@ -31,6 +31,8 @@ export interface Usage {
output_tokens: number; output_tokens: number;
cache_read_tokens: number; cache_read_tokens: number;
cache_write_tokens: number; cache_write_tokens: number;
/** Cost of the request (charged, or valued at list prices for plan requests). */
cost?: number;
} }
export interface Message { export interface Message {
@@ -57,6 +59,12 @@ export interface Session {
parent_id?: string; parent_id?: string;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
/** One line "where things stand" (small model), for the session list. */
summary?: string;
/** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */
color?: string;
/** Icon name of the chat (from the picker). */
icon?: string;
todos: Todo[] | null; todos: Todo[] | null;
last_usage?: Usage | null; last_usage?: Usage | null;
busy?: boolean; busy?: boolean;
@@ -110,6 +118,16 @@ export interface QuestionRequest {
questions: Question[]; questions: Question[];
} }
/** Result of the project's check command after a turn. */
export type CheckStatus = "running" | "passed" | "failed" | "timeout";
export interface TurnCheckInfo {
message_id: string;
status: CheckStatus;
exit_code: number | null;
duration_ms: number;
output: string;
}
export type ServerEvent = export type ServerEvent =
| { type: "session_updated"; session: Session } | { type: "session_updated"; session: Session }
| { type: "session_deleted"; session_id: string } | { type: "session_deleted"; session_id: string }
@@ -127,6 +145,7 @@ export type ServerEvent =
| { type: "waiting"; session_id: string; reason: string | null } | { type: "waiting"; session_id: string; reason: string | null }
| { type: "queue_updated"; session_id: string; items: QueueItem[] } | { type: "queue_updated"; session_id: string; items: QueueItem[] }
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean } | { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
| { type: "check_result"; session_id: string; message_id: string; status: CheckStatus; exit_code: number | null; duration_ms: number; output: string }
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null } | { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
| { type: "mcp_status"; servers: Record<string, McpServerStatus> } | { type: "mcp_status"; servers: Record<string, McpServerStatus> }
| { type: "resync" }; | { type: "resync" };
@@ -168,6 +187,8 @@ export interface AgentInfo {
source: "builtin" | "config" | "file" | "personal"; source: "builtin" | "config" | "file" | "personal";
max_parallel?: number | null; max_parallel?: number | null;
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null; refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
/** binary_info and decompile (Ghidra) available in this mode. */
reverse?: boolean;
/** Source file of Markdown agents. */ /** Source file of Markdown agents. */
path?: string | null; path?: string | null;
} }
+88
View File
@@ -3,6 +3,94 @@
* under "unreleased"; scripts/release.sh renames the key to the release version. * under "unreleased"; scripts/release.sh renames the key to the release version.
*/ */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = { export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00039": {
en: ["The chat colour no longer tints the chat background", "Claude Code can ask you questions again (question card in the chat)"],
de: ["Die Chat-Farbe färbt den Hintergrund des Chats nicht mehr ein", "Claude Code kann dir wieder Fragen stellen (Fragenkarte im Chat)"],
},
"202611-00038": {
en: ["Sidebar chat menu: Delete added; colour and icon are now chosen in the chat's own menu"],
de: ["Chat-Menü in der Sidebar: Löschen hinzugefügt; Farbe und Icon wählst du jetzt im Menü des Chats"],
},
"202611-00037": {
en: ["Deleting a chat now asks whether to delete its own folder too"],
de: ["Beim Löschen eines Chats wirst du gefragt, ob sein eigener Ordner mitgelöscht werden soll"],
},
"202611-00036": {
en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"],
de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"],
},
"202611-00035": {
en: ["Creating SSH keys works in the container now (fixes \"No user exists for uid\")"],
de: ["SSH-Schlüssel lassen sich im Container jetzt erzeugen (behebt \"No user exists for uid\")"],
},
"202611-00034": {
en: ["ssh/scp/sftp now work in the container (fixes \"No user exists for uid\")"],
de: ["ssh/scp/sftp funktionieren jetzt im Container (behebt \"No user exists for uid\")"],
},
"202611-00033": {
en: ["SSH keys in Settings → Account: agents, terminal and git use them to reach other servers"],
de: ["SSH-Schlüssel unter Einstellungen → Account: Agenten, Terminal und Git nutzen sie für den Zugriff auf andere Server"],
},
"202611-00032": {
en: ["Agents now use your stored git credentials instead of searching for them (also Claude Code)"],
de: ["Agenten nutzen deine hinterlegten Git-Zugangsdaten, statt danach zu suchen (auch Claude Code)"],
},
"202611-00031": {
en: ["Images an agent reads are now shown in the chat (also with Claude Code)"],
de: ["Bilder, die ein Agent liest, werden jetzt im Chat angezeigt (auch mit Claude Code)"],
},
"202611-00030": {
en: ["/clear: the model forgets the conversation so far, the chat stays visible"],
de: ["/clear: das Modell vergisst das bisherige Gespräch, der Chat bleibt sichtbar"],
},
"202611-00029": {
en: [
"Statistics: foldable sections, years of history with a year filter, export as XLSX/CSV",
"Prompt snippets (/name), read-only links for chats, commit message suggestions, weekly review, offline copies of recent chats, planning model",
],
de: [
"Statistik: zuklappbare Abschnitte, Historie über Jahre mit Jahresfilter, Export als XLSX/CSV",
"Prompt-Bausteine (/name), Nur-Lese-Links für Chats, Commit-Vorschläge, Wochenrückblick, Offline-Kopien der letzten Chats, Planungsmodell",
],
},
"202611-00028": {
en: ["18 more icons for chats (gamepad, computer, food, …); the ⋯ menu of a message now sits inside its bubble"],
de: ["18 weitere Icons für Chats (Gamepad, Computer, Essen, …); das ⋯-Menü einer Nachricht sitzt jetzt in der Sprechblase"],
},
"202611-00027": {
en: ["Chat bubbles (yours on the right), icons for chats and more vivid, clearly different colour tags (⋯ menu of a chat)"],
de: ["Sprechblasen (deine rechts), Icons für Chats und kräftigere, besser unterscheidbare Farbmarkierungen (⋯-Menü eines Chats)"],
},
"202611-00026": {
en: [
"Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model",
"Image viewer, message menu, colour tags for chats, approvals from push notifications, check after each turn, project page",
],
de: [
"Claude Code als Engine: unter Einstellungen → Konto anmelden, dann ein „Claude Code“-Modell wählen",
"Bildbetrachter, Nachrichtenmenü, Farben für Chats, Freigaben per Push, Prüfung nach jedem Schritt, Projektseite",
],
},
"202611-00024": {
en: ["3D models with fal.ai: from an image (also pasted into the chat), a text prompt or both – open them in the 3D view"],
de: ["3D-Modelle mit fal.ai: aus einem Bild (auch eingefügt), einem Text oder beidem – öffnen in der 3D-Ansicht"],
},
"202611-00023": {
en: ["Tokens and cost under every answer; the total cost of chats with opencode Go is shown correctly again"],
de: ["Tokens und Kosten unter jeder Antwort; der Gesamtpreis von Chats mit opencode Go stimmt wieder"],
},
"202611-00022": {
en: ["New theme “Tintenblau” (Settings → General → Theme)"],
de: ["Neues Theme „Tintenblau“ (Einstellungen → Allgemein → Theme)"],
},
"202611-00021": {
en: ["Git commits of agents name the model: “Co-Authored-By: <model> (<provider>) via CodeCraft” (Settings → General)"],
de: ["Git-Commits von Agenten nennen das Modell: „Co-Authored-By: <Modell> (<Anbieter>) via CodeCraft“ (Einstellungen → Allgemein)"],
},
"202611-00020": {
en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"],
de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"],
},
"202611-00019": { "202611-00019": {
en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"], en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"],
de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"], de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"],