Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca14ba3338 | ||
|
|
97e66d2118 | ||
|
|
b3a298fb7c | ||
|
|
61219dbca2 | ||
|
|
9fd92d5b39 | ||
|
|
b99f71526a | ||
|
|
90d65ec654 | ||
|
|
7298102e93 | ||
|
|
538aac9122 | ||
|
|
05c215f685 | ||
|
|
872ebb94dd | ||
|
|
b302fa0b17 | ||
|
|
bd490efda5 | ||
|
|
9339e2db41 | ||
|
|
d7f89ee257 | ||
|
|
b1fa54a7b6 | ||
|
|
e8e455b779 | ||
|
|
6d656a355d | ||
|
|
ca55404f99 | ||
|
|
0241f6031b | ||
|
|
4244b9370c | ||
|
|
dae76a5636 | ||
|
|
ff8ff585a2 | ||
|
|
434aa84d40 | ||
|
|
0a368e493d | ||
|
|
011756ca3d | ||
|
|
b7c8741a7d | ||
|
|
bf99292f43 | ||
|
|
36de035dd8 | ||
|
|
b59c5b8890 | ||
|
|
eef17eb591 | ||
|
|
6b1d7f91b8 | ||
|
|
0f4bfa0ab0 | ||
|
|
85b68d98ef | ||
|
|
d152ad836d | ||
|
|
81ee084ff2 | ||
|
|
c850586a81 | ||
|
|
242b80605c | ||
|
|
c9f553dc60 | ||
|
|
151a92959d | ||
|
|
bfa1f641d7 | ||
|
|
70367211c1 | ||
|
|
1f69d63b09 |
No files matched your search
+4
-11
@@ -1,11 +1,11 @@
|
||||
# Deployment settings only. API keys, MCP tokens, SSO secrets and everything else
|
||||
# are set in the web UI (Settings) and stored in the configuration file.
|
||||
|
||||
# Optional: create the first admin on first start. Otherwise the server logs a
|
||||
# one-time setup code and you create the admin in the web UI.
|
||||
CODECRAFT_ADMIN_USER=
|
||||
CODECRAFT_ADMIN_PASSWORD=
|
||||
|
||||
# Single sign-on client secret (see [auth.oidc] in config.toml).
|
||||
OIDC_CLIENT_SECRET=
|
||||
|
||||
# Directory with your projects, mounted as /workspace.
|
||||
WORKSPACE=/path/to/your/projects
|
||||
|
||||
@@ -17,17 +17,10 @@ BIND_ADDR=127.0.0.1
|
||||
UID=1000
|
||||
GID=1000
|
||||
|
||||
# Provider keys – referenced in data/config.toml as ${NAME}.
|
||||
ANTHROPIC_API_KEY=
|
||||
GEMINI_API_KEY=
|
||||
OPENCODE_API_KEY=
|
||||
FAL_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# Time zone for automations (cron schedules) and timestamps, e.g. Europe/Berlin
|
||||
TZ=UTC
|
||||
|
||||
# Image version for docker-compose.release.yml (e.g. 0.1.0); default: latest
|
||||
# Image version for docker-compose.release.yml (e.g. v202611-00001); default: latest
|
||||
CODECRAFT_VERSION=latest
|
||||
|
||||
# Log format: empty for text, "json" for one JSON object per line
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
docker-compose.override.yml
|
||||
.env.bak*
|
||||
+259
@@ -3,6 +3,265 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [v202611-00017] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Markdown in your messages:** your messages are shown formatted like the model's answers
|
||||
(headings, lists, tables, code blocks, quotes, bold/italic); single line breaks stay as typed.
|
||||
The eye button next to the text field shows a preview of the formatted message before sending.
|
||||
|
||||
## [v202611-00016] – 2026-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Phone: page stayed shifted after the keyboard closed** – the top bar (menu) was out of
|
||||
reach. On Android the keyboard now shrinks the page instead of shifting it
|
||||
(`interactive-widget=resizes-content`; the chat stays at its latest message); browsers that
|
||||
still shift the page (iOS Safari) get it scrolled back once the keyboard closes.
|
||||
|
||||
## [v202611-00015] – 2026-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Generated images missing on other devices:** when an agent moved or renamed a generated
|
||||
image afterwards (e.g. sorted it into subfolders), the gallery pointed to the old file – only
|
||||
the device that had it in its browser cache still showed it. Now:
|
||||
- generated images are also kept in the image store and shown from there when the file is gone
|
||||
(existing images get their copy on the first start);
|
||||
- their entries follow moves and renames: after an agent's `mv`/`rename`, when moved with the
|
||||
file manager, and on the next view (found by file name, or by content when renamed);
|
||||
- in the chat, an image that cannot be found falls back to the preview the model saw.
|
||||
|
||||
## [v202611-00014] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **New folder in the folder picker:** creating an automation or a chat, *New folder* creates a
|
||||
subfolder of the shown folder and selects it.
|
||||
|
||||
## [v202611-00013] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Automation editor:** *New automation* asks for name, project and a starting point (blank
|
||||
example structure or a template), creates `.codecraft/jobs/<name>.md` and opens an editor:
|
||||
the instructions in a Markdown editor, schedule and settings next to them, and an assistant
|
||||
chat in which a model works out the instructions with you and edits the file (reloaded after
|
||||
each answer). New automations start switched off.
|
||||
- **Graphical schedule:** every few minutes, hourly, daily, on weekdays, monthly or a cron
|
||||
expression, with the next runs in server time.
|
||||
- **Chat list:** folders and projects can be dragged into any order or moved and pinned to the
|
||||
top via their menu; the order is kept per user on all devices. A new message moves the chat
|
||||
to the top of its folder or project, no longer the whole project.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Automation files hold only the instructions;** name, schedule, mode, model, approval mode
|
||||
and time limit are settings of the automation. Existing job files are taken over on the first
|
||||
start (settings into the database, the frontmatter removed from the file). Registering a
|
||||
plain Markdown file creates a switched-off automation to be scheduled in the editor.
|
||||
|
||||
## [v202611-00012] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Approval mode of automations in the list:** *Automations* shows the approval mode of each
|
||||
automation as a selection; the change is written into the job file and applies from the next
|
||||
run. "Allow everything" lets nightly runs work without waiting for approvals (they used to
|
||||
wait for an answer via Telegram until the timeout).
|
||||
- **Telegram per chat:** *⋯ → Send questions to Telegram* (shown with a paired bot) decides
|
||||
whether approvals and questions of that chat and its subagents are sent to Telegram. New chats
|
||||
from the web UI and automation runs start with it switched off (they stay in the web UI);
|
||||
chats started from Telegram and existing chats keep reporting. The chat currently open in
|
||||
Telegram always reports.
|
||||
|
||||
## [v202611-00011] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Collapsible task list:** the model's task list above the composer folds to one line
|
||||
(progress and current task) with a click on its heading; the choice is kept per device.
|
||||
- **Square and short screens** (e.g. Clicks Communicator, phones in landscape): tighter top bar,
|
||||
composer and new-chat wizard, so the chat keeps most of the height.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blank page on phones after the update:** installed apps (PWA) checked their service worker
|
||||
at its old URL, got the web page instead of a script and therefore kept the previous app
|
||||
version forever. With the new image format of v202611-00010 that old version crashed after
|
||||
showing the chat for a moment. Old service worker URLs now get the clean-up worker, which
|
||||
removes the stale worker and reloads the current version. Missing scripts/styles answer
|
||||
404 instead of the web page.
|
||||
|
||||
## [v202611-00010] – 2026-11-09
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chats with many images load much faster:** images (screenshots, generated images, pasted
|
||||
images) are stored separately instead of inside the messages and load on demand when they
|
||||
scroll into view, cached by the browser. Large PNGs without transparency are stored as JPEG.
|
||||
Older chats are converted once on the first start; the database usually shrinks a lot.
|
||||
Example: a chat with 120 screenshots loads 1.5 MB instead of 66 MB.
|
||||
- **Compressed responses:** the API and the web UI are sent with gzip/brotli (not images,
|
||||
downloads or live streams).
|
||||
|
||||
## [v202611-00009] – 2026-11-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sandbox on x86_64:** the start-up check of bubblewrap did not mount `/lib64`, so it failed
|
||||
on amd64 hosts and shell commands of non-admin users were disabled.
|
||||
|
||||
## [v202611-00008] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Instructions per chat:** *⋯ → Instructions for this chat* (Telegram: `/prompt`) sets rules
|
||||
that apply to every message of that chat on top of the mode – language, tone, the part of the
|
||||
project to work on. A chip in the header shows when a chat has them; forks keep them.
|
||||
|
||||
### Fixed
|
||||
|
||||
- OpenAI-compatible providers: tool results no longer carry a `name` field. Gateways that
|
||||
translate to Anthropic (opencode) rejected the request with "anthropic-messages cannot
|
||||
preserve named conversation participants".
|
||||
- Telegram: the token field is no longer filled in by the browser's password manager, the bot
|
||||
token is found in whatever is pasted (also the whole BotFather message), and a wrong format
|
||||
gets a clear message instead of "HTTP 404".
|
||||
|
||||
## [v202611-00007] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Telegram bot as a chat interface:** every user can connect their own bot (*Settings →
|
||||
Account → Telegram*: token from @BotFather, then `/start <code>` pairs it with their Telegram
|
||||
account – other chats are ignored). Messages, photos and files go to the current session; the
|
||||
agent's steps show live, the answer comes formatted, images from tools come as photos, and
|
||||
approvals and questions have buttons. Commands like the terminal client: `/sessions`,
|
||||
`/session`, `/new`, `/clear`, `/mode`, `/model`, `/permissions`, `/effort`, `/stop`, `/status`,
|
||||
`/costs`, `/memory`, `/jobs`, `/compact`, `/help`. The server polls Telegram – no public
|
||||
webhook needed.
|
||||
|
||||
## [v202611-00006] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- The image contains the Docker CLI (static client). With the host's `/var/run/docker.sock`
|
||||
mounted (and its group), admins' agents and terminals can use the host's Docker;
|
||||
`scripts/move-server.sh --docker-sock` sets this up on a new host.
|
||||
- `scripts/move-server.sh --tools-dir DIR`: agent home, users' homes and `/tmp` on a separate disk.
|
||||
|
||||
## [v202611-00005] – 2026-11-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Commits by agents, terminals and the git panel always use the name and e-mail from
|
||||
*Settings → Account → Git* – also in repositories that have their own `user.name`/`user.email`
|
||||
(agents had set those as a workaround while no identity was configured). Agents are told not
|
||||
to change the git identity.
|
||||
|
||||
## [v202611-00004] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Chats side by side:** *Open beside* in a chat's sidebar menu shows up to three chats as
|
||||
columns, each with its own tabs and composer (desktop).
|
||||
- **My modes:** every user can create personal modes under *Settings → My modes* (kind, tools,
|
||||
read-only, prompt, model); they appear only for that user.
|
||||
- **Budget warnings** as push notifications for admins (warning share and 100 %) for the total,
|
||||
provider and image budgets and the opencode Go windows; the status overlay shows all budgets.
|
||||
- `scripts/move-server.sh` moves an installation to another Docker host with bind mounts
|
||||
(e.g. Unraid), `scripts/smoke-test.sh` checks a running instance.
|
||||
|
||||
### Changed
|
||||
|
||||
- File manager tabs of a chat are stored on the server, so they are the same on every device.
|
||||
|
||||
## [v202611-00003] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- The Docker image ships the terminal client again (static Linux x86_64 and aarch64 binaries):
|
||||
`curl -fsSL https://<your-server>/cli/install.sh | sh`, download links in the settings and
|
||||
`codecraft update` work without building from source.
|
||||
- File manager: *Open chat here* in a folder's menu starts a chat in that folder.
|
||||
- **Tabs in a chat:** open terminals and file managers next to the chat (topbar buttons or
|
||||
"+" in the tab strip), as many as needed. Terminals of a chat keep running on the server until
|
||||
they are closed, so they are there again after a reload or on another device (output is
|
||||
replayed); they end when the chat is deleted or the server restarts.
|
||||
- **Chat mode:** *New session → Just chat* (and mode `chat`) for plain conversation – a lean
|
||||
prompt with date and personal memory, web search and fetch only, no project or files.
|
||||
- **Own modes:** *Settings → Agents & commands → New mode* with kind (coding or conversation),
|
||||
read-only switch, tool checkboxes, prompt, model and review loop; custom modes show up in
|
||||
*New session* and the mode selector.
|
||||
- The chat shows when the conversation is being compacted – automatically because the context
|
||||
is nearly full (with tokens and share of the limit) or on request – and labels the summary.
|
||||
|
||||
### Changed
|
||||
|
||||
- The mode selector in the composer is a dropdown.
|
||||
|
||||
### Removed
|
||||
|
||||
- *Settings → Config file* no longer offers to move `${NAME}` values into the configuration (a
|
||||
one-time migration); `${NAME}` references keep working.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Session updates from background tasks (title, token usage, phase, todos) and from the UI
|
||||
no longer overwrite each other. Often the first answer's token usage was lost, so automatic
|
||||
compaction and the context display started one turn late.
|
||||
|
||||
## [v202611-00002] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- **opencode prices:** *Fetch prices* reads prices, context and output limits, image input and
|
||||
reasoning from opencode's model list (Zen and Go), including long-context tiers and
|
||||
peak-hour or date-based factors. Costs apply them per request (`price_tiers`,
|
||||
`price_multipliers` on a model).
|
||||
- **opencode costs and Go limits:** the cost opencode reports per response is recorded instead
|
||||
of an estimate, and with a *plan limit* on the provider the Go allowance is tracked in the
|
||||
5-hour, 7-day and monthly windows (budget table and status overlay; usage through CodeCraft
|
||||
only).
|
||||
- **Settings → Config file** lists `${NAME}` references and moves the values of set variables
|
||||
into the configuration, so `.env` only needs deployment settings.
|
||||
|
||||
### Changed
|
||||
|
||||
- Examples and forms no longer suggest environment variables for keys; `.env.example` holds
|
||||
deployment settings only.
|
||||
|
||||
## [v202611-00001] – 2026-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Image generation picks the model:** the agent drafts cheap previews, asks which one to
|
||||
refine (with the price of the final model), then creates the final image with the best model
|
||||
for the purpose (photo, illustration, text, design). Models that take a reference image
|
||||
(Nano Banana Pro edit) refine the chosen draft. Known fal.ai models come with sensible
|
||||
defaults; `stage`, `good_for` and `reference_param` in `[images].models` override them.
|
||||
- **Restore a backup** in *Settings → Audit*: a stored backup or an uploaded zip. The current
|
||||
state is backed up first (`…-before-restore.zip`), then CodeCraft restarts with the restored
|
||||
database and configuration.
|
||||
- **Test connection** in the provider form checks URL and key before saving.
|
||||
- **Status LEDs** in the sidebar for server connection, models and MCP servers (green, yellow,
|
||||
red in vivid variants of the theme colors); a click opens an overview of every provider and
|
||||
MCP server with its state, latency and tool count.
|
||||
- `scripts/release.sh`: tag, multi-arch image and Gitea release in one step.
|
||||
|
||||
### Changed
|
||||
|
||||
- Saving a provider (or reloading providers) checks its status right away instead of at the
|
||||
next five-minute check.
|
||||
|
||||
### Fixed
|
||||
|
||||
- opencode Zen/Go: requests carry the session in `x-opencode-session`; opencode Go rejected
|
||||
them without it ("Request is missing x-opencode-session").
|
||||
- Two backups in the same second no longer overwrite each other.
|
||||
|
||||
## [0.1.0] – 2026-11-09
|
||||
|
||||
First public release.
|
||||
|
||||
Generated
+68
@@ -52,6 +52,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -153,6 +168,18 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -363,6 +390,27 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "9.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
@@ -682,6 +730,24 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
@@ -3040,6 +3106,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -4034,6 +4101,7 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.2",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
|
||||
+31
-2
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Multi-arch: the web UI and the server are built once on the build
|
||||
# Multi-arch: the web UI, the server and the terminal client are built once on the build
|
||||
# machine (cross-compiled with cargo-zigbuild); only the runtime stage runs
|
||||
# per target platform.
|
||||
|
||||
@@ -19,7 +19,7 @@ ARG ZIG_VERSION=0.17.1
|
||||
RUN curl -fsSL https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz | tar -xJ -C /opt \
|
||||
&& ln -s /opt/zig-*/zig /usr/local/bin/zig \
|
||||
&& cargo install --locked cargo-zigbuild \
|
||||
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
|
||||
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-musl
|
||||
WORKDIR /src
|
||||
|
||||
# --- server (for the target platform, glibc of Debian bookworm) ----------------
|
||||
@@ -45,10 +45,36 @@ COPY server/resources-cli ./server/resources-cli
|
||||
COPY cli/src ./cli/src
|
||||
ARG GIT_COMMIT=""
|
||||
ENV CODECRAFT_COMMIT=${GIT_COMMIT}
|
||||
# Release version (YYYYMM-NNNNN, set by scripts/release.sh); empty = development build.
|
||||
ARG CODECRAFT_VERSION=""
|
||||
ENV CODECRAFT_VERSION=${CODECRAFT_VERSION}
|
||||
RUN touch core/src/lib.rs server/src/main.rs \
|
||||
&& cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \
|
||||
&& cp "target/$(cat /target)/release/codecraft" /codecraft
|
||||
|
||||
# --- terminal client (static Linux binaries for x86_64 and aarch64) -----------
|
||||
# Served by the server for download (/cli/install.sh, codecraft update).
|
||||
FROM toolchain AS cli
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY core ./core
|
||||
COPY server/Cargo.toml ./server/
|
||||
COPY server/resources ./server/resources
|
||||
COPY cli ./cli
|
||||
ARG CODECRAFT_VERSION=""
|
||||
ENV CODECRAFT_VERSION=${CODECRAFT_VERSION}
|
||||
RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
|
||||
&& cargo zigbuild --release -p codecraft-cli --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \
|
||||
&& mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \
|
||||
&& cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64
|
||||
|
||||
# --- Docker CLI (static client; the daemon is the host's via the mounted socket) --
|
||||
FROM toolchain AS dockercli
|
||||
ARG TARGETARCH
|
||||
ARG DOCKER_VERSION=29.9.3
|
||||
RUN case "$TARGETARCH" in amd64) arch=x86_64 ;; arm64) arch=aarch64 ;; *) echo "unsupported $TARGETARCH" >&2; exit 1 ;; esac \
|
||||
&& curl -fsSL "https://download.docker.com/linux/static/stable/$arch/docker-$DOCKER_VERSION.tgz" | tar -xz -C /tmp docker/docker \
|
||||
&& install -m 0755 /tmp/docker/docker /docker
|
||||
|
||||
# --- runtime ------------------------------------------------------------------
|
||||
# Node is included so agents can work on JS projects and run npx-based MCP servers.
|
||||
FROM node:22-bookworm-slim
|
||||
@@ -66,6 +92,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
|
||||
COPY --from=server /codecraft /usr/local/bin/codecraft
|
||||
COPY --from=web /web/dist /app/web
|
||||
COPY --from=cli /out /app/cli
|
||||
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
||||
COPY --from=dockercli /docker /usr/local/bin/docker
|
||||
COPY server/ghidra /app/ghidra
|
||||
COPY server/resources /app/resources
|
||||
# The agent's home lives on the data volume: tools it installs in user space
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version 0.1.0" src="https://img.shields.io/badge/version-0.1.0-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00017" src="https://img.shields.io/badge/version-v202611--00017-d69e2e?style=flat-square">
|
||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||
<img alt="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">
|
||||
@@ -74,17 +74,19 @@ curl -fsSLO https://git.familie-zink.org/zink/codecraft/raw/branch/main/docker-c
|
||||
curl -fsSL --create-dirs -o docker/seccomp-sandbox.json \
|
||||
https://git.familie-zink.org/zink/codecraft/raw/branch/main/docker/seccomp-sandbox.json
|
||||
curl -fsSL -o .env https://git.familie-zink.org/zink/codecraft/raw/branch/main/.env.example
|
||||
$EDITOR .env # workspace path, provider keys, time zone
|
||||
$EDITOR .env # workspace path, port, user id, time zone
|
||||
docker compose -f docker-compose.release.yml up -d
|
||||
docker compose -f docker-compose.release.yml logs codecraft # one-time setup code for the first admin
|
||||
```
|
||||
|
||||
Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a
|
||||
provider under *Settings → Providers & models* – or put the keys into `.env` and reference
|
||||
them in the config as `${NAME}`.
|
||||
provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys,
|
||||
tokens and everything else are set in the UI and stored in the configuration file
|
||||
(`${NAME}` references to environment variables still work).
|
||||
|
||||
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
|
||||
Pin a version with `CODECRAFT_VERSION=0.1.0` in `.env`. Admins see a notice when a new release is out.
|
||||
Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with
|
||||
`CODECRAFT_VERSION=v202611-00001` in `.env`. Admins see a notice when a new release is out.
|
||||
|
||||
**From source:** `git clone https://git.familie-zink.org/zink/codecraft.git && cd codecraft`,
|
||||
`cp .env.example .env`, then `docker compose up -d --build`.
|
||||
@@ -95,6 +97,17 @@ volume `codecraft-data`; recreating the container keeps it. Your projects are mo
|
||||
WebSocket support) and set `auth.public_url` – installing the app and push notifications
|
||||
require HTTPS.
|
||||
|
||||
**Moving to another host** (for example Unraid with bind mounts instead of the Docker volume):
|
||||
`scripts/move-server.sh root@newhost:/mnt/user/appdata/codecraft --workspace /mnt/user/projects`
|
||||
copies the workspace, stops CodeCraft, streams the data volume to `<dir>/data`, writes a
|
||||
`docker-compose.yml` (and a `start.sh` for hosts without Compose) with bind mounts, starts the
|
||||
published image there and runs `scripts/smoke-test.sh`. The old container is only stopped, so
|
||||
you can roll back. `--help` lists the options (owner, port, bind address, dry run).
|
||||
`--tools-dir /mnt/disks/ssd/codecraft` puts everything agents install or download – the agent
|
||||
home (`/data/home`: toolchains, pip/npm/cargo packages, caches), the users' homes and `/tmp` – on
|
||||
a separate disk; the data folder keeps only the database, configuration and backups. Step by step,
|
||||
with rollback and troubleshooting: [docs/MOVING.md](docs/MOVING.md).
|
||||
|
||||
## How it works
|
||||
|
||||
CodeCraft runs on your server, next to your projects. You talk to it from a browser, the
|
||||
@@ -152,8 +165,17 @@ output limit, reasoning effort, extra request options and prices per million tok
|
||||
model's fallback.
|
||||
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
|
||||
process one at a time; waiting time is shown and never counted as model latency.
|
||||
- **Budget warnings:** admins get a push notification when the total, a provider's or the image
|
||||
budget, or an opencode Go window reaches the warning share (default 80 %) and when it is used
|
||||
up; the status overlay shows all budgets with bars.
|
||||
- **Prices:** *Fetch prices* fills in prices for OpenRouter and opencode (Zen and Go), including
|
||||
long-context tiers and peak-hour factors, which are applied per request.
|
||||
- **Status:** every five minutes each provider's model list is fetched (no tokens used), so
|
||||
you see at a glance which hosts answer and which keys are rejected.
|
||||
you see at a glance which hosts answer and which keys are rejected. Saving a provider checks
|
||||
it right away, and *Test connection* checks URL and key before you save.
|
||||
- **Images (fal.ai):** the agent drafts cheap previews (FLUX schnell), asks which one to
|
||||
refine, and creates the final image with the best model for the purpose – Nano Banana Pro
|
||||
refines the chosen draft, Ideogram handles lettering, Recraft design work.
|
||||
|
||||
## Terminal client
|
||||
|
||||
@@ -165,15 +187,14 @@ Anthropic, OpenAI, Gemini, OpenRouter, opencode or a local model server (Ollama,
|
||||
llama.cpp, vLLM – detected automatically); both can be mixed.
|
||||
|
||||
```sh
|
||||
cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli
|
||||
curl -fsSL https://<your-codecraft-server>/cli/install.sh | sh # Linux x86_64 / aarch64
|
||||
codecraft login # server URL, user name, password → creates an API token
|
||||
codecraft # start in the current folder; /help lists the commands
|
||||
```
|
||||
|
||||
There are no prebuilt binaries yet, so the client is installed from source (needs a Rust
|
||||
toolchain). A server that has binaries in `CODECRAFT_CLI_DIR` (`codecraft-linux-x86_64`,
|
||||
`codecraft-linux-aarch64`) also offers `curl -fsSL https://<your-server>/cli/install.sh | sh`
|
||||
and `codecraft update`.
|
||||
The Docker image ships static binaries for Linux x86_64 and aarch64; the server offers them
|
||||
for download (*Settings → Account*) and `codecraft update` fetches new versions from it. Other
|
||||
platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli`.
|
||||
|
||||
## Automations
|
||||
|
||||
@@ -181,17 +202,24 @@ and `codecraft update`.
|
||||
<img src="docs/images/automations.png" alt="Creating an automation from a template: schedule chips, cron expression, mode, permission mode, model and the task for the agent" width="100%">
|
||||
</p>
|
||||
|
||||
An automation is a Markdown file with a cron schedule in its front matter
|
||||
(`.codecraft/jobs/<name>.md`). Each run is a normal chat session, so you can read and continue
|
||||
it; if it needs a permission, you get a push notification. Ask an agent in any chat to set one
|
||||
up, start from a template (dependency check, security review of recent commits, test suite
|
||||
health, daily summary, TODO report, documentation freshness) or write the file yourself.
|
||||
An automation is an agent that starts on a schedule. Its instructions are a Markdown file
|
||||
(`.codecraft/jobs/<name>.md`, only the prompt); name, schedule, mode, model and limits are set
|
||||
in the UI, with a graphical schedule editor (every few minutes, hourly, daily, on weekdays,
|
||||
monthly or a cron expression). *New automation* creates the file – blank with an example
|
||||
structure or from a template (dependency check, security review of recent commits, test suite
|
||||
health, daily summary, TODO report, documentation freshness) – and opens an editor with an
|
||||
assistant chat in which a model works out the instructions with you and writes the file. You
|
||||
can also ask an agent in any chat to set one up. Each run is a normal chat session, so you can
|
||||
read and continue it.
|
||||
|
||||
**Webhooks** start an automation from outside, e.g. on every push: the request is checked with a
|
||||
shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed to the agent.
|
||||
|
||||
## Integrations
|
||||
|
||||
- **Telegram:** connect your own bot (*Settings → Account → Telegram*) and use CodeCraft from
|
||||
Telegram – sessions, modes, models, approvals with buttons, photos and files; `/help` in the
|
||||
bot lists the commands.
|
||||
- **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client.
|
||||
- **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
|
||||
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
|
||||
@@ -199,7 +227,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t
|
||||
- **JSON logs:** `CODECRAFT_LOG_FORMAT=json` writes one JSON object per line.
|
||||
- **Backups:** daily at 03:00 into `/data/backups` (the last 30 are kept), optionally copied to
|
||||
any S3-compatible bucket (AWS, MinIO, Garage, Backblaze B2, Hetzner). *Settings → Audit*
|
||||
shows them, creates one on demand and downloads them.
|
||||
shows them, creates one on demand, downloads them and restores one – a stored backup or an
|
||||
uploaded zip, e.g. to move to another server. The current state is backed up first, then
|
||||
CodeCraft restarts with the restored database and configuration.
|
||||
- **Health:** `GET /api/health` for monitoring, with version and commit.
|
||||
|
||||
## Users and security
|
||||
@@ -245,6 +275,7 @@ place and keep your comments. Secrets belong in the environment and are referenc
|
||||
| `[auth]`, `[auth.oidc]` | sign-in, public URL, single sign-on |
|
||||
| `[sandbox]` | shell sandbox for non-admins (`auto`, `always`, `off`) |
|
||||
| `[backup]`, `[backup.s3]` | daily backups, retention, S3 target |
|
||||
| `[images]` | fal.ai key, budget, `models` with `stage` (`preview` / `final`), `good_for` and `reference_param` |
|
||||
| `[craft]` | headless browser, screen widths, clean room |
|
||||
|
||||
Compound shell commands (`&&`, `;`, `|`) are only auto-allowed when every part is allowed.
|
||||
@@ -324,11 +355,17 @@ web/ React + Vite PWA
|
||||
src/i18n English (reference) and German
|
||||
```
|
||||
|
||||
The Docker build cross-compiles the server with
|
||||
The Docker build cross-compiles the server and the terminal client with
|
||||
[cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild), so a multi-arch image builds
|
||||
quickly on either architecture:
|
||||
`docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/codecraft:<version> --push .`
|
||||
|
||||
Releases are named `vYYYYMM-NNNNN`: year and month, then a counter that starts at 00001 every
|
||||
month. Collect changes under `## [Unreleased]` in the changelog (and under `unreleased` in
|
||||
`web/src/whatsnew.ts`), then `scripts/release.sh` picks the next number, renames both, commits,
|
||||
tags, builds and pushes the image (`:vYYYYMM-NNNNN` and `:latest`) and publishes the Gitea
|
||||
release (`--dry-run` shows the steps). The `Cargo.toml` versions are not used for releases.
|
||||
|
||||
Translations: copy `web/src/i18n/en.ts`, translate the values and register the language in
|
||||
`web/src/i18n/index.ts` – the type checker reports missing keys.
|
||||
|
||||
@@ -336,7 +373,7 @@ Translations: copy `web/src/i18n/en.ts`, translate the values and register the l
|
||||
|
||||
Early, but in daily use. See the [changelog](CHANGELOG.md). Planned next:
|
||||
|
||||
- Prebuilt terminal client binaries (Linux, macOS, Windows)
|
||||
- Terminal client builds for macOS and Windows
|
||||
- MCP OAuth, resources and prompts
|
||||
- The OpenAI Responses API
|
||||
|
||||
|
||||
+2
-2
@@ -508,7 +508,7 @@ impl Agent {
|
||||
let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
||||
for p in &m.parts {
|
||||
let line = match p {
|
||||
Part::Text { text } | Part::Summary { text } => text.clone(),
|
||||
Part::Text { text } | Part::Summary { text, .. } => text.clone(),
|
||||
Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::<String>()),
|
||||
Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::<String>()),
|
||||
_ => continue,
|
||||
@@ -524,7 +524,7 @@ impl Agent {
|
||||
if summary.trim().is_empty() {
|
||||
return Err(anyhow!("compaction produced no summary"));
|
||||
}
|
||||
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary }]));
|
||||
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary, auto: false, tokens: None }]));
|
||||
self.session.context_tokens = 0;
|
||||
self.read_files.lock().unwrap().clear();
|
||||
let _ = self.session.save();
|
||||
|
||||
+3
-2
@@ -421,7 +421,7 @@ impl App {
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) {
|
||||
self.images.push(ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||
self.images.push(ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -721,6 +721,7 @@ fn provider_config(preset: &crate::models::Preset, base_url: &str, key: &str) ->
|
||||
enabled: true,
|
||||
max_concurrency: None,
|
||||
monthly_budget: None,
|
||||
plan_limit: None,
|
||||
admin_key: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -839,7 +840,7 @@ impl App {
|
||||
for (cmd, args) in tries {
|
||||
if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() {
|
||||
if out.status.success() && out.stdout.starts_with(b"\x89PNG") {
|
||||
self.images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
|
||||
self.images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
|
||||
self.status = format!("image {} attached", self.images.len());
|
||||
return;
|
||||
}
|
||||
|
||||
+5
-6
@@ -29,7 +29,7 @@ use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "codecraft", version, about = "CodeCraft in the terminal – a local coding agent")]
|
||||
#[command(name = "codecraft", version = codecraft_core::version::VERSION, about = "CodeCraft in the terminal – a local coding agent")]
|
||||
struct Cli {
|
||||
/// Start with this prompt
|
||||
prompt: Option<String>,
|
||||
@@ -164,8 +164,7 @@ fn prompt_line(label: &str, secret: bool) -> Result<String> {
|
||||
}
|
||||
|
||||
fn version_newer(server: &str, ours: &str) -> bool {
|
||||
let parse = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
|
||||
parse(server) > parse(ours)
|
||||
codecraft_core::version::newer(server, ours)
|
||||
}
|
||||
|
||||
fn target() -> String {
|
||||
@@ -178,8 +177,8 @@ async fn self_update() -> Result<()> {
|
||||
let http = server::http();
|
||||
let info: Value = http.get(format!("{}/api/info", login.url)).send().await?.json().await?;
|
||||
let latest = info["version"].as_str().unwrap_or("0");
|
||||
if !version_newer(latest, env!("CARGO_PKG_VERSION")) {
|
||||
println!("codecraft {} is up to date.", env!("CARGO_PKG_VERSION"));
|
||||
if !version_newer(latest, codecraft_core::version::VERSION) {
|
||||
println!("codecraft {} is up to date.", codecraft_core::version::VERSION);
|
||||
return Ok(());
|
||||
}
|
||||
let url = format!("{}/cli/download/{}", login.url, target());
|
||||
@@ -365,7 +364,7 @@ async fn main() -> Result<()> {
|
||||
// Notice when the server has a newer CLI.
|
||||
let mut status = String::new();
|
||||
if let Some(v) = env.server_cfg.as_ref().and_then(|s| s["version"].as_str()) {
|
||||
if version_newer(v, env!("CARGO_PKG_VERSION")) {
|
||||
if version_newer(v, codecraft_core::version::VERSION) {
|
||||
status = format!("update available: {v} – run `codecraft update`");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ pub async fn stream(
|
||||
mc.effort = Some(e);
|
||||
}
|
||||
let max_tokens = mc.max_output.unwrap_or(16_000);
|
||||
let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true };
|
||||
let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true, session_id: Some(session.to_string()) };
|
||||
provider.stream(req, tx, cancel).await
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ pub struct Server {
|
||||
|
||||
pub fn http() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION")))
|
||||
.user_agent(format!("codecraft-cli/{}", codecraft_core::version::VERSION))
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("http client")
|
||||
|
||||
+1
-1
@@ -408,7 +408,7 @@ pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
|
||||
return Ok(Output {
|
||||
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
|
||||
is_error: false,
|
||||
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
|
||||
images: vec![ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
|
||||
diff: None,
|
||||
});
|
||||
}
|
||||
|
||||
+22
-15
@@ -8,9 +8,9 @@
|
||||
#
|
||||
# Location: $CODECRAFT_CONFIG, otherwise <data_dir>/config.toml (Docker: /data/config.toml)
|
||||
#
|
||||
# Secrets: any string value may reference environment variables with ${NAME},
|
||||
# e.g. api_key = "${ANTHROPIC_API_KEY}". Prefer this over literal keys – keys
|
||||
# entered in the UI are stored here in plain text.
|
||||
# Keys and tokens are entered in the web UI (Settings) and stored here, readable
|
||||
# for admins and included in backups. Any string value may also reference an
|
||||
# environment variable as ${NAME}.
|
||||
# =============================================================================
|
||||
|
||||
[server]
|
||||
@@ -47,7 +47,7 @@ currency = "USD"
|
||||
# proxy's body limit (e.g. Nginx client_max_body_size) does not apply.
|
||||
max_upload_mb = 500
|
||||
# Bearer token for Prometheus at GET /metrics (empty = /metrics disabled).
|
||||
# metrics_token = "${CODECRAFT_METRICS_TOKEN}"
|
||||
# metrics_token = "a-long-random-string"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -81,7 +81,7 @@ max_upload_mb = 500
|
||||
[providers.anthropic]
|
||||
kind = "anthropic"
|
||||
base_url = "https://api.anthropic.com/v1"
|
||||
api_key = "${ANTHROPIC_API_KEY}"
|
||||
api_key = "" # set in Settings
|
||||
# Current Claude models think adaptively; "effort" controls depth and cost.
|
||||
# Prices: Anthropic list prices in USD per 1M tokens (Nov 2026) – adjust if needed.
|
||||
models = [
|
||||
@@ -97,25 +97,25 @@ models = [
|
||||
[providers.gemini]
|
||||
kind = "openai"
|
||||
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
api_key = "${GEMINI_API_KEY}"
|
||||
api_key = "" # set in Settings
|
||||
models = [] # discovered automatically
|
||||
enabled = false # set to true once GEMINI_API_KEY is available
|
||||
enabled = false # enable once the key is set
|
||||
|
||||
# opencode Go subscription (OpenAI-compatible endpoint for most models).
|
||||
# Current model list: https://opencode.ai/inference/go/v1/models
|
||||
[providers.opencode-go]
|
||||
kind = "openai"
|
||||
base_url = "https://opencode.ai/inference/go/openai/v1"
|
||||
api_key = "${OPENCODE_API_KEY}"
|
||||
api_key = "" # set in Settings
|
||||
models = []
|
||||
enabled = false
|
||||
|
||||
# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key <FAL_KEY>".
|
||||
# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key <key>".
|
||||
# NOTE: verify base_url against the fal docs for your account.
|
||||
[providers.fal]
|
||||
kind = "openai"
|
||||
base_url = "https://fal.run/openrouter/router/openai/v1"
|
||||
api_key = "${FAL_KEY}"
|
||||
api_key = "" # set in Settings
|
||||
auth_prefix = "Key "
|
||||
models = [
|
||||
{ id = "google/gemini-2.5-flash", context = 1000000, max_output = 32000, vision = true },
|
||||
@@ -151,7 +151,7 @@ enabled = false
|
||||
# [mcp.trilium]
|
||||
# type = "http"
|
||||
# url = "https://notes.example.org/mcp"
|
||||
# headers = { Authorization = "Bearer ${TRILIUM_TOKEN}" }
|
||||
# headers = { Authorization = "Bearer <token>" }
|
||||
|
||||
# [mcp.filesystem]
|
||||
# type = "stdio"
|
||||
@@ -243,11 +243,18 @@ generate_image = "ask" # costs money per image
|
||||
[images]
|
||||
enabled = false
|
||||
base_url = "https://fal.run"
|
||||
api_key = "${FAL_KEY}"
|
||||
api_key = "" # set in Settings
|
||||
default_model = "fal-ai/flux/schnell"
|
||||
output_dir = "generated"
|
||||
# The agent drafts with a "preview" model, asks which draft to refine, then
|
||||
# creates the final image with the best "final" model for the purpose
|
||||
# (good_for: photo, illustration, text, design), passing the draft as reference
|
||||
# to models with reference_param. The models below have these values built in;
|
||||
# set stage / good_for / reference_param / price to override or for other models.
|
||||
models = [
|
||||
{ id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast", price = 0.003 },
|
||||
{ id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast previews", price = 0.003 },
|
||||
{ id = "fal-ai/nano-banana-pro/edit", name = "Nano Banana Pro – refine an image", price = 0.15 },
|
||||
{ id = "fal-ai/nano-banana-pro", name = "Nano Banana Pro", price = 0.15 },
|
||||
{ id = "fal-ai/flux-2-pro", name = "FLUX.2 pro" },
|
||||
{ id = "fal-ai/ideogram/v3", name = "Ideogram 3 – text in images", size_param = "aspect_ratio" },
|
||||
{ id = "fal-ai/recraft/v4/pro/text-to-image", name = "Recraft V4 pro – design" },
|
||||
@@ -281,7 +288,7 @@ cookie_secure = "auto"
|
||||
enabled = false
|
||||
issuer = "https://auth.example.org/application/o/codecraft/"
|
||||
client_id = "codecraft"
|
||||
client_secret = "${OIDC_CLIENT_SECRET}"
|
||||
client_secret = "" # set in Settings
|
||||
scopes = ["openid", "profile", "email", "groups"]
|
||||
username_claim = "preferred_username"
|
||||
groups_claim = "groups"
|
||||
@@ -302,7 +309,7 @@ button_label = "Single sign-on"
|
||||
# -----------------------------------------------------------------------------
|
||||
[search]
|
||||
# "duckduckgo" (no key, may be rate-limited), "searxng" (self-hosted, set url),
|
||||
# "brave" or "tavily" (api_key, e.g. "${BRAVE_API_KEY}")
|
||||
# "brave" or "tavily" (set api_key in Settings)
|
||||
provider = "duckduckgo"
|
||||
url = ""
|
||||
api_key = ""
|
||||
|
||||
+129
-6
@@ -37,6 +37,10 @@ pub struct ProviderConfig {
|
||||
/// Monthly budget in the configured currency (tracked from recorded costs).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub monthly_budget: Option<f64>,
|
||||
/// Monthly usage limit of a subscription plan in USD (opencode Go). Usage is tracked
|
||||
/// from the allowance each response reports: 5 hours 20 %, week 50 %, month 100 %.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plan_limit: Option<f64>,
|
||||
/// Optional admin key to read the provider's own cost report
|
||||
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
@@ -76,6 +80,13 @@ pub struct ModelConfig {
|
||||
pub cache_read_price: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write_price: Option<f64>,
|
||||
/// Other prices from a context size on (e.g. long-context pricing); the largest
|
||||
/// threshold below the request's context wins, missing prices keep the base price.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub price_tiers: Vec<PriceTier>,
|
||||
/// Time-based price factors (peak hours, promotions); matching factors multiply.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub price_multipliers: Vec<PriceMultiplier>,
|
||||
/// Max. simultaneous requests to this model (None = unlimited).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_concurrency: Option<usize>,
|
||||
@@ -87,15 +98,127 @@ pub struct ModelConfig {
|
||||
pub options: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Prices from a context size on (tokens of the whole prompt, cache included).
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
||||
pub struct PriceTier {
|
||||
pub above_context: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_price: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub output_price: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_price: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write_price: Option<f64>,
|
||||
}
|
||||
|
||||
/// A price factor that applies at certain times (all given conditions must hold, UTC).
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
||||
pub struct PriceMultiplier {
|
||||
pub factor: f64,
|
||||
/// Weekdays ("mon" … "sun"); empty = every day.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub days: Vec<String>,
|
||||
/// Time ranges ["HH:MM", "HH:MM") in UTC; empty = all day.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub hours_utc: Vec<(String, String)>,
|
||||
/// First day it applies (YYYY-MM-DD, UTC).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub from: Option<String>,
|
||||
/// First day it no longer applies (YYYY-MM-DD, UTC).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub until: Option<String>,
|
||||
}
|
||||
|
||||
impl PriceMultiplier {
|
||||
pub fn applies(&self, at: chrono::DateTime<chrono::Utc>) -> bool {
|
||||
use chrono::{Datelike, Timelike};
|
||||
let day = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][at.weekday().num_days_from_monday() as usize];
|
||||
if !self.days.is_empty() && !self.days.iter().any(|d| d.eq_ignore_ascii_case(day)) {
|
||||
return false;
|
||||
}
|
||||
let minutes = |s: &str| s.split_once(':').and_then(|(h, m)| Some(h.trim().parse::<u32>().ok()? * 60 + m.trim().parse::<u32>().ok()?));
|
||||
let now = at.hour() * 60 + at.minute();
|
||||
if !self.hours_utc.is_empty()
|
||||
&& !self.hours_utc.iter().any(|(a, b)| matches!((minutes(a), minutes(b)), (Some(a), Some(b)) if now >= a && now < b))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let date = at.format("%Y-%m-%d").to_string();
|
||||
if self.from.as_deref().is_some_and(|f| date.as_str() < f) || self.until.as_deref().is_some_and(|u| date.as_str() >= u) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
/// Cost of one request. 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).
|
||||
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
||||
let inp = self.input_price.unwrap_or(0.0);
|
||||
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
|
||||
}
|
||||
|
||||
/// Cost of one request at a given time: context tiers and time factors applied.
|
||||
pub fn cost_at(&self, u: &crate::types::Usage, at: chrono::DateTime<chrono::Utc>) -> f64 {
|
||||
let context = u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||||
let tier = self.price_tiers.iter().filter(|t| context > t.above_context).max_by_key(|t| t.above_context);
|
||||
let pick = |base: Option<f64>, tiered: fn(&PriceTier) -> Option<f64>| tier.and_then(tiered).or(base);
|
||||
let inp = pick(self.input_price, |t| t.input_price).unwrap_or(0.0);
|
||||
let out = pick(self.output_price, |t| t.output_price).unwrap_or(0.0);
|
||||
let read = pick(self.cache_read_price, |t| t.cache_read_price).unwrap_or(inp);
|
||||
let write = pick(self.cache_write_price, |t| t.cache_write_price).unwrap_or(inp);
|
||||
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
|
||||
per(u.input_tokens, inp)
|
||||
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
|
||||
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
|
||||
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
|
||||
let factor: f64 = self.price_multipliers.iter().filter(|m| m.applies(at)).map(|m| m.factor).product();
|
||||
(per(u.input_tokens, inp) + per(u.output_tokens, out) + per(u.cache_read_tokens, read) + per(u.cache_write_tokens, write)) * factor
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod price_tests {
|
||||
use super::*;
|
||||
use crate::types::Usage;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn usage(input: u64, output: u64, read: u64) -> Usage {
|
||||
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiers_and_multipliers() {
|
||||
let m = ModelConfig {
|
||||
input_price: Some(2.0),
|
||||
output_price: Some(6.0),
|
||||
cache_read_price: Some(0.5),
|
||||
price_tiers: vec![PriceTier { above_context: 200_000, input_price: Some(4.0), output_price: Some(12.0), cache_read_price: Some(1.0), ..Default::default() }],
|
||||
price_multipliers: vec![PriceMultiplier {
|
||||
factor: 2.0,
|
||||
days: vec!["mon".into(), "tue".into(), "wed".into(), "thu".into(), "fri".into()],
|
||||
hours_utc: vec![("01:00".into(), "04:00".into()), ("06:00".into(), "10:00".into())],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let sat = chrono::Utc.with_ymd_and_hms(2026, 11, 7, 7, 0, 0).unwrap(); // Saturday 07:00
|
||||
let mon_peak = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 7, 30, 0).unwrap(); // Monday 07:30
|
||||
let mon_off = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 10, 0, 0).unwrap(); // Monday 10:00 (end excluded)
|
||||
let small = usage(100_000, 1_000_000, 0);
|
||||
assert!((m.cost_at(&small, sat) - (0.2 + 6.0)).abs() < 1e-9);
|
||||
assert!((m.cost_at(&small, mon_peak) - 2.0 * (0.2 + 6.0)).abs() < 1e-9);
|
||||
assert!((m.cost_at(&small, mon_off) - (0.2 + 6.0)).abs() < 1e-9);
|
||||
// 150k fresh + 100k cached = 250k context: long-context tier.
|
||||
let big = usage(150_000, 0, 100_000);
|
||||
assert!((m.cost_at(&big, sat) - (0.6 + 0.1)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_ranges() {
|
||||
let m = PriceMultiplier { factor: 2.0, from: Some("2026-12-01".into()), ..Default::default() };
|
||||
assert!(!m.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 23, 59, 0).unwrap()));
|
||||
assert!(m.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap()));
|
||||
let until = PriceMultiplier { factor: 0.5, until: Some("2026-12-01".into()), ..Default::default() };
|
||||
assert!(until.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 12, 0, 0).unwrap()));
|
||||
assert!(!until.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod permission;
|
||||
pub mod prompts;
|
||||
pub mod provider;
|
||||
pub mod types;
|
||||
pub mod version;
|
||||
@@ -14,6 +14,7 @@ pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent w
|
||||
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
|
||||
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
|
||||
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
||||
- Git identity and credentials come from the user's settings: never set user.name, user.email or credential helpers yourself (no `git config user.*`, no `-c user.*`).
|
||||
- Reference code as path:line.
|
||||
- Answer in the language the user writes in."#;
|
||||
|
||||
@@ -34,6 +35,9 @@ Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pa
|
||||
pub const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
|
||||
pub const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine.";
|
||||
|
||||
/// Plain conversation (mode "chat"): no project, no coding workflow.
|
||||
pub const CHAT_PROMPT: &str = "You are a helpful, knowledgeable assistant having a conversation with the user. Answer directly and naturally in the user's language; keep answers as short as the question allows and go into depth when asked. Use Markdown only where it helps (lists, code, tables). You can search the web (web_search) and read pages (web_fetch) for current or specific facts – say where information comes from. You have no access to files or a shell in this mode; if the user wants something built or changed in a project, suggest switching to build mode.";
|
||||
|
||||
pub const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
|
||||
pub const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn to_wire_messages(messages: &[Message]) -> Vec<Value> {
|
||||
Part::Text { text } if !text.is_empty() => {
|
||||
content.push(json!({"type": "text", "text": text}))
|
||||
}
|
||||
Part::Summary { text } => content.push(json!({
|
||||
Part::Summary { text, .. } => content.push(json!({
|
||||
"type": "text",
|
||||
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
||||
})),
|
||||
@@ -168,7 +168,8 @@ impl Provider for Anthropic {
|
||||
for (k, v) in &req.model.options {
|
||||
body[k] = v.clone();
|
||||
}
|
||||
let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?;
|
||||
let rb = self.request(reqwest::Method::POST, "/messages").json(&body);
|
||||
let resp = self.ep.with_session(rb, req.session_id.as_deref()).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(http_error(resp).await);
|
||||
}
|
||||
@@ -265,6 +266,7 @@ impl Provider for Anthropic {
|
||||
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
|
||||
usage.output_tokens = o;
|
||||
}
|
||||
usage.read_reported_cost(&data["usage"]);
|
||||
}
|
||||
"error" => {
|
||||
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
|
||||
|
||||
@@ -30,6 +30,9 @@ pub struct ChatRequest {
|
||||
pub max_tokens: u64,
|
||||
/// Enable extended thinking / reasoning when the model supports it.
|
||||
pub reasoning: bool,
|
||||
/// Stable id of the conversation; sent to gateways that route by session
|
||||
/// (opencode: `x-opencode-session`) for routing and prompt caching.
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -84,6 +87,16 @@ impl Endpoint {
|
||||
}
|
||||
}
|
||||
|
||||
/// Session header for gateways that need one (opencode Zen/Go reject requests without it).
|
||||
pub fn with_session(&self, rb: reqwest::RequestBuilder, session_id: Option<&str>) -> reqwest::RequestBuilder {
|
||||
match session_id {
|
||||
Some(id) if self.base_url.contains("opencode.ai") && !self.headers.keys().any(|k| k.eq_ignore_ascii_case("x-opencode-session")) => {
|
||||
rb.header("x-opencode-session", id)
|
||||
}
|
||||
_ => rb,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if !self.api_key.is_empty() {
|
||||
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
|
||||
@@ -99,7 +112,7 @@ impl Endpoint {
|
||||
pub fn http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(20))
|
||||
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
|
||||
.user_agent(format!("codecraft/{}", crate::version::VERSION))
|
||||
.build()
|
||||
.expect("http client")
|
||||
}
|
||||
@@ -128,3 +141,27 @@ pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
|
||||
.unwrap_or_else(|| body.chars().take(500).collect());
|
||||
anyhow!("provider returned HTTP {status}: {detail}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_header_tests {
|
||||
use super::*;
|
||||
|
||||
fn ep(base: &str) -> Endpoint {
|
||||
Endpoint { base_url: base.into(), api_key: String::new(), auth_header: "Authorization".into(), auth_prefix: "Bearer ".into(), headers: BTreeMap::new() }
|
||||
}
|
||||
|
||||
fn header(ep: &Endpoint, id: Option<&str>) -> Option<String> {
|
||||
let rb = ep.with_session(reqwest::Client::new().post(format!("{}/chat/completions", ep.base_url)), id);
|
||||
rb.build().unwrap().headers().get("x-opencode-session").map(|v| v.to_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_gets_the_session_header() {
|
||||
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), Some("s1")).as_deref(), Some("s1"));
|
||||
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), None), None);
|
||||
assert_eq!(header(&ep("https://api.openai.com/v1"), Some("s1")), None);
|
||||
let mut custom = ep("https://opencode.ai/zen/v1");
|
||||
custom.headers.insert("X-Opencode-Session".into(), "fixed".into());
|
||||
assert_eq!(header(&custom, Some("s1")), None, "a configured header wins");
|
||||
}
|
||||
}
|
||||
@@ -40,17 +40,20 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
|
||||
Part::Text { text } if !text.is_empty() => {
|
||||
parts.push(json!({"type": "text", "text": text}))
|
||||
}
|
||||
Part::Summary { text } => parts.push(json!({
|
||||
Part::Summary { text, .. } => parts.push(json!({
|
||||
"type": "text",
|
||||
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
||||
})),
|
||||
Part::Image { image } => parts.push(json!({
|
||||
"type": "image_url", "image_url": {"url": data_url(image)}
|
||||
})),
|
||||
Part::ToolResult { call_id, name, content, images, is_error, .. } => {
|
||||
Part::ToolResult { call_id, content, images, is_error, .. } => {
|
||||
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
|
||||
// No "name": it is not part of the tool message in the Chat
|
||||
// Completions API, and gateways that translate to Anthropic
|
||||
// (opencode) reject named participants.
|
||||
out.push(json!({
|
||||
"role": "tool", "tool_call_id": call_id, "name": name,
|
||||
"role": "tool", "tool_call_id": call_id,
|
||||
"content": if text.is_empty() { "(no output)".into() } else { text }
|
||||
}));
|
||||
tool_images.extend(images.iter().cloned());
|
||||
@@ -198,7 +201,7 @@ impl Provider for OpenAi {
|
||||
body[k] = v.clone();
|
||||
}
|
||||
let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body);
|
||||
let resp = self.ep.apply(rb).send().await?;
|
||||
let resp = self.ep.with_session(self.ep.apply(rb), req.session_id.as_deref()).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(http_error(resp).await);
|
||||
}
|
||||
@@ -231,6 +234,7 @@ impl Provider for OpenAi {
|
||||
usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached);
|
||||
usage.cache_read_tokens = cached;
|
||||
usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0);
|
||||
usage.read_reported_cost(u);
|
||||
}
|
||||
let Some(choice) = data["choices"].get(0) else { continue };
|
||||
let d = &choice["delta"];
|
||||
@@ -327,6 +331,22 @@ mod tests {
|
||||
(text, reasoning)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_messages_have_no_name() {
|
||||
use crate::types::{Message, Part, Role};
|
||||
let call = Part::ToolCall { id: "c1".into(), name: "bash".into(), input: serde_json::json!({"command": "ls"}), extra: None };
|
||||
let result = Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "ok".into(), images: vec![], is_error: false, meta: None };
|
||||
let msgs = vec![
|
||||
Message::new(Role::User, vec![Part::Text { text: "hi".into() }]),
|
||||
Message::new(Role::Assistant, vec![call]),
|
||||
Message::new(Role::User, vec![result]),
|
||||
];
|
||||
let wire = super::to_wire_messages("sys", &msgs);
|
||||
let tool = wire.iter().find(|m| m["role"] == "tool").expect("tool message");
|
||||
assert_eq!(tool["tool_call_id"], "c1");
|
||||
assert!(wire.iter().all(|m| m.get("name").is_none()), "no message may carry a name: {wire:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_think_tags_across_chunks() {
|
||||
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
|
||||
|
||||
+52
-3
@@ -10,11 +10,25 @@ pub enum Role {
|
||||
Assistant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ImageData {
|
||||
pub media_type: String,
|
||||
/// Base64-encoded bytes.
|
||||
/// Base64-encoded bytes. Empty when the image is stored separately (`blob`).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub data: String,
|
||||
/// Server only: SHA-256 of the bytes in the image store; the UI loads
|
||||
/// /api/blobs/<blob> and the server fills `data` in before a model request.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blob: Option<String>,
|
||||
/// Size in bytes of a stored image (shown in the UI without fetching it).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
impl ImageData {
|
||||
pub fn new(media_type: impl Into<String>, data: String) -> Self {
|
||||
Self { media_type: media_type.into(), data, blob: None, size: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -57,6 +71,12 @@ pub enum Part {
|
||||
/// Marks the start of a compacted history; the text is the summary.
|
||||
Summary {
|
||||
text: String,
|
||||
/// Started automatically because the context was nearly full.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
auto: bool,
|
||||
/// Context size (tokens) when it was compacted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tokens: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -93,6 +113,24 @@ pub struct Usage {
|
||||
pub cache_read_tokens: u64,
|
||||
#[serde(default)]
|
||||
pub cache_write_tokens: u64,
|
||||
/// Cost the provider reported for the request (USD; opencode: what was charged).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<f64>,
|
||||
/// opencode Go: how much the request counted toward the plan's usage limits (USD).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowance: Option<f64>,
|
||||
}
|
||||
|
||||
impl Usage {
|
||||
/// Read the cost fields gateways add to `usage` (opencode: `cost`, `opencode.allowance`).
|
||||
pub fn read_reported_cost(&mut self, u: &serde_json::Value) {
|
||||
if let Some(c) = u["cost"].as_f64() {
|
||||
self.cost = Some(c);
|
||||
}
|
||||
if let Some(a) = u["opencode"]["allowance"].as_f64() {
|
||||
self.allowance = Some(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Usage {
|
||||
@@ -142,6 +180,13 @@ pub struct Session {
|
||||
/// User-defined group in the session list.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub folder: Option<String>,
|
||||
/// Instructions the user set for this chat; added to the system prompt of every turn.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub instructions: String,
|
||||
/// Approvals and questions of this chat (and its subagents) are not sent to Telegram,
|
||||
/// unless it is the chat currently open in Telegram.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub telegram_quiet: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -195,6 +240,9 @@ pub enum Event {
|
||||
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
|
||||
/// Messages waiting for a busy session (full list).
|
||||
QueueUpdated { session_id: String, items: Value },
|
||||
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
|
||||
/// a nearly full context (tokens of limit).
|
||||
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
|
||||
}
|
||||
|
||||
impl Event {
|
||||
@@ -216,7 +264,8 @@ impl Event {
|
||||
| Error { session_id, .. }
|
||||
| Waiting { session_id, .. }
|
||||
| QueueUpdated { session_id, .. }
|
||||
| StepStarted { session_id, .. } => Some(session_id),
|
||||
| StepStarted { session_id, .. }
|
||||
| Compaction { session_id, .. } => Some(session_id),
|
||||
McpStatus { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Release version. Releases are named vYYYYMM-NNNNN (a counter per month, from 00001);
|
||||
//! the release build sets CODECRAFT_VERSION (without the leading "v"). Builds without it
|
||||
//! (development) fall back to the crate version.
|
||||
|
||||
pub const VERSION: &str = match option_env!("CODECRAFT_VERSION") {
|
||||
Some(v) if !v.is_empty() => v,
|
||||
_ => env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
|
||||
/// Sort key: monthly releases (YYYYMM-NNNNN) come after the old semantic versions.
|
||||
fn key(v: &str) -> (u8, Vec<u64>) {
|
||||
let v = v.trim().trim_start_matches('v');
|
||||
if let Some((month, n)) = v.split_once('-')
|
||||
&& month.len() == 6
|
||||
&& month.chars().all(|c| c.is_ascii_digit())
|
||||
&& !n.is_empty()
|
||||
&& n.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
return (1, vec![month.parse().unwrap_or(0), n.parse().unwrap_or(0)]);
|
||||
}
|
||||
(0, v.split(['.', '-']).take(3).map(|x| x.parse::<u64>().unwrap_or(0)).collect())
|
||||
}
|
||||
|
||||
/// Is version `a` newer than `b`? Accepts both schemes, with or without "v".
|
||||
pub fn newer(a: &str, b: &str) -> bool {
|
||||
key(a) > key(b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::newer;
|
||||
|
||||
#[test]
|
||||
fn compares_monthly_and_semver() {
|
||||
assert!(newer("v202611-00002", "202611-00001"));
|
||||
assert!(newer("v202612-00001", "v202611-00042"));
|
||||
assert!(!newer("v202611-00001", "202611-00001"));
|
||||
assert!(newer("v202611-00001", "0.1.0"));
|
||||
assert!(!newer("0.9.9", "v202611-00001"));
|
||||
assert!(newer("v0.2.0", "0.1.9"));
|
||||
assert!(newer("1.0.0", "0.9.9"));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
# /data/home home directory of the agent (git config, caches, ...)
|
||||
services:
|
||||
codecraft:
|
||||
# Pin a version (e.g. :0.1.0) or follow :latest.
|
||||
# Pin a version (e.g. :v202611-00001) or follow :latest.
|
||||
image: git.familie-zink.org/zink/codecraft:${CODECRAFT_VERSION:-latest}
|
||||
container_name: codecraft
|
||||
restart: unless-stopped
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Moving CodeCraft to another host (Unraid)
|
||||
|
||||
This guide moves a running installation – Docker volume `codecraft-data` on the current host –
|
||||
to another Docker host that keeps its data in **bind mounts**, for example Unraid. The script
|
||||
`scripts/move-server.sh` does the work; this page explains what goes where, what to prepare and
|
||||
how to check and roll back.
|
||||
|
||||
## What lives where
|
||||
|
||||
| Part | Contents | Today (Docker volume) | On the new host |
|
||||
|---|---|---|---|
|
||||
| **Data** | database `codecraft.db`, `config.toml` (incl. API keys), daily backups | `codecraft-data:/data` | `<dir>/data` → `/data` |
|
||||
| **Agent installs** | the agent's home `/data/home`: toolchains (Rust, …), pip/pipx/npm/cargo packages, downloaded programs (`~/opt`), caches | inside the volume | `<tools-dir>/home` → `/data/home` |
|
||||
| **Users' homes** | homes of sandboxed users `/data/users/<name>/home` (their installs, git settings) | inside the volume | `<tools-dir>/users` → `/data/users` |
|
||||
| **Temporary files** | `/tmp` of agents, builds, downloads | container layer | `<tools-dir>/tmp` → `/tmp` |
|
||||
| **Workspace** | your projects | `WORKSPACE` from `.env` | `--workspace` path → `/workspace` |
|
||||
| **Image** | the program | registry image | registry image |
|
||||
|
||||
The image never changes: the container runs without root (no `apt`, no `sudo`), so agents can
|
||||
only install into their home directory. With `--tools-dir` everything agents install or
|
||||
download lands on a separate disk (e.g. an SSD); the data folder keeps only the database,
|
||||
configuration and backups. If the tools disk is lost, agents simply install what they need
|
||||
again – nothing important is on it. (Backups contain the database and configuration, not the
|
||||
agent home.)
|
||||
|
||||
Sizes on 2026-11-09 for orientation: agent home 2.2 GB (of which downloaded programs 1.3 GB,
|
||||
Rust toolchain 0.8 GB), database/config/backups about 1.2 GB.
|
||||
|
||||
## Before you start
|
||||
|
||||
On the **new host** (Unraid):
|
||||
|
||||
- **SSH with a key** from the current host, without a password prompt. Check from the current
|
||||
host: `ssh root@unraid true` (no output, no question = fine).
|
||||
- **docker**, **rsync** and **tar** available (all three are on Unraid by default).
|
||||
- **Docker Compose** is optional: without it the script starts the container with the generated
|
||||
`start.sh` (Unraid: the "Docker Compose Manager" plugin adds Compose).
|
||||
- The **tools disk** is mounted, e.g. an SSD via Unassigned Devices under `/mnt/disks/ssd`.
|
||||
- **Registry access**: the image comes from `git.familie-zink.org/zink/codecraft`. If the pull is
|
||||
refused, run `docker login git.familie-zink.org` on Unraid once.
|
||||
|
||||
Decide:
|
||||
|
||||
| Question | Option | Example |
|
||||
|---|---|---|
|
||||
| Folder for data, compose file and scripts | target `root@host:/dir` | `/mnt/user/appdata/codecraft` |
|
||||
| Folder for agent installs and `/tmp` | `--tools-dir` | `/mnt/disks/ssd/codecraft` |
|
||||
| Your projects | `--workspace` | `/mnt/user/projects` |
|
||||
| Owner of files (the container runs as this user) | `--uid`, `--gid` | Unraid default `99`/`100` (nobody:users); today `1000`/`1000` |
|
||||
| Docker of the new host for agents and terminals | `--docker-sock` | bind mount of `/var/run/docker.sock` plus its group (see below) |
|
||||
| Port and address | `--port`, `--bind` | `8787`; `0.0.0.0` if the reverse proxy runs on another machine or in another Docker network, `127.0.0.1` if it runs on the same host with host networking |
|
||||
|
||||
## Moving
|
||||
|
||||
On the **current host**, in the repository folder (where `.env` is):
|
||||
|
||||
```sh
|
||||
# 1. Look at the steps first (changes nothing)
|
||||
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
|
||||
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock --dry-run
|
||||
|
||||
# 2. Move (same command without --dry-run)
|
||||
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
|
||||
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock
|
||||
```
|
||||
|
||||
What happens:
|
||||
|
||||
1. **Checks**: docker/tar/rsync on the target; the target data folder (and `home/`, `users/` in
|
||||
the tools folder) must be empty – the script never overwrites.
|
||||
2. **Workspace, first pass** with rsync while CodeCraft keeps running (the long part).
|
||||
3. **Stops CodeCraft here.** Running agents and terminals end. The container is only stopped,
|
||||
not removed.
|
||||
4. **Data volume** is streamed with tar over SSH into `<dir>/data`; with `--tools-dir` the agent
|
||||
home and the users' homes are then moved to the tools folder.
|
||||
5. **Workspace, second pass**: only what changed since step 2.
|
||||
6. **Owner** of data, tools and workspace set to `--uid:--gid` (needs root on the target).
|
||||
7. **Writes** `<dir>/docker-compose.yml`, `<dir>/.env` (your `.env` with the new paths, port and
|
||||
owner), `<dir>/start.sh` (the same as `docker run`, for hosts without Compose) and
|
||||
`<dir>/docker/seccomp-sandbox.json`.
|
||||
8. **Starts** the image `CODECRAFT_VERSION` from `.env` (Compose, else `start.sh`).
|
||||
9. **Smoke test** on the target (`scripts/smoke-test.sh`): health, version, web UI, terminal
|
||||
client download.
|
||||
|
||||
Afterwards:
|
||||
|
||||
- **Reverse proxy**: point the proxy host for `code.familie-zink.org` (Nginx Proxy Manager) to the
|
||||
new host and port; keep WebSocket support on.
|
||||
- **Check in the web UI**: sign in, open a chat, check the status LEDs (providers, MCP servers),
|
||||
let an agent run `git status` in a project (credentials, identity), open a terminal tab.
|
||||
Models or MCP servers on the old host's LAN address or `localhost` need new addresses.
|
||||
- **Clean up** on the old host when everything works: `docker rm codecraft`, later
|
||||
`docker volume rm codecraft-data`.
|
||||
|
||||
## Docker socket (`--docker-sock`)
|
||||
|
||||
With `--docker-sock` the container gets the new host's Docker: `/var/run/docker.sock` is bind
|
||||
mounted and the container joins the socket's group (the script reads its group id on the
|
||||
target). This is **root-equivalent access to the host**: whoever can start containers can mount
|
||||
any host folder.
|
||||
|
||||
- Admins' agents and terminals (they run without sandbox) can use it.
|
||||
- Sandboxed users do not see the socket (the sandbox does not mount `/var/run`).
|
||||
- `docker` and `docker-compose` commands always need an approval, except in *bypass* mode.
|
||||
- The image contains the Docker CLI (static client); the daemon is the host's.
|
||||
|
||||
The current host has the same setup in its local `docker-compose.override.yml` (group 997).
|
||||
|
||||
## Rollback
|
||||
|
||||
Nothing is deleted on the old host. On Unraid stop the new container (`docker stop codecraft`),
|
||||
on the old host start the old one (`docker start codecraft`) and point the reverse proxy back.
|
||||
Changes made on the new host in the meantime (chats, settings) are not in the old installation.
|
||||
|
||||
## Running it on the new host
|
||||
|
||||
In `<dir>` on Unraid:
|
||||
|
||||
| Task | With Compose | Without Compose |
|
||||
|---|---|---|
|
||||
| Update | set `CODECRAFT_VERSION` in `.env`, then `docker compose pull && docker compose up -d` | set `CODECRAFT_VERSION` in `.env`, `docker pull …:<version>`, then `./start.sh` |
|
||||
| Restart | `docker compose restart` | `docker restart codecraft` |
|
||||
| Logs | `docker compose logs -f` | `docker logs -f codecraft` |
|
||||
| Check | `bash smoke-test.sh http://127.0.0.1:8787 <version>` | same |
|
||||
|
||||
Releases are published from the development machine with `scripts/release.sh`; the new host
|
||||
only pulls them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`data on the target is not empty`**: an earlier attempt left files. Move `<dir>/data` (and
|
||||
`home/`, `users/` in the tools folder) away and run the script again.
|
||||
- **Image pull refused**: `docker login git.familie-zink.org` on the target, then
|
||||
`cd <dir> && docker compose up -d` (or `./start.sh`).
|
||||
- **Smoke test fails**: `docker logs codecraft` on the target. The old installation is stopped but
|
||||
intact – `docker start codecraft` on the old host brings it back.
|
||||
- **`could not chown`**: the SSH user is not root; set the owner by hand
|
||||
(`chown -R 99:100 <dir>/data <tools-dir> <workspace>`).
|
||||
- **Permission denied inside the container**: data, tools and workspace must belong to the
|
||||
`UID`/`GID` in `<dir>/.env`.
|
||||
- **Port already in use**: change `PORT` in `<dir>/.env` and start again.
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# Roll out a published CodeCraft image to a remote installation that was set up with
|
||||
# scripts/move-server.sh (docker-compose.yml + .env in one folder on the target).
|
||||
#
|
||||
# scripts/release.sh # build and publish a new version (amd64 + arm64)
|
||||
# scripts/deploy.sh # roll the newest tag out to the target
|
||||
#
|
||||
# Steps:
|
||||
# 1. check that the image exists in the registry for the target's architecture,
|
||||
# 2. wait until no agent or terminal runs on the target (unless --force),
|
||||
# 3. set CODECRAFT_VERSION in the target's .env, docker compose pull && up -d
|
||||
# (or start.sh on hosts without Compose),
|
||||
# 4. run scripts/smoke-test.sh on the target; on failure the previous version is
|
||||
# started again.
|
||||
#
|
||||
# Usage: scripts/deploy.sh [vYYYYMM-NNNNN] [--target user@host:/dir] [--force]
|
||||
# [--wait MINUTES] [--dry-run]
|
||||
# vYYYYMM-NNNNN version to deploy (default: the newest vYYYYMM-NNNNN git tag)
|
||||
# --target installation folder on the target (default: DEPLOY_TARGET from .env)
|
||||
# --force restart even if agents or terminals are running (they end)
|
||||
# --wait N how long to wait for an idle server before giving up (default 15)
|
||||
# --dry-run show the steps only
|
||||
#
|
||||
# Environment:
|
||||
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
|
||||
# CONTAINER container name on the target (default codecraft)
|
||||
# PORT port of CodeCraft on the target, for the smoke test (default: PORT
|
||||
# from the target's .env, else 8787)
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
|
||||
CONTAINER="${CONTAINER:-codecraft}"
|
||||
|
||||
tag="" target="" force=0 wait_min=15 dry=0
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--target) target="$2"; shift ;;
|
||||
--force) force=1 ;;
|
||||
--wait) wait_min="$2"; shift ;;
|
||||
--dry-run) dry=1 ;;
|
||||
-h|--help) sed -n '2,29p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option $1" >&2; exit 2 ;;
|
||||
*) tag="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
fail() { echo "error: $*" >&2; exit 1; }
|
||||
q() { printf '%q' "$1"; }
|
||||
env_get() { [[ -f .env ]] && sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
|
||||
|
||||
target="${target:-$(env_get DEPLOY_TARGET || true)}"
|
||||
[[ "$target" == *:/* ]] || fail "no target – pass --target user@host:/dir or set DEPLOY_TARGET in .env"
|
||||
host="${target%%:*}"
|
||||
dir="${target#*:}"; dir="${dir%/}"
|
||||
remote() { ssh -o BatchMode=yes "$host" "$1"; }
|
||||
run_remote() { if (( dry )); then echo "[dry-run] on $host: $1"; else remote "$1"; fi; }
|
||||
|
||||
if [[ -z "$tag" ]]; then
|
||||
git fetch --tags --quiet origin || true
|
||||
tag=$(git tag -l 'v[0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9]' | sort | tail -1)
|
||||
[[ -n "$tag" ]] || fail "no release tag found"
|
||||
fi
|
||||
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
|
||||
|
||||
step "Checking $host:$dir and the image $REGISTRY:$tag"
|
||||
remote "test -f $(q "$dir/.env") && test -f $(q "$dir/docker-compose.yml")" \
|
||||
|| fail "$dir on $host has no .env/docker-compose.yml – set it up with scripts/move-server.sh"
|
||||
current=$(remote "sed -n 's/^CODECRAFT_VERSION=//p' $(q "$dir/.env") | tail -1")
|
||||
case "$(remote 'uname -m')" in
|
||||
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
|
||||
esac
|
||||
if [[ -n "$arch" ]]; then
|
||||
# Captured first: `inspect | grep -q` fails under pipefail when grep exits early (SIGPIPE).
|
||||
manifests=$(docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null || true)
|
||||
grep -q "Platform:.*linux/$arch" <<<"$manifests" \
|
||||
|| fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first"
|
||||
fi
|
||||
echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})"
|
||||
[[ "$current" == "$tag" ]] && echo "note: $tag is already configured – the container is recreated anyway"
|
||||
|
||||
# Busy = the container runs more than the server process (agents' shells, terminals, ...).
|
||||
busy() { (( $(remote "docker top $(q "$CONTAINER") -o pid 2>/dev/null | tail -n +2 | wc -l") > 1 )); }
|
||||
if (( ! force )) && remote "docker inspect -f '{{.State.Running}}' $(q "$CONTAINER") 2>/dev/null" | grep -q true; then
|
||||
step "Waiting until no agent or terminal runs (up to $wait_min min, --force skips this)"
|
||||
deadline=$(( $(date +%s) + wait_min * 60 ))
|
||||
while busy; do
|
||||
(( $(date +%s) < deadline )) || fail "still busy after $wait_min min – try later or use --force"
|
||||
sleep 15
|
||||
done
|
||||
echo "idle"
|
||||
fi
|
||||
|
||||
step "Updating to $tag"
|
||||
run_remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$tag/' .env \
|
||||
&& (grep -q '^CODECRAFT_VERSION=' .env || echo 'CODECRAFT_VERSION=$tag' >> .env) \
|
||||
&& docker pull -q $(q "$REGISTRY:$tag") >/dev/null \
|
||||
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
|
||||
|
||||
step "Smoke test"
|
||||
port="${PORT:-$(remote "sed -n 's/^PORT=//p' $(q "$dir/.env") | tail -1")}"; port="${port:-8787}"
|
||||
if (( dry )); then
|
||||
echo "[dry-run] smoke test on $host: http://127.0.0.1:$port ${tag#v}"
|
||||
else
|
||||
ssh -o BatchMode=yes "$host" "cat > $(q "$dir/smoke-test.sh")" <scripts/smoke-test.sh
|
||||
if ! remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${tag#v}"; then
|
||||
if [[ -n "$current" && "$current" != "$tag" ]]; then
|
||||
echo "smoke test failed – going back to $current" >&2
|
||||
remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$current/' .env \
|
||||
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
|
||||
fi
|
||||
fail "deploy of $tag failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
step "Done"
|
||||
echo "$host runs CodeCraft $tag."
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
# Move this CodeCraft installation to another Docker host (e.g. Unraid) that keeps its
|
||||
# data in bind mounts instead of the Docker volume "codecraft-data".
|
||||
#
|
||||
# Run it on the CURRENT host, in the repository folder (where .env lives):
|
||||
#
|
||||
# scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||
# --workspace /mnt/user/projects --uid 99 --gid 100 --bind 0.0.0.0
|
||||
#
|
||||
# What it does:
|
||||
# 1. copies the workspace while CodeCraft keeps running (rsync, first pass),
|
||||
# 2. stops the CodeCraft container here (it is kept for a rollback),
|
||||
# 3. streams the data volume (database, config, backups, agent home) to <dir>/data,
|
||||
# 4. syncs the workspace again (only what changed),
|
||||
# 5. writes <dir>/docker-compose.yml, <dir>/.env and <dir>/start.sh (docker run, for hosts
|
||||
# without Compose) with bind mounts, and starts the published image there,
|
||||
# 6. runs scripts/smoke-test.sh on the new host.
|
||||
# Afterwards point the reverse proxy to the new host. To roll back: start the old container
|
||||
# here again (docker compose start) – nothing is deleted on this host.
|
||||
#
|
||||
# Target: user@host:/dir (SSH; rsync and docker on the target), or local:/dir to try it on
|
||||
# this host (use --name/--port so it does not clash with the running installation).
|
||||
#
|
||||
# Options:
|
||||
# --workspace DIR project folder on the target (default <dir>/workspace)
|
||||
# --tools-dir DIR separate disk for what agents install and download: the agent home
|
||||
# (/data/home), the users' homes (/data/users) and /tmp are bind
|
||||
# mounts from DIR/home, DIR/users and DIR/tmp (default: inside <dir>/data)
|
||||
# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env)
|
||||
# --docker-sock give the container the target's Docker (bind mount of
|
||||
# /var/run/docker.sock plus its group). Root-equivalent access to the
|
||||
# target host for admins' agents and terminals; sandboxed users do not
|
||||
# see it, and docker commands still need approval
|
||||
# --port N host port on the target (default: PORT of .env)
|
||||
# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1)
|
||||
# --name NAME container name on the target (default codecraft)
|
||||
# --source-container C container to stop here (default codecraft)
|
||||
# --source-volume V data volume here (default codecraft-data)
|
||||
# --source-workspace D project folder here (default: WORKSPACE of .env)
|
||||
# --exclude PATTERN leave this out of the workspace copy (rsync pattern, relative to the
|
||||
# workspace, e.g. /old-project/ or /app/target/); repeatable
|
||||
# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
|
||||
# --dry-run show the steps only
|
||||
set -euo pipefail
|
||||
|
||||
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
|
||||
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
|
||||
excludes=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--workspace) ws_target="$2"; shift ;;
|
||||
--tools-dir) tools_dir="$2"; shift ;;
|
||||
--docker-sock) docker_sock=1 ;;
|
||||
--uid) uid="$2"; shift ;;
|
||||
--gid) gid="$2"; shift ;;
|
||||
--port) port="$2"; shift ;;
|
||||
--bind) bind="$2"; shift ;;
|
||||
--name) name="$2"; shift ;;
|
||||
--source-container) src_container="$2"; shift ;;
|
||||
--source-volume) src_volume="$2"; shift ;;
|
||||
--source-workspace) ws_source="$2"; shift ;;
|
||||
--exclude) excludes+=(--exclude "$2"); shift ;;
|
||||
--no-stop) stop=0 ;;
|
||||
--dry-run) dry=1 ;;
|
||||
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option $1" >&2; exit 2 ;;
|
||||
*) target="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[[ "$target" == *:/* ]] || { echo "usage: $0 user@host:/dir | local:/dir [options] (see --help)" >&2; exit 2; }
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
|
||||
# Value of a .env variable, without surrounding quotes.
|
||||
env_get() { sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
|
||||
|
||||
host="${target%%:*}"
|
||||
dir="${target#*:}"
|
||||
dir="${dir%/}"
|
||||
ws_target="${ws_target:-$dir/workspace}"
|
||||
uid="${uid:-$(env_get UID)}"; uid="${uid:-1000}"
|
||||
gid="${gid:-$(env_get GID)}"; gid="${gid:-1000}"
|
||||
port="${port:-$(env_get PORT)}"; port="${port:-8787}"
|
||||
bind="${bind:-$(env_get BIND_ADDR)}"; bind="${bind:-127.0.0.1}"
|
||||
version="$(env_get CODECRAFT_VERSION)"; version="${version:-latest}"
|
||||
ws_source="${ws_source:-$(env_get WORKSPACE)}"; ws_source="${ws_source:-./workspace}"
|
||||
image="$(docker inspect "$src_container" --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
image_repo="${image%:*}"
|
||||
[[ -n "$image_repo" && "$image_repo" != codecraft ]] || image_repo="git.familie-zink.org/zink/codecraft"
|
||||
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
fail() { echo "error: $*" >&2; exit 1; }
|
||||
# Run a shell command on the target.
|
||||
remote() {
|
||||
if (( dry )); then echo "[dry-run] on $host: $1"; return 0; fi
|
||||
if [[ "$host" == local ]]; then bash -c "$1"; else ssh -o BatchMode=yes "$host" "$1"; fi
|
||||
}
|
||||
q() { printf '%q' "$1"; }
|
||||
sync_workspace() {
|
||||
if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
|
||||
if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
|
||||
else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
|
||||
}
|
||||
put_file() { # put_file <remote path> < content
|
||||
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
|
||||
if [[ "$host" == local ]]; then cat >"$1"; else ssh -o BatchMode=yes "$host" "cat > $(q "$1")"; fi
|
||||
}
|
||||
|
||||
step "Checking source and target"
|
||||
docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
|
||||
[[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
|
||||
echo "source: container $src_container, volume $src_volume, workspace $ws_source"
|
||||
tools_dir="${tools_dir%/}"
|
||||
echo "target: $host:$dir (data in $dir/data, workspace $ws_target${tools_dir:+, agent installs and /tmp in $tools_dir}), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version"
|
||||
remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync"
|
||||
remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \
|
||||
|| fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)"
|
||||
if [[ -n "$tools_dir" ]]; then
|
||||
remote "mkdir -p $(q "$tools_dir") && test ! -e $(q "$tools_dir/home") && test ! -e $(q "$tools_dir/users")" \
|
||||
|| fail "$tools_dir already has home/ or users/ – refusing to overwrite (move them away first)"
|
||||
fi
|
||||
|
||||
step "Copying the workspace (first pass, CodeCraft keeps running)"
|
||||
sync_workspace
|
||||
|
||||
if (( stop )); then
|
||||
step "Stopping $src_container here (running agents and terminals end)"
|
||||
(( dry )) && echo "[dry-run] docker stop $src_container" || docker stop "$src_container" >/dev/null
|
||||
fi
|
||||
|
||||
step "Copying the data volume"
|
||||
if (( dry )); then
|
||||
echo "[dry-run] tar of volume $src_volume -> $host:$dir/data"
|
||||
else
|
||||
docker run --rm --entrypoint tar --user 0:0 -v "$src_volume:/data:ro" "${image:-$image_repo:$version}" -C /data -cf - . \
|
||||
| remote "tar -C $(q "$dir/data") -xpf -"
|
||||
fi
|
||||
|
||||
if [[ -n "$tools_dir" ]]; then
|
||||
step "Moving the agent home and the users' homes to $tools_dir"
|
||||
remote "mkdir -p $(q "$tools_dir/tmp") $(q "$dir/data/home") $(q "$dir/data/users") \
|
||||
&& mv $(q "$dir/data/home") $(q "$tools_dir/home") && mv $(q "$dir/data/users") $(q "$tools_dir/users") \
|
||||
&& mkdir -p $(q "$dir/data/home") $(q "$dir/data/users") && chmod 1777 $(q "$tools_dir/tmp")"
|
||||
fi
|
||||
|
||||
step "Copying the workspace (changes since the first pass)"
|
||||
sync_workspace
|
||||
remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") ${tools_dir:+$(q "$tools_dir/home") $(q "$tools_dir/users")} 2>/dev/null || echo 'note: could not chown (not root) – check file owners'"
|
||||
# Bind mounts for the tools disk and the Docker socket (compose lines and docker run flags).
|
||||
tool_mounts="" tool_flags="" sock_compose="" sock_flags=""
|
||||
if (( docker_sock )); then
|
||||
if (( dry )); then sock_gid=0; else
|
||||
sock_gid=$(remote "stat -c %g /var/run/docker.sock") || fail "no /var/run/docker.sock on the target"
|
||||
fi
|
||||
sock_compose=$(printf ' - /var/run/docker.sock:/var/run/docker.sock\n group_add:\n - "%s"\n' "$sock_gid")
|
||||
sock_flags="-v /var/run/docker.sock:/var/run/docker.sock --group-add $sock_gid "
|
||||
fi
|
||||
if [[ -n "$tools_dir" ]]; then
|
||||
tool_mounts=$(printf ' - %s/home:/data/home\n - %s/users:/data/users\n - %s/tmp:/tmp\n' "$tools_dir" "$tools_dir" "$tools_dir")
|
||||
tool_flags="-v $tools_dir/home:/data/home -v $tools_dir/users:/data/users -v $tools_dir/tmp:/tmp "
|
||||
fi
|
||||
|
||||
step "Writing docker-compose.yml, .env and start.sh"
|
||||
put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json
|
||||
grep -vE '^(COMPOSE_FILE|WORKSPACE|UID|GID|PORT|BIND_ADDR)=' .env | {
|
||||
cat
|
||||
printf '\n# Written by scripts/move-server.sh\nWORKSPACE=%s\nUID=%s\nGID=%s\nPORT=%s\nBIND_ADDR=%s\n' "$ws_target" "$uid" "$gid" "$port" "$bind"
|
||||
} | put_file "$dir/.env"
|
||||
put_file "$dir/docker-compose.yml" <<EOF
|
||||
# CodeCraft with bind mounts (written by scripts/move-server.sh).
|
||||
# Update: set CODECRAFT_VERSION in .env, then docker compose pull && docker compose up -d
|
||||
services:
|
||||
codecraft:
|
||||
image: $image_repo:\${CODECRAFT_VERSION:-latest}
|
||||
container_name: $name
|
||||
restart: unless-stopped
|
||||
user: "\${UID}:\${GID}"
|
||||
ports:
|
||||
- "\${BIND_ADDR}:\${PORT}:8787"
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
- $dir/data:/data
|
||||
- \${WORKSPACE}:/workspace
|
||||
$tool_mounts
|
||||
$sock_compose
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
shm_size: "1gb"
|
||||
security_opt:
|
||||
- seccomp=$dir/docker/seccomp-sandbox.json
|
||||
- apparmor=unconfined
|
||||
- systempaths=unconfined
|
||||
EOF
|
||||
put_file "$dir/start.sh" <<EOF
|
||||
#!/bin/sh
|
||||
# Same as docker-compose.yml, for hosts without Docker Compose.
|
||||
set -e
|
||||
cd "\$(dirname "\$0")"
|
||||
# Read values instead of sourcing .env: UID is read-only in bash.
|
||||
val() { sed -n "s/^\$1=//p" .env | tail -1; }
|
||||
docker rm -f $name >/dev/null 2>&1 || true
|
||||
exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
|
||||
-p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
|
||||
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" $tool_flags$sock_flags\\
|
||||
--add-host host.docker.internal:host-gateway --shm-size 1g \\
|
||||
--security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
|
||||
--security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
|
||||
$image_repo:\$(val CODECRAFT_VERSION)
|
||||
EOF
|
||||
remote "chmod +x $(q "$dir/start.sh")"
|
||||
|
||||
step "Starting CodeCraft on the target"
|
||||
remote "cd $(q "$dir") && docker pull -q $image_repo:$version >/dev/null && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" \
|
||||
|| fail "start failed – if the image pull was refused, run 'docker login ${image_repo%%/*}' on the target and start again with: cd $dir && docker compose up -d (or ./start.sh)"
|
||||
|
||||
step "Smoke test on the target"
|
||||
put_file "$dir/smoke-test.sh" <scripts/smoke-test.sh
|
||||
remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${version#v}" || fail "smoke test failed – the old installation here is stopped but intact (docker start $src_container)"
|
||||
|
||||
step "Done"
|
||||
cat <<EOF
|
||||
CodeCraft runs on $host ($bind:$port). Next:
|
||||
- point the reverse proxy (e.g. Nginx Proxy Manager) for your domain to the new host and port,
|
||||
- check the agents' tools in a chat (git credentials, MCP servers, LAN addresses),
|
||||
- when everything works, remove the old container here: docker rm $src_container
|
||||
(the volume $src_volume stays until you delete it: docker volume rm $src_volume).
|
||||
Rollback: on the target 'docker stop $name', here 'docker start $src_container'.
|
||||
EOF
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish a CodeCraft release named vYYYYMM-NNNNN (year, month, then a counter that
|
||||
# starts at 00001 every month):
|
||||
# 1. pick the next number from the existing tags of this month,
|
||||
# 2. rename "## [Unreleased]" in CHANGELOG.md and the "unreleased" entry in
|
||||
# web/src/whatsnew.ts to the version, update the README badge, commit,
|
||||
# 3. tag and push branch and tag,
|
||||
# 4. build the multi-arch image (amd64 + arm64) with the version embedded and push
|
||||
# :vYYYYMM-NNNNN (and :latest),
|
||||
# 5. create the Gitea release with the changelog section as notes.
|
||||
#
|
||||
# Usage: scripts/release.sh [vYYYYMM-NNNNN] [--dry-run] [--no-latest] [--skip-image]
|
||||
# (without a version the next number of the current month is used)
|
||||
#
|
||||
# Environment:
|
||||
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
|
||||
# GITEA_URL Gitea base URL (default https://git.familie-zink.org)
|
||||
# GITEA_REPO owner/repo (default zink/codecraft)
|
||||
# GITEA_TOKEN API token; if empty, the git credential helper is asked
|
||||
# PLATFORMS buildx platforms (default linux/amd64,linux/arm64)
|
||||
# BUILDER buildx builder name (default codecraft-multi, created if missing)
|
||||
#
|
||||
# Requires: git, docker with buildx (logged in to the registry), curl, jq.
|
||||
# Building for a foreign architecture needs QEMU/binfmt:
|
||||
# docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
|
||||
GITEA_URL="${GITEA_URL:-https://git.familie-zink.org}"
|
||||
GITEA_REPO="${GITEA_REPO:-zink/codecraft}"
|
||||
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
|
||||
BUILDER="${BUILDER:-codecraft-multi}"
|
||||
|
||||
tag="" dry=0 latest=1 image=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) dry=1 ;;
|
||||
--no-latest) latest=0 ;;
|
||||
--skip-image) image=0 ;;
|
||||
-h|--help) sed -n '2,26p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option $arg" >&2; exit 2 ;;
|
||||
*) tag="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
run() { if (( dry )); then echo "[dry-run] $*"; else "$@"; fi; }
|
||||
fail() { echo "error: $*" >&2; exit 1; }
|
||||
|
||||
step "Checking the working tree"
|
||||
for cmd in git docker curl jq; do command -v "$cmd" >/dev/null || fail "$cmd is not installed"; done
|
||||
[[ -z "$(git status --porcelain --untracked-files=no)" ]] || fail "uncommitted changes – commit or stash them first"
|
||||
run git fetch --tags --quiet origin
|
||||
|
||||
if [[ -z "$tag" ]]; then
|
||||
month=$(date +%Y%m)
|
||||
last=$(git tag -l "v$month-*" | sed -n "s/^v$month-\([0-9]\{5\}\)$/\1/p" | sort -n | tail -1)
|
||||
tag=$(printf 'v%s-%05d' "$month" $((10#${last:-0} + 1)))
|
||||
fi
|
||||
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
|
||||
git rev-parse -q --verify "refs/tags/$tag" >/dev/null && fail "tag $tag already exists"
|
||||
version=${tag#v}
|
||||
today=$(date +%Y-%m-%d)
|
||||
echo "release $tag"
|
||||
|
||||
# Release notes: the [Unreleased] section of the changelog (without its heading).
|
||||
notes=$(awk '/^## \[Unreleased\]/ { found=1; next } found && /^## \[/ { exit } found { print }' CHANGELOG.md)
|
||||
[[ -n "${notes//[[:space:]]/}" ]] || fail "CHANGELOG.md has no (or an empty) \"## [Unreleased]\" section"
|
||||
notes+=$'\n\n'"Image: \`$REGISTRY:$tag\` ($(tr ',' ' ' <<<"$PLATFORMS"))."
|
||||
|
||||
step "Naming the release in CHANGELOG.md, whatsnew.ts and README.md"
|
||||
if (( dry )); then
|
||||
echo "[dry-run] ## [Unreleased] -> ## [$tag] – $today; whatsnew unreleased -> \"$version\"; README badge"
|
||||
else
|
||||
sed -i "s/^## \[Unreleased\].*/## [$tag] – $today/" CHANGELOG.md
|
||||
grep -q '^ unreleased: {' web/src/whatsnew.ts && sed -i "s/^ unreleased: {/ \"$version\": {/" web/src/whatsnew.ts
|
||||
# shields.io escapes "-" as "--".
|
||||
sed -i -E "s|(alt=\"Version )[^\"]*(\" src=\"https://img.shields.io/badge/version-)[^?]*-([0-9a-f]{6}\?)|\1$tag\2${tag//-/--}-\3|" README.md
|
||||
git add CHANGELOG.md web/src/whatsnew.ts README.md
|
||||
git commit -q -m "Release $tag"
|
||||
fi
|
||||
|
||||
step "Tagging $tag and pushing"
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
run git tag -a "$tag" -m "CodeCraft $tag"
|
||||
run git push origin "$branch" "$tag"
|
||||
|
||||
if (( image )); then
|
||||
step "Building and pushing $REGISTRY:$tag for $PLATFORMS"
|
||||
docker buildx inspect "$BUILDER" >/dev/null 2>&1 || run docker buildx create --name "$BUILDER" --driver docker-container >/dev/null
|
||||
tags=(-t "$REGISTRY:$tag")
|
||||
(( latest )) && tags+=(-t "$REGISTRY:latest")
|
||||
run docker buildx build --builder "$BUILDER" --platform "$PLATFORMS" \
|
||||
--build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" --build-arg CODECRAFT_VERSION="$version" \
|
||||
"${tags[@]}" --push .
|
||||
fi
|
||||
|
||||
step "Creating the Gitea release $tag"
|
||||
token="${GITEA_TOKEN:-}"
|
||||
user=""
|
||||
if [[ -z "$token" ]]; then
|
||||
host=${GITEA_URL#*://}
|
||||
creds=$(printf 'protocol=%s\nhost=%s\n\n' "${GITEA_URL%%://*}" "${host%%/*}" | git credential fill 2>/dev/null || true)
|
||||
user=$(sed -n 's/^username=//p' <<<"$creds")
|
||||
token=$(sed -n 's/^password=//p' <<<"$creds")
|
||||
fi
|
||||
[[ -n "$token" ]] || fail "no Gitea credentials – set GITEA_TOKEN"
|
||||
auth=(-H "Authorization: token $token")
|
||||
[[ -n "$user" ]] && auth=(-u "$user:$token")
|
||||
body=$(jq -n --arg tag "$tag" --arg name "CodeCraft $tag" --arg body "$notes" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||
if (( dry )); then
|
||||
echo "[dry-run] POST $GITEA_URL/api/v1/repos/$GITEA_REPO/releases"
|
||||
else
|
||||
url=$(curl -fsS "${auth[@]}" -H 'Content-Type: application/json' -X POST \
|
||||
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" -d "$body" | jq -r .html_url)
|
||||
echo "published: $url"
|
||||
fi
|
||||
|
||||
step "Done"
|
||||
echo "Update a deployment: set CODECRAFT_VERSION=$tag in .env, then docker compose pull && docker compose up -d"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Quick check of a running CodeCraft: health, version, web UI, terminal client download.
|
||||
#
|
||||
# Usage: scripts/smoke-test.sh [URL] [EXPECTED_VERSION]
|
||||
# URL base URL (default http://127.0.0.1:8787)
|
||||
# EXPECTED_VERSION e.g. 202611-00003 (without "v"); checked when given
|
||||
#
|
||||
# Exit code 0 when everything answers, 1 otherwise.
|
||||
set -uo pipefail
|
||||
|
||||
url="${1:-http://127.0.0.1:8787}"
|
||||
url="${url%/}"
|
||||
want="${2:-}"
|
||||
want="${want#v}"
|
||||
fail=0
|
||||
check() { if [[ "$2" == ok ]]; then printf ' ok %s\n' "$1"; else printf ' FAIL %s (%s)\n' "$1" "$2"; fail=1; fi; }
|
||||
|
||||
echo "smoke test: $url"
|
||||
health=""
|
||||
for _ in $(seq 1 60); do
|
||||
health=$(curl -fsS -m 5 "$url/api/health" 2>/dev/null) && break
|
||||
sleep 2
|
||||
done
|
||||
[[ -n "$health" ]] && check "health" ok || check "health" "no answer after 2 minutes"
|
||||
if [[ -n "$health" ]]; then
|
||||
version=$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' <<<"$health")
|
||||
if [[ -n "$want" && "$version" != "$want" ]]; then check "version $version" "expected $want"; else check "version ${version:-?}" ok; fi
|
||||
grep -q '"ok":true' <<<"$health" && check "server reports ok" ok || check "server reports ok" "$health"
|
||||
fi
|
||||
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/")
|
||||
[[ "$code" == 200 ]] && check "web UI" ok || check "web UI" "HTTP $code"
|
||||
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/cli/install.sh")
|
||||
[[ "$code" == 200 ]] && check "terminal client install script" ok || check "terminal client install script" "HTTP $code"
|
||||
code=$(curl -s -o /dev/null -m 60 -w '%{http_code}' "$url/cli/download/linux-x86_64")
|
||||
[[ "$code" == 200 ]] && check "terminal client download" ok || check "terminal client download" "HTTP $code"
|
||||
exit $fail
|
||||
+2
-2
@@ -28,7 +28,7 @@ shell-words = "1"
|
||||
libc = "0.2"
|
||||
ignore = "0.4"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form", "multipart"] }
|
||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -38,7 +38,7 @@ tokio-tungstenite = "0.29"
|
||||
tokio-util = "0.7"
|
||||
toml = "1"
|
||||
toml_edit = "0.25"
|
||||
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
|
||||
tower-http = { version = "0.7", features = ["fs", "cors", "trace", "compression-gzip", "compression-br"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1", features = ["v4", "v7"] }
|
||||
|
||||
+580
-74
@@ -15,7 +15,7 @@ use axum::middleware;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -69,6 +69,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/providers/reload", post(reload_providers))
|
||||
.route("/models", get(models))
|
||||
.route("/agents", get(agents))
|
||||
.route("/agents/tools", get(agent_tools))
|
||||
.route("/agents/{name}", delete(delete_agent))
|
||||
.route("/commands", get(commands))
|
||||
.route("/sessions", get(list_sessions).post(create_session))
|
||||
@@ -77,7 +78,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/title", post(suggest_title))
|
||||
.route("/sessions/{id}/regenerate", post(regenerate))
|
||||
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
|
||||
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
||||
.route("/sessions/{id}/export", get(export_session))
|
||||
.route("/blobs/{hash}", get(get_blob))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
.route("/sessions/{id}/abort", post(abort))
|
||||
@@ -119,18 +122,25 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/backup", get(backup))
|
||||
.route("/backups", get(list_backups).post(run_backup))
|
||||
.route("/backups/{name}", get(download_backup))
|
||||
.route("/backups/{name}/restore", post(restore_stored_backup))
|
||||
.route("/backups/restore", post(restore_uploaded_backup).layer(axum::extract::DefaultBodyLimit::max(4 * 1024 * 1024 * 1024)))
|
||||
.route("/health", get(health))
|
||||
.route("/tools", get(crate::reverse::status))
|
||||
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
|
||||
.route("/providers/{name}/prices", get(provider_prices))
|
||||
.route("/providers/status", get(provider_status).post(recheck_providers))
|
||||
.route("/providers/test", post(test_provider))
|
||||
.route("/status", get(status_overview))
|
||||
.route("/images", get(list_images).post(create_images))
|
||||
.route("/images/config", get(images_config))
|
||||
.route("/images/{id}", delete(delete_image))
|
||||
.route("/images/{id}/file", get(image_file))
|
||||
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
||||
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
||||
.route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
|
||||
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
||||
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
|
||||
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
|
||||
.route("/templates", get(crate::cli_api::get_templates))
|
||||
.route("/templates/{scope}", put(crate::cli_api::put_templates))
|
||||
.route("/cli/config", get(crate::cli_api::config))
|
||||
@@ -141,7 +151,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/account/tokens/{id}", delete(auth::delete_api_token))
|
||||
.route("/jobs", get(list_jobs).post(register_job))
|
||||
.route("/jobs/create", post(create_job))
|
||||
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
|
||||
.route("/jobs/schedule", get(schedule_preview))
|
||||
.route("/jobs/{id}", get(get_job).patch(update_job).delete(delete_job))
|
||||
.route("/jobs/{id}/assistant", post(job_assistant))
|
||||
.route("/jobs/{id}/run", post(run_job))
|
||||
.route("/jobs/{id}/webhook", post(job_webhook_settings))
|
||||
.route("/terminals", get(list_terminals).post(open_terminal))
|
||||
@@ -163,8 +175,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.find(|p| p.join("index.html").exists())
|
||||
.unwrap_or_else(|| PathBuf::from("./web/dist"))
|
||||
});
|
||||
let index = web_dir.join("index.html");
|
||||
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
|
||||
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(missing_static_file(web_dir.clone()));
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api)
|
||||
@@ -173,9 +184,54 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/cli/install.sh", get(cli_install_script))
|
||||
.route("/cli/download/{target}", get(cli_download))
|
||||
.fallback_service(static_files)
|
||||
.layer(compression())
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
/// Paths that are not a file of the web build. Usually a client-side route → index.html,
|
||||
/// except for:
|
||||
/// - `/sw-<build>.js` of an earlier build: installed apps check their service worker at
|
||||
/// that URL for updates. Answering with index.html (not a script) makes the update
|
||||
/// fail, so the old worker kept serving the old app shell forever – which broke when
|
||||
/// the API changed. They get the kill switch /sw.js instead: it removes the old worker
|
||||
/// and reloads, and the page then registers the current worker.
|
||||
/// - other missing scripts/styles (old chunks): 404 instead of HTML.
|
||||
fn missing_static_file(web_dir: PathBuf) -> axum::routing::MethodRouter {
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
axum::routing::any(move |uri: axum::http::Uri| {
|
||||
let web_dir = web_dir.clone();
|
||||
async move {
|
||||
let path = uri.path();
|
||||
let name = path.trim_start_matches('/');
|
||||
if name.starts_with("sw-") && name.ends_with(".js") && !name.contains('/') {
|
||||
return match tokio::fs::read(web_dir.join("sw.js")).await {
|
||||
Ok(js) => ([(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache, no-store")], js).into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
}
|
||||
if [".js", ".mjs", ".css", ".map"].iter().any(|ext| path.ends_with(ext)) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
match tokio::fs::read(web_dir.join("index.html")).await {
|
||||
Ok(html) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], html).into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// gzip/brotli for JSON, HTML, JS and CSS. Images and SSE streams are left alone (default
|
||||
/// predicate), and so are binary downloads (already compressed or large).
|
||||
fn compression() -> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate + Clone> {
|
||||
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
|
||||
let predicate = DefaultPredicate::new()
|
||||
.and(NotForContentType::const_new("application/octet-stream"))
|
||||
.and(NotForContentType::const_new("application/zip"))
|
||||
.and(NotForContentType::const_new("application/gzip"));
|
||||
tower_http::compression::CompressionLayer::new().compress_when(predicate)
|
||||
}
|
||||
|
||||
fn cli_dir() -> PathBuf {
|
||||
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
|
||||
}
|
||||
@@ -317,7 +373,86 @@ fn scope(app: &App, user: &User, cwd: Option<String>) -> (PathBuf, PathBuf) {
|
||||
|
||||
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||
Json(json!(prompts::agents(&app, &root, &cwd)))
|
||||
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id))))
|
||||
}
|
||||
|
||||
/// Telegram bot of the user: bot name, pairing state, errors (the token stays hidden).
|
||||
async fn get_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let s = crate::telegram::load(&app, &user.id);
|
||||
let (running, error) = crate::telegram::status(&user.id);
|
||||
Ok(Json(json!({
|
||||
"configured": !s.token.is_empty(),
|
||||
"bot": s.bot,
|
||||
"paired": s.chat_id.is_some(),
|
||||
"pair_code": if s.chat_id.is_none() { s.pair_code } else { String::new() },
|
||||
"running": running,
|
||||
"error": error,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TelegramBody {
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Connect a bot: check the token, start polling and create a pairing code.
|
||||
async fn put_telegram(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<TelegramBody>) -> ApiResult {
|
||||
let token = crate::telegram::extract_token(&b.token)
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "that is not a bot token – it looks like 123456789:AAH… (paste only the token from @BotFather)".into()))?;
|
||||
let bot = crate::telegram::check_token(&token).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("token rejected by Telegram: {e:#}")))?;
|
||||
let s = crate::telegram::Settings { token, bot, pair_code: crate::telegram::new_pair_code(), ..Default::default() };
|
||||
app.store.set_user_setting(&user.id, "telegram", &s)?;
|
||||
crate::telegram::restart(&app, &user.id);
|
||||
app.audit(Some(&user), "telegram.connect", &s.bot, "");
|
||||
get_telegram(Extension(user), State(app)).await
|
||||
}
|
||||
|
||||
async fn delete_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
crate::telegram::stop(&user.id);
|
||||
app.store.set_user_setting(&user.id, "telegram", &crate::telegram::Settings::default())?;
|
||||
app.audit(Some(&user), "telegram.disconnect", "", "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// The user's personal modes (name -> settings).
|
||||
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
|
||||
}
|
||||
|
||||
/// Replace the user's personal modes. Names of built-in, admin or project modes are
|
||||
/// taken; tools and read-only work like in admin modes (the user's sandbox still applies).
|
||||
async fn put_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>, Json(modes): Json<std::collections::BTreeMap<String, crate::config::AgentConfig>>) -> ApiResult {
|
||||
if modes.len() > 30 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "too many personal modes (max 30)".into()));
|
||||
}
|
||||
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||
let taken: Vec<String> = prompts::agents(&app, &root, &root).into_iter().map(|a| a.name).collect();
|
||||
let mut clean = std::collections::BTreeMap::new();
|
||||
for (name, mut m) in modes {
|
||||
if name.is_empty() || name.len() > 40 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid mode name '{name}' (letters, digits, - and _)")));
|
||||
}
|
||||
if taken.contains(&name) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("the name '{name}' is already used by another mode")));
|
||||
}
|
||||
if m.prompt.as_deref().is_some_and(|p| p.len() > 20_000) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "prompt too long (max 20,000 characters)".into()));
|
||||
}
|
||||
m.mode = Some("primary".into());
|
||||
m.max_parallel = None;
|
||||
clean.insert(name, m);
|
||||
}
|
||||
app.store.set_user_setting(&user.id, "modes", &clean)?;
|
||||
Ok(Json(json!(clean)))
|
||||
}
|
||||
|
||||
/// Built-in tools for the mode editors (name, read-only, first line of the description).
|
||||
async fn agent_tools() -> ApiResult {
|
||||
let list: Vec<Value> = crate::tools::builtin_tools()
|
||||
.iter()
|
||||
.map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()}))
|
||||
.collect();
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
||||
@@ -487,6 +622,9 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
pinned: false,
|
||||
archived: false,
|
||||
folder: None,
|
||||
instructions: String::new(),
|
||||
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
||||
telegram_quiet: true,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
Ok(Json(json!(s)))
|
||||
@@ -656,7 +794,8 @@ struct ExportQ {
|
||||
/// Download a session as Markdown (default) or JSON.
|
||||
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
|
||||
let s = readable(&app, &user, &id)?;
|
||||
let messages = app.store.messages(&id)?;
|
||||
// JSON exports are self-contained: images inline instead of store references.
|
||||
let messages = if q.format == "json" { app.store.messages_for_model(&id)? } else { app.store.messages(&id)? };
|
||||
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
|
||||
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
|
||||
let (body, mime, ext) = if q.format == "json" {
|
||||
@@ -695,50 +834,97 @@ struct UpdateSession {
|
||||
archived: Option<bool>,
|
||||
/// "" removes the folder.
|
||||
folder: Option<String>,
|
||||
/// Instructions for this chat ("" clears them).
|
||||
instructions: Option<String>,
|
||||
/// Approvals and questions of this chat are not sent to Telegram.
|
||||
telegram_quiet: Option<bool>,
|
||||
}
|
||||
|
||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||
let mut s = writable(&app, &user, &id)?;
|
||||
writable(&app, &user, &id)?;
|
||||
// Organising the list must not move the session to "today".
|
||||
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none();
|
||||
if let Some(t) = body.title {
|
||||
s.title = t;
|
||||
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none() && body.instructions.is_none();
|
||||
if body.instructions.as_ref().is_some_and(|i| i.len() > 20_000) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "instructions too long (max 20,000 characters)".into()));
|
||||
}
|
||||
if let Some(m) = body.model {
|
||||
app.registry().resolve(&m)?;
|
||||
s.model = m;
|
||||
if let Some(m) = &body.model {
|
||||
app.registry().resolve(m)?;
|
||||
}
|
||||
if let Some(a) = body.agent {
|
||||
s.agent = a;
|
||||
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
}
|
||||
if let Some(c) = body.clean_room {
|
||||
s.clean_room = Some(c);
|
||||
// Atomic: a running turn updates the same session (usage, title, todos) meanwhile.
|
||||
let s = app.modify_session(&id, !organise_only, |s| {
|
||||
if let Some(t) = body.title {
|
||||
s.title = t;
|
||||
}
|
||||
if let Some(m) = body.model {
|
||||
s.model = m;
|
||||
}
|
||||
if let Some(a) = body.agent {
|
||||
s.agent = a;
|
||||
}
|
||||
if let Some(c) = body.clean_room {
|
||||
s.clean_room = Some(c);
|
||||
}
|
||||
if let Some(p) = body.pinned {
|
||||
s.pinned = p;
|
||||
}
|
||||
if let Some(a) = body.archived {
|
||||
s.archived = a;
|
||||
if a {
|
||||
s.pinned = false;
|
||||
}
|
||||
}
|
||||
if let Some(f) = body.folder {
|
||||
let f = f.trim().chars().take(60).collect::<String>();
|
||||
s.folder = (!f.is_empty()).then_some(f);
|
||||
}
|
||||
if let Some(p) = body.permission_mode {
|
||||
s.permission_mode = p;
|
||||
}
|
||||
if let Some(i) = body.instructions {
|
||||
s.instructions = i.trim().to_string();
|
||||
}
|
||||
if let Some(q) = body.telegram_quiet {
|
||||
s.telegram_quiet = q;
|
||||
}
|
||||
})?;
|
||||
Ok(Json(json!(s)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct FileTab {
|
||||
id: String,
|
||||
dir: String,
|
||||
}
|
||||
|
||||
fn file_tabs_key(session_id: &str) -> String {
|
||||
format!("file_tabs:{session_id}")
|
||||
}
|
||||
|
||||
/// Tabs of a chat for this user: its terminals and file managers (kept on the server,
|
||||
/// so they are the same on every device).
|
||||
async fn session_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let files: Vec<FileTab> = app.store.user_setting(&user.id, &file_tabs_key(&id))?;
|
||||
Ok(Json(json!({"terminals": app.terminals.for_session(&id, &user.id), "files": files})))
|
||||
}
|
||||
|
||||
async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(tabs): Json<Vec<FileTab>>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
if tabs.len() > 20 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "too many file manager tabs".into()));
|
||||
}
|
||||
if let Some(p) = body.pinned {
|
||||
s.pinned = p;
|
||||
}
|
||||
if let Some(a) = body.archived {
|
||||
s.archived = a;
|
||||
if a {
|
||||
s.pinned = false;
|
||||
let roots = app.user_roots(Some(&user.id));
|
||||
for t in &tabs {
|
||||
let p = crate::tools::normalize(&PathBuf::from(&t.dir));
|
||||
if t.id.len() > 64 || !roots.iter().any(|r| p.starts_with(r)) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "folder is outside your folders".into()));
|
||||
}
|
||||
}
|
||||
if let Some(f) = body.folder {
|
||||
let f = f.trim().chars().take(60).collect::<String>();
|
||||
s.folder = (!f.is_empty()).then_some(f);
|
||||
}
|
||||
if organise_only {
|
||||
app.store.save_session(&s)?;
|
||||
app.emit(Event::SessionUpdated { session: s.clone() });
|
||||
return Ok(Json(json!(s)));
|
||||
}
|
||||
if let Some(p) = body.permission_mode {
|
||||
if !crate::permission::MODES.contains(&p.as_str()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
}
|
||||
s.permission_mode = p;
|
||||
}
|
||||
Ok(Json(json!(app.save_session(s)?)))
|
||||
app.store.set_user_setting(&user.id, &file_tabs_key(&id), &tabs)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
@@ -747,10 +933,35 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
||||
runner::abort(&app, &id);
|
||||
crate::shells::kill_session(&app, &id);
|
||||
app.terminals.kill_session(&id);
|
||||
app.store.delete_session(&id)?;
|
||||
// Images only this session used.
|
||||
let a = app.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e) = a.store.gc_blobs() {
|
||||
tracing::warn!("image cleanup: {e:#}");
|
||||
}
|
||||
});
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
|
||||
/// so it never changes and the browser may keep it.
|
||||
async fn get_blob(Extension(_user): Extension<User>, State(app): State<AppState>, Path(hash): Path<String>) -> Result<axum::response::Response, ApiError> {
|
||||
if !crate::blobs::is_hash(&hash) {
|
||||
return Err(not_found("image"));
|
||||
}
|
||||
let (mime, bytes) = app.store.blob(&hash)?.ok_or_else(|| not_found("image"))?;
|
||||
Ok((
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, mime),
|
||||
(axum::http::header::CACHE_CONTROL, "private, max-age=31536000, immutable".to_string()),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PromptBody {
|
||||
text: String,
|
||||
@@ -775,41 +986,48 @@ fn set_turn_effort(app: &App, id: &str, effort: Option<&str>) -> Result<(), ApiE
|
||||
|
||||
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
||||
let session = writable(&app, &user, &id)?;
|
||||
let text = body.text.trim().to_string();
|
||||
send_prompt(&app, session, body.text, body.images, body.effort.as_deref()).map(Json)
|
||||
}
|
||||
|
||||
/// Handle a user message for a session (web UI and Telegram): built-in commands,
|
||||
/// slash command expansion, then start or queue the turn.
|
||||
pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text: String, images: Vec<ImageData>, effort: Option<&str>) -> Result<Value, ApiError> {
|
||||
let id = session.id.clone();
|
||||
let text = text.trim().to_string();
|
||||
// Built-in commands.
|
||||
if text == "/compact" {
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
tokio::spawn(do_compact(app.clone(), id.clone()));
|
||||
return Ok(Json(json!({"ok": true, "command": "compact"})));
|
||||
return Ok(json!({"ok": true, "command": "compact"}));
|
||||
}
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let mut prompt_text = text.clone();
|
||||
if text.starts_with('/') {
|
||||
if let Some((expanded, agent, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) {
|
||||
if let Some((expanded, agent, clean_room)) = prompts::expand_command(app, &root, &cwd, &text) {
|
||||
prompt_text = expanded;
|
||||
if agent.is_some() || clean_room.is_some() {
|
||||
let mut s = session.clone();
|
||||
if let Some(a) = agent {
|
||||
s.agent = a;
|
||||
}
|
||||
if clean_room.is_some() {
|
||||
s.clean_room = clean_room;
|
||||
}
|
||||
app.save_session(s)?;
|
||||
app.update_session(&id, |s| {
|
||||
if let Some(a) = agent {
|
||||
s.agent = a;
|
||||
}
|
||||
if clean_room.is_some() {
|
||||
s.clean_room = clean_room;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
set_turn_effort(&app, &id, body.effort.as_deref())?;
|
||||
set_turn_effort(app, &id, effort)?;
|
||||
let mut parts = vec![Part::Text { text: prompt_text }];
|
||||
for image in body.images {
|
||||
for image in images {
|
||||
parts.push(Part::Image { image });
|
||||
}
|
||||
match runner::submit(app.clone(), id, parts, "user")? {
|
||||
runner::Submitted::Started => Ok(Json(json!({"ok": true}))),
|
||||
runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))),
|
||||
runner::Submitted::Started => Ok(json!({"ok": true})),
|
||||
runner::Submitted::Queued(item) => Ok(json!({"ok": true, "queued": item})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,11 +1051,11 @@ async fn flush_queue(Extension(user): Extension<User>, State(app): State<AppStat
|
||||
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
|
||||
}
|
||||
|
||||
async fn do_compact(app: AppState, id: String) {
|
||||
pub(crate) async fn do_compact(app: AppState, id: String) {
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
|
||||
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
|
||||
if let Err(e) = runner::compact(app.clone(), &id, cancel).await {
|
||||
if let Err(e) = runner::compact(app.clone(), &id, cancel, None).await {
|
||||
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
|
||||
}
|
||||
app.with_runtime(&id, |r| r.cancel = None);
|
||||
@@ -1116,6 +1334,33 @@ async fn run_backup(Extension(user): Extension<User>, State(app): State<AppState
|
||||
Ok(Json(json!(res)))
|
||||
}
|
||||
|
||||
/// Stage a restore and restart: the database is replaced before it is opened again.
|
||||
/// In Docker the restart policy brings the server back up.
|
||||
async fn restore_and_restart(app: &App, user: &User, bytes: Vec<u8>, source: &str) -> ApiResult {
|
||||
crate::backup::stage_restore(app, bytes).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.audit(Some(user), "backup.restore", source, "");
|
||||
tracing::warn!(source, "backup staged for restore – restarting");
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
std::process::exit(0);
|
||||
});
|
||||
Ok(Json(json!({"ok": true, "restart": true})))
|
||||
}
|
||||
|
||||
async fn restore_stored_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "no such backup".into()));
|
||||
}
|
||||
let bytes = tokio::fs::read(crate::backup::backup_dir(&app).join(&name)).await?;
|
||||
restore_and_restart(&app, &user, bytes, &name).await
|
||||
}
|
||||
|
||||
async fn restore_uploaded_backup(Extension(user): Extension<User>, State(app): State<AppState>, body: axum::body::Bytes) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
restore_and_restart(&app, &user, body.to_vec(), &format!("upload ({} bytes)", body.len())).await
|
||||
}
|
||||
|
||||
async fn download_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> Result<Response, ApiError> {
|
||||
require_admin(&user)?;
|
||||
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
|
||||
@@ -1138,10 +1383,91 @@ async fn health(State(app): State<AppState>) -> Json<Value> {
|
||||
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
|
||||
}
|
||||
|
||||
/// Status overlay: every enabled provider and MCP server with its state. Error texts can
|
||||
/// contain URLs or hosts, so only admins get them.
|
||||
async fn status_overview(Extension(user): Extension<User>, State(app): State<AppState>) -> Json<Value> {
|
||||
let admin = user.is_admin();
|
||||
let cfg = app.config();
|
||||
let registry = app.registry();
|
||||
let health = app.health.all();
|
||||
let providers: serde_json::Map<String, Value> = cfg
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|(_, p)| p.enabled)
|
||||
.map(|(name, _)| {
|
||||
let models = registry.providers.get(name).map(|e| e.models.len()).unwrap_or(0);
|
||||
let v = match health.get(name) {
|
||||
Some(h) => json!({
|
||||
"state": h.state,
|
||||
"latency_ms": h.latency_ms,
|
||||
"checked_at": h.checked_at,
|
||||
"error": if admin { h.error.clone() } else { None },
|
||||
"models": models,
|
||||
}),
|
||||
None => json!({"state": "unknown", "models": models}),
|
||||
};
|
||||
let mut v = v;
|
||||
if let Some(limit) = cfg.providers.get(name).and_then(|p| p.plan_limit).filter(|l| *l > 0.0) {
|
||||
v["plan"] = crate::budget::plan_usage(&app, name, limit).unwrap_or(Value::Null);
|
||||
}
|
||||
(name.clone(), v)
|
||||
})
|
||||
.collect();
|
||||
let mcp: serde_json::Map<String, Value> = app
|
||||
.mcp
|
||||
.states()
|
||||
.into_iter()
|
||||
.filter(|(_, st)| !matches!(st, crate::mcp::McpState::Disabled))
|
||||
.map(|(name, st)| {
|
||||
let v = match st {
|
||||
crate::mcp::McpState::Connected { tools } => json!({"state": "connected", "tools": tools}),
|
||||
crate::mcp::McpState::Failed { error } => json!({"state": "failed", "error": if admin { Some(error) } else { None }}),
|
||||
_ => json!({"state": "connecting"}),
|
||||
};
|
||||
(name, v)
|
||||
})
|
||||
.collect();
|
||||
// Budgets and plan windows (admins: all; plan windows are shown per provider anyway).
|
||||
let budgets = if admin { crate::budget::meters(&app).unwrap_or_default() } else { vec![] };
|
||||
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp, "budgets": budgets, "currency": cfg.server.currency}))
|
||||
}
|
||||
|
||||
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
|
||||
Json(json!(app.health.all()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TestProviderBody {
|
||||
/// Existing provider: its stored key is used when `api_key` is empty.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
kind: String,
|
||||
base_url: String,
|
||||
#[serde(default)]
|
||||
api_key: String,
|
||||
#[serde(default)]
|
||||
auth_prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Check URL and key from the edit form without saving them.
|
||||
async fn test_provider(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<TestProviderBody>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
let stored = body.name.as_ref().and_then(|n| app.config().providers.get(n).cloned());
|
||||
let mut value = stored.as_ref().map(|p| serde_json::to_value(p)).transpose()?.unwrap_or_else(|| json!({}));
|
||||
value["kind"] = json!(body.kind);
|
||||
value["base_url"] = json!(body.base_url);
|
||||
if !body.api_key.is_empty() {
|
||||
value["api_key"] = json!(body.api_key);
|
||||
}
|
||||
if let Some(prefix) = body.auth_prefix {
|
||||
value["auth_prefix"] = if prefix.is_empty() { Value::Null } else { json!(prefix) };
|
||||
}
|
||||
let pc: crate::config::ProviderConfig = serde_json::from_value(value).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("invalid provider: {e}")))?;
|
||||
let provider = crate::provider::build(crate::provider::http_client(), &pc);
|
||||
let (status, models) = crate::health::probe(provider).await;
|
||||
Ok(Json(json!({"status": status, "models": models})))
|
||||
}
|
||||
|
||||
async fn recheck_providers(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
crate::health::check_all(&app).await;
|
||||
@@ -1220,6 +1546,7 @@ async fn create_images(Extension(user): Extension<User>, State(app): State<AppSt
|
||||
dir,
|
||||
user_id: Some(user.id.clone()),
|
||||
session_id: None,
|
||||
references: vec![],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -1236,13 +1563,26 @@ fn own_image(app: &App, user: &User, id: &str) -> Result<crate::store::ImageReco
|
||||
|
||||
async fn image_file(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> Result<Response, ApiError> {
|
||||
let img = own_image(&app, &user, &id)?;
|
||||
let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?;
|
||||
let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) {
|
||||
let ctype_of = |p: &str| match std::path::Path::new(p).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
Ok(([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400")], bytes).into_response())
|
||||
let respond = |ctype: String, bytes: Vec<u8>| ([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400".to_string())], bytes).into_response();
|
||||
if let Ok(bytes) = tokio::fs::read(&img.path).await {
|
||||
return Ok(respond(ctype_of(&img.path).into(), bytes));
|
||||
}
|
||||
// The file was moved or renamed (agents tidy up folders): with a copy in the image store,
|
||||
// answer from there and look for the file in the background; otherwise look now.
|
||||
if let Some((mime, bytes)) = img.blob.as_deref().map(|h| app.store.blob(h)).transpose()?.flatten() {
|
||||
let a = app.clone();
|
||||
tokio::task::spawn_blocking(move || crate::images::relocate(&a, &img));
|
||||
return Ok(respond(mime, bytes));
|
||||
}
|
||||
let a = app.clone();
|
||||
let found = tokio::task::spawn_blocking(move || crate::images::relocate(&a, &img)).await.ok().flatten().ok_or_else(|| not_found("image file"))?;
|
||||
let bytes = tokio::fs::read(&found).await.map_err(|_| not_found("image file"))?;
|
||||
Ok(respond(ctype_of(&found.display().to_string()).into(), bytes))
|
||||
}
|
||||
|
||||
async fn delete_image(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
@@ -1297,15 +1637,24 @@ async fn list_jobs(Extension(user): Extension<User>, State(app): State<AppState>
|
||||
Ok(Json(json!(jobs)))
|
||||
}
|
||||
|
||||
async fn get_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterJob {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Register an existing Markdown file (older files: settings from their frontmatter).
|
||||
async fn register_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<RegisterJob>) -> ApiResult {
|
||||
let p = crate::files::checked_path(&app, &user, &b.path, true)?;
|
||||
crate::jobs::parse(&p).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
if !p.is_file() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("{} is not a file", p.display())));
|
||||
}
|
||||
let job = app.store.job_register(&user.id, &p.display().to_string())?;
|
||||
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.audit(Some(&user), "job.register", &job.path, "");
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
@@ -1314,22 +1663,38 @@ async fn register_job(Extension(user): Extension<User>, State(app): State<AppSta
|
||||
struct CreateJob {
|
||||
/// Project folder; the file goes to <cwd>/.codecraft/jobs/<name>.md.
|
||||
cwd: String,
|
||||
#[serde(flatten)]
|
||||
job: crate::jobs::NewJob,
|
||||
name: String,
|
||||
/// Initial content of the instructions file (example or template).
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
schedule: Option<String>,
|
||||
#[serde(default)]
|
||||
agent: Option<String>,
|
||||
#[serde(default)]
|
||||
permission_mode: Option<String>,
|
||||
/// Default false: the user switches it on once prompt and schedule are ready.
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
async fn create_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<CreateJob>) -> ApiResult {
|
||||
let dir = crate::files::checked_path(&app, &user, &b.cwd, true)?;
|
||||
if b.job.name.trim().is_empty() || b.job.prompt.trim().is_empty() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "name and prompt are required".into()));
|
||||
if b.prompt.trim().is_empty() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "the instructions are empty".into()));
|
||||
}
|
||||
if !crate::permission::MODES.contains(&b.job.permission_mode.as_str()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
let mut settings = crate::jobs::JobSettings::new(&b.name);
|
||||
if let Some(x) = b.schedule {
|
||||
settings.schedule = x;
|
||||
}
|
||||
if let Some(x) = b.agent {
|
||||
settings.agent = x;
|
||||
}
|
||||
if let Some(x) = b.permission_mode {
|
||||
settings.permission_mode = x;
|
||||
}
|
||||
// Inside the checked folder: the file name is a slug of the name.
|
||||
let path = crate::jobs::new_job_path(&dir, &b.job.name);
|
||||
crate::jobs::write_file(&path, &b.job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
let job = app.store.job_register(&user.id, &path.display().to_string())?;
|
||||
let path = crate::jobs::new_job_path(&dir, &b.name);
|
||||
let job = crate::jobs::create(&app.store, &user.id, &path, settings, &b.prompt, b.enabled).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.audit(Some(&user), "job.create", &job.path, "");
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
@@ -1337,10 +1702,46 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateJob {
|
||||
enabled: Option<bool>,
|
||||
name: Option<String>,
|
||||
schedule: Option<String>,
|
||||
agent: Option<String>,
|
||||
/// "" = the server's default model.
|
||||
model: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
timeout_minutes: Option<u64>,
|
||||
}
|
||||
|
||||
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some();
|
||||
if changes {
|
||||
let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
|
||||
if let Some(x) = b.name {
|
||||
st.name = x;
|
||||
}
|
||||
if let Some(x) = b.schedule {
|
||||
st.schedule = x;
|
||||
}
|
||||
if let Some(x) = b.agent {
|
||||
st.agent = x;
|
||||
}
|
||||
if let Some(x) = b.model {
|
||||
if !x.is_empty() {
|
||||
app.registry().resolve(&x)?;
|
||||
}
|
||||
st.model = Some(x);
|
||||
}
|
||||
if let Some(x) = b.permission_mode {
|
||||
st.permission_mode = x;
|
||||
}
|
||||
if let Some(x) = b.timeout_minutes {
|
||||
st.timeout_minutes = x;
|
||||
}
|
||||
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.store.job_set_settings(&job.id, &st)?;
|
||||
app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
|
||||
}
|
||||
if let Some(e) = b.enabled {
|
||||
app.store.job_set_enabled(&job.id, e)?;
|
||||
}
|
||||
@@ -1348,6 +1749,96 @@ async fn update_job(Extension(user): Extension<User>, State(app): State<AppState
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
|
||||
/// Chat in which the user works out the instructions with a model (created once, reused
|
||||
/// while it exists). The model edits the job file directly.
|
||||
async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
let mut st = job.settings.clone().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "automation without settings".into()))?;
|
||||
if let Some(sid) = st.assistant_session.as_deref() {
|
||||
if app.store.get_session(sid)?.is_some_and(|s| s.owner_id.as_deref() == Some(user.id.as_str())) {
|
||||
return Ok(Json(json!({"session_id": sid})));
|
||||
}
|
||||
}
|
||||
let path = std::path::Path::new(&job.path);
|
||||
let cwd = crate::jobs::cwd_of(&job.path, st.cwd.as_deref());
|
||||
let cfg = app.config();
|
||||
let model = st
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
|
||||
.or_else(|| app.registry().first_model())
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
|
||||
let now = now_ms();
|
||||
let instructions = format!(
|
||||
"You help the user write the instructions of the automation \"{name}\". They live in the Markdown file {file} \
|
||||
and are executed unattended by an agent (mode {agent}) on a schedule the user sets in the UI. That run starts a \
|
||||
fresh session and sees only this file – no conversation, nobody to ask.\n\
|
||||
- Work on that file directly with your edit tools and keep it up to date after every round; the user sees it next to this chat.\n\
|
||||
- Find out what the automation should achieve, then shape the file into clear, self-contained instructions: \
|
||||
goal, steps, expected result (e.g. a short report), limits (what must not be done).\n\
|
||||
- Write only the instructions: no frontmatter, no schedule or settings in the file.\n\
|
||||
- Do not carry out the automation's task yourself and do not create other files. Use the language the user writes in.",
|
||||
name = st.name,
|
||||
file = path.display(),
|
||||
agent = st.agent,
|
||||
);
|
||||
let s = Session {
|
||||
id: new_id(),
|
||||
title: format!("{} – {}", st.name, if crate::telegram::german(&app, &user.id) { "Anweisungen" } else { "instructions" }),
|
||||
cwd: cwd.display().to_string(),
|
||||
model,
|
||||
agent: "build".into(),
|
||||
permission_mode: "auto".into(),
|
||||
parent_id: None,
|
||||
owner_id: Some(user.id.clone()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
todos: Value::Null,
|
||||
last_usage: None,
|
||||
clean_room: None,
|
||||
phase: None,
|
||||
worktree: None,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
folder: None,
|
||||
instructions,
|
||||
telegram_quiet: true,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
st.assistant_session = Some(s.id.clone());
|
||||
app.store.job_set_settings(&job.id, &st)?;
|
||||
Ok(Json(json!({"session_id": s.id})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScheduleQ {
|
||||
expr: String,
|
||||
}
|
||||
|
||||
/// Check a cron expression and list its next runs (for the schedule editor).
|
||||
async fn schedule_preview(Query(q): Query<ScheduleQ>) -> ApiResult {
|
||||
let expr = q.expr.trim();
|
||||
if let Err(e) = crate::jobs::check_schedule(expr) {
|
||||
return Ok(Json(json!({"ok": false, "error": format!("{e:#}")})));
|
||||
}
|
||||
let mut next = Vec::new();
|
||||
let mut t = now_ms();
|
||||
for _ in 0..5 {
|
||||
match crate::jobs::next_run(expr, t) {
|
||||
Some(n) => {
|
||||
next.push(n);
|
||||
t = n + 1000;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
// Wall-clock times of the server: cron works in its time zone, the browser may be elsewhere.
|
||||
let local: Vec<String> = next.iter().filter_map(|n| chrono::DateTime::from_timestamp_millis(*n)).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%dT%H:%M").to_string()).collect();
|
||||
let tz = std::env::var("TZ").ok().filter(|z| !z.is_empty()).unwrap_or_else(|| chrono::Local::now().format("UTC%:z").to_string());
|
||||
Ok(Json(json!({"ok": true, "next": next, "next_local": local, "tz": tz})))
|
||||
}
|
||||
|
||||
async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
app.store.job_delete(&job.id)?;
|
||||
@@ -1433,20 +1924,35 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
|
||||
|
||||
// --- web terminal ------------------------------------------------------------------
|
||||
|
||||
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!(app.terminals.list(&user.id))))
|
||||
#[derive(Deserialize)]
|
||||
struct TerminalQuery {
|
||||
session: Option<String>,
|
||||
}
|
||||
|
||||
/// All terminals of the user, or the tabs of one chat (?session=…).
|
||||
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<TerminalQuery>) -> ApiResult {
|
||||
Ok(Json(json!(match q.session {
|
||||
Some(s) => app.terminals.for_session(&s, &user.id),
|
||||
None => app.terminals.list(&user.id),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenTerminal {
|
||||
cwd: Option<String>,
|
||||
/// Open as a tab of this chat (in its folder unless `cwd` is given).
|
||||
session_id: Option<String>,
|
||||
cols: Option<u16>,
|
||||
rows: Option<u16>,
|
||||
}
|
||||
|
||||
async fn open_terminal(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<OpenTerminal>) -> ApiResult {
|
||||
let cwd = validate_cwd(&app, &user, b.cwd)?;
|
||||
let term = crate::terminal::spawn(&app, &user, &cwd, b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
let session = match &b.session_id {
|
||||
Some(id) => Some(writable(&app, &user, id)?),
|
||||
None => None,
|
||||
};
|
||||
let cwd = validate_cwd(&app, &user, b.cwd.or_else(|| session.as_ref().map(|s| s.cwd.clone())))?;
|
||||
let term = crate::terminal::spawn(&app, &user, &cwd, session.map(|s| s.id), b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.audit(Some(&user), "terminal.open", cwd.display().to_string(), &crate::auth::client_ip(&headers));
|
||||
Ok(Json(term.info()))
|
||||
}
|
||||
|
||||
+57
-3
@@ -46,6 +46,8 @@ pub struct Runtime {
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
/// Serializes read-modify-write of sessions (see `update_session`).
|
||||
session_write: Mutex<()>,
|
||||
config_path: PathBuf,
|
||||
config: RwLock<Arc<Config>>,
|
||||
registry: RwLock<Arc<Registry>>,
|
||||
@@ -101,6 +103,7 @@ impl App {
|
||||
let registry = Registry::build(&cfg).await;
|
||||
let (events, _) = broadcast::channel(4096);
|
||||
let app = Arc::new(Self {
|
||||
session_write: Mutex::new(()),
|
||||
config_path,
|
||||
config: RwLock::new(Arc::new(cfg)),
|
||||
registry: RwLock::new(Arc::new(registry)),
|
||||
@@ -128,9 +131,37 @@ impl App {
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
app.clone().start_image_migration();
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// Move inline images of older chats to the image store, drop unused images and
|
||||
/// shrink the database file (in the background; chats load meanwhile as before).
|
||||
fn start_image_migration(self: Arc<Self>) {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
match self.store.migrate_inline_images() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
||||
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
||||
}
|
||||
match crate::images::backfill_copies(&self) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
||||
Err(e) => tracing::warn!("copies of generated images: {e:#}"),
|
||||
}
|
||||
match self.store.gc_blobs() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("removed {n} unused images"),
|
||||
Err(e) => tracing::warn!("image cleanup: {e:#}"),
|
||||
}
|
||||
match self.store.vacuum_if_worthwhile() {
|
||||
Ok(true) => tracing::info!("database compacted"),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("database compaction: {e:#}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn start_mcp(self: Arc<Self>) {
|
||||
let servers = self.config().mcp.clone();
|
||||
let mcp = self.mcp.clone();
|
||||
@@ -247,11 +278,16 @@ impl App {
|
||||
std::fs::rename(&tmp, &self.config_path)?;
|
||||
let registry = Registry::build(&cfg).await;
|
||||
let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?;
|
||||
let providers_changed = serde_json::to_value(&cfg.providers)? != serde_json::to_value(&self.config().providers)?;
|
||||
*self.config.write().unwrap() = Arc::new(cfg);
|
||||
*self.registry.write().unwrap() = Arc::new(registry);
|
||||
if mcp_changed {
|
||||
self.clone().start_mcp();
|
||||
}
|
||||
// A new key or URL should show its status right away, not at the next 5-minute check.
|
||||
if providers_changed {
|
||||
crate::health::check_all(self).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -263,6 +299,7 @@ impl App {
|
||||
pub async fn reload_providers(&self) {
|
||||
let registry = Registry::build(&self.config()).await;
|
||||
*self.registry.write().unwrap() = Arc::new(registry);
|
||||
crate::health::check_all(self).await;
|
||||
}
|
||||
|
||||
// --- events ---------------------------------------------------------
|
||||
@@ -320,10 +357,27 @@ impl App {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Load, change and save a session atomically: background tasks (title, usage, phase,
|
||||
/// todos) change different fields at the same time and must not undo each other.
|
||||
pub fn update_session(&self, id: &str, f: impl FnOnce(&mut Session)) -> Result<Session> {
|
||||
self.modify_session(id, true, f)
|
||||
}
|
||||
|
||||
/// Like `update_session`; `touch = false` keeps `updated_at` (organising the list).
|
||||
pub fn modify_session(&self, id: &str, touch: bool, f: impl FnOnce(&mut Session)) -> Result<Session> {
|
||||
let _guard = self.session_write.lock().unwrap();
|
||||
let mut s = self.session(id)?;
|
||||
f(&mut s);
|
||||
if touch {
|
||||
return self.save_session(s);
|
||||
}
|
||||
self.store.save_session(&s)?;
|
||||
self.emit(Event::SessionUpdated { session: s.clone() });
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> {
|
||||
let mut s = self.session(session_id)?;
|
||||
s.todos = todos;
|
||||
self.save_session(s)?;
|
||||
self.update_session(session_id, |s| s.todos = todos)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+63
-1
@@ -56,6 +56,56 @@ pub async fn create_zip(app: &App) -> Result<Vec<u8>> {
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Directory a restore is staged in; applied at the next start (see `apply_pending_restore`).
|
||||
fn pending_dir(data_dir: &std::path::Path) -> PathBuf {
|
||||
data_dir.join("restore-pending")
|
||||
}
|
||||
|
||||
/// Read config.toml and codecraft.db from a backup zip and check both.
|
||||
fn unpack(bytes: &[u8]) -> Result<(String, Vec<u8>)> {
|
||||
use std::io::Read;
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).context("not a zip file")?;
|
||||
let mut config = String::new();
|
||||
z.by_name("config.toml").context("config.toml missing in the backup")?.read_to_string(&mut config)?;
|
||||
let mut db = Vec::new();
|
||||
z.by_name("codecraft.db").context("codecraft.db missing in the backup")?.read_to_end(&mut db)?;
|
||||
if !db.starts_with(b"SQLite format 3\0") {
|
||||
bail!("codecraft.db in the backup is not an SQLite database");
|
||||
}
|
||||
crate::config::Config::parse(&config).context("config.toml in the backup is invalid")?;
|
||||
Ok((config, db))
|
||||
}
|
||||
|
||||
/// Check a backup, back up the current state, and stage the backup for the next start.
|
||||
pub async fn stage_restore(app: &App, bytes: Vec<u8>) -> Result<()> {
|
||||
let (config, db) = tokio::task::spawn_blocking(move || unpack(&bytes)).await??;
|
||||
// Safety net: the current state as a normal backup file.
|
||||
run_named(app, "-before-restore").await.context("could not back up the current state first")?;
|
||||
let dir = pending_dir(&app.config().data_dir());
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
tokio::fs::write(dir.join("codecraft.db"), db).await?;
|
||||
tokio::fs::write(dir.join("config.toml"), config).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called at startup before the database is opened: move a staged restore into place.
|
||||
pub fn apply_pending_restore(data_dir: &std::path::Path, config_path: &std::path::Path) -> Result<bool> {
|
||||
let dir = pending_dir(data_dir);
|
||||
let (db, cfg) = (dir.join("codecraft.db"), dir.join("config.toml"));
|
||||
if !db.is_file() || !cfg.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let target = data_dir.join("codecraft.db");
|
||||
for ext in ["db-wal", "db-shm"] {
|
||||
let _ = std::fs::remove_file(target.with_extension(ext));
|
||||
}
|
||||
std::fs::rename(&db, &target)?;
|
||||
std::fs::copy(&cfg, config_path)?;
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn backup_dir(app: &App) -> PathBuf {
|
||||
let cfg = app.config();
|
||||
if cfg.backup.dir.is_empty() { cfg.data_dir().join("backups") } else { PathBuf::from(expand_env(&cfg.backup.dir)) }
|
||||
@@ -89,12 +139,24 @@ pub fn list(app: &App) -> Vec<BackupFile> {
|
||||
|
||||
/// Create a backup now: local file (+ pruning) and S3 upload if configured.
|
||||
pub async fn run(app: &App) -> Result<LastRun> {
|
||||
run_named(app, "").await
|
||||
}
|
||||
|
||||
/// Like `run`, with a suffix in the file name (e.g. "-before-restore").
|
||||
pub async fn run_named(app: &App, suffix: &str) -> Result<LastRun> {
|
||||
let _guard = app.backup.running.lock().await;
|
||||
let cfg = app.config().backup.clone();
|
||||
let dir = backup_dir(app);
|
||||
tokio::fs::create_dir_all(&dir).await.with_context(|| format!("create {}", dir.display()))?;
|
||||
let bytes = create_zip(app).await?;
|
||||
let name = format!("codecraft-backup-{}.zip", Local::now().format("%Y%m%d-%H%M%S"));
|
||||
// Names have second resolution: never overwrite a backup made in the same second.
|
||||
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let mut name = format!("codecraft-backup-{stamp}{suffix}.zip");
|
||||
let mut n = 1;
|
||||
while dir.join(&name).exists() {
|
||||
n += 1;
|
||||
name = format!("codecraft-backup-{stamp}{suffix}-{n}.zip");
|
||||
}
|
||||
let path = dir.join(&name);
|
||||
tokio::fs::write(&path, &bytes).await.with_context(|| format!("write {}", path.display()))?;
|
||||
// Restrictive permissions: the zip contains the config with API keys.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Images of chat messages are stored in the `blobs` table instead of as base64 inside the
|
||||
//! message JSON. A chat then loads without its images; the UI fetches them on demand from
|
||||
//! /api/blobs/<hash> (cached by the browser), and the runner puts the bytes back in before
|
||||
//! a model request. Large PNGs without transparency are stored as JPEG.
|
||||
|
||||
use crate::types::{ImageData, Message, Part};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
/// PNGs above this size are re-encoded as JPEG when that is smaller and loses no transparency.
|
||||
const RECOMPRESS_ABOVE: usize = 100 * 1024;
|
||||
const JPEG_QUALITY: u8 = 85;
|
||||
|
||||
/// An image to put into the store.
|
||||
pub struct Blob {
|
||||
pub hash: String,
|
||||
pub media_type: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Call `f` for every image of a message (pasted images and tool results).
|
||||
pub fn for_each_image(m: &mut Message, mut f: impl FnMut(&mut ImageData)) {
|
||||
for part in &mut m.parts {
|
||||
match part {
|
||||
Part::Image { image } => f(image),
|
||||
Part::ToolResult { images, .. } => images.iter_mut().for_each(&mut f),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the inline images of `m` out: each becomes a reference (`blob`, `size`, no
|
||||
/// `data`). Returns the blobs to store. Images that already are references stay as they are.
|
||||
pub fn externalize(m: &mut Message) -> Vec<Blob> {
|
||||
let mut out = Vec::new();
|
||||
for_each_image(m, |img| {
|
||||
if img.data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(raw) = B64.decode(img.data.as_bytes()) else { return };
|
||||
let (media_type, bytes) = compact(&img.media_type, raw);
|
||||
let hash = hash(&bytes);
|
||||
img.media_type = media_type.clone();
|
||||
img.data.clear();
|
||||
img.blob = Some(hash.clone());
|
||||
img.size = Some(bytes.len() as u64);
|
||||
out.push(Blob { hash, media_type, bytes });
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `s` looks like a blob hash (64 lowercase hex digits).
|
||||
pub fn is_hash(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
/// Address of `bytes` in the image store (SHA-256, hex).
|
||||
pub fn hash(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Re-encode a large opaque PNG as JPEG if that is smaller; anything else is kept.
|
||||
fn compact(media_type: &str, raw: Vec<u8>) -> (String, Vec<u8>) {
|
||||
if media_type != "image/png" || raw.len() <= RECOMPRESS_ABOVE {
|
||||
return (media_type.to_string(), raw);
|
||||
}
|
||||
let Ok(img) = image::load_from_memory_with_format(&raw, image::ImageFormat::Png) else {
|
||||
return (media_type.to_string(), raw);
|
||||
};
|
||||
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p.0[3] != 255) {
|
||||
return (media_type.to_string(), raw);
|
||||
}
|
||||
let mut jpeg = Vec::new();
|
||||
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, JPEG_QUALITY);
|
||||
if img.to_rgb8().write_with_encoder(enc).is_err() || jpeg.len() >= raw.len() {
|
||||
return (media_type.to_string(), raw);
|
||||
}
|
||||
("image/jpeg".to_string(), jpeg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::Role;
|
||||
|
||||
fn png(w: u32, h: u32, alpha: u8) -> Vec<u8> {
|
||||
// Noise, so the PNG is large and JPEG wins.
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
let v = (x.wrapping_mul(7919) ^ y.wrapping_mul(104_729)).wrapping_mul(2_654_435_761) >> 24;
|
||||
image::Rgba([v as u8, (v >> 1) as u8, (v >> 2) as u8, alpha])
|
||||
});
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
fn message(img: ImageData) -> Message {
|
||||
let mut m = Message { id: "m1".into(), role: Role::User, parts: vec![], model: None, usage: None, created_at: 0 };
|
||||
m.parts.push(Part::ToolResult { call_id: "c".into(), name: "browser".into(), content: String::new(), images: vec![img], is_error: false, meta: None });
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_opaque_png_becomes_a_jpeg_reference() {
|
||||
let raw = png(400, 400, 255);
|
||||
let mut m = message(ImageData::new("image/png", B64.encode(&raw)));
|
||||
let blobs = externalize(&mut m);
|
||||
assert_eq!(blobs.len(), 1);
|
||||
assert_eq!(blobs[0].media_type, "image/jpeg");
|
||||
assert!(blobs[0].bytes.len() < raw.len());
|
||||
let Part::ToolResult { images, .. } = &m.parts[0] else { panic!() };
|
||||
assert!(images[0].data.is_empty());
|
||||
assert_eq!(images[0].blob.as_deref(), Some(blobs[0].hash.as_str()));
|
||||
assert_eq!(images[0].size, Some(blobs[0].bytes.len() as u64));
|
||||
assert!(is_hash(&blobs[0].hash));
|
||||
// Already a reference: nothing to do.
|
||||
assert!(externalize(&mut m).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_and_small_pngs_stay_png() {
|
||||
let mut m = message(ImageData::new("image/png", B64.encode(png(400, 400, 128))));
|
||||
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
|
||||
let mut m = message(ImageData::new("image/png", B64.encode(png(8, 8, 255))));
|
||||
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_reference_has_no_data() {
|
||||
let img = ImageData { media_type: "image/jpeg".into(), data: String::new(), blob: Some("ab".into()), size: Some(3) };
|
||||
assert_eq!(serde_json::to_value(&img).unwrap(), serde_json::json!({"media_type": "image/jpeg", "blob": "ab", "size": 3}));
|
||||
}
|
||||
}
|
||||
+301
-2
@@ -129,6 +129,146 @@ fn entry(name: &str, budget: Option<f64>, spent: f64, warn: f64) -> Value {
|
||||
}
|
||||
|
||||
/// Budget overview for the current month.
|
||||
/// Subscription windows of opencode Go, as share of the plan's monthly limit: rolling
|
||||
/// 5 hours (20 %) and 7 days (50 %), and the calendar month in UTC (100 %). Only usage
|
||||
/// through CodeCraft is counted.
|
||||
pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result<Value> {
|
||||
let now = chrono::Utc::now();
|
||||
let month = chrono::Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0).single().map(|d| d.timestamp_millis()).unwrap_or(0);
|
||||
let ms = now.timestamp_millis();
|
||||
let window = |name: &str, from: i64, share: f64| -> Result<Value> {
|
||||
let used = app.store.allowance_since(provider, from)?;
|
||||
let cap = limit * share;
|
||||
Ok(json!({"window": name, "used": used, "limit": cap, "ratio": if cap > 0.0 { used / cap } else { 0.0 }}))
|
||||
};
|
||||
Ok(json!({
|
||||
"limit": limit,
|
||||
"windows": [
|
||||
window("5h", ms - 5 * 3_600_000, 0.2)?,
|
||||
window("week", ms - 7 * 86_400_000, 0.5)?,
|
||||
window("month", month, 1.0)?,
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
/// One budget gauge: what is used of a limit (monthly budgets, Go plan windows).
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Meter {
|
||||
/// Stable id for alert state, e.g. "provider:anthropic" or "plan:opencode-go:5h".
|
||||
pub key: String,
|
||||
pub provider: String,
|
||||
/// "total", "provider", "images" or the plan window ("5h", "week", "month").
|
||||
pub kind: String,
|
||||
pub used: f64,
|
||||
pub limit: f64,
|
||||
pub ratio: f64,
|
||||
}
|
||||
|
||||
/// All configured budgets and plan windows with their current use (no remote calls).
|
||||
pub fn meters(app: &App) -> Result<Vec<Meter>> {
|
||||
let cfg = app.config();
|
||||
let spent = app.store.cost_by_provider(month_start())?;
|
||||
let mut out = Vec::new();
|
||||
let mut push = |key: String, provider: &str, kind: &str, used: f64, limit: f64| {
|
||||
if limit > 0.0 {
|
||||
out.push(Meter { key, provider: provider.into(), kind: kind.into(), used, limit, ratio: used / limit });
|
||||
}
|
||||
};
|
||||
push("total".into(), "total", "total", spent.values().sum(), cfg.server.monthly_budget);
|
||||
for (name, p) in cfg.providers.iter().filter(|(_, p)| p.enabled) {
|
||||
push(format!("provider:{name}"), name, "provider", spent.get(name).copied().unwrap_or(0.0), p.monthly_budget.unwrap_or(0.0));
|
||||
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
|
||||
let plan = plan_usage(app, name, limit)?;
|
||||
for w in plan["windows"].as_array().into_iter().flatten() {
|
||||
let window = w["window"].as_str().unwrap_or("");
|
||||
push(format!("plan:{name}:{window}"), name, window, w["used"].as_f64().unwrap_or(0.0), w["limit"].as_f64().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.images.enabled {
|
||||
push("images".into(), "fal", "images", spent.get("fal").copied().unwrap_or(0.0), cfg.images.monthly_budget.unwrap_or(0.0));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const ALERT_STATE: &str = "budget_alert_state";
|
||||
/// Alert state is global; it is kept under a pseudo user id.
|
||||
const SYSTEM: &str = "_system";
|
||||
|
||||
fn alert_text(m: &Meter, level: u8, de: bool) -> String {
|
||||
let pct = (m.ratio * 100.0).round() as i64;
|
||||
let what = match (m.kind.as_str(), de) {
|
||||
("total", true) => "Gesamtbudget des Monats".to_string(),
|
||||
("total", false) => "Monthly total budget".to_string(),
|
||||
("provider", true) => format!("Monatsbudget {}", m.provider),
|
||||
("provider", false) => format!("Monthly budget of {}", m.provider),
|
||||
("images", true) => "Bildbudget des Monats".to_string(),
|
||||
("images", false) => "Monthly image budget".to_string(),
|
||||
("5h", true) => format!("{}: 5-Stunden-Limit", m.provider),
|
||||
("5h", false) => format!("{}: 5-hour limit", m.provider),
|
||||
("week", true) => format!("{}: Wochenlimit", m.provider),
|
||||
("week", false) => format!("{}: weekly limit", m.provider),
|
||||
(_, true) => format!("{}: Monatslimit des Plans", m.provider),
|
||||
(_, false) => format!("{}: monthly plan limit", m.provider),
|
||||
};
|
||||
match (level, de) {
|
||||
(2, true) => format!("{what} ausgeschöpft ({pct} %)"),
|
||||
(2, false) => format!("{what} used up ({pct} %)"),
|
||||
(_, true) => format!("{what} zu {pct} % verbraucht"),
|
||||
(_, false) => format!("{what} {pct} % used"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push admins when a budget or plan window crosses the warning share or 100 %. Each
|
||||
/// level is sent once; dropping below (new month, rolling window) re-arms it.
|
||||
pub async fn check_alerts(app: &App) -> Result<()> {
|
||||
let warn = app.config().server.budget_warning;
|
||||
let mut state: std::collections::BTreeMap<String, u8> = app.store.user_setting(SYSTEM, ALERT_STATE)?;
|
||||
let mut alerts = Vec::new();
|
||||
let meters = meters(app)?;
|
||||
for m in &meters {
|
||||
let level = if m.ratio >= 1.0 { 2 } else if warn > 0.0 && m.ratio >= warn { 1 } else { 0 };
|
||||
let before = state.get(&m.key).copied().unwrap_or(0);
|
||||
if level > before {
|
||||
alerts.push((m.clone(), level));
|
||||
}
|
||||
state.insert(m.key.clone(), level);
|
||||
}
|
||||
state.retain(|k, _| meters.iter().any(|m| &m.key == k));
|
||||
app.store.set_user_setting(SYSTEM, ALERT_STATE, &state)?;
|
||||
if alerts.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for user in app.store.users()?.into_iter().filter(|u| u.is_admin() && !u.disabled) {
|
||||
let prefs: Value = app.store.user_setting(&user.id, "prefs").unwrap_or_default();
|
||||
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
|
||||
let body = alerts.iter().map(|(m, l)| alert_text(m, *l, de)).collect::<Vec<_>>().join("\n");
|
||||
let used_up = alerts.iter().any(|(_, l)| *l == 2);
|
||||
let title = match (used_up, de) {
|
||||
(true, true) => "CodeCraft – Budget ausgeschöpft",
|
||||
(true, false) => "CodeCraft – budget used up",
|
||||
(false, true) => "CodeCraft – Budget fast verbraucht",
|
||||
(false, false) => "CodeCraft – budget nearly used",
|
||||
};
|
||||
let payload = json!({"kind": "budget", "title": title, "body": body, "tag": "budget", "url": "/#/stats"});
|
||||
let (sent, _) = crate::push::send_to_user(app, &user.id, &payload).await;
|
||||
tracing::info!(user = %user.username, sent, alerts = alerts.len(), "budget alert");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn spawn_alerts(app: std::sync::Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(120)).await;
|
||||
loop {
|
||||
if let Err(e) = check_alerts(&app).await {
|
||||
tracing::warn!("budget alerts: {e:#}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(600)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn overview(app: &App) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let spent = app.store.cost_by_provider(month_start())?;
|
||||
@@ -139,7 +279,11 @@ pub async fn overview(app: &App) -> Result<Value> {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
rows.push(entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn));
|
||||
let mut row = entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn);
|
||||
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
|
||||
row["plan"] = plan_usage(app, name, limit)?;
|
||||
}
|
||||
rows.push(row);
|
||||
let p = p.clone();
|
||||
tasks.push(tokio::spawn(async move { remote_balance(&p).await }));
|
||||
}
|
||||
@@ -174,13 +318,138 @@ pub async fn overview(app: &App) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Per-token prices from the provider's model list, converted to per 1M tokens.
|
||||
fn is_opencode(p: &ProviderConfig) -> bool {
|
||||
p.base_url.contains("opencode.ai")
|
||||
}
|
||||
|
||||
async fn get_json(url: &str) -> Result<Value> {
|
||||
Ok(crate::provider::http_client().get(url).timeout(Duration::from_secs(30)).send().await?.error_for_status()?.json().await?)
|
||||
}
|
||||
|
||||
/// One opencode condition object → (days, hours, from, until); None if it uses
|
||||
/// something we do not understand.
|
||||
type When = (Vec<String>, Vec<(String, String)>, Option<String>, Option<String>);
|
||||
|
||||
fn opencode_when(w: &Value) -> Option<When> {
|
||||
let obj = w.as_object()?;
|
||||
let (mut days, mut hours, mut from, mut until) = (Vec::new(), Vec::new(), None, None);
|
||||
// A pair of utc_hours bounds, given as {"all": [{"utc_hours": {"gte"}}, {"utc_hours": {"lt"}}]}
|
||||
// or directly as {"utc_hours": {"gte", "lt"}}.
|
||||
let range = |v: &Value| -> Option<(String, String)> {
|
||||
if let Some(h) = v.get("utc_hours") {
|
||||
return Some((h["gte"].as_str()?.to_string(), h["lt"].as_str()?.to_string()));
|
||||
}
|
||||
let all = v.get("all")?.as_array()?;
|
||||
let get = |k: &str| all.iter().find_map(|x| x["utc_hours"][k].as_str()).map(String::from);
|
||||
Some((get("gte")?, get("lt")?))
|
||||
};
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"utc_days" => days = v["in"].as_array()?.iter().map(|d| d.as_str().map(String::from)).collect::<Option<_>>()?,
|
||||
"any" => hours = v.as_array()?.iter().map(range).collect::<Option<_>>()?,
|
||||
"all" | "utc_hours" => hours = vec![range(w)?],
|
||||
"date" => {
|
||||
from = v["gte"].as_str().map(String::from);
|
||||
until = v["lt"].as_str().map(String::from);
|
||||
if from.is_none() && until.is_none() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some((days, hours, from, until))
|
||||
}
|
||||
|
||||
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
||||
fn opencode_model(m: &Value) -> Value {
|
||||
let cost = &m["cost"]["text"];
|
||||
let (input, output) = (&cost["input"], &cost["output"]);
|
||||
// Context tiers: input and output overrides share their thresholds.
|
||||
let mut tiers: std::collections::BTreeMap<u64, serde_json::Map<String, Value>> = Default::default();
|
||||
for (side, field) in [(input, "input_price"), (output, "output_price")] {
|
||||
for o in side["overrides"].as_array().into_iter().flatten() {
|
||||
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
||||
let t = tiers.entry(gt).or_default();
|
||||
t.insert(field.into(), o["price"].clone());
|
||||
if field == "input_price" {
|
||||
if o["cache"]["read"].is_number() {
|
||||
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
|
||||
}
|
||||
if o["cache"]["write"].is_number() {
|
||||
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let price_tiers: Vec<Value> = tiers.into_iter().map(|(gt, mut t)| {
|
||||
t.insert("above_context".into(), json!(gt));
|
||||
Value::Object(t)
|
||||
}).collect();
|
||||
let mut notes = Vec::new();
|
||||
let price_multipliers: Vec<Value> = m["cost"]["multipliers"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|x| match opencode_when(&x["when"]) {
|
||||
Some((days, hours, from, until)) => Some(json!({"factor": x["factor"], "days": days, "hours_utc": hours, "from": from, "until": until})),
|
||||
None => {
|
||||
notes.push(format!("unsupported price condition: {}", x["when"]));
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|mut v| {
|
||||
// Drop empty conditions so the config stays short.
|
||||
v.as_object_mut().unwrap().retain(|_, x| !(x.is_null() || x.as_array().is_some_and(|a| a.is_empty())));
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let images = m["modalities"]["input"].as_array().is_some_and(|a| a.iter().any(|x| x.as_str().is_some_and(|s| s.starts_with("image"))));
|
||||
json!({
|
||||
"id": m["id"],
|
||||
"context": m["limit"]["context"],
|
||||
"max_output": m["limit"]["output"],
|
||||
"vision": images,
|
||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||
"input_price": input["price"],
|
||||
"output_price": output["price"],
|
||||
"cache_read_price": input["cache"]["read"],
|
||||
"cache_write_price": input["cache"]["write"],
|
||||
"price_tiers": price_tiers,
|
||||
"price_multipliers": price_multipliers,
|
||||
"notes": notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// opencode (Zen and Go) publishes prices in its model list. The Anthropic-protocol
|
||||
/// endpoint lists models without them, so fall back to the gateway's own /v1/models.
|
||||
async fn opencode_prices(base: &str) -> Result<Value> {
|
||||
let mut urls = vec![format!("{base}/models")];
|
||||
for suffix in ["/openai/v1", "/anthropic/v1"] {
|
||||
if let Some(root) = base.strip_suffix(suffix) {
|
||||
urls.push(format!("{root}/v1/models"));
|
||||
}
|
||||
}
|
||||
for url in &urls {
|
||||
let Ok(v) = get_json(url).await else { continue };
|
||||
let models: Vec<Value> = v["data"].as_array().into_iter().flatten().filter(|m| m.get("cost").is_some()).map(opencode_model).collect();
|
||||
if !models.is_empty() {
|
||||
return Ok(json!({"currency": "USD", "models": models}));
|
||||
}
|
||||
}
|
||||
bail!("opencode did not return prices for this endpoint")
|
||||
}
|
||||
|
||||
pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let p = cfg.providers.get(provider).context("unknown provider")?;
|
||||
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
||||
if is_opencode(p) {
|
||||
return opencode_prices(&base).await;
|
||||
}
|
||||
if !is_openrouter(p) {
|
||||
bail!("this provider does not publish prices via its API – enter them manually");
|
||||
}
|
||||
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
||||
let v: Value = crate::provider::http_client()
|
||||
.get(format!("{base}/models"))
|
||||
.timeout(Duration::from_secs(30))
|
||||
@@ -211,6 +480,36 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn opencode_cost_conversion() {
|
||||
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
||||
"modalities":{"input":["text","image/png"]},"capabilities":{"reasoning":{"supported":true}},
|
||||
"cost":{"text":{"input":{"price":2,"cache":{"read":0.5},"overrides":[{"when":{"context":{"gt":200000}},"price":4,"cache":{"read":1}}]},
|
||||
"output":{"price":6,"overrides":[{"when":{"context":{"gt":200000}},"price":12}]}},
|
||||
"multipliers":[{"when":{"utc_days":{"in":["mon","tue"]},"any":[{"all":[{"utc_hours":{"gte":"01:00"}},{"utc_hours":{"lt":"04:00"}}]}]},"factor":2},
|
||||
{"when":{"date":{"gte":"2026-12-01"}},"factor":2},
|
||||
{"when":{"region":"eu"},"factor":3}]}}"#).unwrap();
|
||||
let r = super::opencode_model(&m);
|
||||
assert_eq!(r["input_price"], 2);
|
||||
assert_eq!(r["cache_read_price"], 0.5);
|
||||
assert_eq!(r["vision"], true);
|
||||
assert_eq!(r["price_tiers"][0], serde_json::json!({"above_context": 200000, "input_price": 4, "cache_read_price": 1, "output_price": 12}));
|
||||
assert_eq!(r["price_multipliers"][0], serde_json::json!({"factor": 2, "days": ["mon", "tue"], "hours_utc": [["01:00", "04:00"]]}));
|
||||
assert_eq!(r["price_multipliers"][1], serde_json::json!({"factor": 2, "from": "2026-12-01"}));
|
||||
assert_eq!(r["notes"].as_array().unwrap().len(), 1, "unknown condition is reported, not guessed");
|
||||
// The result must deserialize into the model config.
|
||||
let mc: crate::config::ModelConfig = serde_json::from_value(serde_json::json!({"id": "grok", "price_tiers": r["price_tiers"], "price_multipliers": r["price_multipliers"]})).unwrap();
|
||||
assert_eq!(mc.price_tiers[0].above_context, 200000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_texts() {
|
||||
let m = super::Meter { key: "plan:go:5h".into(), provider: "opencode-go".into(), kind: "5h".into(), used: 1.7, limit: 2.0, ratio: 0.85 };
|
||||
assert_eq!(super::alert_text(&m, 1, true), "opencode-go: 5-Stunden-Limit zu 85 % verbraucht");
|
||||
let t = super::Meter { key: "total".into(), provider: "total".into(), kind: "total".into(), used: 10.0, limit: 10.0, ratio: 1.0 };
|
||||
assert_eq!(super::alert_text(&t, 2, false), "Monthly total budget used up (100 %)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_entry_flags() {
|
||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||
|
||||
+21
-3
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
|
||||
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let cfg = app.config();
|
||||
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||
let agents: Vec<Value> = crate::prompts::agents(&app, &root, &root)
|
||||
let agents: Vec<Value> = crate::prompts::agents_for(&app, &root, &root, Some(&user.id))
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let mut v = json!(a);
|
||||
@@ -47,7 +47,7 @@ pub async fn config(Extension(user): Extension<User>, State(app): State<AppState
|
||||
let memory: String = app.store.user_setting(&user.id, "memory")?;
|
||||
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
|
||||
Ok(Json(json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"version": codecraft_core::version::VERSION,
|
||||
"user": user,
|
||||
"models": app.registry().models_json(),
|
||||
"default_model": default_model,
|
||||
@@ -115,8 +115,8 @@ pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppS
|
||||
let provider_name = b.model.split_once('/').map(|(p, _)| p.to_string()).unwrap_or_default();
|
||||
crate::budget::check(&app, &provider_name, Some(&user.id)).map_err(|e| ApiError(StatusCode::PAYMENT_REQUIRED, format!("{e:#}")))?;
|
||||
let max_tokens = b.max_tokens.or(model.max_output).unwrap_or(16_000);
|
||||
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning };
|
||||
let session = format!("cli:{}", b.session.unwrap_or_else(|| "default".into()));
|
||||
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning, session_id: Some(session.clone()) };
|
||||
let agent = format!("cli:{}", b.agent.unwrap_or_else(|| "build".into()));
|
||||
let spec = b.model.clone();
|
||||
let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
|
||||
@@ -175,6 +175,7 @@ pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppS
|
||||
cost: model.cost(&usage),
|
||||
error,
|
||||
user_id: Some(user.id.clone()),
|
||||
allowance: usage.allowance,
|
||||
});
|
||||
});
|
||||
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
|
||||
@@ -230,6 +231,23 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
|
||||
Ok(Json(b))
|
||||
}
|
||||
|
||||
/// Order and pinning of the groups (folders, projects) in the chat list – per user, so
|
||||
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
|
||||
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let v: Value = app.store.user_setting(&user.id, "sidebar")?;
|
||||
Ok(Json(if v.is_null() { json!({"order": [], "pinned": []}) } else { v }))
|
||||
}
|
||||
|
||||
pub async fn put_sidebar(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
|
||||
let ids = |k: &str| b[k].as_array().is_some_and(|a| a.iter().all(|x| x.as_str().is_some_and(|s| s.len() <= 1000)));
|
||||
if !ids("order") || !ids("pinned") || b.to_string().len() > 100_000 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid chat list layout".into()));
|
||||
}
|
||||
let v = json!({"order": b["order"], "pinned": b["pinned"]});
|
||||
app.store.set_user_setting(&user.id, "sidebar", &v)?;
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
/// Personal memory (applies to all projects, web and CLI).
|
||||
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let m: String = app.store.user_setting(&user.id, "memory")?;
|
||||
|
||||
+17
-1
@@ -193,6 +193,15 @@ pub struct ImageModel {
|
||||
/// Extra request fields sent as-is.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub options: serde_json::Map<String, serde_json::Value>,
|
||||
/// "preview" (cheap drafts) or "final" (best quality). Known fal.ai models have defaults.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stage: Option<String>,
|
||||
/// What the model is good at: photo, illustration, text, design.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub good_for: Vec<String>,
|
||||
/// Request field for reference images (e.g. "image_urls"); set for models that refine an image.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reference_param: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ImagesConfig {
|
||||
@@ -376,6 +385,13 @@ pub struct AgentConfig {
|
||||
pub tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub prompt: Option<String>,
|
||||
/// Only read-only tools (like plan mode).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub read_only: Option<bool>,
|
||||
/// "coding" (default: project context and coding instructions) or "chat" (plain
|
||||
/// conversation with only this prompt, like the built-in chat mode).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
/// Max. instances of this subagent running at the same time.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_parallel: Option<usize>,
|
||||
@@ -630,7 +646,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint."));
|
||||
assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available"));
|
||||
assert!(out.contains("enabled = true # enable once the key is set"));
|
||||
let cfg = Config::parse(&out).unwrap();
|
||||
assert!(cfg.providers["gemini"].enabled);
|
||||
}
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ async fn render_page(pdf: &str, page: u32) -> Result<ImageData> {
|
||||
let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle);
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
|
||||
Ok(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||
Ok(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||
}
|
||||
|
||||
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn markdown(s: &Session, messages: &[Message]) -> String {
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())),
|
||||
Part::Summary { text } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
|
||||
Part::Summary { text, .. } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
|
||||
Part::Reasoning { text, .. } if !text.trim().is_empty() => {
|
||||
out.push_str(&format!("<details><summary>Thinking</summary>\n\n{}\n\n</details>\n\n", text.trim()))
|
||||
}
|
||||
|
||||
@@ -353,6 +353,10 @@ pub async fn move_or_copy(Extension(user): Extension<User>, State(app): State<Ap
|
||||
}
|
||||
let (f, t) = (from.clone(), to.clone());
|
||||
tokio::task::spawn_blocking(move || if b.copy { copy_recursive(&f, &t) } else { std::fs::rename(&f, &t) }).await??;
|
||||
if !b.copy {
|
||||
// Generated images in the moved file or folder keep their entries.
|
||||
app.store.move_image_paths(&from.display().to_string(), &to.display().to_string())?;
|
||||
}
|
||||
Ok(Json(json!({"ok": true, "path": to})))
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -44,6 +44,8 @@ tokio::task_local! {
|
||||
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
||||
/// The user's git config (credentials, identity) for unsandboxed runs.
|
||||
static GITCONFIG: Option<std::path::PathBuf>;
|
||||
/// The user's commit identity (overrides a repository's own user.name/email).
|
||||
static GITENV: Vec<(String, String)>;
|
||||
}
|
||||
|
||||
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||
@@ -63,6 +65,7 @@ async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
}
|
||||
c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default());
|
||||
c
|
||||
}
|
||||
};
|
||||
@@ -171,7 +174,8 @@ fn parse_status(raw: &str) -> Value {
|
||||
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
||||
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
||||
let cfg = crate::gitcreds::global_config(&app, &user);
|
||||
SANDBOX.scope(spec, GITCONFIG.scope(cfg, handle_inner(user, app, b))).await
|
||||
let env = crate::gitcreds::identity_env(&app, &user.id);
|
||||
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
|
||||
}
|
||||
|
||||
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
||||
@@ -388,8 +392,9 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
|
||||
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
||||
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
||||
let cfg = crate::gitcreds::global_config(app, user);
|
||||
let env = crate::gitcreds::identity_env(app, &user.id);
|
||||
let s = session.clone();
|
||||
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
|
||||
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
|
||||
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
||||
session.cwd = cwd.to_string();
|
||||
}
|
||||
|
||||
@@ -35,6 +35,24 @@ pub struct GitSettings {
|
||||
pub credentials: Vec<GitCredential>,
|
||||
}
|
||||
|
||||
/// The user's commit identity as environment variables. They take precedence over a
|
||||
/// repository's own `user.name`/`user.email`, so commits made by agents, terminals and
|
||||
/// the git panel always carry the identity from the settings.
|
||||
pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
|
||||
let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default();
|
||||
let mut env = Vec::new();
|
||||
let (name, email) = (s.name.trim(), s.email.trim());
|
||||
if !name.is_empty() {
|
||||
env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string()));
|
||||
env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string()));
|
||||
}
|
||||
if !email.is_empty() {
|
||||
env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string()));
|
||||
env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string()));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
||||
app.data_dir().join("users").join(&user.username).join("home")
|
||||
}
|
||||
|
||||
+20
-17
@@ -33,29 +33,32 @@ impl Health {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the model list once and classify the answer.
|
||||
pub async fn probe(provider: Arc<dyn crate::provider::Provider>) -> (ProviderStatus, usize) {
|
||||
let start = Instant::now();
|
||||
let res = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await;
|
||||
let (error, count): (Option<String>, usize) = match res {
|
||||
Ok(Ok(m)) => (None, m.len()),
|
||||
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
|
||||
Err(_) => (Some("timed out".to_string()), 0),
|
||||
};
|
||||
// The host answered but has no model list: reachable.
|
||||
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
|
||||
let state = match &error {
|
||||
None => "ok",
|
||||
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
|
||||
Some(_) => "down",
|
||||
};
|
||||
(ProviderStatus { up: error.is_none(), state, error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() }, count)
|
||||
}
|
||||
|
||||
pub async fn check_all(app: &App) {
|
||||
let registry = app.registry();
|
||||
let mut found = BTreeMap::new();
|
||||
let checks = registry.providers.iter().map(|(name, entry)| {
|
||||
let provider = entry.provider.clone();
|
||||
let name = name.clone();
|
||||
async move {
|
||||
let start = Instant::now();
|
||||
let res = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await;
|
||||
let error: Option<String> = match res {
|
||||
Ok(Ok(_)) => None,
|
||||
Ok(Err(e)) => Some(e.to_string().chars().take(300).collect()),
|
||||
Err(_) => Some("timed out".to_string()),
|
||||
};
|
||||
// The host answered but has no model list: reachable.
|
||||
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
|
||||
let state = match &error {
|
||||
None => "ok",
|
||||
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
|
||||
Some(_) => "down",
|
||||
};
|
||||
(name, ProviderStatus { up: error.is_none(), state, error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() })
|
||||
}
|
||||
async move { (name, probe(provider).await.0) }
|
||||
});
|
||||
for (name, st) in futures::future::join_all(checks).await {
|
||||
if !st.up {
|
||||
|
||||
+306
-18
@@ -34,6 +34,8 @@ pub struct GenerateRequest {
|
||||
pub dir: PathBuf,
|
||||
pub user_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
/// Images to refine (sent to models with a `reference_param`).
|
||||
pub references: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> String {
|
||||
@@ -47,14 +49,90 @@ fn slug(s: &str) -> String {
|
||||
out.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// Purposes the agent can ask for; models list theirs in `good_for`.
|
||||
pub const PURPOSES: &[&str] = &["photo", "illustration", "text", "design"];
|
||||
|
||||
/// Defaults for well-known fal.ai models: (id, stage, good_for, reference_param, size_param, price).
|
||||
const KNOWN: &[(&str, &str, &[&str], Option<&str>, Option<&str>, f64)] = &[
|
||||
("fal-ai/flux/schnell", "preview", &["photo", "illustration"], None, None, 0.003),
|
||||
("fal-ai/nano-banana-pro", "final", &["photo", "illustration", "text"], None, Some("aspect_ratio"), 0.15),
|
||||
("fal-ai/nano-banana-pro/edit", "final", &["photo", "illustration", "text", "design"], Some("image_urls"), Some("aspect_ratio"), 0.15),
|
||||
("fal-ai/flux-2-pro", "final", &["photo"], None, None, 0.03),
|
||||
("fal-ai/ideogram/v3", "final", &["text", "design"], None, Some("aspect_ratio"), 0.06),
|
||||
("fal-ai/recraft/v4/pro/text-to-image", "final", &["design", "illustration"], None, None, 0.05),
|
||||
];
|
||||
|
||||
/// A configured model with the known defaults filled in where the config leaves them out.
|
||||
fn with_defaults(mut m: ImageModel) -> ImageModel {
|
||||
if let Some((_, stage, good_for, reference, size, price)) = KNOWN.iter().find(|k| k.0 == m.id) {
|
||||
m.stage.get_or_insert_with(|| stage.to_string());
|
||||
if m.good_for.is_empty() {
|
||||
m.good_for = good_for.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
if m.reference_param.is_none() {
|
||||
m.reference_param = reference.map(String::from);
|
||||
}
|
||||
if m.size_param.is_none() {
|
||||
m.size_param = size.map(String::from);
|
||||
}
|
||||
m.price.get_or_insert(*price);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn models(app: &App) -> Vec<ImageModel> {
|
||||
app.config().images.models.iter().cloned().map(with_defaults).collect()
|
||||
}
|
||||
|
||||
fn model_config(app: &App, id: &str) -> ImageModel {
|
||||
app.config()
|
||||
.images
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ImageModel { id: id.to_string(), ..Default::default() })
|
||||
models(app).into_iter().find(|m| m.id == id).unwrap_or_else(|| with_defaults(ImageModel { id: id.to_string(), ..Default::default() }))
|
||||
}
|
||||
|
||||
/// Pick the model for a request: an explicit model wins; previews use the first preview model
|
||||
/// (else the default); final images use the best-matching final model, preferring one that can
|
||||
/// take the reference image when there is one.
|
||||
pub fn choose_model(app: &App, stage: &str, purpose: Option<&str>, with_reference: bool) -> ImageModel {
|
||||
let default = app.config().images.default_model.clone();
|
||||
pick(&models(app), stage, purpose, with_reference).unwrap_or_else(|| model_config(app, &default))
|
||||
}
|
||||
|
||||
fn pick(all: &[ImageModel], stage: &str, purpose: Option<&str>, with_reference: bool) -> Option<ImageModel> {
|
||||
if stage == "preview" && !with_reference {
|
||||
return all.iter().find(|m| m.stage.as_deref() == Some("preview")).cloned();
|
||||
}
|
||||
let score = |m: &ImageModel| {
|
||||
let mut s = 0;
|
||||
if m.stage.as_deref() == Some("final") {
|
||||
s += 4;
|
||||
}
|
||||
match (with_reference, m.reference_param.is_some()) {
|
||||
(true, true) => s += 8,
|
||||
// Without a reference, plain text-to-image endpoints come first.
|
||||
(false, true) => s -= 1,
|
||||
_ => {}
|
||||
}
|
||||
if purpose.is_some_and(|p| m.good_for.iter().any(|g| g == p)) {
|
||||
s += 2;
|
||||
}
|
||||
s
|
||||
};
|
||||
// max_by_key keeps the last of equal scores; reverse so the config order wins ties.
|
||||
all.iter().rev().max_by_key(|m| score(m)).cloned()
|
||||
}
|
||||
|
||||
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
|
||||
async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
|
||||
let mime = match p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
out.push(format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
|
||||
@@ -82,15 +160,22 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
crate::budget::check(app, "fal", req.user_id.as_deref())?;
|
||||
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
|
||||
let model = model_config(app, &model_id);
|
||||
let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into());
|
||||
let explicit_size = req.size.clone().filter(|s| SIZES.contains(&s.as_str()));
|
||||
let size = explicit_size.clone().unwrap_or_else(|| "landscape_4_3".into());
|
||||
let count = req.count.clamp(1, 4);
|
||||
|
||||
let mut body = json!({"prompt": req.prompt, "num_images": count, "output_format": "png"});
|
||||
let refining = !req.references.is_empty() && model.reference_param.is_some();
|
||||
match model.size_param.as_deref() {
|
||||
// When refining without an explicit size, keep the reference's aspect ratio.
|
||||
Some("aspect_ratio") if refining && explicit_size.is_none() => body["aspect_ratio"] = json!("auto"),
|
||||
Some("aspect_ratio") => body["aspect_ratio"] = json!(aspect_ratio(&size)),
|
||||
Some("none") => {}
|
||||
_ => body["image_size"] = json!(size),
|
||||
}
|
||||
if refining && let Some(param) = &model.reference_param {
|
||||
body[param.as_str()] = json!(reference_uris(&req.references).await?);
|
||||
}
|
||||
for (k, v) in &model.options {
|
||||
body[k] = v.clone();
|
||||
}
|
||||
@@ -128,6 +213,8 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
|
||||
let path = req.dir.join(name);
|
||||
tokio::fs::write(&path, &bytes).await?;
|
||||
// A copy in the image store keeps it viewable when the file is moved or deleted.
|
||||
let blob = app.store.put_blob(&ctype, &bytes).map_err(|e| tracing::warn!("image copy: {e:#}")).ok();
|
||||
let rec = ImageRecord {
|
||||
id: new_id(),
|
||||
user_id: req.user_id.clone(),
|
||||
@@ -138,6 +225,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
width: img["width"].as_i64().unwrap_or(0),
|
||||
height: img["height"].as_i64().unwrap_or(0),
|
||||
created_at: now_ms(),
|
||||
blob,
|
||||
};
|
||||
app.store.add_image(&rec)?;
|
||||
records.push(rec);
|
||||
@@ -162,17 +250,110 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
cost,
|
||||
error: false,
|
||||
user_id: None,
|
||||
allowance: None,
|
||||
});
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// A generated image whose file is gone: the same file name below its original folder
|
||||
/// (or the folder above it), e.g. after an agent sorted the images into subfolders.
|
||||
/// Only inside `roots`; looks at a few thousand entries at most.
|
||||
pub fn find_moved(path: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
|
||||
let name = path.file_name()?;
|
||||
search_near(path, roots, |p, _| p.file_name() == Some(name))
|
||||
}
|
||||
|
||||
/// Like `find_moved`, but by content: an image file with the same bytes (renamed).
|
||||
pub fn find_by_content(path: &Path, roots: &[PathBuf], hash: &str, size: u64) -> Option<PathBuf> {
|
||||
search_near(path, roots, |p, len| {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
len == size && matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg" | "webp")) && std::fs::read(p).is_ok_and(|b| crate::blobs::hash(&b) == hash)
|
||||
})
|
||||
}
|
||||
|
||||
/// Files below the folder of `path` and the folder above it (3 levels, hidden folders
|
||||
/// skipped, inside `roots`) for which `matches(path, size)` holds.
|
||||
fn search_near(path: &Path, roots: &[PathBuf], matches: impl Fn(&Path, u64) -> bool) -> Option<PathBuf> {
|
||||
let dir = path.parent()?;
|
||||
let mut budget = 5000usize;
|
||||
for base in [Some(dir), dir.parent()].into_iter().flatten() {
|
||||
if !roots.iter().any(|r| base.starts_with(r)) {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![(base.to_path_buf(), 0usize)];
|
||||
while let Some((d, depth)) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&d) else { continue };
|
||||
for e in entries.flatten() {
|
||||
if budget == 0 {
|
||||
return None;
|
||||
}
|
||||
budget -= 1;
|
||||
let p = e.path();
|
||||
let Ok(ft) = e.file_type() else { continue };
|
||||
if ft.is_file() && p != path && matches(&p, e.metadata().map(|m| m.len()).unwrap_or(0)) {
|
||||
return Some(p);
|
||||
}
|
||||
let hidden = p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.') || n == "node_modules");
|
||||
if ft.is_dir() && depth < 3 && !hidden {
|
||||
stack.push((p, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where the file of a generated image is now: by name, then (with a copy in the image
|
||||
/// store) by content. Corrects the stored path when found.
|
||||
pub fn relocate(app: &App, rec: &ImageRecord) -> Option<PathBuf> {
|
||||
let path = Path::new(&rec.path);
|
||||
let roots = app.user_roots(rec.user_id.as_deref());
|
||||
let found = find_moved(path, &roots).or_else(|| {
|
||||
let hash = rec.blob.as_deref()?;
|
||||
let (_, bytes) = app.store.blob(hash).ok()??;
|
||||
find_by_content(path, &roots, hash, bytes.len() as u64)
|
||||
})?;
|
||||
let _ = app.store.set_image_path(&rec.id, &found.display().to_string());
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// After an agent moved or renamed files: find the generated images of `owner` from the
|
||||
/// last two weeks whose files are gone and correct their paths. Returns how many.
|
||||
pub fn relocate_missing(app: &App, owner: Option<&str>) -> usize {
|
||||
let since = now_ms() - 14 * 86_400_000;
|
||||
let Ok(list) = app.store.images(owner, 500) else { return 0 };
|
||||
list.iter().filter(|r| r.created_at >= since && !Path::new(&r.path).exists()).filter(|r| relocate(app, r).is_some()).count()
|
||||
}
|
||||
|
||||
/// Copies in the image store for generated images from before they were kept (once per
|
||||
/// image, files up to 25 MB): later moves and renames are then found by content.
|
||||
pub fn backfill_copies(app: &App) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for (id, path) in app.store.images_without_copy()? {
|
||||
let Ok(meta) = std::fs::metadata(&path) else { continue };
|
||||
if !meta.is_file() || meta.len() > 25 * 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||||
let mime = match Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
let hash = app.store.put_blob(mime, &bytes)?;
|
||||
app.store.set_image_blob(&id, &hash)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Downscaled copy for the model's context.
|
||||
fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
let img = image::open(path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
|
||||
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||
Some(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||
}
|
||||
|
||||
pub struct GenerateImage;
|
||||
@@ -184,9 +365,13 @@ impl Tool for GenerateImage {
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Generate images from a text prompt (fal.ai) and save them in the project. Use for \
|
||||
illustrations, icons, backgrounds or placeholder photos the user needs. Write a detailed \
|
||||
visual prompt in English (subject, style, composition, colours, lighting). Each image \
|
||||
costs money – generate only what is needed. Returns the file paths and a preview."
|
||||
illustrations, icons, backgrounds or photos the user needs. Write a detailed visual prompt \
|
||||
in English (subject, style, composition, colours, lighting). The server picks the model. \
|
||||
Work in two steps: first stage \"preview\" (cheap drafts, count 2-4); show them and ask the \
|
||||
user with ask_user which one to refine – include the final model and price the preview \
|
||||
result names. Then call again with stage \"final\", the chosen file in \"reference\" and \
|
||||
the same or an improved prompt. Skip the preview only if the user asks for a final image \
|
||||
directly. Each image costs money – generate only what is needed."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
@@ -194,37 +379,55 @@ impl Tool for GenerateImage {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string"},
|
||||
"size": {"type": "string", "enum": SIZES},
|
||||
"stage": {"type": "string", "enum": ["preview", "final"], "description": "preview = cheap drafts (default), final = best model for the purpose"},
|
||||
"purpose": {"type": "string", "enum": PURPOSES, "description": "What the image is: photo (realistic), illustration, text (lettering, posters, signs), design (logos, icons, UI, vector style)"},
|
||||
"reference": {"type": "array", "items": {"type": "string"}, "maxItems": 4, "description": "Image files (relative paths) to refine, e.g. the preview the user chose"},
|
||||
"size": {"type": "string", "enum": SIZES, "description": "Default: landscape_4_3; when refining, the reference's aspect ratio"},
|
||||
"count": {"type": "integer", "minimum": 1, "maximum": 4},
|
||||
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: images.output_dir)"},
|
||||
"model": {"type": "string", "description": "fal.ai model id (optional, default from the settings)"}
|
||||
"model": {"type": "string", "description": "fal.ai model id – only if the user asks for a specific model"}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
})
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("Generate image: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
|
||||
let stage = if input["stage"].as_str() == Some("final") { "Generate final image" } else { "Generate image" };
|
||||
format!("{stage}: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let prompt = arg_str(&input, "prompt")?.to_string();
|
||||
let stage = if input["stage"].as_str() == Some("final") { "final" } else { "preview" };
|
||||
let purpose = input["purpose"].as_str().filter(|p| PURPOSES.contains(p));
|
||||
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| ctx.app.config().images.output_dir.clone());
|
||||
let dir = ctx.resolve(&dir_arg)?;
|
||||
let mut references = Vec::new();
|
||||
for r in input["reference"].as_array().into_iter().flatten().filter_map(|v| v.as_str()).take(4) {
|
||||
references.push(ctx.resolve(r)?);
|
||||
}
|
||||
let model = match input["model"].as_str().filter(|m| !m.is_empty()) {
|
||||
Some(m) => model_config(&ctx.app, m),
|
||||
None => choose_model(&ctx.app, stage, purpose, !references.is_empty()),
|
||||
};
|
||||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
|
||||
let refined = !references.is_empty() && model.reference_param.is_some();
|
||||
let records = generate(
|
||||
&ctx.app,
|
||||
GenerateRequest {
|
||||
prompt,
|
||||
model: input["model"].as_str().map(String::from),
|
||||
model: Some(model.id.clone()),
|
||||
size: input["size"].as_str().map(String::from),
|
||||
count: input["count"].as_u64().unwrap_or(1) as u32,
|
||||
count: input["count"].as_u64().unwrap_or(if stage == "preview" { 2 } else { 1 }) as u32,
|
||||
dir,
|
||||
user_id: owner,
|
||||
session_id: Some(ctx.session_id.clone()),
|
||||
references,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
let mut text = String::from("Generated:\n");
|
||||
let price = |m: &ImageModel| m.price.map(|p| format!(" (${p:.3} per image)")).unwrap_or_default();
|
||||
let label = |m: &ImageModel| m.name.clone().unwrap_or_else(|| m.id.clone());
|
||||
let mut text = format!("Model: {}{}\nGenerated:\n", label(&model), price(&model));
|
||||
let mut images = Vec::new();
|
||||
for r in &records {
|
||||
let rel = Path::new(&r.path).strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| r.path.clone());
|
||||
@@ -233,7 +436,92 @@ impl Tool for GenerateImage {
|
||||
images.push(p);
|
||||
}
|
||||
}
|
||||
if stage == "preview" && !refined {
|
||||
let fin = choose_model(&ctx.app, "final", purpose, true);
|
||||
if fin.id != model.id {
|
||||
let how = if fin.reference_param.is_some() { "refines the chosen preview" } else { "generates it again from the prompt" };
|
||||
text.push_str(&format!(
|
||||
"\nThese are previews. Ask the user (ask_user) which one to refine. The final image would use {}{} and {how}.",
|
||||
label(&fin),
|
||||
price(&fin)
|
||||
));
|
||||
}
|
||||
} else if !input["reference"].as_array().is_none_or(|a| a.is_empty()) && !refined {
|
||||
text.push_str("\nNote: this model takes no reference image, so it was generated from the prompt only.");
|
||||
}
|
||||
let ids: Vec<&str> = records.iter().map(|r| r.id.as_str()).collect();
|
||||
Ok(ToolOutput { content: text, images, meta: Some(json!({"image_ids": ids})), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_moved_files_and_keeps_their_copies() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-img-moved-{}", std::process::id()));
|
||||
let art = dir.join("art");
|
||||
std::fs::create_dir_all(art.join("older")).unwrap();
|
||||
std::fs::write(art.join("older/20261110-a.png"), b"x").unwrap();
|
||||
// Moved into a subfolder: found; renamed or outside the roots: not.
|
||||
assert_eq!(find_moved(&art.join("20261110-a.png"), std::slice::from_ref(&dir)), Some(art.join("older/20261110-a.png")));
|
||||
assert_eq!(find_moved(&art.join("20261110-b.png"), std::slice::from_ref(&dir)), None);
|
||||
assert_eq!(find_moved(&art.join("20261110-a.png"), &[dir.join("elsewhere")]), None);
|
||||
// Renamed: found by content.
|
||||
std::fs::write(art.join("older/level-5.png"), b"renamed bytes").unwrap();
|
||||
let h = crate::blobs::hash(b"renamed bytes");
|
||||
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &h, 13), Some(art.join("older/level-5.png")));
|
||||
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &crate::blobs::hash(b"other"), 13), None);
|
||||
// The image store keeps the copy of a generated image.
|
||||
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
|
||||
let hash = store.put_blob("image/png", b"png bytes").unwrap();
|
||||
store
|
||||
.add_image(&ImageRecord { id: "i".into(), user_id: None, session_id: None, prompt: "p".into(), model: "m".into(), path: "/gone.png".into(), width: 1, height: 1, created_at: 0, blob: Some(hash.clone()) })
|
||||
.unwrap();
|
||||
assert_eq!(store.gc_blobs().unwrap(), 0);
|
||||
// Moved with the file manager: the entry follows (file or folder, not look-alikes).
|
||||
assert_eq!(store.move_image_paths("/gone.png", "/art/new.png").unwrap(), 1);
|
||||
assert_eq!(store.move_image_paths("/art", "/bilder/").unwrap(), 1);
|
||||
assert_eq!(store.move_image_paths("/bild", "/x").unwrap(), 0);
|
||||
assert_eq!(store.image("i").unwrap().unwrap().path, "/bilder/new.png");
|
||||
assert_eq!(store.blob(&hash).unwrap().unwrap().1, b"png bytes");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn m(id: &str) -> ImageModel {
|
||||
with_defaults(ImageModel { id: id.into(), ..Default::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_by_stage_purpose_and_reference() {
|
||||
let all = vec![
|
||||
m("fal-ai/flux/schnell"),
|
||||
m("fal-ai/nano-banana-pro/edit"),
|
||||
m("fal-ai/nano-banana-pro"),
|
||||
m("fal-ai/flux-2-pro"),
|
||||
m("fal-ai/ideogram/v3"),
|
||||
m("fal-ai/recraft/v4/pro/text-to-image"),
|
||||
];
|
||||
let id = |stage, purpose, r| pick(&all, stage, purpose, r).unwrap().id;
|
||||
assert_eq!(id("preview", Some("photo"), false), "fal-ai/flux/schnell");
|
||||
// Refining a preview: the edit model, whatever the purpose.
|
||||
assert_eq!(id("final", Some("photo"), true), "fal-ai/nano-banana-pro/edit");
|
||||
assert_eq!(id("final", Some("design"), true), "fal-ai/nano-banana-pro/edit");
|
||||
// From the prompt only: the first fitting text-to-image model (config order).
|
||||
assert_eq!(id("final", Some("photo"), false), "fal-ai/nano-banana-pro");
|
||||
assert_eq!(id("final", Some("text"), false), "fal-ai/nano-banana-pro");
|
||||
assert_eq!(id("final", Some("design"), false), "fal-ai/ideogram/v3");
|
||||
let no_edit: Vec<_> = all.iter().filter(|m| m.reference_param.is_none()).cloned().collect();
|
||||
assert_eq!(pick(&no_edit, "final", Some("design"), true).unwrap().id, "fal-ai/ideogram/v3");
|
||||
assert_eq!(pick(&no_edit, "final", Some("photo"), false).unwrap().id, "fal-ai/nano-banana-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_overrides_known_defaults() {
|
||||
let custom = with_defaults(ImageModel { id: "fal-ai/flux/schnell".into(), stage: Some("final".into()), price: Some(0.01), ..Default::default() });
|
||||
assert_eq!(custom.stage.as_deref(), Some("final"));
|
||||
assert_eq!(custom.price, Some(0.01));
|
||||
assert!(m("some/unknown-model").stage.is_none());
|
||||
}
|
||||
}
|
||||
+237
-68
@@ -1,20 +1,23 @@
|
||||
//! Scheduled agents ("automations"): a Markdown file with a cron schedule in
|
||||
//! its frontmatter starts an agent session at the given times.
|
||||
//! Scheduled agents ("automations"). The instructions are a Markdown file (only the
|
||||
//! prompt, e.g. `<project>/.codecraft/jobs/<name>.md`); name, schedule, mode, model and
|
||||
//! limits are settings of the automation in the database, edited in the UI.
|
||||
//!
|
||||
//! Older job files carried the settings as frontmatter:
|
||||
//!
|
||||
//! ```markdown
|
||||
//! ---
|
||||
//! name: Nightly dependency check
|
||||
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
|
||||
//! agent: build # mode (default build)
|
||||
//! model: provider/model # optional, default: server default model
|
||||
//! permission_mode: auto # default auto; "bypass" runs without asking
|
||||
//! timeout_minutes: 60 # optional, default 60
|
||||
//! agent: build
|
||||
//! permission_mode: auto
|
||||
//! timeout_minutes: 60
|
||||
//! ---
|
||||
//! Check for outdated dependencies and open a summary …
|
||||
//! ```
|
||||
//!
|
||||
//! The file is re-read on every run, so edits apply to the next run. Each run
|
||||
//! is a normal chat session owned by the job's owner.
|
||||
//! They are adopted once (settings into the database, the frontmatter removed from the
|
||||
//! file). The file is re-read on every run, so edits apply to the next run. Each run is
|
||||
//! a normal chat session owned by the job's owner.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||
@@ -40,6 +43,70 @@ pub struct Job {
|
||||
pub created_at: i64,
|
||||
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
|
||||
pub webhook: bool,
|
||||
/// None: not adopted yet (settings still in the file's frontmatter).
|
||||
#[serde(skip)]
|
||||
pub settings: Option<JobSettings>,
|
||||
}
|
||||
|
||||
/// Settings of an automation (database; the file holds only the prompt).
|
||||
#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)]
|
||||
pub struct JobSettings {
|
||||
pub name: String,
|
||||
/// 5-field cron in the server's time zone.
|
||||
pub schedule: String,
|
||||
#[serde(default = "default_agent")]
|
||||
pub agent: String,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default = "default_mode")]
|
||||
pub permission_mode: String,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_minutes: u64,
|
||||
/// Working directory (absolute or relative to the file); default: the project of the file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
/// Chat in which the user works out the prompt with a model.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub assistant_session: Option<String>,
|
||||
}
|
||||
|
||||
impl JobSettings {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.trim().to_string(),
|
||||
schedule: "0 3 * * *".into(),
|
||||
agent: default_agent(),
|
||||
model: None,
|
||||
permission_mode: default_mode(),
|
||||
timeout_minutes: default_timeout(),
|
||||
cwd: None,
|
||||
assistant_session: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.name = self.name.trim().chars().take(120).collect();
|
||||
if self.name.is_empty() {
|
||||
bail!("the automation needs a name");
|
||||
}
|
||||
self.schedule = self.schedule.trim().to_string();
|
||||
check_schedule(&self.schedule)?;
|
||||
if !crate::permission::MODES.contains(&self.permission_mode.as_str()) {
|
||||
bail!("invalid permission_mode '{}'", self.permission_mode);
|
||||
}
|
||||
self.model = self.model.take().map(|m| m.trim().to_string()).filter(|m| !m.is_empty());
|
||||
self.timeout_minutes = self.timeout_minutes.clamp(1, 24 * 60);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Valid 5-field cron expression?
|
||||
pub fn check_schedule(schedule: &str) -> Result<()> {
|
||||
if schedule.split_whitespace().count() != 5 {
|
||||
bail!("invalid schedule '{schedule}': expected 5 fields (minute hour day month weekday)");
|
||||
}
|
||||
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parsed job file.
|
||||
@@ -52,15 +119,89 @@ pub struct JobSpec {
|
||||
pub permission_mode: String,
|
||||
pub timeout_minutes: u64,
|
||||
pub cwd: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assistant_session: Option<String>,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
impl JobSpec {
|
||||
fn from_settings(s: &JobSettings, prompt: String) -> Self {
|
||||
Self {
|
||||
name: s.name.clone(),
|
||||
schedule: s.schedule.clone(),
|
||||
agent: s.agent.clone(),
|
||||
model: s.model.clone(),
|
||||
permission_mode: s.permission_mode.clone(),
|
||||
timeout_minutes: s.timeout_minutes,
|
||||
cwd: s.cwd.clone(),
|
||||
assistant_session: s.assistant_session.clone(),
|
||||
prompt,
|
||||
}
|
||||
}
|
||||
|
||||
fn settings(&self) -> JobSettings {
|
||||
JobSettings {
|
||||
name: self.name.clone(),
|
||||
schedule: self.schedule.clone(),
|
||||
agent: self.agent.clone(),
|
||||
model: self.model.clone(),
|
||||
permission_mode: self.permission_mode.clone(),
|
||||
timeout_minutes: self.timeout_minutes,
|
||||
cwd: self.cwd.clone(),
|
||||
assistant_session: self.assistant_session.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings and prompt of a job: settings from the database, the prompt from its file
|
||||
/// (a leftover frontmatter is ignored); not yet adopted jobs from the frontmatter.
|
||||
pub fn spec(job: &Job) -> Result<JobSpec> {
|
||||
let path = Path::new(&job.path);
|
||||
let Some(settings) = &job.settings else { return parse(path) };
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let (_, body) = crate::prompts::frontmatter(&text);
|
||||
if body.trim().is_empty() {
|
||||
bail!("{}: the instructions are empty", path.display());
|
||||
}
|
||||
Ok(JobSpec::from_settings(settings, body.trim().to_string()))
|
||||
}
|
||||
|
||||
/// Move the frontmatter of an older job file into the database and remove it from the
|
||||
/// file. Files without frontmatter get default settings (named after the file) and are
|
||||
/// switched off until the user sets a schedule. Returns the updated job.
|
||||
pub fn adopt(store: &crate::store::Store, job: Job) -> Result<Job> {
|
||||
if job.settings.is_some() {
|
||||
return Ok(job);
|
||||
}
|
||||
let path = Path::new(&job.path);
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let (meta, body) = crate::prompts::frontmatter(&text);
|
||||
let settings = if meta.is_empty() {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("automation").replace(['-', '_'], " ");
|
||||
store.job_set_enabled(&job.id, false)?;
|
||||
JobSettings::new(&stem)
|
||||
} else {
|
||||
let spec = parse(path)?;
|
||||
// Prompt only from now on.
|
||||
std::fs::write(path, format!("{}\n", body.trim()))?;
|
||||
spec.settings()
|
||||
};
|
||||
store.job_set_settings(&job.id, &settings)?;
|
||||
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
|
||||
}
|
||||
|
||||
/// Settings from the frontmatter of an older job file.
|
||||
pub fn parse(path: &Path) -> Result<JobSpec> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let (meta, body) = crate::prompts::frontmatter(&text);
|
||||
let unquote = |s: &String| s.trim().trim_matches('"').trim_matches('\'').to_string();
|
||||
// Values may carry a trailing comment (`auto # default`).
|
||||
let unquote = |s: &String| {
|
||||
let v = s.trim();
|
||||
let v = if v.starts_with('"') || v.starts_with('\'') { v } else { v.split(" #").next().unwrap_or(v).trim() };
|
||||
v.trim_matches('"').trim_matches('\'').to_string()
|
||||
};
|
||||
let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"<cron>\"`", path.display()))?;
|
||||
croner::Cron::from_str(&schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
|
||||
check_schedule(&schedule)?;
|
||||
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
|
||||
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
|
||||
bail!("invalid permission_mode '{permission_mode}'");
|
||||
@@ -76,6 +217,7 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
|
||||
permission_mode,
|
||||
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
|
||||
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
|
||||
assistant_session: None,
|
||||
prompt: body.trim().to_string(),
|
||||
})
|
||||
}
|
||||
@@ -89,7 +231,13 @@ pub fn next_run(schedule: &str, after_ms: i64) -> Option<i64> {
|
||||
|
||||
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
|
||||
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
|
||||
if let Some(c) = &spec.cwd {
|
||||
cwd_of(&path.display().to_string(), spec.cwd.as_deref())
|
||||
}
|
||||
|
||||
/// Working directory for a job file and its optional `cwd` setting.
|
||||
pub fn cwd_of(file: &str, cwd: Option<&str>) -> PathBuf {
|
||||
let path = Path::new(file);
|
||||
if let Some(c) = cwd {
|
||||
let p = PathBuf::from(c);
|
||||
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
|
||||
}
|
||||
@@ -106,13 +254,19 @@ fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
|
||||
/// Job with its parsed file and next run, for the UI and the tool.
|
||||
pub fn describe(job: &Job) -> Value {
|
||||
let mut v = json!(job);
|
||||
match parse(Path::new(&job.path)) {
|
||||
match spec(job) {
|
||||
Ok(spec) => {
|
||||
v["spec"] = json!(spec);
|
||||
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000);
|
||||
v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None });
|
||||
}
|
||||
Err(e) => v["error"] = json!(format!("{e:#}")),
|
||||
Err(e) => {
|
||||
v["error"] = json!(format!("{e:#}"));
|
||||
// The settings stay editable when the file is missing or empty.
|
||||
if let Some(st) = &job.settings {
|
||||
v["spec"] = json!(JobSpec::from_settings(st, String::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -125,7 +279,7 @@ pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
||||
/// Start a run; `payload` (webhook body) is appended to the instructions.
|
||||
pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
|
||||
let path = PathBuf::from(&job.path);
|
||||
let spec = match parse(&path) {
|
||||
let spec = match spec(job) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
|
||||
@@ -164,6 +318,9 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
||||
pinned: false,
|
||||
archived: false,
|
||||
folder: None,
|
||||
instructions: String::new(),
|
||||
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
||||
telegram_quiet: true,
|
||||
};
|
||||
let session = app.save_session(session)?;
|
||||
app.store.job_started(&job.id, &session.id, now)?;
|
||||
@@ -201,12 +358,20 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
||||
/// Background scheduler: checks every 30 s which jobs are due.
|
||||
pub fn spawn_scheduler(app: Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
// Older job files: settings from the frontmatter into the database.
|
||||
for job in app.store.jobs(None).unwrap_or_default().into_iter().filter(|j| j.settings.is_none()) {
|
||||
let path = job.path.clone();
|
||||
match adopt(&app.store, job) {
|
||||
Ok(_) => tracing::info!("automation {path}: settings moved from the file into the database"),
|
||||
Err(e) => tracing::warn!("automation {path}: {e:#}"),
|
||||
}
|
||||
}
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
let Ok(jobs) = app.store.jobs(None) else { continue };
|
||||
let now = now_ms();
|
||||
for job in jobs.into_iter().filter(|j| j.enabled) {
|
||||
let Ok(spec) = parse(Path::new(&job.path)) else { continue };
|
||||
let Ok(spec) = spec(&job) else { continue };
|
||||
// Due if a scheduled time passed since the last run (or since creation).
|
||||
let base = job.last_run.unwrap_or(job.created_at);
|
||||
let Some(next) = next_run(&spec.schedule, base) else { continue };
|
||||
@@ -233,22 +398,6 @@ pub fn spawn_scheduler(app: Arc<App>) {
|
||||
/// Tool: create, list, run and delete automations.
|
||||
pub struct Schedule;
|
||||
|
||||
/// Fields of a job file written by the agent tool or the "from template" dialog.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct NewJob {
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
#[serde(default = "default_agent")]
|
||||
pub agent: String,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default = "default_mode")]
|
||||
pub permission_mode: String,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_minutes: u64,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
fn default_agent() -> String {
|
||||
"build".into()
|
||||
}
|
||||
@@ -259,28 +408,20 @@ fn default_timeout() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
/// Write (and validate) a job file.
|
||||
pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
|
||||
croner::Cron::from_str(&j.schedule).map_err(|e| anyhow!("invalid schedule '{}': {e}", j.schedule))?;
|
||||
let q = |s: &str| format!("\"{}\"", s.replace('"', "'").replace('\n', " "));
|
||||
let plain = |s: &str| s.chars().filter(|c| c.is_alphanumeric() || "-_/.:".contains(*c)).collect::<String>();
|
||||
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(&j.name), q(&j.schedule), plain(&j.agent));
|
||||
if let Some(m) = j.model.as_deref().filter(|m| !m.is_empty()) {
|
||||
fm.push_str(&format!("model: {}\n", plain(m)));
|
||||
/// Write the instructions file, register it and store the settings.
|
||||
pub fn create(store: &crate::store::Store, owner: &str, path: &Path, mut settings: JobSettings, prompt: &str, enabled: bool) -> Result<Job> {
|
||||
settings.validate()?;
|
||||
if path.exists() {
|
||||
bail!("{} already exists", path.display());
|
||||
}
|
||||
fm.push_str(&format!("permission_mode: {}\n", plain(&j.permission_mode)));
|
||||
fm.push_str(&format!("timeout_minutes: {}\n---\n", j.timeout_minutes.clamp(1, 24 * 60)));
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, format!("{fm}{}\n", j.prompt.trim()))?;
|
||||
match parse(path) {
|
||||
Ok(spec) => Ok(spec),
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(path);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
std::fs::write(path, format!("{}\n", prompt.trim()))?;
|
||||
let job = store.job_register(owner, &path.display().to_string())?;
|
||||
store.job_set_settings(&job.id, &settings)?;
|
||||
store.job_set_enabled(&job.id, enabled)?;
|
||||
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
|
||||
}
|
||||
|
||||
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
|
||||
@@ -302,10 +443,11 @@ impl Tool for Schedule {
|
||||
"schedule"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Manage automations: agents that run on a cron schedule from a Markdown job file \
|
||||
(.codecraft/jobs/<name>.md in the working directory). Actions: \
|
||||
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the job file and registers it; \
|
||||
`register` (path) registers an existing job file; `list`; `run` (id) starts a run now; \
|
||||
"Manage automations: agents that run on a cron schedule. The instructions are a Markdown file \
|
||||
(.codecraft/jobs/<name>.md in the working directory, only the prompt); name, schedule, mode, model and \
|
||||
limits are settings of the automation. Actions: \
|
||||
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the file and registers it; \
|
||||
`register` (path) registers an existing Markdown file (switched off until a schedule is set in the UI); `list`; `run` (id) starts a run now; \
|
||||
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
|
||||
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
|
||||
The prompt must be self-contained: the run starts a fresh session without this conversation."
|
||||
@@ -348,19 +490,15 @@ impl Tool for Schedule {
|
||||
let prompt = arg_str(&input, "prompt")?;
|
||||
let rel = format!(".codecraft/jobs/{}.md", slug(name));
|
||||
let path = ctx.resolve(&rel)?;
|
||||
write_file(
|
||||
&path,
|
||||
&NewJob {
|
||||
name: name.into(),
|
||||
schedule: schedule.into(),
|
||||
agent: input["agent"].as_str().unwrap_or("build").into(),
|
||||
model: input["model"].as_str().map(String::from),
|
||||
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
|
||||
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
|
||||
prompt: prompt.into(),
|
||||
},
|
||||
)?;
|
||||
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
|
||||
let settings = JobSettings {
|
||||
schedule: schedule.into(),
|
||||
agent: input["agent"].as_str().unwrap_or("build").into(),
|
||||
model: input["model"].as_str().map(String::from),
|
||||
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
|
||||
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
|
||||
..JobSettings::new(name)
|
||||
};
|
||||
let job = create(&ctx.app.store, &owner, &path, settings, prompt, true)?;
|
||||
let d = describe(&job);
|
||||
Ok(ToolOutput::text(format!(
|
||||
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
|
||||
@@ -372,8 +510,10 @@ impl Tool for Schedule {
|
||||
}
|
||||
"register" => {
|
||||
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||
parse(&path)?;
|
||||
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
|
||||
if !path.is_file() {
|
||||
bail!("{} is not a file", path.display());
|
||||
}
|
||||
let job = adopt(&ctx.app.store, ctx.app.store.job_register(&owner, &path.display().to_string())?)?;
|
||||
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
|
||||
}
|
||||
"list" => {
|
||||
@@ -447,6 +587,35 @@ mod tests {
|
||||
assert_eq!(job_cwd(&p, &s), dir);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopts_frontmatter_and_creates_prompt_only_files() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-job-adopt-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
|
||||
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
|
||||
// Older file: settings move into the database, the file keeps only the prompt.
|
||||
let p = dir.join(".codecraft/jobs/old.md");
|
||||
std::fs::write(&p, "---\nname: \"Old\"\nschedule: \"0 4 * * *\"\npermission_mode: bypass # comment\n---\nDo old things.\n").unwrap();
|
||||
let job = adopt(&store, store.job_register("u", &p.display().to_string()).unwrap()).unwrap();
|
||||
let st = job.settings.clone().unwrap();
|
||||
assert_eq!((st.name.as_str(), st.schedule.as_str(), st.permission_mode.as_str()), ("Old", "0 4 * * *", "bypass"));
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "Do old things.\n");
|
||||
assert_eq!(spec(&job).unwrap().prompt, "Do old things.");
|
||||
assert!(job.enabled);
|
||||
// Plain Markdown: default settings, switched off.
|
||||
let q = dir.join(".codecraft/jobs/weekly-report.md");
|
||||
std::fs::write(&q, "Write a report.\n").unwrap();
|
||||
let job = adopt(&store, store.job_register("u", &q.display().to_string()).unwrap()).unwrap();
|
||||
assert_eq!(job.settings.as_ref().unwrap().name, "weekly report");
|
||||
assert!(!job.enabled);
|
||||
// New automation.
|
||||
let r = dir.join(".codecraft/jobs/new.md");
|
||||
assert!(create(&store, "u", &r, JobSettings { schedule: "nope".into(), ..JobSettings::new("N") }, "x", false).is_err());
|
||||
let job = create(&store, "u", &r, JobSettings::new("New"), "# New\n\nGoal", false).unwrap();
|
||||
assert_eq!(spec(&job).unwrap().prompt, "# New\n\nGoal");
|
||||
assert!(check_schedule("0 3 * *").is_err() && check_schedule("*/15 8-18 * * 1-5").is_ok());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// --- incoming webhooks -------------------------------------------------------------
|
||||
|
||||
+10
-1
@@ -4,6 +4,7 @@ mod api;
|
||||
mod app;
|
||||
mod auth;
|
||||
mod backup;
|
||||
mod blobs;
|
||||
mod cli_api;
|
||||
mod budget;
|
||||
mod config;
|
||||
@@ -30,6 +31,7 @@ mod runner;
|
||||
mod sandbox;
|
||||
mod shells;
|
||||
mod store;
|
||||
mod telegram;
|
||||
mod terminal;
|
||||
mod tools;
|
||||
mod types;
|
||||
@@ -54,7 +56,12 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
let path = config::config_path();
|
||||
let (cfg, _) = config::load_or_init(&path)?;
|
||||
let (mut cfg, _) = config::load_or_init(&path)?;
|
||||
// A backup restored from the settings is applied here, before the database is opened.
|
||||
if backup::apply_pending_restore(&cfg.data_dir(), &path)? {
|
||||
tracing::warn!("restored database and config from a backup");
|
||||
cfg = config::load_or_init(&path)?.0;
|
||||
}
|
||||
tracing::info!("config: {}", path.display());
|
||||
tracing::info!("workspace: {}", cfg.workspace().display());
|
||||
let _ = sandbox::probe().await;
|
||||
@@ -67,6 +74,8 @@ async fn main() -> Result<()> {
|
||||
update::spawn_checker(app.clone());
|
||||
health::spawn_checker(app.clone());
|
||||
digest::spawn(app.clone());
|
||||
budget::spawn_alerts(app.clone());
|
||||
telegram::spawn(app.clone());
|
||||
backup::spawn(app.clone());
|
||||
let router = api::router(app);
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
|
||||
+8
-1
@@ -160,7 +160,7 @@ impl McpServer {
|
||||
json!({
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {"elicitation": {}, "roots": {"listChanged": false}},
|
||||
"clientInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")}
|
||||
"clientInfo": {"name": "codecraft", "version": codecraft_core::version::VERSION}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -212,6 +212,8 @@ impl McpServer {
|
||||
match c["type"].as_str().unwrap_or_default() {
|
||||
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
|
||||
"image" => out.images.push(ImageData {
|
||||
blob: None,
|
||||
size: None,
|
||||
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
|
||||
data: c["data"].as_str().unwrap_or_default().into(),
|
||||
}),
|
||||
@@ -452,6 +454,11 @@ pub struct McpManager {
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
/// Connection state per server (for the status overlay).
|
||||
pub fn states(&self) -> BTreeMap<String, McpState> {
|
||||
self.states.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn status_json(&self) -> Value {
|
||||
let states = self.states.lock().unwrap();
|
||||
let servers = self.servers.lock().unwrap();
|
||||
|
||||
+74
-9
@@ -23,6 +23,8 @@ pub struct Agent {
|
||||
/// Allowed tools; None = all.
|
||||
pub tools: Option<Vec<String>>,
|
||||
pub read_only: bool,
|
||||
/// "coding" or "chat" (plain conversation prompt).
|
||||
pub style: String,
|
||||
pub model: Option<String>,
|
||||
/// "builtin", "config" or "file".
|
||||
pub source: String,
|
||||
@@ -41,11 +43,12 @@ pub use codecraft_core::prompts::*;
|
||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||
let craft = craft_prompt(clean_room);
|
||||
vec![
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -113,7 +116,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
|
||||
prompt: body,
|
||||
tools,
|
||||
read_only: false,
|
||||
read_only: meta.get("read_only").is_some_and(|v| v == "true"),
|
||||
style: meta.get("style").filter(|v| *v == "chat").cloned().unwrap_or_else(|| "coding".into()),
|
||||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||
source: "file".into(),
|
||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||
@@ -131,7 +135,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
|
||||
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
|
||||
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
|
||||
read_only: base.as_ref().is_some_and(|b| b.read_only),
|
||||
read_only: a.read_only.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.read_only)),
|
||||
style: a.style.clone().filter(|s| s == "chat" || s == "coding").or(base.as_ref().map(|b| b.style.clone())).unwrap_or_else(|| "coding".into()),
|
||||
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
|
||||
source: "config".into(),
|
||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||
@@ -142,8 +147,40 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
map.into_values().collect()
|
||||
}
|
||||
|
||||
pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent {
|
||||
agents(app, root, cwd)
|
||||
/// Personal modes a user created for themselves (user setting "modes").
|
||||
pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap<String, crate::config::AgentConfig> {
|
||||
app.store.user_setting(user_id, "modes").unwrap_or_default()
|
||||
}
|
||||
|
||||
/// All agents plus the user's personal modes. Personal modes never replace built-in,
|
||||
/// admin or project ones of the same name.
|
||||
pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> Vec<Agent> {
|
||||
let mut list = agents(app, root, cwd);
|
||||
let Some(uid) = user_id else { return list };
|
||||
for (name, a) in personal_modes(app, uid) {
|
||||
if list.iter().any(|x| x.name == name) {
|
||||
continue;
|
||||
}
|
||||
list.push(Agent {
|
||||
name,
|
||||
description: a.description.unwrap_or_default(),
|
||||
mode: "primary".into(),
|
||||
prompt: a.prompt.unwrap_or_default(),
|
||||
tools: a.tools,
|
||||
read_only: a.read_only.unwrap_or(false),
|
||||
style: a.style.filter(|s| s == "chat").unwrap_or_else(|| "coding".into()),
|
||||
model: a.model.filter(|m| !m.is_empty()),
|
||||
source: "personal".into(),
|
||||
max_parallel: None,
|
||||
refine: a.refine,
|
||||
path: None,
|
||||
});
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
pub fn find_agent_for(app: &App, root: &Path, cwd: &Path, name: &str, user_id: Option<&str>) -> Agent {
|
||||
agents_for(app, root, cwd, user_id)
|
||||
.into_iter()
|
||||
.find(|a| a.name == name)
|
||||
.unwrap_or_else(|| builtin_agents(true).remove(0))
|
||||
@@ -177,7 +214,35 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
|
||||
out
|
||||
}
|
||||
|
||||
/// The plain chat mode: no coding instructions, project files or memory – only the
|
||||
/// date, the model and what the user keeps in their personal memory.
|
||||
/// Add the instructions the user set for this chat (they come last, so they win).
|
||||
pub fn with_chat_instructions(mut system: String, instructions: &str) -> String {
|
||||
if !instructions.trim().is_empty() {
|
||||
system.push_str(&format!(
|
||||
"\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n",
|
||||
instructions.trim()
|
||||
));
|
||||
}
|
||||
system
|
||||
}
|
||||
|
||||
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
|
||||
let mut s = agent.prompt.clone();
|
||||
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
|
||||
if let Some(u) = owner {
|
||||
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
|
||||
if !memory.trim().is_empty() {
|
||||
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
|
||||
if agent.style == "chat" {
|
||||
return chat_prompt(app, agent, model, owner);
|
||||
}
|
||||
let mut s = String::from(BASE_PROMPT);
|
||||
s.push_str("\n\n");
|
||||
s.push_str(&agent.prompt);
|
||||
|
||||
+69
-27
@@ -268,7 +268,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let roots = app.user_roots(session.owner_id.as_deref());
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let mut agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
|
||||
let mut agent = prompts::find_agent_for(&app, &root, &cwd, &session.agent, session.owner_id.as_deref());
|
||||
let clean_room = session.clean_room(app.config().craft.clean_room);
|
||||
// The craft prompt follows the session's clean room / deep switch.
|
||||
if agent.name == "craft" && agent.source == "builtin" {
|
||||
@@ -284,20 +284,21 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
|
||||
let threshold = app.config().server.auto_compact_threshold;
|
||||
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
|
||||
compact(app.clone(), session_id, cancel.clone()).await?;
|
||||
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
||||
let tools = available_tools(&app, &agent, owner.as_ref());
|
||||
let history = active_history(app.store.messages(session_id)?);
|
||||
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||
let req = ChatRequest {
|
||||
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()),
|
||||
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
|
||||
messages: history,
|
||||
tools: tools.iter().map(|t| t.def()).collect(),
|
||||
max_tokens: model.max_output.unwrap_or(16_000),
|
||||
reasoning: true,
|
||||
session_id: Some(session_id.to_string()),
|
||||
model: model.clone(),
|
||||
};
|
||||
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
|
||||
@@ -562,7 +563,15 @@ async fn execute_call(
|
||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||
run_task(app, &ctx, input, depth).await
|
||||
} else {
|
||||
tool.run(input, &ctx).await
|
||||
let moves = name == "bash" && input["command"].as_str().is_some_and(moves_files);
|
||||
let out = tool.run(input, &ctx).await;
|
||||
if moves {
|
||||
// Generated images the command moved or renamed keep their entries.
|
||||
let a = app.clone();
|
||||
let owner = session.owner_id.clone();
|
||||
tokio::task::spawn_blocking(move || crate::images::relocate_missing(&a, owner.as_deref()));
|
||||
}
|
||||
out
|
||||
};
|
||||
// Diagnostics: report new errors of the changed file right away.
|
||||
let res = match res {
|
||||
@@ -608,6 +617,11 @@ async fn execute_call(
|
||||
}
|
||||
}
|
||||
|
||||
/// A shell command that may move or rename files (`mv`, `git mv`, `rename`).
|
||||
fn moves_files(cmd: &str) -> bool {
|
||||
cmd.split(|c: char| c.is_whitespace() || ";&|()`$".contains(c)).any(|w| w == "mv" || w == "rename")
|
||||
}
|
||||
|
||||
async fn run_task(app: &Arc<App>, ctx: &ToolCtx, input: Value, depth: usize) -> Result<tools::ToolOutput> {
|
||||
let prompt = tools::arg_str(&input, "prompt")?.to_string();
|
||||
let description = input["description"].as_str().unwrap_or("subagent").to_string();
|
||||
@@ -666,7 +680,7 @@ pub async fn run_subagent(app: Arc<App>, ctx: &ToolCtx, agent: &str, description
|
||||
fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> {
|
||||
let parent = app.session(&ctx.session_id)?;
|
||||
let root = ctx.root();
|
||||
let parent_agent = prompts::find_agent(app, &root, &ctx.cwd, &parent.agent);
|
||||
let parent_agent = prompts::find_agent_for(app, &root, &ctx.cwd, &parent.agent, parent.owner_id.as_deref());
|
||||
let agents = prompts::agents(app, &root, &ctx.cwd);
|
||||
let mut agent = agents
|
||||
.iter()
|
||||
@@ -703,6 +717,8 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
||||
pinned: false,
|
||||
archived: false,
|
||||
folder: None,
|
||||
instructions: String::new(),
|
||||
telegram_quiet: false,
|
||||
};
|
||||
let child = app.save_session(child)?;
|
||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||
@@ -745,7 +761,7 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
|
||||
for m in &req.messages {
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(),
|
||||
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => chars += text.len(),
|
||||
Part::ToolCall { input, .. } => chars += input.to_string().len(),
|
||||
Part::ToolResult { content, images: imgs, .. } => {
|
||||
chars += content.len();
|
||||
@@ -881,6 +897,7 @@ async fn stream_step(
|
||||
cost: req.model.cost(usage),
|
||||
error,
|
||||
user_id: None,
|
||||
allowance: usage.allowance,
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
@@ -911,9 +928,7 @@ async fn stream_step(
|
||||
app.store.add_message(&session.id, &message)?;
|
||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||
}
|
||||
let mut s = app.session(&session.id)?;
|
||||
s.last_usage = Some(usage);
|
||||
app.save_session(s)?;
|
||||
app.update_session(&session.id, |s| s.last_usage = Some(usage))?;
|
||||
return Ok(Step { message, stop_reason });
|
||||
}
|
||||
}
|
||||
@@ -966,6 +981,7 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
|
||||
cost: model.cost(usage),
|
||||
error: false,
|
||||
user_id: None,
|
||||
allowance: usage.allowance,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -985,17 +1001,19 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||
tools: vec![],
|
||||
max_tokens: 2000,
|
||||
reasoning: false,
|
||||
session_id: Some(session.id.clone()),
|
||||
model: model.clone(),
|
||||
};
|
||||
match complete_text(&app, &spec, provider, req, CancellationToken::new()).await {
|
||||
Ok((title, usage)) => {
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "title");
|
||||
let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string();
|
||||
if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) {
|
||||
if s.title.is_empty() {
|
||||
s.title = title.chars().take(80).collect();
|
||||
let _ = app.save_session(s);
|
||||
}
|
||||
if !title.is_empty() {
|
||||
let _ = app.update_session(&session.id, |s| {
|
||||
if s.title.is_empty() {
|
||||
s.title = title.chars().take(80).collect();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("title generation failed: {e:#}"),
|
||||
@@ -1032,6 +1050,7 @@ pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<S
|
||||
tools: vec![],
|
||||
max_tokens: 2000,
|
||||
reasoning: false,
|
||||
session_id: Some(session.id.clone()),
|
||||
model: model.clone(),
|
||||
};
|
||||
let (text, usage) = complete_text(&app, &spec, provider, req, CancellationToken::new()).await?;
|
||||
@@ -1091,7 +1110,7 @@ fn infer_phase(tool: &str, input: &Value, deep: bool) -> Option<&'static str> {
|
||||
/// Move the craft phase forward (never backwards); `only_from` restricts the transition.
|
||||
fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) {
|
||||
let root = app.root_session_id(session_id);
|
||||
let Ok(mut s) = app.session(&root) else { return };
|
||||
let Ok(s) = app.session(&root) else { return };
|
||||
let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p));
|
||||
if let Some(from) = only_from {
|
||||
if s.phase.as_deref() != Some(from) {
|
||||
@@ -1108,8 +1127,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
|
||||
if s.phase.as_deref() == Some(phase) {
|
||||
return;
|
||||
}
|
||||
s.phase = Some(phase.to_string());
|
||||
let _ = app.save_session(s);
|
||||
let _ = app.update_session(&root, |s| s.phase = Some(phase.to_string()));
|
||||
}
|
||||
|
||||
/// Review the last turn against the user's request. Returns feedback for the
|
||||
@@ -1122,7 +1140,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
model.effort = Some("medium".into());
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
|
||||
let result = async {
|
||||
let history = active_history(app.store.messages(&session.id)?);
|
||||
let history = active_history(app.store.messages_for_model(&session.id)?);
|
||||
// The user's request: the last user message with text that is not feedback/tool output.
|
||||
let req_idx = history
|
||||
.iter()
|
||||
@@ -1159,7 +1177,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
agent.name,
|
||||
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
|
||||
);
|
||||
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone() };
|
||||
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) };
|
||||
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
|
||||
record_side_usage(app, session, &spec, &model, &usage, "review");
|
||||
let text = strip_think(&text);
|
||||
@@ -1212,7 +1230,7 @@ pub fn transcript_of(history: &[Message]) -> String {
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } => body.push_str(&format!("{text}\n")),
|
||||
Part::Summary { text } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
|
||||
Part::Summary { text, .. } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
|
||||
Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))),
|
||||
Part::ToolResult { name, content, is_error, .. } => {
|
||||
body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000)))
|
||||
@@ -1229,9 +1247,25 @@ pub fn transcript_of(history: &[Message]) -> String {
|
||||
}
|
||||
|
||||
/// Replace the active history with a summary (the old messages stay visible in the UI).
|
||||
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<()> {
|
||||
/// Summarize the history. `auto` = (context tokens, context limit) when triggered by a
|
||||
/// nearly full context; the chat shows the progress and the reason.
|
||||
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
|
||||
let event = |state: &str| Event::Compaction {
|
||||
session_id: session_id.to_string(),
|
||||
state: state.into(),
|
||||
auto: auto.is_some(),
|
||||
tokens: auto.map(|a| a.0),
|
||||
limit: auto.map(|a| a.1),
|
||||
};
|
||||
app.emit(event("started"));
|
||||
let res = compact_inner(app.clone(), session_id, cancel, auto).await;
|
||||
app.emit(event(if res.is_ok() { "done" } else { "failed" }));
|
||||
res
|
||||
}
|
||||
|
||||
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
|
||||
let session = app.session(session_id)?;
|
||||
let history = active_history(app.store.messages(session_id)?);
|
||||
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||
if history.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1254,6 +1288,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
tools: vec![],
|
||||
max_tokens: 16_000,
|
||||
reasoning: false,
|
||||
session_id: Some(session.id.clone()),
|
||||
model: model.clone(),
|
||||
};
|
||||
let (text, usage) = complete_text(&app, &spec, provider.clone(), req, cancel.clone()).await?;
|
||||
@@ -1267,12 +1302,11 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
if summary.trim().is_empty() {
|
||||
bail!("compaction produced an empty summary");
|
||||
}
|
||||
save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?;
|
||||
let mut s = app.session(session_id)?;
|
||||
s.last_usage = None;
|
||||
let tokens = auto.map(|a| a.0).or_else(|| session.last_usage.as_ref().map(|u| u.context_tokens()));
|
||||
save_user_parts(&app, session_id, vec![Part::Summary { text: summary, auto: auto.is_some(), tokens }])?;
|
||||
app.update_session(session_id, |s| s.last_usage = None)?;
|
||||
// Files must be re-read after compaction before editing.
|
||||
app.with_runtime(session_id, |r| r.read_files.clear());
|
||||
app.save_session(s)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1280,6 +1314,14 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_commands_that_move_files() {
|
||||
assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/"));
|
||||
assert!(moves_files("git mv a.png b.png"));
|
||||
assert!(moves_files("cd art;mv x y"));
|
||||
assert!(!moves_files("ls -la movies && cat remove.txt"));
|
||||
}
|
||||
|
||||
fn call(id: &str) -> Part {
|
||||
Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None }
|
||||
}
|
||||
|
||||
+27
-1
@@ -18,6 +18,8 @@ pub struct SandboxSpec {
|
||||
pub roots: Vec<PathBuf>,
|
||||
/// Host directory mounted as the user's home.
|
||||
pub home: PathBuf,
|
||||
/// Extra environment (the user's git identity).
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl SandboxSpec {
|
||||
@@ -35,6 +37,8 @@ pub async fn probe() -> Result<(), String> {
|
||||
}
|
||||
let res = match Command::new("bwrap")
|
||||
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
|
||||
// The dynamic loader lives in /lib64 on x86_64 (/lib on arm64).
|
||||
.args(["--ro-bind-try", "/lib64", "/lib64", "--ro-bind-try", "/lib32", "/lib32"])
|
||||
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
|
||||
.env_clear()
|
||||
.output()
|
||||
@@ -69,7 +73,7 @@ pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
|
||||
}
|
||||
let home = app.data_dir().join("users").join(&user.username).join("home");
|
||||
let _ = std::fs::create_dir_all(&home);
|
||||
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
|
||||
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home, env: crate::gitcreds::identity_env(app, &user.id) })
|
||||
}
|
||||
|
||||
/// Build a command that runs `program args` inside the sandbox in `cwd`.
|
||||
@@ -148,9 +152,31 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
|
||||
] {
|
||||
push(&[&"--setenv", &k, &v]);
|
||||
}
|
||||
for (k, v) in &spec.env {
|
||||
push(&[&"--setenv", k, v]);
|
||||
}
|
||||
push(&[&"--chdir", &cwd, &program]);
|
||||
for a in args {
|
||||
push(&[a]);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn git_identity_reaches_the_sandbox() {
|
||||
let spec = SandboxSpec {
|
||||
username: "ann".into(),
|
||||
roots: vec![],
|
||||
home: PathBuf::from("/tmp/ann"),
|
||||
env: vec![("GIT_AUTHOR_NAME".into(), "Ann Example".into())],
|
||||
};
|
||||
let args: Vec<String> = bwrap_args(&spec, Path::new("/tmp"), &[], true, "bash", &[]).into_iter().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
let i = args.iter().position(|a| a == "GIT_AUTHOR_NAME").expect("identity set");
|
||||
assert_eq!(args[i - 1], "--setenv");
|
||||
assert_eq!(args[i + 1], "Ann Example");
|
||||
}
|
||||
}
|
||||
+206
-12
@@ -1,6 +1,6 @@
|
||||
//! SQLite persistence for sessions and messages.
|
||||
|
||||
use crate::types::{Message, Part, Session, now_ms};
|
||||
use crate::types::{ImageData, Message, Part, Session, now_ms};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use anyhow::Result;
|
||||
@@ -90,6 +90,13 @@ impl Store {
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
|
||||
-- Images of messages, referenced by hash from the message JSON (see blobs.rs).
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
hash TEXT PRIMARY KEY,
|
||||
media_type TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
@@ -162,6 +169,9 @@ impl Store {
|
||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
||||
("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings TEXT"),
|
||||
("images", "blob", "ALTER TABLE images ADD COLUMN blob TEXT"),
|
||||
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
||||
] {
|
||||
let exists: bool = conn
|
||||
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
||||
@@ -238,7 +248,7 @@ impl Store {
|
||||
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
|
||||
for p in &m.parts {
|
||||
let text = match p {
|
||||
Part::Text { text } | Part::Summary { text } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::ToolResult { content, .. } => content.as_str(),
|
||||
_ => continue,
|
||||
};
|
||||
@@ -273,12 +283,21 @@ impl Store {
|
||||
}
|
||||
|
||||
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
|
||||
// Images go to the blob table (encoded before taking the lock).
|
||||
let mut m = m.clone();
|
||||
let blobs = crate::blobs::externalize(&mut m);
|
||||
let conn = self.conn.lock().unwrap();
|
||||
for b in &blobs {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![b.hash, b.media_type, b.bytes, now_ms()],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
|
||||
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
|
||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data",
|
||||
params![m.id, session_id, serde_json::to_string(m)?],
|
||||
params![m.id, session_id, serde_json::to_string(&m)?],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
|
||||
@@ -299,6 +318,107 @@ impl Store {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Messages with the bytes of their images filled in – for model requests and exports.
|
||||
pub fn messages_for_model(&self, session_id: &str) -> Result<Vec<Message>> {
|
||||
let mut out = self.messages(session_id)?;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
for m in &mut out {
|
||||
crate::blobs::for_each_image(m, |img| fill_image(&conn, &mut cache, img));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fill in the bytes of one image reference (no-op for inline images).
|
||||
pub fn resolve_image(&self, img: &ImageData) -> ImageData {
|
||||
let mut img = img.clone();
|
||||
let conn = self.conn.lock().unwrap();
|
||||
fill_image(&conn, &mut std::collections::HashMap::new(), &mut img);
|
||||
img
|
||||
}
|
||||
|
||||
/// One stored image: (media type, bytes).
|
||||
pub fn blob(&self, hash: &str) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row("SELECT media_type, data FROM blobs WHERE hash = ?1", [hash], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Move inline images of older messages to the blob table (once, after the update
|
||||
/// that introduced it). Does not touch `updated_at` of the sessions.
|
||||
pub fn migrate_inline_images(&self) -> Result<usize> {
|
||||
let ids: Vec<String> = {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM messages WHERE data LIKE '%\"media_type\"%\"data\":\"%'")?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
rows
|
||||
};
|
||||
let mut moved = 0;
|
||||
for id in ids {
|
||||
let data: Option<String> = {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row("SELECT data FROM messages WHERE id = ?1", [&id], |r| r.get(0)).optional()?
|
||||
};
|
||||
let Some(data) = data else { continue };
|
||||
let Ok(mut m) = serde_json::from_str::<Message>(&data) else { continue };
|
||||
let blobs = crate::blobs::externalize(&mut m);
|
||||
if blobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
for b in &blobs {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![b.hash, b.media_type, b.bytes, now_ms()],
|
||||
)?;
|
||||
}
|
||||
conn.execute("UPDATE messages SET data = ?2 WHERE id = ?1", params![id, serde_json::to_string(&m)?])?;
|
||||
moved += blobs.len();
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Delete images no message refers to any more (deleted or truncated sessions).
|
||||
pub fn gc_blobs(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
let mut stmt = conn.prepare("SELECT data FROM messages WHERE data LIKE '%\"blob\":\"%'")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let data: String = row.get(0)?;
|
||||
for (i, _) in data.match_indices("\"blob\":\"") {
|
||||
if let Some(h) = data.get(i + 8..i + 72) {
|
||||
used.insert(h.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
// Copies of generated images (see ImageRecord::blob).
|
||||
for h in conn.prepare("SELECT blob FROM images WHERE blob IS NOT NULL")?.query_map([], |r| r.get::<_, String>(0))? {
|
||||
used.insert(h?);
|
||||
}
|
||||
let all: Vec<String> = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
|
||||
let mut removed = 0;
|
||||
for h in all.iter().filter(|h| !used.contains(*h)) {
|
||||
removed += conn.execute("DELETE FROM blobs WHERE hash = ?1", [h])?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Give the space of deleted data back to the file system when it is worth it.
|
||||
pub fn vacuum_if_worthwhile(&self) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let free: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
|
||||
let page: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
|
||||
if free * page < 32 * 1024 * 1024 {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.execute_batch("VACUUM")?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Remove all messages after (and including) the given one – used for "edit & resend".
|
||||
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -311,6 +431,20 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the bytes of a stored image into `img.data`.
|
||||
fn fill_image(conn: &Connection, cache: &mut std::collections::HashMap<String, String>, img: &mut ImageData) {
|
||||
use base64::Engine;
|
||||
let Some(hash) = img.blob.clone().filter(|_| img.data.is_empty()) else { return };
|
||||
if !cache.contains_key(&hash) {
|
||||
let Ok(bytes) = conn.query_row("SELECT data FROM blobs WHERE hash = ?1", [&hash], |r| r.get::<_, Vec<u8>>(0)) else {
|
||||
tracing::warn!("image {hash} is missing in the store");
|
||||
return;
|
||||
};
|
||||
cache.insert(hash.clone(), base64::engine::general_purpose::STANDARD.encode(bytes));
|
||||
}
|
||||
img.data = cache[&hash].clone();
|
||||
}
|
||||
|
||||
/// One model request, as recorded for statistics.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UsageRecord {
|
||||
@@ -331,6 +465,8 @@ pub struct UsageRecord {
|
||||
pub error: bool,
|
||||
/// User to bill when there is no session (CLI requests); else the session owner.
|
||||
pub user_id: Option<String>,
|
||||
/// opencode Go: share of the plan's usage limits (USD), as reported by the provider.
|
||||
pub allowance: Option<f64>,
|
||||
}
|
||||
|
||||
/// Aggregation SQL shared by all statistics queries.
|
||||
@@ -376,13 +512,14 @@ impl Store {
|
||||
conn.execute(
|
||||
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
ttft_ms, duration_ms, cost, error, user_id)
|
||||
ttft_ms, duration_ms, cost, error, user_id, allowance)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
|
||||
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)))",
|
||||
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)), ?16)",
|
||||
params![
|
||||
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
||||
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
|
||||
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id
|
||||
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id,
|
||||
u.allowance
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -834,6 +971,10 @@ pub struct ImageRecord {
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
pub created_at: i64,
|
||||
/// Copy of the bytes in the image store: the file may be moved or deleted later
|
||||
/// (agents tidy up folders), the image stays viewable.
|
||||
#[serde(skip)]
|
||||
pub blob: Option<String>,
|
||||
}
|
||||
|
||||
fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
|
||||
@@ -847,21 +988,62 @@ fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
|
||||
width: r.get(6)?,
|
||||
height: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
blob: r.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at";
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at, blob";
|
||||
|
||||
impl Store {
|
||||
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at],
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at, img.blob],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store bytes in the image store; returns their hash.
|
||||
pub fn put_blob(&self, media_type: &str, bytes: &[u8]) -> Result<String> {
|
||||
let hash = crate::blobs::hash(bytes);
|
||||
self.conn.lock().unwrap().execute(
|
||||
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![hash, media_type, bytes, now_ms()],
|
||||
)?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Generated images from before the copies in the image store (id, path).
|
||||
pub fn images_without_copy(&self) -> Result<Vec<(String, String)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, path FROM images WHERE blob IS NULL")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn set_image_blob(&self, id: &str, hash: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE images SET blob = ?2 WHERE id = ?1", params![id, hash])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file or folder was moved: generated images in it follow.
|
||||
pub fn move_image_paths(&self, from: &str, to: &str) -> Result<usize> {
|
||||
let from = from.trim_end_matches('/');
|
||||
let to = to.trim_end_matches('/');
|
||||
Ok(self.conn.lock().unwrap().execute(
|
||||
"UPDATE images SET path = ?2 || substr(path, length(?1) + 1)
|
||||
WHERE path = ?1 OR substr(path, 1, length(?1) + 1) = ?1 || '/'",
|
||||
params![from, to],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// The file of a generated image was found somewhere else.
|
||||
pub fn set_image_path(&self, id: &str, path: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE images SET path = ?2 WHERE id = ?1", params![id, path])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Latest images; `user = None` lists everybody's (admins).
|
||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -886,6 +1068,12 @@ impl Store {
|
||||
|
||||
impl Store {
|
||||
/// Cost per provider since `from` (ms).
|
||||
/// opencode Go: sum of reported allowance of one provider since `from` (ms).
|
||||
pub fn allowance_since(&self, provider: &str, from: i64) -> Result<f64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row("SELECT COALESCE(SUM(allowance), 0) FROM usage WHERE provider = ?1 AND ts >= ?2", params![provider, from], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
pub fn cost_by_provider(&self, from: i64) -> Result<std::collections::BTreeMap<String, f64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?;
|
||||
@@ -998,13 +1186,14 @@ impl Store {
|
||||
last_status: r.get(6)?,
|
||||
created_at: r.get(7)?,
|
||||
webhook: r.get::<_, Option<String>>(8)?.is_some(),
|
||||
settings: r.get::<_, Option<String>>(9)?.and_then(|j| serde_json::from_str(&j).ok()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn jobs(&self, owner: Option<&str>) -> Result<Vec<crate::jobs::Job>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs
|
||||
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner], Self::job_row)?;
|
||||
@@ -1014,7 +1203,7 @@ impl Store {
|
||||
pub fn job(&self, id: &str) -> Result<Option<crate::jobs::Job>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE id = ?1", [id], Self::job_row)
|
||||
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE id = ?1", [id], Self::job_row)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
@@ -1030,7 +1219,7 @@ impl Store {
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE path = ?1",
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE path = ?1",
|
||||
[path],
|
||||
Self::job_row,
|
||||
)?)
|
||||
@@ -1063,6 +1252,11 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Set (Some) or remove (None) the webhook secret of a job.
|
||||
pub fn job_set_settings(&self, id: &str, settings: &crate::jobs::JobSettings) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE jobs SET settings = ?2 WHERE id = ?1", rusqlite::params![id, serde_json::to_string(settings)?])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn job_set_webhook(&self, id: &str, secret: Option<&str>) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE jobs SET webhook_secret = ?2 WHERE id = ?1", rusqlite::params![id, secret])?;
|
||||
Ok(())
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+39
-5
@@ -18,13 +18,16 @@ use tokio::sync::broadcast;
|
||||
|
||||
/// Output kept for re-attaching clients.
|
||||
const SCROLLBACK: usize = 256 * 1024;
|
||||
/// Detached terminals are stopped after this time.
|
||||
/// Detached terminals outside a chat are stopped after this time; terminals that
|
||||
/// belong to a chat stay open until they are closed, exit or the chat is deleted.
|
||||
const DETACHED_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const MAX_PER_USER: usize = 8;
|
||||
const MAX_PER_USER: usize = 16;
|
||||
|
||||
pub struct Term {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
/// Chat the terminal is a tab of.
|
||||
pub session_id: Option<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub created_at: i64,
|
||||
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
|
||||
@@ -57,6 +60,7 @@ impl Term {
|
||||
json!({
|
||||
"id": self.id,
|
||||
"cwd": self.cwd,
|
||||
"session_id": self.session_id,
|
||||
"created_at": self.created_at,
|
||||
"alive": self.alive(),
|
||||
"attached": *self.attached.lock().unwrap() > 0,
|
||||
@@ -76,6 +80,30 @@ impl Terminals {
|
||||
v.into_iter().map(|(_, i)| i).collect()
|
||||
}
|
||||
|
||||
/// Terminals of one chat (oldest first).
|
||||
pub fn for_session(&self, session_id: &str, owner_id: &str) -> Vec<Value> {
|
||||
let mut v: Vec<_> = self
|
||||
.map
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|t| t.owner_id == owner_id && t.session_id.as_deref() == Some(session_id))
|
||||
.map(|t| (t.created_at, t.info()))
|
||||
.collect();
|
||||
v.sort_by_key(|(c, _)| *c);
|
||||
v.into_iter().map(|(_, i)| i).collect()
|
||||
}
|
||||
|
||||
/// Close all terminals of a deleted chat.
|
||||
pub fn kill_session(&self, session_id: &str) {
|
||||
let gone: Vec<Arc<Term>> = {
|
||||
let mut map = self.map.lock().unwrap();
|
||||
let ids: Vec<String> = map.values().filter(|t| t.session_id.as_deref() == Some(session_id)).map(|t| t.id.clone()).collect();
|
||||
ids.iter().filter_map(|id| map.remove(id)).collect()
|
||||
};
|
||||
gone.iter().for_each(|t| t.kill());
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str, owner_id: &str) -> Option<Arc<Term>> {
|
||||
self.map.lock().unwrap().get(id).filter(|t| t.owner_id == owner_id).cloned()
|
||||
}
|
||||
@@ -100,7 +128,7 @@ impl Terminals {
|
||||
pub fn reap(&self) {
|
||||
let mut map = self.map.lock().unwrap();
|
||||
map.retain(|_, t| {
|
||||
let stale = t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
|
||||
let stale = t.session_id.is_none() && t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
|
||||
let gone = !t.alive() && *t.attached.lock().unwrap() == 0;
|
||||
if stale || gone {
|
||||
t.kill();
|
||||
@@ -113,7 +141,7 @@ impl Terminals {
|
||||
}
|
||||
|
||||
/// Start a shell for `user` in `cwd` (already access-checked).
|
||||
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> Result<Arc<Term>> {
|
||||
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>, cols: u16, rows: u16) -> Result<Arc<Term>> {
|
||||
if app.terminals.list(&user.id).iter().filter(|t| t["alive"] == true).count() >= MAX_PER_USER {
|
||||
anyhow::bail!("too many open terminals (max {MAX_PER_USER}) – close one first");
|
||||
}
|
||||
@@ -129,7 +157,12 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
|
||||
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
|
||||
}
|
||||
None => {
|
||||
let c = pty_process::Command::new(shell[0]).args(&shell[1..]).current_dir(cwd).env("TERM", "xterm-256color").env("CODECRAFT", "1");
|
||||
let c = pty_process::Command::new(shell[0])
|
||||
.args(&shell[1..])
|
||||
.current_dir(cwd)
|
||||
.env("TERM", "xterm-256color")
|
||||
.env("CODECRAFT", "1")
|
||||
.envs(crate::gitcreds::identity_env(app, &user.id));
|
||||
match crate::gitcreds::global_config(app, user) {
|
||||
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
||||
None => c,
|
||||
@@ -142,6 +175,7 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
|
||||
let term = Arc::new(Term {
|
||||
id: crate::types::new_id(),
|
||||
owner_id: user.id.clone(),
|
||||
session_id,
|
||||
cwd: cwd.to_path_buf(),
|
||||
created_at: crate::types::now_ms(),
|
||||
writer: tokio::sync::Mutex::new(writer),
|
||||
|
||||
@@ -150,8 +150,11 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
||||
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||
// The session owner's git credentials and identity.
|
||||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
|
||||
if let Some(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
if let Some(u) = owner {
|
||||
if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
}
|
||||
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ impl Tool for OpenScad {
|
||||
continue;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(&out) {
|
||||
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
|
||||
images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
|
||||
files.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub(crate) fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
|
||||
Ok(ImageData {
|
||||
blob: None,
|
||||
size: None,
|
||||
media_type: "image/png".into(),
|
||||
data: base64::engine::general_purpose::STANDARD.encode(&buf),
|
||||
})
|
||||
|
||||
@@ -107,6 +107,8 @@ impl Tool for Read {
|
||||
return Ok(ToolOutput {
|
||||
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
|
||||
images: vec![ImageData {
|
||||
blob: None,
|
||||
size: None,
|
||||
media_type: mt.into(),
|
||||
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}],
|
||||
|
||||
+3
-14
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub use codecraft_core::version::VERSION;
|
||||
|
||||
/// Git commit the image was built from (Docker build arg GIT_COMMIT).
|
||||
pub fn commit() -> &'static str {
|
||||
@@ -17,10 +17,7 @@ pub struct UpdateState {
|
||||
pub latest: Mutex<Option<Value>>,
|
||||
}
|
||||
|
||||
fn newer(a: &str, b: &str) -> bool {
|
||||
let p = |v: &str| v.trim_start_matches('v').split(['.', '-']).take(3).map(|x| x.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
|
||||
p(a) > p(b)
|
||||
}
|
||||
use codecraft_core::version::newer;
|
||||
|
||||
async fn check(app: &App) -> Option<Value> {
|
||||
let url = app.config().server.update_url.clone();
|
||||
@@ -46,12 +43,4 @@ pub fn spawn_checker(app: Arc<App>) {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn version_compare() {
|
||||
assert!(super::newer("v0.2.0", "0.1.9"));
|
||||
assert!(super::newer("1.0.0", "0.9.9"));
|
||||
assert!(!super::newer("v0.1.0", "0.1.0"));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!-- interactive-widget: the on-screen keyboard shrinks the page (Android) instead of
|
||||
shifting it up – the top bar stays in place. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
+16
-3
@@ -1,7 +1,7 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
||||
import { useT } from "./i18n";
|
||||
import { navigate, useRoute } from "./router";
|
||||
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
|
||||
import { applyEvent, loadSessions, resync, setState } from "./store";
|
||||
import type { Info } from "./types";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
@@ -12,6 +12,7 @@ import { Settings } from "./components/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
import { Images } from "./components/Images";
|
||||
import { Jobs } from "./components/Jobs";
|
||||
import { JobEditor } from "./components/JobEditor";
|
||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||
import { DialogHost } from "./dialogs";
|
||||
import { startPrefsSync } from "./prefsSync";
|
||||
@@ -80,10 +81,22 @@ export function App() {
|
||||
|
||||
const [section, arg] = route;
|
||||
let content;
|
||||
if (section === "s" && arg) content = <ChatView key={arg} sessionId={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
const chatIds = section === "s" && arg ? arg.split("+").filter(Boolean).slice(0, MAX_PANES) : [];
|
||||
if (chatIds.length === 1) content = <ChatView key={chatIds[0]} sessionId={chatIds[0]} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (chatIds.length > 1)
|
||||
content = (
|
||||
<div className="split-view">
|
||||
{chatIds.map((id) => (
|
||||
<div key={id} className="split-pane">
|
||||
<ChatView sessionId={id} onMenu={() => setMenuOpen(true)} onClosePane={() => closePane(id)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "jobs") content = <Jobs onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "terminal")
|
||||
content = (
|
||||
@@ -102,7 +115,7 @@ export function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
||||
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
|
||||
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
|
||||
<main className="main">{content}</main>
|
||||
<WhatsNew version={info.version} />
|
||||
<Palette />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Fragment } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCost, fmtPercent, useT } from "../i18n";
|
||||
@@ -11,16 +12,39 @@ interface BudgetRow {
|
||||
warning: boolean;
|
||||
exceeded: boolean;
|
||||
images?: boolean;
|
||||
plan?: Plan;
|
||||
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
|
||||
}
|
||||
|
||||
/** opencode Go: usage windows against the plan's limits (USD). */
|
||||
export interface Plan {
|
||||
limit: number;
|
||||
windows: { window: "5h" | "week" | "month"; used: number; limit: number; ratio: number }[];
|
||||
}
|
||||
|
||||
/** Three small meters (5 hours, week, month) for a subscription plan. */
|
||||
export function PlanUsage({ plan }: { plan: Plan }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="plan-usage">
|
||||
{plan.windows.map((w) => (
|
||||
<div key={w.window} className="plan-window" title={`${fmtCost(w.used, "USD")} / ${fmtCost(w.limit, "USD")}`}>
|
||||
<span className="plan-label">{t(`budget.window.${w.window}`)}</span>
|
||||
<Bar share={w.ratio} warning={w.ratio >= 0.8} exceeded={w.ratio >= 1} />
|
||||
<span className="plan-pct">{fmtPercent(w.ratio)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BudgetData {
|
||||
currency: string;
|
||||
total: BudgetRow;
|
||||
providers: BudgetRow[];
|
||||
}
|
||||
|
||||
function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
|
||||
export function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
|
||||
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
|
||||
return (
|
||||
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||
@@ -62,20 +86,30 @@ export function BudgetPanel() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.provider}>
|
||||
<td>
|
||||
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
||||
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
||||
</td>
|
||||
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
||||
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
||||
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
||||
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
||||
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
||||
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
||||
</td>
|
||||
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
||||
</tr>
|
||||
<Fragment key={r.provider}>
|
||||
<tr>
|
||||
<td>
|
||||
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
||||
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
||||
</td>
|
||||
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
||||
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
||||
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
||||
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
||||
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
||||
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
||||
</td>
|
||||
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
||||
</tr>
|
||||
{r.plan && (
|
||||
<tr className="plan-row">
|
||||
<td className="muted">{t("budget.plan", { v: fmtCost(r.plan.limit, "USD") })}</td>
|
||||
<td colSpan={5}>
|
||||
<PlanUsage plan={r.plan} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { del, get, post, put } from "../api";
|
||||
import { confirmDialog, toast } from "../dialogs";
|
||||
import { useT } from "../i18n";
|
||||
import { Icon } from "./Icon";
|
||||
import type { TermInfo } from "./Terminal";
|
||||
|
||||
// xterm and the editor are large: load them only when such a tab is shown.
|
||||
const TermView = lazy(() => import("./Terminal").then((m) => ({ default: m.TermView })));
|
||||
const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files })));
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`;
|
||||
|
||||
/** A file manager tab (stored per user and chat on the server). */
|
||||
interface FileTab {
|
||||
id: string;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabs of a chat: the chat itself, its terminals and file managers. Both are kept on the
|
||||
* server, so they show up again after a reload or on another device (the list is
|
||||
* refreshed periodically); which tab is active is per browser tab.
|
||||
*/
|
||||
export function useChatTabs(sessionId: string, cwd: string) {
|
||||
const t = useT();
|
||||
const [terms, setTerms] = useState<TermInfo[]>([]);
|
||||
const [files, setFilesState] = useState<FileTab[]>([]);
|
||||
// Local file tab changes not yet saved: polling must not undo them.
|
||||
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [tab, setTabState] = useState<string>(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
||||
|
||||
const setTab = useCallback(
|
||||
(v: string) => {
|
||||
setTabState(v);
|
||||
sessionStorage.setItem(tabKey(sessionId), v);
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
const setFiles = useCallback(
|
||||
(update: (f: FileTab[]) => FileTab[], delay = 0) =>
|
||||
setFilesState((cur) => {
|
||||
const next = update(cur);
|
||||
clearTimeout(pending.current);
|
||||
pending.current = setTimeout(() => {
|
||||
pending.current = undefined;
|
||||
void put(`/sessions/${sessionId}/tabs`, next).catch((e) => toast(e));
|
||||
}, delay);
|
||||
return next;
|
||||
}),
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
get<{ terminals: TermInfo[]; files: FileTab[] }>(`/sessions/${sessionId}/tabs`)
|
||||
.then((r) => {
|
||||
setTerms(r.terminals);
|
||||
if (!pending.current) setFilesState(r.files);
|
||||
})
|
||||
.catch(() => undefined),
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
||||
setFilesState([]);
|
||||
setTerms([]);
|
||||
void reload();
|
||||
const id = setInterval(reload, POLL_MS);
|
||||
const onFocus = () => void reload();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [sessionId, reload]);
|
||||
|
||||
// A tab closed elsewhere (or a stale stored tab) falls back to the chat.
|
||||
const activeTerm = terms.find((x) => x.id === tab);
|
||||
const activeFiles = files.find((f) => f.id === tab);
|
||||
const current = activeTerm || activeFiles ? tab : "chat";
|
||||
|
||||
const openTerminal = async () => {
|
||||
try {
|
||||
const info = await post<TermInfo>("/terminals", { session_id: sessionId, cols: 100, rows: 30 });
|
||||
await reload();
|
||||
setTab(info.id);
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
}
|
||||
};
|
||||
|
||||
const closeTerminal = async (x: TermInfo, n: number) => {
|
||||
if (x.alive && !(await confirmDialog({ message: t("terminal.closeConfirm", { name: t("terminal.tab", { n }) }), danger: true, confirmLabel: t("terminal.close") }))) return;
|
||||
await del(`/terminals/${x.id}`).catch(() => undefined);
|
||||
if (tab === x.id) setTab("chat");
|
||||
await reload();
|
||||
};
|
||||
|
||||
const openFiles = () => {
|
||||
const id = `files:${Date.now().toString(36)}`;
|
||||
setFiles((f) => [...f, { id, dir: cwd }]);
|
||||
setTab(id);
|
||||
};
|
||||
|
||||
const closeFiles = (id: string) => {
|
||||
setFiles((f) => f.filter((x) => x.id !== id));
|
||||
if (tab === id) setTab("chat");
|
||||
};
|
||||
|
||||
// Browsing saves the folder after a short pause, not on every click.
|
||||
const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x)), 1000), [setFiles]);
|
||||
|
||||
return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged };
|
||||
}
|
||||
|
||||
type Tabs = ReturnType<typeof useChatTabs>;
|
||||
|
||||
const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/";
|
||||
|
||||
/** Tab strip above the chat: the chat, one tab per terminal and file manager, and "+". */
|
||||
export function ChatTabBar({ state }: { state: Tabs }) {
|
||||
const t = useT();
|
||||
const { terms, files, tab, setTab, openTerminal, closeTerminal, openFiles, closeFiles } = state;
|
||||
if (terms.length === 0 && files.length === 0) return null;
|
||||
return (
|
||||
<div className="chat-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === "chat"} className={`chat-tab ${tab === "chat" ? "active" : ""}`} onClick={() => setTab("chat")}>
|
||||
<Icon name="sparkle" /> {t("terminal.chatTab")}
|
||||
</button>
|
||||
{terms.map((x, i) => (
|
||||
<span key={x.id} className={`chat-tab ${tab === x.id ? "active" : ""} ${x.alive ? "" : "dead"}`}>
|
||||
<button role="tab" aria-selected={tab === x.id} className="ghost" onClick={() => setTab(x.id)} title={x.cwd}>
|
||||
<Icon name="terminal" /> {t("terminal.tab", { n: i + 1 })}
|
||||
</button>
|
||||
<button className="ghost close" onClick={() => void closeTerminal(x, i + 1)} aria-label={t("terminal.close")} title={t("terminal.close")}>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{files.map((f) => (
|
||||
<span key={f.id} className={`chat-tab ${tab === f.id ? "active" : ""}`}>
|
||||
<button role="tab" aria-selected={tab === f.id} className="ghost" onClick={() => setTab(f.id)} title={f.dir}>
|
||||
<Icon name="folder" /> {basename(f.dir)}
|
||||
</button>
|
||||
<button className="ghost close" onClick={() => closeFiles(f.id)} aria-label={t("files.closeTab")} title={t("files.closeTab")}>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button className="ghost chat-tab-add" onClick={() => void openTerminal()} title={t("terminal.new")} aria-label={t("terminal.new")}>
|
||||
<Icon name="plus" /> <Icon name="terminal" />
|
||||
</button>
|
||||
<button className="ghost chat-tab-add" onClick={openFiles} title={t("files.newTab")} aria-label={t("files.newTab")}>
|
||||
<Icon name="plus" /> <Icon name="folder" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab contents besides the chat. The active terminal is mounted (it re-attaches and
|
||||
* replays its output); file managers stay mounted while hidden, so unsaved edits survive.
|
||||
*/
|
||||
export function ChatTabPanes({ state }: { state: Tabs }) {
|
||||
const t = useT();
|
||||
const { tab, activeTerm, files, fileDirChanged } = state;
|
||||
return (
|
||||
<Suspense fallback={<div className="empty">{t("common.loading")}</div>}>
|
||||
{activeTerm && <TermView key={activeTerm.id} info={activeTerm} />}
|
||||
{files.map((f) => (
|
||||
<div key={f.id} className="chat-pane chat-files" hidden={tab !== f.id}>
|
||||
<Files embed={{ startDir: f.dir, onDirChange: (d) => fileDirChanged(f.id, d) }} />
|
||||
</div>
|
||||
))}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+113
-18
@@ -1,18 +1,19 @@
|
||||
import { takePrefill } from "../share";
|
||||
import { Icon } from "./Icon";
|
||||
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
|
||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { del, get, patch, post } from "../api";
|
||||
import { useModels } from "../hooks";
|
||||
import { useFetch, useModels } from "../hooks";
|
||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
||||
import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode } from "../prefs";
|
||||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||
import { Composer } from "./Composer";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import { RenameDialog } from "./RenameDialog";
|
||||
import { InstructionsDialog } from "./InstructionsDialog";
|
||||
import { RegenerateButton } from "./Regenerate";
|
||||
import { WorktreeChip } from "./WorktreeChip";
|
||||
import { QueueList } from "./QueueList";
|
||||
@@ -24,6 +25,7 @@ import { RewindDialog } from "./RewindDialog";
|
||||
import { ShellsChip } from "./ShellsPanel";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
|
||||
@@ -135,7 +137,7 @@ function RefineFeedback({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
|
||||
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
||||
const t = useT();
|
||||
const session = useStore((s) => s.sessionById[sessionId]);
|
||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||
@@ -145,9 +147,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
const allPermissions = useStore((s) => s.permissions);
|
||||
const allQuestions = useStore((s) => s.questions);
|
||||
const waiting = useStore((s) => s.waiting[sessionId]);
|
||||
const compacting = useStore((s) => s.compacting[sessionId]);
|
||||
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
||||
const [sharing, setSharing] = useState(false);
|
||||
// The Telegram switch in the ⋯ menu only matters with a paired bot.
|
||||
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
|
||||
const [editingInstructions, setEditingInstructions] = useState(false);
|
||||
const models = useModels();
|
||||
const [loadError, setLoadError] = useState<string>();
|
||||
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
||||
@@ -195,6 +202,17 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
const el = scrollRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||||
const hasSession = !!session;
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [hasSession, sessionId]);
|
||||
|
||||
if (loadError) return <div className="empty">{loadError}</div>;
|
||||
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
||||
@@ -304,6 +322,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<ShellsChip sessionId={sessionId} busy={busy} />
|
||||
{isAdmin && <BudgetChip />}
|
||||
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
||||
{!!session.instructions && (
|
||||
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
|
||||
<Icon name="edit" /> {t("instructions.chip")}
|
||||
</button>
|
||||
)}
|
||||
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
||||
{!readOnly && (
|
||||
<>
|
||||
@@ -314,14 +337,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
)}
|
||||
<button
|
||||
className="small ghost hide-mobile"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("codecraft.termCwd", session.cwd);
|
||||
navigate("/terminal/new");
|
||||
}}
|
||||
onClick={() => void tabs.openTerminal()}
|
||||
title={t("terminal.here")}
|
||||
>
|
||||
<Icon name="terminal" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||
<Icon name="folder" />
|
||||
</button>
|
||||
{!session.parent_id && (
|
||||
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||
<Icon name="fork" />
|
||||
@@ -332,6 +355,30 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<Icon name="more" />
|
||||
</summary>
|
||||
<div className="menu-pop-body">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||
setEditingInstructions(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="edit" /> {t("instructions.title")}
|
||||
</a>
|
||||
{telegramPaired && !session.parent_id && (
|
||||
<a
|
||||
href="#"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={!session.telegram_quiet}
|
||||
title={t("chat.telegramHint")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void update({ telegram_quiet: !session.telegram_quiet });
|
||||
}}
|
||||
>
|
||||
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
|
||||
</a>
|
||||
)}
|
||||
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
||||
<Icon name="download" /> {t("chat.exportMd")}
|
||||
</a>
|
||||
@@ -365,7 +412,15 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onClosePane && (
|
||||
<button className="small ghost" onClick={onClosePane} title={t("chat.closePane")} aria-label={t("chat.closePane")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ChatTabBar state={tabs} />
|
||||
<ChatTabPanes state={tabs} />
|
||||
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
||||
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
||||
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
||||
<div
|
||||
@@ -402,7 +457,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details key={m.id} className="summary-divider">
|
||||
<summary>{t("chat.compacted")}</summary>
|
||||
<summary>
|
||||
{summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
<Markdown text={summary.text} />
|
||||
</details>
|
||||
);
|
||||
@@ -437,8 +498,8 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
|
||||
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
@@ -452,14 +513,24 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||
</div>
|
||||
)}
|
||||
{compacting && (
|
||||
<div className={`compacting-card ${compacting.auto ? "auto" : ""}`} role="status">
|
||||
<Spinner small />
|
||||
<div>
|
||||
<b>{t(compacting.auto ? "chat.compactingAuto" : "chat.compactingManual")}</b>
|
||||
{compacting.auto && compacting.tokens && compacting.limit && (
|
||||
<div className="hint">
|
||||
{t("chat.compactingDetail", { tokens: fmtCompact(compacting.tokens), limit: fmtCompact(compacting.limit), pct: fmtPercent(compacting.tokens / compacting.limit) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(busy || waiting) && (
|
||||
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
||||
)}
|
||||
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
||||
<div className="todos">
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||
<TodoList todos={todos} />
|
||||
</div>
|
||||
<TodoPanel todos={todos} />
|
||||
)}
|
||||
{permissions.map((p) => (
|
||||
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
||||
@@ -497,6 +568,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
|
||||
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||
@@ -514,6 +586,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
onUpdate={update}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -535,9 +608,10 @@ function UserText({ text }: { text: string }) {
|
||||
const last = rest.pop()!;
|
||||
if (last.trim().endsWith(":")) break;
|
||||
}
|
||||
const body = rest.join("\n").trim();
|
||||
return (
|
||||
<>
|
||||
{rest.join("\n").trimEnd()}
|
||||
{body && <Markdown text={body} breaks />}
|
||||
{files.length > 0 && (
|
||||
<div className="user-files">
|
||||
{files.map((f) => (
|
||||
@@ -584,3 +658,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tasks of the model above the composer; folds to one line (current task, progress). */
|
||||
function TodoPanel({ todos }: { todos: Todo[] }) {
|
||||
const t = useT();
|
||||
const [collapsed, setCollapsed] = useTodosCollapsed();
|
||||
const done = todos.filter((x) => x.status === "completed").length;
|
||||
const current = todos.find((x) => x.status === "in_progress");
|
||||
return (
|
||||
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
|
||||
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
|
||||
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
|
||||
<span>{t("tool.todos")}</span>
|
||||
<span className="todos-count">
|
||||
{done}/{todos.length}
|
||||
</span>
|
||||
{collapsed && current && <span className="todos-current">{current.content}</span>}
|
||||
</button>
|
||||
{!collapsed && <TodoList todos={todos} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TemplatePicker } from "./TemplatePicker";
|
||||
import { Icon } from "./Icon";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { formatSize, imageSrc } from "./FileFigure";
|
||||
import { uploadFile } from "../api";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAgents, useFetch, useModels } from "../hooks";
|
||||
@@ -9,6 +9,7 @@ import type { ImageData, Session } from "../types";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { EffortSelect } from "./Regenerate";
|
||||
import { VoiceButton } from "./Voice";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
|
||||
interface Suggestion {
|
||||
@@ -67,6 +68,8 @@ export function Composer({
|
||||
const [effort, setEffort] = useState("");
|
||||
const [interim, setInterim] = useState("");
|
||||
const [picker, setPicker] = useState(false);
|
||||
// Rendered Markdown instead of the text field (to check formatting before sending).
|
||||
const [preview, setPreview] = useState(false);
|
||||
const models = useModels();
|
||||
const [prov, ...mid] = session.model.split("/");
|
||||
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
|
||||
@@ -257,7 +260,7 @@ export function Composer({
|
||||
)}
|
||||
{images.map((img, i) => (
|
||||
<div className="att" key={i}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<img src={imageSrc(img)} alt="" />
|
||||
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
@@ -265,7 +268,20 @@ export function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{preview && (
|
||||
<div
|
||||
className="composer-preview"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={t("composer.previewEdit")}
|
||||
onClick={() => (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
|
||||
>
|
||||
{text.trim() ? <Markdown text={text} breaks /> : <span className="hint">{t("composer.previewEmpty")}</span>}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
style={preview ? { display: "none" } : undefined}
|
||||
ref={ref}
|
||||
rows={1}
|
||||
value={text}
|
||||
@@ -288,20 +304,20 @@ export function Composer({
|
||||
/>
|
||||
<div className="composer-bar">
|
||||
<div className="composer-options">
|
||||
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
|
||||
<select
|
||||
className="mode-select"
|
||||
value={session.agent}
|
||||
onChange={(e) => onUpdate({ agent: e.target.value })}
|
||||
aria-label={t("composer.mode")}
|
||||
title={agents.find((a) => a.name === session.agent)?.description ?? t("composer.mode")}
|
||||
>
|
||||
{!agents.some((a) => a.name === session.agent) && <option value={session.agent}>{session.agent}</option>}
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
key={a.name}
|
||||
role="tab"
|
||||
aria-selected={session.agent === a.name}
|
||||
className={session.agent === a.name ? "active" : ""}
|
||||
title={a.description}
|
||||
onClick={() => onUpdate({ agent: a.name })}
|
||||
>
|
||||
<option key={a.name} value={a.name} title={a.description}>
|
||||
{a.name}
|
||||
</button>
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
</select>
|
||||
{session.agent === "craft" && (
|
||||
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
|
||||
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
|
||||
@@ -337,6 +353,15 @@ export function Composer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={`ghost ${preview ? "active" : ""}`}
|
||||
style={{ padding: "3px 6px" }}
|
||||
onClick={() => setPreview(!preview)}
|
||||
aria-pressed={preview}
|
||||
title={t(preview ? "composer.previewEdit" : "composer.preview")}
|
||||
>
|
||||
<Icon name={preview ? "edit" : "eye"} />
|
||||
</button>
|
||||
<VoiceButton onText={(d) => setText(text ? `${text.replace(/\s+$/, "")} ${d}` : d)} onInterim={setInterim} />
|
||||
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
||||
<Icon name="clip" />
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get } from "../api";
|
||||
import { getLang, useT } from "../i18n";
|
||||
|
||||
/**
|
||||
* Graphical editor for a 5-field cron expression (minute hour day month weekday,
|
||||
* server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a
|
||||
* custom expression. Shows the next runs as computed by the server.
|
||||
*/
|
||||
type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom";
|
||||
|
||||
interface State {
|
||||
mode: Mode;
|
||||
every: number;
|
||||
minute: number;
|
||||
time: string; // HH:MM
|
||||
days: number[]; // cron weekdays, 0 = Sunday
|
||||
dom: number;
|
||||
custom: string;
|
||||
}
|
||||
|
||||
const MINUTE_STEPS = [5, 10, 15, 20, 30];
|
||||
/** Monday first; cron numbers (0 = Sunday). */
|
||||
const WEEK = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`;
|
||||
|
||||
function expandDays(field: string): number[] | null {
|
||||
const out = new Set<number>();
|
||||
for (const part of field.split(",")) {
|
||||
const m = part.match(/^(\d)(?:-(\d))?$/);
|
||||
if (!m) return null;
|
||||
const a = Number(m[1]);
|
||||
const b = m[2] ? Number(m[2]) : a;
|
||||
if (a > 7 || b > 7 || b < a) return null;
|
||||
for (let d = a; d <= b; d++) out.add(d % 7);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
export function parseCron(expr: string): State {
|
||||
const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() };
|
||||
const f = expr.trim().split(/\s+/);
|
||||
if (f.length !== 5) return base;
|
||||
const [mi, h, dom, mon, dow] = f;
|
||||
const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max;
|
||||
if (mon !== "*") return base;
|
||||
let m;
|
||||
if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) };
|
||||
if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) };
|
||||
if (!num(mi, 59) || !num(h, 23)) return base;
|
||||
const time = hhmm(h, mi);
|
||||
if (dom === "*" && dow === "*") return { ...base, mode: "daily", time };
|
||||
if (dom === "*") {
|
||||
const days = expandDays(dow);
|
||||
return days?.length ? { ...base, mode: "weekly", time, days } : base;
|
||||
}
|
||||
if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) };
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildCron(s: State): string {
|
||||
const [h, m] = s.time.split(":").map(Number);
|
||||
switch (s.mode) {
|
||||
case "minutes":
|
||||
return `*/${s.every} * * * *`;
|
||||
case "hourly":
|
||||
return `${s.minute} * * * *`;
|
||||
case "daily":
|
||||
return `${m} ${h} * * *`;
|
||||
case "weekly": {
|
||||
const days = [...s.days].sort((a, b) => a - b);
|
||||
return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`;
|
||||
}
|
||||
case "monthly":
|
||||
return `${m} ${h} ${s.dom} * *`;
|
||||
default:
|
||||
return s.custom.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const dayName = (d: number, style: "short" | "long") => {
|
||||
// 2023-01-01 was a Sunday.
|
||||
return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style });
|
||||
};
|
||||
|
||||
/** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */
|
||||
const fmtWall = (s: string) => {
|
||||
const [d, hm] = s.split("T");
|
||||
const [y, mo, da] = d.split("-").map(Number);
|
||||
const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" });
|
||||
return `${date}, ${hm}`;
|
||||
};
|
||||
|
||||
export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) {
|
||||
const t = useT();
|
||||
const [s, setS] = useState<State>(() => parseCron(value));
|
||||
const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>();
|
||||
|
||||
// A value from outside (another automation, reset) that the builder did not produce.
|
||||
useEffect(() => {
|
||||
if (value.trim() !== buildCron(s)) setS(parseCron(value));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const expr = value.trim();
|
||||
if (!expr) return setPreview(undefined);
|
||||
let alive = true;
|
||||
const h = setTimeout(() => {
|
||||
get<typeof preview>(`/jobs/schedule?expr=${encodeURIComponent(expr)}`)
|
||||
.then((p) => alive && setPreview(p))
|
||||
.catch(() => alive && setPreview(undefined));
|
||||
}, 250);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(h);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const set = (patch: Partial<State>) => {
|
||||
const next = { ...s, ...patch };
|
||||
// Switching to "custom" starts from the current expression.
|
||||
if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s);
|
||||
setS(next);
|
||||
onChange(buildCron(next));
|
||||
};
|
||||
|
||||
const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"];
|
||||
return (
|
||||
<div className="cron-builder">
|
||||
<div className="cron-modes" role="radiogroup" aria-label={t("cron.label")}>
|
||||
{modes.map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={s.mode === m} className={`chip-btn ${s.mode === m ? "active" : ""}`} onClick={() => set({ mode: m })}>
|
||||
{t(`cron.mode.${m}` as const)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cron-fields">
|
||||
{s.mode === "minutes" && (
|
||||
<label>
|
||||
{t("cron.every")}
|
||||
<select value={s.every} onChange={(e) => set({ every: Number(e.target.value) })}>
|
||||
{MINUTE_STEPS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{t("cron.minutes")}
|
||||
</label>
|
||||
)}
|
||||
{s.mode === "hourly" && (
|
||||
<label>
|
||||
{t("cron.atMinute")}
|
||||
<input type="number" min={0} max={59} value={s.minute} onChange={(e) => set({ minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })} />
|
||||
</label>
|
||||
)}
|
||||
{s.mode === "weekly" && (
|
||||
<div className="cron-days" role="group" aria-label={t("cron.days")}>
|
||||
{WEEK.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
aria-pressed={s.days.includes(d)}
|
||||
className={`chip-btn ${s.days.includes(d) ? "active" : ""}`}
|
||||
title={dayName(d, "long")}
|
||||
onClick={() => set({ days: s.days.includes(d) ? s.days.filter((x) => x !== d) : [...s.days, d] })}
|
||||
>
|
||||
{dayName(d, "short")}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="link-btn" onClick={() => set({ days: [1, 2, 3, 4, 5] })}>
|
||||
{t("cron.workdays")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{s.mode === "monthly" && (
|
||||
<label>
|
||||
{t("cron.onDay")}
|
||||
<select value={s.dom} onChange={(e) => set({ dom: Number(e.target.value) })}>
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}.
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && (
|
||||
<label>
|
||||
{t("cron.at")}
|
||||
<input type="time" value={s.time} onChange={(e) => e.target.value && set({ time: e.target.value })} />
|
||||
</label>
|
||||
)}
|
||||
{s.mode === "custom" && (
|
||||
<label className="cron-custom">
|
||||
<input className="mono" value={s.custom} placeholder="0 3 * * *" spellCheck={false} onChange={(e) => set({ custom: e.target.value })} />
|
||||
<small className="hint">{t("cron.customHint")}</small>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="cron-preview">
|
||||
<code>{value || "–"}</code>
|
||||
{preview?.ok === false && <span className="err">{preview.error}</span>}
|
||||
{preview?.ok && preview.next_local && preview.next_local.length > 0 && (
|
||||
<span>
|
||||
{t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
{preview?.tz && s.mode !== "minutes" && <span className="hint">{t("cron.serverTz", { tz: preview.tz })}</span>}
|
||||
{s.mode === "monthly" && s.dom > 28 && <span className="hint">{t("cron.shortMonths")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { ImageData } from "../types";
|
||||
|
||||
/** Human-readable file size. */
|
||||
export function formatSize(n: number) {
|
||||
@@ -32,6 +33,16 @@ export function basename(p: string) {
|
||||
}
|
||||
|
||||
/** Bytes of a base64 payload. */
|
||||
/** URL of an image of a message: inline (data URL) or from the image store. */
|
||||
export function imageSrc(img: ImageData) {
|
||||
return img.data ? `data:${img.media_type};base64,${img.data}` : `/api/blobs/${img.blob}`;
|
||||
}
|
||||
|
||||
/** Size in bytes of an image of a message. */
|
||||
export function imageSize(img: ImageData) {
|
||||
return img.data ? base64Size(img.data) : img.size;
|
||||
}
|
||||
|
||||
export function base64Size(b64: string) {
|
||||
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
||||
return Math.floor((b64.length * 3) / 4) - pad;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
|
||||
import { GitPanel, type GitStatus } from "./GitPanel";
|
||||
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
import { navigate } from "../router";
|
||||
|
||||
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||
|
||||
@@ -55,10 +56,16 @@ function icon(e: Entry) {
|
||||
return <Icon name="file" className="ficon" />;
|
||||
}
|
||||
|
||||
export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
/** Embedded in a chat tab: own start folder, no page header, folder kept by the caller. */
|
||||
export interface FilesEmbed {
|
||||
startDir: string;
|
||||
onDirChange?: (dir: string) => void;
|
||||
}
|
||||
|
||||
export function Files({ onMenu, embed }: { onMenu?: () => void; embed?: FilesEmbed }) {
|
||||
const t = useT();
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const [dir, setDir] = useState<string>(() => localStorage.getItem("codecraft.filesDir") ?? "");
|
||||
const [dir, setDir] = useState<string>(() => embed?.startDir ?? localStorage.getItem("codecraft.filesDir") ?? "");
|
||||
const [listing, setListing] = useState<Listing>();
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -78,7 +85,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
|
||||
setListing(l);
|
||||
setDir(l.path);
|
||||
localStorage.setItem("codecraft.filesDir", l.path);
|
||||
if (embed) embed.onDirChange?.(l.path);
|
||||
else localStorage.setItem("codecraft.filesDir", l.path);
|
||||
setError(undefined);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
@@ -142,7 +150,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
|
||||
// "Edit file" links from other pages (e.g. automations) open the file directly.
|
||||
useEffect(() => {
|
||||
const target = sessionStorage.getItem("codecraft.openFile");
|
||||
const target = embed ? null : sessionStorage.getItem("codecraft.openFile");
|
||||
if (!target) return;
|
||||
sessionStorage.removeItem("codecraft.openFile");
|
||||
setDir(target.slice(0, target.lastIndexOf("/")) || "/");
|
||||
@@ -186,6 +194,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
});
|
||||
};
|
||||
|
||||
/** New chat with the folder as working directory (default model and mode). */
|
||||
const chatHere = async (e: Entry) => {
|
||||
try {
|
||||
const s = await post<{ id: string }>("/sessions", { cwd: e.path });
|
||||
navigate(`/s/${s.id}`);
|
||||
} catch (err) {
|
||||
toast(err);
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (e: Entry) => {
|
||||
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
|
||||
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
|
||||
@@ -324,6 +342,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
<details className="entry-menu">
|
||||
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
|
||||
<div className="menu">
|
||||
{e.is_dir && (
|
||||
<button onClick={() => void chatHere(e)}>{t("files.chatHere")}</button>
|
||||
)}
|
||||
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
||||
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
||||
{!e.is_dir && (
|
||||
@@ -418,12 +439,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div className={embed ? "files-embed-bar" : "topbar"}>
|
||||
{!embed && (
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
)}
|
||||
<div className="grow">
|
||||
<h1>{t("nav.files")}</h1>
|
||||
{!embed && <h1>{t("nav.files")}</h1>}
|
||||
<div className="path">{listing?.path}</div>
|
||||
</div>
|
||||
{listing?.repo && (
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { patch } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
/** Instructions for one chat: added to the system prompt of every turn. */
|
||||
export function InstructionsDialog({ sessionId, value, onClose }: { sessionId: string; value: string; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const [text, setText] = useState(value);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const save = async (next: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await patch(`/sessions/${sessionId}`, { instructions: next });
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal instructions-modal" role="dialog" aria-label={t("instructions.title")} onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("instructions.title")}</h3>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>
|
||||
{t("instructions.hint")}
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={10}
|
||||
style={{ width: "100%" }}
|
||||
value={text}
|
||||
placeholder={t("instructions.placeholder")}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => (e.ctrlKey || e.metaKey) && e.key === "Enter" && !busy && void save(text)}
|
||||
/>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="row" style={{ marginTop: 10, justifyContent: "flex-end", gap: 6 }}>
|
||||
{value && (
|
||||
<button className="danger" onClick={() => void save("")} disabled={busy}>
|
||||
{t("instructions.clear")}
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||
<button className="primary" onClick={() => void save(text)} disabled={busy || text.trim() === value.trim()}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ApiError, get, patch, post, put } from "../api";
|
||||
import { confirmDialog, toast } from "../dialogs";
|
||||
import { fmtRelative, useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { Spinner } from "./Activity";
|
||||
import { ChatView } from "./ChatView";
|
||||
import { CronBuilder } from "./CronBuilder";
|
||||
import { Icon } from "./Icon";
|
||||
import type { JobInfo } from "./Jobs";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
|
||||
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||
|
||||
interface Settings {
|
||||
name: string;
|
||||
schedule: string;
|
||||
agent: string;
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
timeout_minutes: number;
|
||||
}
|
||||
|
||||
const settingsOf = (j: JobInfo): Settings => ({
|
||||
name: j.spec?.name ?? "",
|
||||
schedule: j.spec?.schedule ?? "0 3 * * *",
|
||||
agent: j.spec?.agent ?? "build",
|
||||
model: j.spec?.model ?? "",
|
||||
permission_mode: j.spec?.permission_mode ?? "auto",
|
||||
timeout_minutes: j.spec?.timeout_minutes ?? 60,
|
||||
});
|
||||
|
||||
type Tab = "prompt" | "settings" | "assistant";
|
||||
|
||||
/**
|
||||
* One automation: its instructions (Markdown file) in an editor, schedule and settings,
|
||||
* and a chat with a model that helps to write the instructions (it edits the file).
|
||||
*/
|
||||
export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const [job, setJob] = useState<JobInfo>();
|
||||
const [settings, setSettings] = useState<Settings>();
|
||||
const [saved, setSaved] = useState<Settings>();
|
||||
const [text, setText] = useState<string>();
|
||||
const [savedText, setSavedText] = useState<string>();
|
||||
const [mtime, setMtime] = useState<number>();
|
||||
const [changedOnDisk, setChangedOnDisk] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [assistant, setAssistant] = useState<string>();
|
||||
const [tab, setTab] = useState<Tab>("prompt");
|
||||
|
||||
const loadJob = useCallback(async () => {
|
||||
const j = await get<JobInfo>(`/jobs/${id}`);
|
||||
setJob(j);
|
||||
const s = settingsOf(j);
|
||||
setSettings((cur) => (cur && saved && JSON.stringify(cur) !== JSON.stringify(saved) ? cur : s));
|
||||
setSaved(s);
|
||||
setAssistant(j.spec?.assistant_session ?? undefined);
|
||||
return j;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const loadFile = useCallback(async (path: string) => {
|
||||
const f = await get<{ content?: string; mtime?: number }>(`/files/read?path=${encodeURIComponent(path)}`);
|
||||
setText(f.content ?? "");
|
||||
setSavedText(f.content ?? "");
|
||||
setMtime(f.mtime);
|
||||
setChangedOnDisk(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadJob()
|
||||
.then((j) => loadFile(j.path))
|
||||
.catch((e) => toast(e.message));
|
||||
}, [loadJob, loadFile]);
|
||||
|
||||
const fileDirty = text !== undefined && text !== savedText;
|
||||
const settingsDirty = !!settings && !!saved && JSON.stringify(settings) !== JSON.stringify(saved);
|
||||
const dirty = fileDirty || settingsDirty;
|
||||
|
||||
// The assistant edits the file: reload it when a turn ends (unless there are unsaved edits).
|
||||
const busy = useStore((s) => (assistant ? !!s.busy[assistant] : false));
|
||||
const wasBusy = useRef(false);
|
||||
const dirtyRef = useRef(fileDirty);
|
||||
dirtyRef.current = fileDirty;
|
||||
useEffect(() => {
|
||||
if (wasBusy.current && !busy && job) {
|
||||
get<{ mtime?: number }>(`/files/stat?path=${encodeURIComponent(job.path)}`)
|
||||
.then((st) => {
|
||||
if (st.mtime && mtime && st.mtime <= mtime) return;
|
||||
if (dirtyRef.current) setChangedOnDisk(true);
|
||||
else void loadFile(job.path);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
wasBusy.current = busy;
|
||||
}, [busy, job, mtime, loadFile]);
|
||||
|
||||
// Unsaved changes: ask before leaving the page.
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [dirty]);
|
||||
|
||||
const save = async (force = false) => {
|
||||
if (!job || !settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (settingsDirty) {
|
||||
const j = await patch<JobInfo>(`/jobs/${id}`, settings);
|
||||
setJob(j);
|
||||
const s = settingsOf(j);
|
||||
setSettings(s);
|
||||
setSaved(s);
|
||||
}
|
||||
if (fileDirty) {
|
||||
const r = await put<{ mtime?: number }>("/files/write", { path: job.path, content: text, expected_mtime: mtime, force });
|
||||
setSavedText(text);
|
||||
setMtime(r.mtime);
|
||||
setChangedOnDisk(false);
|
||||
void loadJob();
|
||||
}
|
||||
toast(t("jobEditor.saved"), "success");
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
if (await confirmDialog({ message: t("jobEditor.conflict"), confirmLabel: t("jobEditor.overwrite"), danger: true })) return save(true);
|
||||
} else toast(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setEnabled = async (enabled: boolean) => {
|
||||
try {
|
||||
setJob(await patch<JobInfo>(`/jobs/${id}`, { enabled }));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
if (dirty && !(await confirmDialog({ message: t("jobEditor.runUnsaved") }))) return;
|
||||
try {
|
||||
const r = await post<{ session_id: string }>(`/jobs/${id}/run`, {});
|
||||
navigate(`/s/${r.session_id}`);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const startAssistant = async () => {
|
||||
try {
|
||||
const r = await post<{ session_id: string }>(`/jobs/${id}/assistant`, {});
|
||||
setAssistant(r.session_id);
|
||||
setTab("assistant");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const back = async () => {
|
||||
if (dirty && !(await confirmDialog({ message: t("jobEditor.discard"), danger: true }))) return;
|
||||
navigate("/jobs");
|
||||
};
|
||||
|
||||
if (!job || !settings) return <div className="empty"><Spinner /></div>;
|
||||
const up = (p: Partial<Settings>) => setSettings({ ...settings, ...p });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={back} title={t("jobEditor.back")} aria-label={t("jobEditor.back")}>
|
||||
<Icon name="arrow-left" />
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{settings.name || t("nav.jobs")}</h1>
|
||||
<div className="path mono">{job.path}</div>
|
||||
</div>
|
||||
<label className="switch" title={t(job.enabled ? "jobs.disable" : "jobs.enable")}>
|
||||
<input type="checkbox" checked={job.enabled} onChange={(e) => void setEnabled(e.target.checked)} />
|
||||
<span>{t(job.enabled ? "jobs.on" : "jobs.off")}</span>
|
||||
</label>
|
||||
<button className="small ghost" onClick={runNow} title={t("jobs.runNow")}>
|
||||
<Icon name="play" /> <span className="hide-mobile">{t("jobs.runNow")}</span>
|
||||
</button>
|
||||
<button className="small primary" disabled={!dirty || saving} onClick={() => void save()}>
|
||||
{saving ? <Spinner small /> : <Icon name="check" />} {t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="job-tabs" role="tablist">
|
||||
{(["prompt", "settings", "assistant"] as Tab[]).map((x) => (
|
||||
<button key={x} role="tab" aria-selected={tab === x} className={tab === x ? "active" : ""} onClick={() => setTab(x)}>
|
||||
{t(`jobEditor.tab.${x}` as const)}
|
||||
{x === "prompt" && fileDirty && " •"}
|
||||
{x === "settings" && settingsDirty && " •"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={`job-editor tab-${tab}`}>
|
||||
<div className="job-editor-main">
|
||||
<section className="job-editor-settings panel">
|
||||
<div className="job-editor-row">
|
||||
<label className="field" style={{ flex: 2 }}>
|
||||
<span>{t("jobs.tplName")}</span>
|
||||
<input value={settings.name} onChange={(e) => up({ name: e.target.value })} />
|
||||
</label>
|
||||
<div className="job-editor-status">
|
||||
<span>
|
||||
{t("jobs.next")}: {job.enabled && job.next_run ? new Date(job.next_run).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"}
|
||||
</span>
|
||||
<span>
|
||||
{t("jobs.last")}: {job.last_run ? fmtRelative(job.last_run) : "–"}
|
||||
{job.last_session_id && (
|
||||
<button className="link-btn" style={{ marginLeft: 6 }} onClick={() => navigate(`/s/${job.last_session_id}`)}>
|
||||
{t("jobs.openRun")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span>{t("jobs.schedule")}</span>
|
||||
<CronBuilder value={settings.schedule} onChange={(schedule) => up({ schedule })} />
|
||||
</div>
|
||||
<div className="job-editor-row">
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplMode")}</span>
|
||||
<select value={settings.agent} onChange={(e) => up({ agent: e.target.value })}>
|
||||
<option value="build">build</option>
|
||||
<option value="plan">plan</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("jobs.permissionMode")}</span>
|
||||
<select value={settings.permission_mode} title={t("jobs.permissionModeHint")} onChange={(e) => up({ permission_mode: e.target.value })}>
|
||||
<option value="default">{t("permissionMode.default")}</option>
|
||||
<option value="auto">{t("permissionMode.auto")}</option>
|
||||
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
||||
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("composer.model")}</span>
|
||||
<ModelSelect value={settings.model} onChange={(model) => up({ model })} emptyLabel={t("jobEditor.defaultModel")} />
|
||||
</label>
|
||||
<label className="field" style={{ maxWidth: 140 }}>
|
||||
<span>{t("jobEditor.timeout")}</span>
|
||||
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
|
||||
</label>
|
||||
</div>
|
||||
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
|
||||
</section>
|
||||
<section className="job-editor-prompt">
|
||||
<div className="job-editor-prompt-head">
|
||||
<b>{t("jobEditor.instructions")}</b>
|
||||
<span className="hint">{t("jobEditor.instructionsHint")}</span>
|
||||
</div>
|
||||
{changedOnDisk && (
|
||||
<div className="notice">
|
||||
{t("jobEditor.changedOnDisk")}{" "}
|
||||
<button className="small" onClick={() => void loadFile(job.path)}>
|
||||
{t("jobEditor.reload")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{job.error && !job.error.includes("empty") && <div className="notice err">{job.error}</div>}
|
||||
{text === undefined ? (
|
||||
<div className="empty"><Spinner /></div>
|
||||
) : (
|
||||
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
||||
<CodeEditor value={text} filename={job.path} onChange={setText} onSave={() => void save()} />
|
||||
</Suspense>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<aside className="job-editor-chat">
|
||||
{assistant ? (
|
||||
<ChatView key={assistant} sessionId={assistant} onMenu={onMenu} />
|
||||
) : (
|
||||
<div className="job-assistant-start">
|
||||
<Icon name="sparkle" />
|
||||
<p>{t("jobEditor.assistantIntro")}</p>
|
||||
<button className="primary" onClick={startAssistant}>
|
||||
{t("jobEditor.assistantStart")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFetch } from "../hooks";
|
||||
import { post } from "../api";
|
||||
import { getLang, useT } from "../i18n";
|
||||
import { JOB_TEMPLATES, SCHEDULES, type JobTemplate } from "../jobTemplates";
|
||||
import { Icon } from "./Icon";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { FolderPicker } from "./NewSession";
|
||||
|
||||
/** Create an automation from a template: pick it, choose the project, adjust, create. */
|
||||
export function JobTemplateDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const t = useT();
|
||||
const de = getLang() === "de";
|
||||
const [tpl, setTpl] = useState<JobTemplate>();
|
||||
const [name, setName] = useState("");
|
||||
const [schedule, setSchedule] = useState("");
|
||||
const [agent, setAgent] = useState("build");
|
||||
const [mode, setMode] = useState("auto");
|
||||
const [model, setModel] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
|
||||
const [error, setError] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const config = useFetch<{ default_model?: string }>("/config").data;
|
||||
useEffect(() => {
|
||||
if (!model && config?.default_model) setModel(config.default_model);
|
||||
}, [config, model]);
|
||||
|
||||
const choose = (x: JobTemplate) => {
|
||||
setTpl(x);
|
||||
setName(de ? x.name.de : x.name.en);
|
||||
setSchedule(x.schedule);
|
||||
setAgent(x.agent);
|
||||
setPrompt(x.prompt);
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await post("/jobs/create", { cwd, name: name.trim(), schedule: schedule.trim(), agent, permission_mode: mode, model: model || undefined, prompt });
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.fromTemplate")} onClick={(e) => e.stopPropagation()}>
|
||||
{!tpl ? (
|
||||
<>
|
||||
<h3>{t("jobs.fromTemplate")}</h3>
|
||||
<div className="job-tpl-grid">
|
||||
{JOB_TEMPLATES.map((x) => (
|
||||
<button key={x.id} className="job-tpl-card" onClick={() => choose(x)}>
|
||||
<Icon name={x.icon} />
|
||||
<span>
|
||||
<b>{de ? x.name.de : x.name.en}</b>
|
||||
<small>{de ? x.desc.de : x.desc.en}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3>
|
||||
<Icon name={tpl.icon} /> {de ? tpl.name.de : tpl.name.en}
|
||||
</h3>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplName")}</span>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<div className="field">
|
||||
<span>{t("jobs.tplProject")}</span>
|
||||
<FolderPicker path={cwd} onPick={setCwd} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span>{t("jobs.tplSchedule")}</span>
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
{SCHEDULES.map((s) => (
|
||||
<button key={s.cron} type="button" className={`chip-btn ${schedule === s.cron ? "active" : ""}`} onClick={() => setSchedule(s.cron)}>
|
||||
{de ? s.de : s.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="mono" value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="0 3 * * *" />
|
||||
<small className="hint">{t("jobs.tplCronHint")}</small>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 10, flexWrap: "wrap" }}>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplMode")}</span>
|
||||
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
|
||||
<option value="build">build</option>
|
||||
<option value="plan">plan</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("composer.permissionMode")}</span>
|
||||
<select value={mode} onChange={(e) => setMode(e.target.value)}>
|
||||
<option value="auto">{t("permissionMode.auto")}</option>
|
||||
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
||||
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("composer.model")}</span>
|
||||
<ModelSelect value={model} onChange={setModel} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplPrompt")}</span>
|
||||
<textarea rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
</label>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setTpl(undefined)}>{t("common.back")}</button>
|
||||
<button className="primary" disabled={busy || !cwd || !name.trim() || !schedule.trim() || !prompt.trim()} onClick={create}>
|
||||
{t("jobs.tplCreate")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+16
-25
@@ -5,9 +5,9 @@ import { navigate } from "../router";
|
||||
import { confirmDialog, toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
import { Spinner } from "./Activity";
|
||||
import { JobTemplateDialog } from "./JobTemplateDialog";
|
||||
import { NewJobDialog } from "./NewJobDialog";
|
||||
|
||||
interface JobInfo {
|
||||
export interface JobInfo {
|
||||
id: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
@@ -17,12 +17,12 @@ interface JobInfo {
|
||||
next_run?: number | null;
|
||||
error?: string;
|
||||
webhook?: boolean;
|
||||
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
|
||||
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null };
|
||||
}
|
||||
|
||||
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
|
||||
|
||||
/** Automations: agents started on a schedule from a Markdown job file. */
|
||||
/** Automations: agents started on a schedule with the instructions from a Markdown file. */
|
||||
export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const [jobs, setJobs] = useState<JobInfo[]>();
|
||||
@@ -68,10 +68,10 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="notice">{t("jobs.intro")}</div>
|
||||
<div className="row" style={{ margin: "12px 0" }}>
|
||||
<button className="primary" onClick={() => setTemplates(true)}>
|
||||
<Icon name="plus" /> {t("jobs.fromTemplate")}
|
||||
<Icon name="plus" /> {t("jobs.new")}
|
||||
</button>
|
||||
</div>
|
||||
{templates && <JobTemplateDialog onClose={() => setTemplates(false)} onCreated={() => void reload()} />}
|
||||
{templates && <NewJobDialog onClose={() => setTemplates(false)} />}
|
||||
{!jobs && <div className="empty"><Spinner /></div>}
|
||||
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
|
||||
{jobs?.map((j) => (
|
||||
@@ -79,7 +79,11 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="job-head">
|
||||
<Icon name="clock" className="job-icon" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3>{j.spec?.name ?? j.path.split("/").pop()}</h3>
|
||||
<h3>
|
||||
<button className="link-btn job-title" onClick={() => navigate(`/jobs/${j.id}`)}>
|
||||
{j.spec?.name ?? j.path.split("/").pop()}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="hint mono">{j.path}</div>
|
||||
</div>
|
||||
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
|
||||
@@ -107,7 +111,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
</span>
|
||||
<span>{t("jobs.mode")}</span>
|
||||
<span>
|
||||
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min
|
||||
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {t(`permissionMode.${j.spec?.permission_mode ?? "auto"}` as any)} · max {j.spec?.timeout_minutes} min
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -163,14 +167,8 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
})}>
|
||||
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
|
||||
</button>
|
||||
<button
|
||||
className="small ghost"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("codecraft.openFile", j.path);
|
||||
navigate("/files");
|
||||
}}
|
||||
>
|
||||
<Icon name="edit" /> {t("jobs.editFile")}
|
||||
<button className="small" onClick={() => navigate(`/jobs/${j.id}`)}>
|
||||
<Icon name="edit" /> {t("jobs.edit")}
|
||||
</button>
|
||||
<span className="grow" />
|
||||
<button
|
||||
@@ -190,20 +188,13 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
|
||||
<button disabled={!path.trim()} onClick={() => act("new", async () => {
|
||||
await post("/jobs", { path: path.trim() });
|
||||
const j = await post<JobInfo>("/jobs", { path: path.trim() });
|
||||
setPath("");
|
||||
navigate(`/jobs/${j.id}`);
|
||||
})}>
|
||||
{t("jobs.registerBtn")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="job-example">{`---
|
||||
name: "Nightly dependency check"
|
||||
schedule: "0 3 * * *"
|
||||
agent: build
|
||||
permission_mode: auto
|
||||
timeout_minutes: 60
|
||||
---
|
||||
Check the project for outdated dependencies …`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,30 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
|
||||
/**
|
||||
* Keep single line breaks as typed (Markdown would join the lines into one paragraph):
|
||||
* a hard break at the end of every line that continues a paragraph. Code blocks stay as
|
||||
* they are.
|
||||
*/
|
||||
export function keepLineBreaks(text: string) {
|
||||
let fence: string | null = null;
|
||||
const lines = text.split("\n");
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
const f = line.match(/^\s*(`{3,}|~{3,})/);
|
||||
if (f) {
|
||||
if (!fence) fence = f[1][0];
|
||||
else if (f[1][0] === fence) fence = null;
|
||||
return line;
|
||||
}
|
||||
const next = lines[i + 1];
|
||||
if (fence || next === undefined || !line.trim() || !next.trim() || / {2}$|\\$/.test(line)) return line;
|
||||
return `${line} `;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text, baseDir, breaks }: { text: string; baseDir?: string; breaks?: boolean }) {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
@@ -81,7 +104,7 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
{breaks ? keepLineBreaks(text) : text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { getLang, useT } from "../i18n";
|
||||
import { JOB_TEMPLATES, type JobTemplate } from "../jobTemplates";
|
||||
import { navigate } from "../router";
|
||||
import { Icon } from "./Icon";
|
||||
import { FolderPicker } from "./NewSession";
|
||||
|
||||
/** Starting point of a new instructions file: headings to fill in (with the assistant). */
|
||||
function example(name: string, de: boolean) {
|
||||
return de
|
||||
? `# ${name}
|
||||
|
||||
## Ziel
|
||||
Was soll diese Automatisierung erreichen? (ein, zwei Sätze)
|
||||
|
||||
## Vorgehen
|
||||
1. …
|
||||
2. …
|
||||
|
||||
## Ergebnis
|
||||
Was am Ende vorliegen soll – z. B. ein kurzer Bericht, geänderte Dateien, ein Commit.
|
||||
|
||||
## Grenzen
|
||||
- Was der Agent nicht tun darf (z. B. nichts pushen, nichts löschen).
|
||||
`
|
||||
: `# ${name}
|
||||
|
||||
## Goal
|
||||
What should this automation achieve? (one or two sentences)
|
||||
|
||||
## Steps
|
||||
1. …
|
||||
2. …
|
||||
|
||||
## Result
|
||||
What should exist at the end – e.g. a short report, changed files, a commit.
|
||||
|
||||
## Limits
|
||||
- What the agent must not do (e.g. no pushing, no deleting).
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* New automation: name, project and a starting point (example or template). Creates the
|
||||
* instructions file (<project>/.codecraft/jobs/<name>.md, switched off) and opens the
|
||||
* editor, where the schedule is set and the instructions are worked out.
|
||||
*/
|
||||
export function NewJobDialog({ onClose }: { onClose: () => void }) {
|
||||
const t = useT();
|
||||
const de = getLang() === "de";
|
||||
const [tpl, setTpl] = useState<JobTemplate>();
|
||||
const [name, setName] = useState("");
|
||||
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
|
||||
const [error, setError] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const choose = (x?: JobTemplate) => {
|
||||
setTpl(x);
|
||||
if (x && (!name.trim() || JOB_TEMPLATES.some((y) => name === y.name.de || name === y.name.en))) setName(de ? x.name.de : x.name.en);
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const n = name.trim();
|
||||
const job = await post<{ id: string }>("/jobs/create", {
|
||||
cwd,
|
||||
name: n,
|
||||
prompt: tpl ? `# ${n}\n\n${tpl.prompt}\n` : example(n, de),
|
||||
schedule: tpl?.schedule,
|
||||
agent: tpl?.agent,
|
||||
});
|
||||
onClose();
|
||||
navigate(`/jobs/${job.id}`);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.new")} onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("jobs.new")}</h3>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplName")}</span>
|
||||
<input autoFocus value={name} placeholder={t("jobs.namePlaceholder")} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<div className="field">
|
||||
<span>{t("jobs.tplProject")}</span>
|
||||
<FolderPicker path={cwd} onPick={setCwd} />
|
||||
<small className="hint">{t("jobs.fileHint")}</small>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span>{t("jobs.startWith")}</span>
|
||||
<div className="job-tpl-grid">
|
||||
<button className={`job-tpl-card ${!tpl ? "active" : ""}`} onClick={() => choose(undefined)}>
|
||||
<Icon name="file-text" />
|
||||
<span>
|
||||
<b>{t("jobs.blank")}</b>
|
||||
<small>{t("jobs.blankDesc")}</small>
|
||||
</span>
|
||||
</button>
|
||||
{JOB_TEMPLATES.map((x) => (
|
||||
<button key={x.id} className={`job-tpl-card ${tpl?.id === x.id ? "active" : ""}`} onClick={() => choose(x)}>
|
||||
<Icon name={x.icon} />
|
||||
<span>
|
||||
<b>{de ? x.name.de : x.name.en}</b>
|
||||
<small>{de ? x.desc.de : x.desc.en}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||
<button className="primary" disabled={busy || !cwd || !name.trim()} onClick={create}>
|
||||
{t("jobs.createAndEdit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { post, uploadFile } from "../api";
|
||||
import { promptDialog, toast } from "../dialogs";
|
||||
import { useAgents, useFetch } from "../hooks";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import type { ImageData } from "../types";
|
||||
import { readImage } from "./Composer";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { formatSize, imageSrc } from "./FileFigure";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { takePrefill } from "../share";
|
||||
|
||||
@@ -103,6 +104,7 @@ const ICONS: Record<string, ReactNode> = {
|
||||
),
|
||||
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
|
||||
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
|
||||
chat: svg(<path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm4 6h.01M12 11h.01M16 11h.01" />),
|
||||
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
|
||||
};
|
||||
|
||||
@@ -185,7 +187,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
const [creating, setCreating] = useState<string>();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
|
||||
const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name));
|
||||
const custom = agents.filter((a) => !["build", "plan", "craft", "chat"].includes(a.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (!model && config?.default_model) setModel(config.default_model);
|
||||
@@ -244,7 +246,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
const canStart =
|
||||
!creating &&
|
||||
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
|
||||
(folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
|
||||
(task === "chat" ? !!rootPath : folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
|
||||
|
||||
/** The first message(s) for the agent. */
|
||||
const prompts = (uploaded: string[]): string[] => {
|
||||
@@ -275,7 +277,10 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
try {
|
||||
// 1. Folder
|
||||
let cwd = picked;
|
||||
if (folderMode === "git") {
|
||||
if (task === "chat") {
|
||||
// A plain conversation needs no project: it runs in the user's own folder.
|
||||
cwd = rootPath;
|
||||
} else if (folderMode === "git") {
|
||||
setCreating(t("wizard.cloning"));
|
||||
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
|
||||
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
|
||||
@@ -295,7 +300,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
}
|
||||
if (!cwd) throw new Error("could not create the folder");
|
||||
}
|
||||
localStorage.setItem(LAST_DIR, cwd);
|
||||
if (task !== "chat") localStorage.setItem(LAST_DIR, cwd);
|
||||
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
|
||||
const uploaded: string[] = [];
|
||||
const mkdirs = async (dir: string) => {
|
||||
@@ -327,7 +332,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
agent: task === "cad" ? "build" : task,
|
||||
clean_room: isCraft ? !deep : undefined,
|
||||
});
|
||||
if (isolated && folderMode !== "new") {
|
||||
if (isolated && folderMode !== "new" && task !== "chat") {
|
||||
setCreating(t("worktree.creating"));
|
||||
await post(`/sessions/${s.id}/worktree`, { action: "create" });
|
||||
}
|
||||
@@ -359,6 +364,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
<div className="panel wiz-panel">
|
||||
<Step n={n++} title={t("wizard.whatToDo")}>
|
||||
<div className="wiz-cards">
|
||||
<Card icon="chat" title={t("wizard.task.chat")} desc={t("wizard.task.chatDesc")} active={task === "chat"} onClick={() => setTask("chat")} />
|
||||
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
|
||||
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
|
||||
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
|
||||
@@ -454,7 +460,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
>
|
||||
<textarea
|
||||
value={text}
|
||||
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : "wizard.placeholder.task")}
|
||||
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : task === "chat" ? "wizard.placeholder.chat" : "wizard.placeholder.task")}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length) {
|
||||
@@ -473,7 +479,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
<div className="attachments">
|
||||
{images.map((img, i) => (
|
||||
<div className="att" key={`i${i}`}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<img src={imageSrc(img)} alt="" />
|
||||
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
@@ -512,57 +518,61 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
|
||||
<div className="wiz-foot">
|
||||
<div className="wiz-foot-row">
|
||||
<label>{t("wizard.builtBy")}</label>
|
||||
<label>{task === "chat" ? t("composer.model") : t("wizard.builtBy")}</label>
|
||||
<ModelSelect value={model} onChange={setModel} />
|
||||
</div>
|
||||
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
||||
{(["new", "existing", "git"] as const).map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
||||
{ICONS[m === "git" ? "git" : "folder"]}
|
||||
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{folderMode === "git" && (
|
||||
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
|
||||
)}
|
||||
{folderMode !== "existing" && (
|
||||
<div className="wiz-folder">
|
||||
{ICONS.folder}
|
||||
<span className="wiz-folder-text">
|
||||
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
|
||||
{roots.length > 1 ? (
|
||||
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name === "/" ? "workspace" : r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
|
||||
)}
|
||||
<span>/</span>
|
||||
<input
|
||||
className="wiz-folder-name"
|
||||
value={folderName}
|
||||
onChange={(e) => {
|
||||
setFolderName(e.target.value);
|
||||
setNameTouched(true);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
|
||||
{folderMode !== "new" && (
|
||||
<label className="wiz-check-row">
|
||||
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t("worktree.isolate")}</b>
|
||||
<span className="hint"> – {t("worktree.isolateHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
{task !== "chat" && (
|
||||
<>
|
||||
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
||||
{(["new", "existing", "git"] as const).map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
||||
{ICONS[m === "git" ? "git" : "folder"]}
|
||||
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{folderMode === "git" && (
|
||||
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
|
||||
)}
|
||||
{folderMode !== "existing" && (
|
||||
<div className="wiz-folder">
|
||||
{ICONS.folder}
|
||||
<span className="wiz-folder-text">
|
||||
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
|
||||
{roots.length > 1 ? (
|
||||
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name === "/" ? "workspace" : r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
|
||||
)}
|
||||
<span>/</span>
|
||||
<input
|
||||
className="wiz-folder-name"
|
||||
value={folderName}
|
||||
onChange={(e) => {
|
||||
setFolderName(e.target.value);
|
||||
setNameTouched(true);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
|
||||
{folderMode !== "new" && (
|
||||
<label className="wiz-check-row">
|
||||
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t("worktree.isolate")}</b>
|
||||
<span className="hint"> – {t("worktree.isolateHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="wiz-start-row">
|
||||
@@ -593,6 +603,20 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listing?.path]);
|
||||
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||
/** Create a subfolder of the shown folder and select it. */
|
||||
const newFolder = async () => {
|
||||
if (!listing) return;
|
||||
const name = (await promptDialog({ title: t("files.newFolder"), message: t("files.newFolderName") }))?.trim();
|
||||
if (!name) return;
|
||||
if (name.includes("/") || name === "." || name === "..") return toast(t("new.invalidFolderName"));
|
||||
const target = `${listing.path.replace(/\/$/, "")}/${name}`;
|
||||
try {
|
||||
await post("/files/create", { path: target, dir: true });
|
||||
setBrowse(target);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="wiz-picker">
|
||||
{roots.length > 1 && (
|
||||
@@ -613,6 +637,11 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
|
||||
<Icon name="arrow-up" /> {t("new.up")}
|
||||
</button>
|
||||
)}
|
||||
{listing && (
|
||||
<button className="small" onClick={() => void newFolder()}>
|
||||
<Icon name="plus" /> {t("files.newFolder")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="dir-list">
|
||||
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||||
|
||||
@@ -28,7 +28,7 @@ function recent() {
|
||||
/** Move to the next/previous session relative to the open one. */
|
||||
function step(dir: 1 | -1) {
|
||||
const list = recent();
|
||||
const cur = location.hash.match(/^#\/s\/([^/]+)/)?.[1];
|
||||
const cur = location.hash.match(/^#\/s\/([^/+]+)/)?.[1];
|
||||
const i = list.findIndex((s) => s.id === cur);
|
||||
const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)];
|
||||
if (next) navigate(`/s/${next.id}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { del, post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore } from "../store";
|
||||
import type { QueueItem } from "../types";
|
||||
import { imageSrc } from "./FileFigure";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
|
||||
const EMPTY: QueueItem[] = [];
|
||||
@@ -30,7 +31,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
|
||||
{it.source === "background" && <span className="chip">{t("tool.background")}</span>}
|
||||
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
|
||||
{images.map((p, i) => (
|
||||
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
<img key={i} src={imageSrc(p.image)} alt="" />
|
||||
))}
|
||||
{it.source !== "background" && (
|
||||
<button
|
||||
|
||||
@@ -12,6 +12,7 @@ import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { Account } from "./settings/Account";
|
||||
import { Templates } from "./settings/Templates";
|
||||
import { PersonalModes } from "./settings/PersonalModes";
|
||||
import { AgentsEditor } from "./settings/AgentsEditor";
|
||||
import { Folders } from "./settings/Folders";
|
||||
import { Users } from "./settings/Users";
|
||||
@@ -257,7 +258,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
chromium: c.chromium ?? "chromium",
|
||||
widths: (c.widths ?? []).join(", "),
|
||||
images_enabled: cfg.config.images?.enabled ?? false,
|
||||
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? "${FAL_KEY}"),
|
||||
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? ""),
|
||||
images_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell",
|
||||
images_dir: cfg.config.images?.output_dir ?? "generated",
|
||||
});
|
||||
@@ -344,7 +345,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<label>{t("settings.imagesEnabled")}</label>
|
||||
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
<label>{t("settings.imagesKey")}</label>
|
||||
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "${FAL_KEY}"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
|
||||
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "fal.ai API key"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
|
||||
<label>{t("settings.imagesDefault")}</label>
|
||||
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
|
||||
{(cfg.config.images?.models ?? []).map((m: any) => (
|
||||
@@ -385,14 +386,16 @@ interface ModelRow {
|
||||
max_concurrency?: number;
|
||||
fallback?: string;
|
||||
options?: Record<string, unknown>;
|
||||
price_tiers?: { above_context: number; input_price?: number; output_price?: number; cache_read_price?: number; cache_write_price?: number }[];
|
||||
price_multipliers?: { factor: number; days?: string[]; hours_utc?: [string, string][]; from?: string; until?: string }[];
|
||||
}
|
||||
|
||||
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
|
||||
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "${ANTHROPIC_API_KEY}" },
|
||||
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "${GEMINI_API_KEY}" },
|
||||
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "${OPENCODE_API_KEY}" },
|
||||
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "${FAL_KEY}", auth_prefix: "Key " },
|
||||
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "${OPENROUTER_API_KEY}" },
|
||||
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "" },
|
||||
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "" },
|
||||
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "" },
|
||||
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "", auth_prefix: "Key " },
|
||||
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "" },
|
||||
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
|
||||
custom: { kind: "openai", base_url: "http://", api_key: "" },
|
||||
};
|
||||
@@ -401,11 +404,23 @@ function normalizeModels(models: Json[]): ModelRow[] {
|
||||
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
|
||||
}
|
||||
|
||||
/** Readable summary of long-context tiers and time factors (tooltip); empty if there are none. */
|
||||
function priceExtras(m: ModelRow): string {
|
||||
const lines = [
|
||||
...(m.price_tiers ?? []).map((t) => `> ${Math.round(t.above_context / 1000)}k context: ${[t.input_price, t.output_price].map((x) => x ?? "–").join(" / ")}`),
|
||||
...(m.price_multipliers ?? []).map((x) =>
|
||||
[`×${x.factor}`, x.days?.join(","), x.hours_utc?.map(([a, b]) => `${a}–${b} UTC`).join(", "), x.from && `from ${x.from}`, x.until && `until ${x.until}`].filter(Boolean).join(" "),
|
||||
),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function cleanModel(m: ModelRow) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
|
||||
if (v === false && (k === "reasoning" || k === "vision")) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
@@ -428,7 +443,20 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
const f = byId.get(m.id);
|
||||
if (!f) return m;
|
||||
n++;
|
||||
return { ...m, input_price: f.input_price ?? m.input_price, output_price: f.output_price ?? m.output_price, cache_read_price: f.cache_read_price ?? m.cache_read_price, cache_write_price: f.cache_write_price ?? m.cache_write_price, context: m.context ?? f.context };
|
||||
return {
|
||||
...m,
|
||||
input_price: f.input_price ?? m.input_price,
|
||||
output_price: f.output_price ?? m.output_price,
|
||||
cache_read_price: f.cache_read_price ?? m.cache_read_price,
|
||||
cache_write_price: f.cache_write_price ?? m.cache_write_price,
|
||||
// Long-context tiers and time factors replace the stored ones when the provider publishes them.
|
||||
price_tiers: f.price_tiers ?? m.price_tiers,
|
||||
price_multipliers: f.price_multipliers ?? m.price_multipliers,
|
||||
context: m.context ?? f.context,
|
||||
max_output: m.max_output ?? f.max_output,
|
||||
vision: m.vision || !!f.vision,
|
||||
reasoning: m.reasoning || !!f.reasoning,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setPriceNote(t("settings.pricesFetched", { n }));
|
||||
@@ -454,6 +482,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
|
||||
{ path: ["providers", name, "max_concurrency"], value: form.max_concurrency ? Number(form.max_concurrency) : null },
|
||||
{ path: ["providers", name, "monthly_budget"], value: form.monthly_budget ? Number(form.monthly_budget) : null },
|
||||
{ path: ["providers", name, "plan_limit"], value: form.plan_limit ? Number(form.plan_limit) : null },
|
||||
];
|
||||
// Empty key field = keep the stored (masked) key.
|
||||
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
|
||||
@@ -461,6 +490,33 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
return savePatches(patches);
|
||||
});
|
||||
|
||||
// Check URL and key as entered, without saving.
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string }>();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(undefined);
|
||||
try {
|
||||
const r = await post<{ status: ProviderStatus; models: number }>("/providers/test", {
|
||||
name,
|
||||
kind: form.kind,
|
||||
base_url: form.base_url,
|
||||
api_key: form.api_key,
|
||||
auth_prefix: form.auth_prefix ?? "",
|
||||
});
|
||||
const st = r.status;
|
||||
setTestResult(
|
||||
st.up
|
||||
? { ok: true, text: t("settings.testOk", { ms: st.latency_ms, n: r.models }) }
|
||||
: { ok: false, text: `${st.state === "auth" ? t("settings.providerAuth") : t("settings.providerDown")}: ${st.error ?? ""}` },
|
||||
);
|
||||
} catch (e: any) {
|
||||
setTestResult({ ok: false, text: e.message });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||
void run(() => savePatches([{ path: ["providers", name], value: null }]));
|
||||
@@ -506,7 +562,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<input
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : "${ENV_VAR}"}
|
||||
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : ""}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
/>
|
||||
<div className="hint">{t("settings.apiKeyHint")}</div>
|
||||
@@ -514,10 +570,17 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
|
||||
<label>{t("settings.monthlyBudget")}</label>
|
||||
<input type="number" min={0} step="1" value={form.monthly_budget ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||
{/opencode\.ai\/(inference|zen)\/go/.test(form.base_url ?? "") && (
|
||||
<>
|
||||
<label>{t("settings.planLimit")}</label>
|
||||
<input type="number" min={0} step="1" value={form.plan_limit ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, plan_limit: e.target.value })} />
|
||||
<div className="hint">{t("settings.planLimitHint")}</div>
|
||||
</>
|
||||
)}
|
||||
{form.kind === "anthropic" && (
|
||||
<>
|
||||
<label>{t("settings.adminKey")}</label>
|
||||
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "${ANTHROPIC_ADMIN_KEY}"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
|
||||
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "sk-ant-admin01-…"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
|
||||
<div className="hint">{t("settings.adminKeyHint")}</div>
|
||||
</>
|
||||
)}
|
||||
@@ -528,7 +591,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<h3 style={{ marginTop: 16 }}>
|
||||
{t("settings.models")}
|
||||
<span className="spacer" />
|
||||
{form.base_url?.includes("openrouter.ai") && (
|
||||
{(form.base_url?.includes("openrouter.ai") || form.base_url?.includes("opencode.ai")) && (
|
||||
<button className="small" onClick={fetchPrices}>
|
||||
{t("settings.fetchPrices")}
|
||||
</button>
|
||||
@@ -594,6 +657,11 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
{(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => (
|
||||
<td key={k}>
|
||||
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
||||
{k === "cache_write_price" && priceExtras(m) && (
|
||||
<span className="chip price-extra" title={priceExtras(m)}>
|
||||
+{(m.price_tiers?.length ?? 0) + (m.price_multipliers?.length ?? 0)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
@@ -614,7 +682,15 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
</div>
|
||||
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
|
||||
{view}
|
||||
{testResult && (
|
||||
<div className={`notice ${testResult.ok ? "ok" : "err"}`} style={{ marginTop: 12 }}>
|
||||
{testResult.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button onClick={test} disabled={testing || !form.base_url}>
|
||||
<Icon name="plug" /> {testing ? t("settings.testing") : t("settings.testConnection")}
|
||||
</button>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
@@ -637,9 +713,10 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
|
||||
const providers = cfg.config.providers ?? {};
|
||||
const [status, setStatus] = useState<Record<string, ProviderStatus>>({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
// Saving a provider re-checks it on the server, so refresh whenever the config changes.
|
||||
useEffect(() => {
|
||||
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
}, [cfg]);
|
||||
const recheck = () => {
|
||||
setChecking(true);
|
||||
post<Record<string, ProviderStatus>>("/providers/status")
|
||||
@@ -963,8 +1040,8 @@ function Raw({ reload }: { reload: () => void }) {
|
||||
|
||||
// --- page -------------------------------------------------------------------
|
||||
|
||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "account", "raw"] as const;
|
||||
const USER_TABS = ["general", "templates", "account"] as const;
|
||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "modes", "account", "raw"] as const;
|
||||
const USER_TABS = ["general", "templates", "modes", "account"] as const;
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
general: "gear",
|
||||
providers: "brain",
|
||||
@@ -976,6 +1053,7 @@ const TAB_ICONS: Record<string, string> = {
|
||||
tools: "toolbox",
|
||||
audit: "scroll",
|
||||
templates: "template",
|
||||
modes: "sparkle",
|
||||
account: "user",
|
||||
raw: "file-text",
|
||||
};
|
||||
@@ -1012,6 +1090,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
||||
{!isAdmin && active === "general" && <InterfaceSettings />}
|
||||
{active === "account" && <Account />}
|
||||
{active === "templates" && <Templates />}
|
||||
{active === "modes" && <PersonalModes />}
|
||||
{cfg && active === "users" && <Users />}
|
||||
{cfg && active === "folders" && <Folders />}
|
||||
{cfg && active === "audit" && <Audit />}
|
||||
|
||||
+206
-17
@@ -1,10 +1,11 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { StatusLeds } from "./StatusLeds";
|
||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { get, patch, post } from "../api";
|
||||
import { get, patch, post, put } from "../api";
|
||||
import { promptDialog, toast } from "../dialogs";
|
||||
import { fmtRelative, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { navigate, openBeside } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { Session } from "../types";
|
||||
|
||||
@@ -81,6 +82,9 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
||||
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
|
||||
</button>
|
||||
)}
|
||||
<button role="menuitem" className="hide-mobile" onClick={() => (setMenu(false), openBeside(s.id))}>
|
||||
<Icon name="external" /> {t("sidebar.openBeside")}
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
|
||||
<Icon name="folder" /> {t("sidebar.moveTo")}
|
||||
</button>
|
||||
@@ -98,11 +102,117 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
|
||||
/** Order and pinning of folders and projects in the list (per user, all devices). */
|
||||
interface Layout {
|
||||
order: string[];
|
||||
pinned: string[];
|
||||
}
|
||||
|
||||
/** Folders and projects can be moved and pinned; time groups and the archive cannot. */
|
||||
const movable = (id: string) => id.startsWith("folder:") || id.startsWith("project:");
|
||||
|
||||
const DRAG_TYPE = "application/x-codecraft-group";
|
||||
|
||||
/** Header of a group: collapses it; folders and projects also have a menu and can be dragged. */
|
||||
function GroupHeader({
|
||||
g,
|
||||
collapsed,
|
||||
pinned,
|
||||
onToggle,
|
||||
onPin,
|
||||
onMove,
|
||||
onDrop,
|
||||
}: {
|
||||
g: { id: string; label: string; icon?: string; items: Session[] };
|
||||
collapsed: boolean;
|
||||
pinned: boolean;
|
||||
onToggle: () => void;
|
||||
onPin?: () => void;
|
||||
onMove?: (dir: -1 | 1) => void;
|
||||
onDrop?: (dragged: string, after: boolean) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [over, setOver] = useState<"before" | "after">();
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => setMenu(false);
|
||||
window.addEventListener("click", close);
|
||||
window.addEventListener("keydown", close);
|
||||
return () => {
|
||||
window.removeEventListener("click", close);
|
||||
window.removeEventListener("keydown", close);
|
||||
};
|
||||
}, [menu]);
|
||||
const canMove = !!onMove;
|
||||
return (
|
||||
<div
|
||||
className={`session-row group-row ${menu ? "menu-open" : ""} ${over ? `drop-${over}` : ""}`}
|
||||
draggable={canMove}
|
||||
onDragStart={(e) => {
|
||||
if (!canMove) return;
|
||||
e.dataTransfer.setData(DRAG_TYPE, g.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!onDrop || !e.dataTransfer.types.includes(DRAG_TYPE)) return;
|
||||
e.preventDefault();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setOver(e.clientY > r.top + r.height / 2 ? "after" : "before");
|
||||
}}
|
||||
onDragLeave={() => setOver(undefined)}
|
||||
onDrop={(e) => {
|
||||
const id = e.dataTransfer.getData(DRAG_TYPE);
|
||||
const after = over === "after";
|
||||
setOver(undefined);
|
||||
if (id && id !== g.id && onDrop) {
|
||||
e.preventDefault();
|
||||
onDrop(id, after);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="session-group" onClick={onToggle} aria-expanded={!collapsed}>
|
||||
<Icon name={collapsed ? "chevron-right" : "chevron-down"} className="tiny" />
|
||||
{g.icon && <Icon name={g.icon} className="tiny" />}
|
||||
<span className="grow">{g.label}</span>
|
||||
{pinned && <Icon name="pin" className="tiny group-pin" />}
|
||||
{collapsed && <span className="count">{g.items.length}</span>}
|
||||
</button>
|
||||
{canMove && (
|
||||
<button
|
||||
className="session-more ghost"
|
||||
aria-label={t("sidebar.groupMenu")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenu(!menu);
|
||||
}}
|
||||
>
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
)}
|
||||
{menu && (
|
||||
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
|
||||
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(-1))}>
|
||||
<Icon name="arrow-up" /> {t("sidebar.groupUp")}
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(1))}>
|
||||
<Icon name="arrow-down" /> {t("sidebar.groupDown")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
|
||||
const t = useT();
|
||||
const sessions = useStore((s) => s.sessions);
|
||||
const busy = useStore((s) => s.busy);
|
||||
const connected = useStore((s) => s.connected);
|
||||
const permissions = useStore((s) => s.permissions);
|
||||
const questions = useStore((s) => s.questions);
|
||||
const me = useStore((s) => s.me);
|
||||
@@ -161,14 +271,15 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
const live = sessions.filter((s) => !s.archived);
|
||||
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
|
||||
const rest = live.filter((s) => !s.pinned);
|
||||
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
|
||||
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])];
|
||||
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
|
||||
const loose = rest.filter((s) => !s.folder);
|
||||
if (groupBy === "project") {
|
||||
const byProject = new Map<string, Session[]>();
|
||||
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
|
||||
// Projects with the most recent activity first.
|
||||
for (const [cwd, items] of [...byProject].sort((a, b) => b[1][0].updated_at - a[1][0].updated_at)) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
|
||||
// Order of the projects: see `ordered` below (not by activity – a new message
|
||||
// moves the chat to the top of its project, not the project).
|
||||
for (const [cwd, items] of byProject) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
|
||||
} else {
|
||||
for (const s of loose) {
|
||||
const key = groupKey(s.updated_at);
|
||||
@@ -178,6 +289,81 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
|
||||
return out;
|
||||
}, [sessions, groupBy, t]);
|
||||
// Folders and projects: pinned first, then in the user's order. Groups not ordered yet
|
||||
// (new folder or project) start at the top and are then kept where they are.
|
||||
const [layout, setLayout] = useState<Layout>();
|
||||
useEffect(() => {
|
||||
get<Layout>("/account/sidebar")
|
||||
.then((l) => setLayout({ order: l.order ?? [], pinned: l.pinned ?? [] }))
|
||||
.catch(() => setLayout({ order: [], pinned: [] }));
|
||||
}, []);
|
||||
const ordered = useMemo(() => {
|
||||
const l = layout ?? { order: [], pinned: [] };
|
||||
const head = groups.filter((g) => g.id === "pinned");
|
||||
const tail = groups.filter((g) => g.id !== "pinned" && !movable(g.id));
|
||||
const rank = (id: string) => l.order.indexOf(id);
|
||||
const mid = groups
|
||||
.filter((g) => movable(g.id))
|
||||
.sort((a, b) => {
|
||||
const pa = l.pinned.includes(a.id);
|
||||
const pb = l.pinned.includes(b.id);
|
||||
if (pa !== pb) return pa ? -1 : 1;
|
||||
const ra = rank(a.id);
|
||||
const rb = rank(b.id);
|
||||
if (ra < 0 && rb < 0) return b.items[0].updated_at - a.items[0].updated_at;
|
||||
if (ra < 0 || rb < 0) return ra < 0 ? -1 : 1;
|
||||
return ra - rb;
|
||||
});
|
||||
return { head, mid, tail };
|
||||
}, [groups, layout]);
|
||||
const saveLayout = (next: Layout) => {
|
||||
// Forget groups that no longer exist (no chat in that folder or project).
|
||||
const known = new Set(sessions.flatMap((s) => [`project:${s.cwd}`, ...(s.folder ? [`folder:${s.folder}`] : [])]));
|
||||
const clean = { order: next.order.filter((id) => known.has(id)), pinned: next.pinned.filter((id) => known.has(id)) };
|
||||
setLayout(clean);
|
||||
void put("/account/sidebar", clean).catch((e) => toast(e.message));
|
||||
};
|
||||
// Freeze the position of groups that appeared since the last visit.
|
||||
useEffect(() => {
|
||||
if (!layout) return;
|
||||
const fresh = ordered.mid.map((g) => g.id).filter((id) => !layout.order.includes(id));
|
||||
if (fresh.length) saveLayout({ ...layout, order: [...fresh, ...layout.order] });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ordered, layout]);
|
||||
/** Order of the shown groups with `id` moved: by a step, or next to another group. */
|
||||
const reorder = (ids: string[]) => {
|
||||
if (!layout) return;
|
||||
const shown = new Set(ids);
|
||||
saveLayout({ ...layout, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||||
};
|
||||
const moveGroup = (id: string, dir: -1 | 1) => {
|
||||
if (!layout) return;
|
||||
const ids = ordered.mid.map((g) => g.id);
|
||||
const i = ids.indexOf(id);
|
||||
const j = i + dir;
|
||||
// Only within the pinned or the unpinned part.
|
||||
if (j < 0 || j >= ids.length || layout.pinned.includes(ids[j]) !== layout.pinned.includes(id)) return;
|
||||
[ids[i], ids[j]] = [ids[j], ids[i]];
|
||||
reorder(ids);
|
||||
};
|
||||
const dropGroup = (dragged: string, target: string, after: boolean) => {
|
||||
if (!layout || !movable(dragged)) return;
|
||||
const ids = ordered.mid.map((g) => g.id).filter((id) => id !== dragged);
|
||||
const at = ids.indexOf(target);
|
||||
if (at < 0) return;
|
||||
ids.splice(after ? at + 1 : at, 0, dragged);
|
||||
// Dropped among pinned groups: pinned too (and the other way round).
|
||||
const pinTarget = layout.pinned.includes(target);
|
||||
const pinned = pinTarget ? [...new Set([...layout.pinned, dragged])] : layout.pinned.filter((p) => p !== dragged);
|
||||
const shown = new Set(ids);
|
||||
saveLayout({ pinned, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||||
};
|
||||
const pinGroup = (id: string) => {
|
||||
if (!layout) return;
|
||||
const pinned = layout.pinned.includes(id) ? layout.pinned.filter((p) => p !== id) : [...layout.pinned, id];
|
||||
saveLayout({ ...layout, pinned });
|
||||
};
|
||||
|
||||
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
|
||||
|
||||
return (
|
||||
@@ -187,7 +373,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
<img src="/icon.svg" alt="" />
|
||||
CodeCraft
|
||||
</div>
|
||||
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
|
||||
<StatusLeds />
|
||||
</div>
|
||||
<div style={{ padding: "0 12px 8px" }}>
|
||||
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
|
||||
@@ -210,7 +396,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
|
||||
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
|
||||
{hits.map((h) => (
|
||||
<button key={h.id} className={`session-item ${h.id === activeId ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
||||
<button key={h.id} className={`session-item ${activeIds.includes(h.id) ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
||||
<div className="title">
|
||||
<span>{h.title || t("session.untitled")}</span>
|
||||
</div>
|
||||
@@ -232,17 +418,20 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
</div>
|
||||
)}
|
||||
{!hits &&
|
||||
groups.map((g) => (
|
||||
[...ordered.head, ...ordered.mid, ...ordered.tail].map((g) => (
|
||||
<div key={g.id}>
|
||||
<button className="session-group" onClick={() => toggle(g.id)} aria-expanded={!collapsed.has(g.id)}>
|
||||
<Icon name={collapsed.has(g.id) ? "chevron-right" : "chevron-down"} className="tiny" />
|
||||
{g.icon && <Icon name={g.icon} className="tiny" />}
|
||||
<span className="grow">{g.label}</span>
|
||||
{collapsed.has(g.id) && <span className="count">{g.items.length}</span>}
|
||||
</button>
|
||||
<GroupHeader
|
||||
g={g}
|
||||
collapsed={collapsed.has(g.id)}
|
||||
pinned={!!layout?.pinned.includes(g.id)}
|
||||
onToggle={() => toggle(g.id)}
|
||||
onPin={movable(g.id) ? () => pinGroup(g.id) : undefined}
|
||||
onMove={movable(g.id) ? (dir) => moveGroup(g.id, dir) : undefined}
|
||||
onDrop={movable(g.id) ? (dragged, after) => dropGroup(dragged, g.id, after) : undefined}
|
||||
/>
|
||||
{!collapsed.has(g.id) &&
|
||||
g.items.map((s) => (
|
||||
<SessionRow key={s.id} s={s} active={s.id === activeId} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
||||
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { get, post } from "../api";
|
||||
import { fmtCost, fmtPercent, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { Icon } from "./Icon";
|
||||
import { Bar, type Plan } from "./Budget";
|
||||
|
||||
type Level = "ok" | "warn" | "err" | "off";
|
||||
|
||||
interface ProviderState {
|
||||
state: "ok" | "auth" | "down" | "unknown";
|
||||
latency_ms?: number;
|
||||
checked_at?: number;
|
||||
error?: string | null;
|
||||
models: number;
|
||||
plan?: Plan | null;
|
||||
}
|
||||
interface McpEntry {
|
||||
state: "connected" | "connecting" | "failed";
|
||||
tools?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
interface Meter {
|
||||
key: string;
|
||||
provider: string;
|
||||
kind: "total" | "provider" | "images" | "5h" | "week" | "month";
|
||||
used: number;
|
||||
limit: number;
|
||||
ratio: number;
|
||||
}
|
||||
interface Overview {
|
||||
version: string;
|
||||
providers: Record<string, ProviderState>;
|
||||
mcp: Record<string, McpEntry>;
|
||||
/** Budgets and plan windows (admins only). */
|
||||
budgets?: Meter[];
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
const meterLevel = (m: Meter): Level => (m.ratio >= 1 ? "err" : m.ratio >= 0.8 ? "warn" : "ok");
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
|
||||
const RECONNECT_GRACE_MS = 10_000;
|
||||
|
||||
/** Combined level of a group: all fine → ok, some broken → warn, none working → err. */
|
||||
function combine(levels: Level[]): Level {
|
||||
const active = levels.filter((l) => l !== "off");
|
||||
if (active.length === 0) return "off";
|
||||
if (active.every((l) => l === "ok")) return "ok";
|
||||
if (active.some((l) => l === "ok" || l === "warn")) return "warn";
|
||||
return "err";
|
||||
}
|
||||
|
||||
/** The fullest window of a subscription plan (5 hours, week, month). */
|
||||
const fullest = (plan?: Plan | null) => plan?.windows.reduce((a, b) => (b.ratio > a.ratio ? b : a));
|
||||
|
||||
const providerLevel = (p: ProviderState): Level => {
|
||||
const health: Level = p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err";
|
||||
const w = fullest(p.plan);
|
||||
// A used-up plan blocks requests just like a rejected key.
|
||||
const plan: Level = !w ? "ok" : w.ratio >= 1 ? "err" : w.ratio >= 0.8 ? "warn" : "ok";
|
||||
return health === "err" || plan === "err" ? "err" : health === "warn" || plan === "warn" ? "warn" : "ok";
|
||||
};
|
||||
const mcpLevel = (m: McpEntry): Level => (m.state === "connected" ? "ok" : m.state === "connecting" ? "warn" : "err");
|
||||
|
||||
function Led({ level, title }: { level: Level; title?: string }) {
|
||||
return <span className={`led led-${level}`} title={title} />;
|
||||
}
|
||||
|
||||
/** Three status LEDs (server, models, MCP) in the sidebar; a click opens the details. */
|
||||
export function StatusLeds() {
|
||||
const t = useT();
|
||||
const connected = useStore((s) => s.connected);
|
||||
const admin = useStore((s) => s.me?.role === "admin");
|
||||
const [data, setData] = useState<Overview>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [lostAt, setLostAt] = useState<number>();
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>();
|
||||
const [, tick] = useState(0);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(() => get<Overview>("/status").then(setData).catch(() => {}), []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Track when the event connection dropped (yellow while reconnecting, red after a while).
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
setLostAt(undefined);
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
setLostAt((v) => v ?? Date.now());
|
||||
const id = setTimeout(() => tick((n) => n + 1), RECONNECT_GRACE_MS + 100);
|
||||
return () => clearTimeout(id);
|
||||
}, [connected, load]);
|
||||
|
||||
// Close on outside click and Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => !root.current?.contains(e.target as Node) && setOpen(false);
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const server: Level = connected ? "ok" : lostAt && Date.now() - lostAt > RECONNECT_GRACE_MS ? "err" : "warn";
|
||||
const providers = Object.entries(data?.providers ?? {});
|
||||
const mcp = Object.entries(data?.mcp ?? {});
|
||||
const models: Level = data ? (providers.length ? combine(providers.map(([, p]) => providerLevel(p))) : "err") : "off";
|
||||
const tools: Level = combine(mcp.map(([, m]) => mcpLevel(m)));
|
||||
|
||||
const label = (l: Level) => t(`status.level.${l}` as Key);
|
||||
const summary = `${t("status.server")}: ${label(server)} · ${t("status.models")}: ${label(models)} · ${t("status.mcp")}: ${label(tools)}`;
|
||||
|
||||
const recheck = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
await post("/providers/status");
|
||||
await load();
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
const go = (tab: string) => {
|
||||
setOpen(false);
|
||||
navigate(`/settings/${tab}`);
|
||||
};
|
||||
|
||||
const providerText = (p: ProviderState) =>
|
||||
p.state === "ok"
|
||||
? t("status.providerOk", { n: p.models, ms: p.latency_ms ?? 0 })
|
||||
: p.state === "auth"
|
||||
? t("settings.providerAuth")
|
||||
: p.state === "down"
|
||||
? t("settings.providerDown")
|
||||
: t("status.notChecked");
|
||||
|
||||
return (
|
||||
<div className="status-leds" ref={root}>
|
||||
<button
|
||||
className="ghost status-leds-btn"
|
||||
onClick={(e) => {
|
||||
// Fixed position below the LEDs, kept inside the window.
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - 328)), top: r.bottom + 6 });
|
||||
setOpen(!open);
|
||||
}} title={summary} aria-label={summary} aria-expanded={open}>
|
||||
<Led level={server} />
|
||||
<Led level={models} />
|
||||
<Led level={tools} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="status-pop" role="dialog" aria-label={t("status.title")} style={pos}>
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={server} /> {t("status.server")}
|
||||
</div>
|
||||
<div className="status-row">
|
||||
<span className="grow">{connected ? t("sidebar.connected") : t("sidebar.disconnected")}</span>
|
||||
{data && <span className="hint">v{data.version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={models} /> {t("status.models")}
|
||||
<span className="spacer" />
|
||||
{admin && (
|
||||
<button className="small ghost" onClick={recheck} disabled={checking} title={t("status.recheck")}>
|
||||
<Icon name="refresh" size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{providers.length === 0 && <div className="status-row hint">{t("status.noProviders")}</div>}
|
||||
{providers.map(([name, p]) => (
|
||||
<div key={name} className="status-row" title={p.error ?? undefined}>
|
||||
<Led level={providerLevel(p)} />
|
||||
<span className="grow mono">{name}</span>
|
||||
<span className="hint">
|
||||
{providerText(p)}
|
||||
{fullest(p.plan) && ` · ${t("status.plan", { pct: fmtPercent(fullest(p.plan)!.ratio), window: t(`budget.window.${fullest(p.plan)!.window}` as Key) })}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={tools} /> {t("status.mcp")}
|
||||
</div>
|
||||
{mcp.length === 0 && <div className="status-row hint">{t("status.noMcp")}</div>}
|
||||
{mcp.map(([name, m]) => (
|
||||
<div key={name} className="status-row" title={m.error ?? undefined}>
|
||||
<Led level={mcpLevel(m)} />
|
||||
<span className="grow mono">{name}</span>
|
||||
<span className="hint">{m.state === "connected" ? t("mcp.tools", { n: m.tools ?? 0 }) : t(`mcp.state.${m.state}` as Key)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!data?.budgets?.length && (
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={combine(data.budgets.map(meterLevel))} /> {t("status.budgets")}
|
||||
</div>
|
||||
{data.budgets.map((m) => (
|
||||
<div key={m.key} className="status-row status-meter" title={`${fmtCost(m.used, data.currency ?? "USD")} / ${fmtCost(m.limit, data.currency ?? "USD")}`}>
|
||||
<Led level={meterLevel(m)} />
|
||||
<span className="grow">{m.kind === "total" ? t("stats.total") : m.kind === "images" ? t("nav.images") : m.kind === "provider" ? m.provider : `${m.provider} · ${t(`budget.window.${m.kind}` as Key)}`}</span>
|
||||
<Bar share={m.ratio} warning={m.ratio >= 0.8} exceeded={m.ratio >= 1} />
|
||||
<span className="hint pct">{fmtPercent(m.ratio)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{admin && (
|
||||
<div className="status-foot">
|
||||
<button className="small ghost" onClick={() => go("providers")}>
|
||||
<Icon name="brain" size={13} /> {t("status.providerSettings")}
|
||||
</button>
|
||||
<button className="small ghost" onClick={() => go("mcp")}>
|
||||
<Icon name="plug" size={13} /> {t("status.mcpSettings")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import { onThemeChange } from "../theme";
|
||||
import { toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface TermInfo {
|
||||
export interface TermInfo {
|
||||
id: string;
|
||||
cwd: string;
|
||||
session_id?: string | null;
|
||||
created_at: number;
|
||||
alive: boolean;
|
||||
attached: boolean;
|
||||
@@ -132,7 +133,7 @@ export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }
|
||||
);
|
||||
}
|
||||
|
||||
function TermView({ info }: { info: TermInfo }) {
|
||||
export function TermView({ info }: { info: TermInfo }) {
|
||||
const t = useT();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { base64Size, FileFigure } from "./FileFigure";
|
||||
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -158,16 +158,30 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
)}
|
||||
{Array.isArray(meta?.image_ids) ? (
|
||||
<div className="gallery small">
|
||||
{meta.image_ids.map((id: string) => (
|
||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||
<img src={`/api/images/${id}/file`} alt="" />
|
||||
</FileFigure>
|
||||
))}
|
||||
{meta.image_ids.map((id: string, i: number) => {
|
||||
// The file may be gone (moved or deleted later): fall back to the preview the model saw.
|
||||
const preview = result?.images?.[i];
|
||||
return (
|
||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||
<img
|
||||
src={`/api/images/${id}/file`}
|
||||
alt=""
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (preview && !img.dataset.fallback) {
|
||||
img.dataset.fallback = "1";
|
||||
img.src = imageSrc(preview);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileFigure>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
result?.images?.map((img, i) => (
|
||||
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
|
||||
<img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
|
||||
import { ApiTokens } from "./ApiTokens";
|
||||
import { CliInstall } from "./CliInstall";
|
||||
import { Memory } from "./Memory";
|
||||
import { Telegram } from "./Telegram";
|
||||
|
||||
export function Account() {
|
||||
const t = useT();
|
||||
@@ -51,6 +52,7 @@ export function Account() {
|
||||
<Memory />
|
||||
<GitAccount />
|
||||
<CliInstall />
|
||||
<Telegram />
|
||||
<ApiTokens />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,25 +9,60 @@ import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||
|
||||
type Patch = { path: string[]; value: unknown };
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
read_only: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Tool checkboxes: nothing checked = all tools. */
|
||||
export function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
|
||||
const t = useT();
|
||||
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
|
||||
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
|
||||
const extra = value.filter((v) => !tools.some((x) => x.name === v));
|
||||
return (
|
||||
<div className="tool-picker">
|
||||
<div className="hint">{value.length ? t("agents.toolsSome", { n: value.length }) : t("agents.toolsAll")}</div>
|
||||
<div className="tool-grid">
|
||||
{tools.map((x) => (
|
||||
<label key={x.name} className={`tool-item ${readOnly && !x.read_only ? "disabled" : ""}`} title={x.description}>
|
||||
<input type="checkbox" checked={value.includes(x.name)} disabled={readOnly && !x.read_only} onChange={() => toggle(x.name)} />
|
||||
<span className="mono">{x.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{extra.map((n) => (
|
||||
<label key={n} className="tool-item">
|
||||
<input type="checkbox" checked onChange={() => toggle(n)} />
|
||||
<span className="mono">{n}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
|
||||
documentation, summaries, step-by-step guides and translations.
|
||||
Return only the finished text.`;
|
||||
|
||||
function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: boolean, text: string) => void }) {
|
||||
function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: ToolInfo[]; onSaved: (ok: boolean, text: string) => void }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(!!raw && !raw.prompt && !raw.description && raw.mode === "primary");
|
||||
const [form, setForm] = useState({
|
||||
model: a.model ?? "",
|
||||
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
|
||||
description: raw?.description ?? "",
|
||||
tools: (raw?.tools ?? []).join(", "),
|
||||
tools: (raw?.tools ?? []) as string[],
|
||||
read_only: !!(raw?.read_only ?? a.read_only),
|
||||
style: (raw?.style ?? a.style ?? "coding") as "coding" | "chat",
|
||||
prompt: raw?.prompt ?? "",
|
||||
mode: raw?.mode ?? a.mode,
|
||||
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
||||
refine_model: raw?.refine?.model ?? "",
|
||||
refine_criteria: raw?.refine?.criteria ?? "",
|
||||
});
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||
|
||||
const save = async () => {
|
||||
const base = ["agents", a.name];
|
||||
@@ -45,7 +80,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||
if (!builtin) {
|
||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||
patches.push({ path: [...base, "tools"], value: form.tools.trim() ? form.tools.split(/[\s,]+/).filter(Boolean) : null });
|
||||
patches.push({ path: [...base, "tools"], value: form.tools.length ? form.tools : null });
|
||||
patches.push({ path: [...base, "read_only"], value: form.read_only || null });
|
||||
patches.push({ path: [...base, "style"], value: form.style === "chat" ? "chat" : null });
|
||||
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
|
||||
}
|
||||
try {
|
||||
@@ -133,8 +170,23 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
<option value="subagent">{t("agents.modeSubagent")}</option>
|
||||
<option value="primary">{t("agents.modePrimary")}</option>
|
||||
</select>
|
||||
{form.mode === "primary" && (
|
||||
<>
|
||||
<label>{t("agents.style")}</label>
|
||||
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
|
||||
<option value="coding">{t("agents.styleCoding")}</option>
|
||||
<option value="chat">{t("agents.styleChat")}</option>
|
||||
</select>
|
||||
<div className="hint">{t("agents.styleHint")}</div>
|
||||
</>
|
||||
)}
|
||||
<label>{t("agents.readOnlyLabel")}</label>
|
||||
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
|
||||
<span className="hint">{t("agents.readOnlyHint")}</span>
|
||||
</label>
|
||||
<label>{t("agents.tools")}</label>
|
||||
<input value={form.tools} placeholder={t("agents.toolsAll")} onChange={(e) => setForm({ ...form, tools: e.target.value })} className="mono" />
|
||||
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
|
||||
<label>{t("agents.prompt")}</label>
|
||||
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||
</>
|
||||
@@ -159,7 +211,10 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
|
||||
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
||||
const t = useT();
|
||||
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
|
||||
const { data: all, reload } = useFetch<AgentInfo[]>("/agents");
|
||||
// Personal modes are managed under "My modes".
|
||||
const agents = all?.filter((a) => a.source !== "personal");
|
||||
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||||
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
const [name, setName] = useState("");
|
||||
@@ -172,13 +227,15 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
||||
}
|
||||
};
|
||||
|
||||
const add = async (template: "writer" | "blank") => {
|
||||
const add = async (template: "writer" | "blank" | "mode") => {
|
||||
const n = name.trim() || (template === "writer" ? "writer" : "");
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||
const value =
|
||||
template === "writer"
|
||||
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
|
||||
: { mode: "subagent", description: "", prompt: "" };
|
||||
: template === "mode"
|
||||
? { mode: "primary" }
|
||||
: { mode: "subagent", description: "", prompt: "" };
|
||||
try {
|
||||
await patch("/config", { patches: [{ path: ["agents", n], value }] });
|
||||
setName("");
|
||||
@@ -194,13 +251,16 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
||||
<div className="notice">{t("agents.intro")}</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||||
<div className="panel">
|
||||
<h3>{t("agents.add")}</h3>
|
||||
<div className="row">
|
||||
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button className="primary" onClick={() => add("mode")} disabled={!name.trim()}>
|
||||
+ {t("agents.addMode")}
|
||||
</button>
|
||||
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
|
||||
<button onClick={() => add("blank")} disabled={!name.trim()}>
|
||||
+ {t("agents.addBlank")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, patch, post } from "../../api";
|
||||
import { toast } from "../../dialogs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, get, patch, post } from "../../api";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { fmtDate, useT } from "../../i18n";
|
||||
import { formatSize } from "../FileFigure";
|
||||
@@ -90,6 +90,32 @@ export function Backups() {
|
||||
}
|
||||
};
|
||||
|
||||
// Restore: the server stages the backup, restarts and applies it before opening the database.
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const restore = async (source: { name: string } | { file: File }) => {
|
||||
const label = "name" in source ? source.name : source.file.name;
|
||||
const ok = await confirmDialog({ title: t("backup.restoreTitle"), message: t("backup.restoreConfirm", { file: label }), confirmLabel: t("backup.restore"), danger: true });
|
||||
if (!ok) return;
|
||||
setRestoring(true);
|
||||
try {
|
||||
if ("name" in source) await post(`/backups/${encodeURIComponent(source.name)}/restore`);
|
||||
else await api("/backups/restore", { method: "POST", body: source.file, headers: { "Content-Type": "application/zip" } });
|
||||
toast(t("backup.restarting"), "info");
|
||||
// Wait until the server is gone and back, then reload with the restored data.
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (await fetch("/api/health").then((r) => r.ok).catch(() => false)) return location.reload();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
toast(t("backup.notBack"));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") =>
|
||||
form && (
|
||||
<label className="field">
|
||||
@@ -111,6 +137,20 @@ export function Backups() {
|
||||
<a className="button small" href="/api/backup">
|
||||
<Icon name="download" /> {t("audit.download")}
|
||||
</a>
|
||||
<button className="small" onClick={() => fileInput.current?.click()} disabled={restoring}>
|
||||
<Icon name="upload" /> {restoring ? t("backup.restoring") : t("backup.restoreFile")}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void restore({ file });
|
||||
}}
|
||||
/>
|
||||
<button className="small primary" onClick={runNow} disabled={busy}>
|
||||
{busy ? t("backup.running") : t("backup.now")}
|
||||
</button>
|
||||
@@ -176,12 +216,17 @@ export function Backups() {
|
||||
{data && data.files.length > 0 && (
|
||||
<div className="backup-list">
|
||||
{data.files.map((f) => (
|
||||
<a key={f.name} href={`/api/backups/${encodeURIComponent(f.name)}`} className="backup-file">
|
||||
<div key={f.name} className="backup-file">
|
||||
<Icon name="file" />
|
||||
<span className="grow">{f.name}</span>
|
||||
<span className="hint">{formatSize(f.size)}</span>
|
||||
<Icon name="download" />
|
||||
</a>
|
||||
<button className="small" title={t("backup.restore")} onClick={() => void restore({ name: f.name })} disabled={restoring}>
|
||||
<Icon name="undo" />
|
||||
</button>
|
||||
<a className="button small" title={t("audit.download")} href={`/api/backups/${encodeURIComponent(f.name)}`}>
|
||||
<Icon name="download" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, put } from "../../api";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import { Icon } from "../Icon";
|
||||
import { ModelSelect } from "../ModelSelect";
|
||||
import { ToolPicker, type ToolInfo } from "./AgentsEditor";
|
||||
|
||||
interface Mode {
|
||||
description?: string | null;
|
||||
prompt?: string | null;
|
||||
model?: string | null;
|
||||
tools?: string[] | null;
|
||||
read_only?: boolean | null;
|
||||
style?: "coding" | "chat" | null;
|
||||
}
|
||||
|
||||
/** Modes a user creates for themselves; they appear in New session and the mode selector. */
|
||||
export function PersonalModes() {
|
||||
const t = useT();
|
||||
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||||
const [modes, setModes] = useState<Record<string, Mode>>();
|
||||
const [open, setOpen] = useState<string>();
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
get<Record<string, Mode>>("/account/modes")
|
||||
.then(setModes)
|
||||
.catch((e) => toast(e));
|
||||
}, []);
|
||||
|
||||
const save = async (next: Record<string, Mode>) => {
|
||||
try {
|
||||
setModes(await put<Record<string, Mode>>("/account/modes", next));
|
||||
toast(t("settings.saved"), "success");
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const add = async () => {
|
||||
const n = name.trim();
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||
if (modes?.[n]) return toast(t("modes.exists", { name: n }));
|
||||
if (await save({ ...modes, [n]: { style: "chat", description: "", prompt: "" } })) {
|
||||
setName("");
|
||||
setOpen(n);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (n: string) => {
|
||||
if (!(await confirmDialog({ message: t("agents.deleteConfirm", { name: n }), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||
const next = { ...modes };
|
||||
delete next[n];
|
||||
void save(next);
|
||||
};
|
||||
|
||||
if (!modes) return <div className="empty">{t("common.loading")}</div>;
|
||||
const names = Object.keys(modes).sort();
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("modes.intro")}</div>
|
||||
{names.map((n) => (
|
||||
<ModeRow key={n + JSON.stringify(modes[n])} name={n} mode={modes[n]} tools={tools} open={open === n} onToggle={() => setOpen(open === n ? undefined : n)} onSave={(m) => save({ ...modes, [n]: m })} onRemove={() => remove(n)} />
|
||||
))}
|
||||
{names.length === 0 && <div className="empty">{t("modes.none")}</div>}
|
||||
<div className="panel">
|
||||
<h3>{t("agents.addMode")}</h3>
|
||||
<div className="row">
|
||||
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && void add()} />
|
||||
<button className="primary" onClick={() => void add()} disabled={!name.trim()}>
|
||||
+ {t("agents.addMode")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeRow({ name, mode, tools, open, onToggle, onSave, onRemove }: { name: string; mode: Mode; tools: ToolInfo[]; open: boolean; onToggle: () => void; onSave: (m: Mode) => Promise<boolean>; onRemove: () => void }) {
|
||||
const t = useT();
|
||||
const [form, setForm] = useState({
|
||||
description: mode.description ?? "",
|
||||
prompt: mode.prompt ?? "",
|
||||
model: mode.model ?? "",
|
||||
tools: mode.tools ?? [],
|
||||
read_only: !!mode.read_only,
|
||||
style: (mode.style ?? "coding") as "coding" | "chat",
|
||||
});
|
||||
const submit = () =>
|
||||
void onSave({
|
||||
description: form.description || null,
|
||||
prompt: form.prompt || null,
|
||||
model: form.model || null,
|
||||
tools: form.tools.length ? form.tools : null,
|
||||
read_only: form.read_only || null,
|
||||
style: form.style === "chat" ? "chat" : null,
|
||||
});
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{name}
|
||||
<span className="chip">{form.style === "chat" ? t("agents.styleChatShort") : t("agents.styleCodingShort")}</span>
|
||||
{form.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||
<span className="spacer" />
|
||||
<button className="small ghost danger" onClick={onRemove} title={t("common.delete")} aria-label={t("common.delete")}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
<button className="small" onClick={onToggle}>
|
||||
{open ? t("common.collapse") : t("common.edit")}
|
||||
</button>
|
||||
</h3>
|
||||
{!open && mode.description && <div className="hint">{mode.description}</div>}
|
||||
{open && (
|
||||
<>
|
||||
<div className="form">
|
||||
<label>{t("agents.description")}</label>
|
||||
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<label>{t("agents.style")}</label>
|
||||
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
|
||||
<option value="coding">{t("agents.styleCoding")}</option>
|
||||
<option value="chat">{t("agents.styleChat")}</option>
|
||||
</select>
|
||||
<label>{t("agents.model")}</label>
|
||||
<ModelSelect value={form.model} onChange={(v) => setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} />
|
||||
<label>{t("agents.readOnlyLabel")}</label>
|
||||
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
|
||||
<span className="hint">{t("agents.readOnlyHint")}</span>
|
||||
</label>
|
||||
<label>{t("agents.tools")}</label>
|
||||
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
|
||||
<label>{t("agents.prompt")}</label>
|
||||
<textarea rows={8} value={form.prompt} placeholder={t("modes.promptPlaceholder")} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="primary" onClick={submit}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { del, put } from "../../api";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
interface Status {
|
||||
configured: boolean;
|
||||
bot: string;
|
||||
paired: boolean;
|
||||
pair_code: string;
|
||||
running: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** Connect a personal Telegram bot as a chat interface. */
|
||||
export function Telegram() {
|
||||
const t = useT();
|
||||
const { data, reload } = useFetch<Status>("/account/telegram");
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const connect = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await put("/account/telegram", { token: token.trim() });
|
||||
setToken("");
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const disconnect = async () => {
|
||||
if (!(await confirmDialog({ message: t("telegram.disconnectConfirm"), danger: true, confirmLabel: t("telegram.disconnect") }))) return;
|
||||
await del("/account/telegram").catch((e) => toast(e));
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<Icon name="share" /> {t("telegram.title")}
|
||||
<span className="spacer" />
|
||||
{data?.configured && (
|
||||
<button className="small ghost danger" onClick={disconnect}>
|
||||
{t("telegram.disconnect")}
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
{t("telegram.intro")}
|
||||
</div>
|
||||
{data && !data.configured && (
|
||||
<>
|
||||
<ol className="cli-steps">
|
||||
<li>{t("telegram.step1")}</li>
|
||||
<li>{t("telegram.step2")}</li>
|
||||
</ol>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
name="telegram-bot-token"
|
||||
value={token}
|
||||
placeholder="123456789:AAH…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="mono"
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button className="primary" onClick={() => void connect()} disabled={busy || !token.trim()}>
|
||||
{busy ? t("telegram.checking") : t("telegram.connect")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{data?.configured && (
|
||||
<>
|
||||
<div className="row" style={{ gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<span className="chip">@{data.bot}</span>
|
||||
<span className={`chip ${data.running && !data.error ? "ok" : "warn"}`}>{data.running && !data.error ? t("telegram.running") : t("telegram.stopped")}</span>
|
||||
{data.paired && <span className="chip ok">{t("telegram.paired")}</span>}
|
||||
</div>
|
||||
{!data.paired && data.pair_code && (
|
||||
<div className="notice" style={{ marginTop: 10 }}>
|
||||
{t("telegram.pairHint", { bot: data.bot })}{" "}
|
||||
<a href={`https://t.me/${data.bot}?start=${data.pair_code}`} target="_blank" rel="noreferrer">
|
||||
t.me/{data.bot}
|
||||
</a>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<code className="token-value">/start {data.pair_code}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.error && <div className="notice err" style={{ marginTop: 10 }}>{data.error}</div>}
|
||||
{data.paired && <div className="hint" style={{ marginTop: 10 }}>{t("telegram.commands")}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+145
-11
@@ -40,6 +40,13 @@ export const de: Messages = {
|
||||
"settings.imagesDefault": "Standardmodell",
|
||||
"settings.imagesDir": "Ordner im Projekt",
|
||||
"settings.imagesHint": "Modelle und Preise pro Bild werden in der Config-Datei bearbeitet ([images]).",
|
||||
"budget.plan": "Go-Plan ({v}/Monat)",
|
||||
"budget.window.5h": "5 Stunden",
|
||||
"budget.window.week": "7 Tage",
|
||||
"budget.window.month": "Monat",
|
||||
"settings.planLimit": "Planlimit (USD/Monat)",
|
||||
"settings.planLimitHint": "opencode Go: das monatliche Nutzungslimit deines Plans. CodeCraft zählt den Verbrauch, den jede Antwort meldet (5 Stunden 20 %, Woche 50 %, Monat 100 %) – nur Nutzung über CodeCraft.",
|
||||
"status.plan": "Go {pct} ({window})",
|
||||
"budget.title": "Budgets in diesem Monat",
|
||||
"budget.spent": "Verbraucht",
|
||||
"budget.budget": "Budget",
|
||||
@@ -78,6 +85,9 @@ export const de: Messages = {
|
||||
"files.newFolderName": "Ordnername:",
|
||||
"files.renameTo": "Neuer Name:",
|
||||
"files.copyTo": "Name der Kopie:",
|
||||
"files.chatHere": "Chat hier öffnen",
|
||||
"files.newTab": "Dateimanager öffnen",
|
||||
"files.closeTab": "Dateimanager schließen",
|
||||
"files.rename": "Umbenennen",
|
||||
"files.duplicate": "Duplizieren",
|
||||
"files.download": "Herunterladen",
|
||||
@@ -204,6 +214,22 @@ export const de: Messages = {
|
||||
"sidebar.yesterday": "Gestern",
|
||||
"sidebar.week": "Letzte 7 Tage",
|
||||
"sidebar.older": "Älter",
|
||||
"status.title": "Status",
|
||||
"status.budgets": "Budgets",
|
||||
"status.server": "Server",
|
||||
"status.models": "Modelle",
|
||||
"status.mcp": "MCP",
|
||||
"status.level.ok": "alles in Ordnung",
|
||||
"status.level.warn": "teilweise nicht verfügbar",
|
||||
"status.level.err": "nicht verfügbar",
|
||||
"status.level.off": "keine konfiguriert",
|
||||
"status.providerOk": "{n} Modelle · {ms} ms",
|
||||
"status.notChecked": "noch nicht geprüft",
|
||||
"status.recheck": "Erneut prüfen",
|
||||
"status.noProviders": "Keine Provider aktiv.",
|
||||
"status.noMcp": "Keine MCP-Server konfiguriert.",
|
||||
"status.providerSettings": "Provider",
|
||||
"status.mcpSettings": "MCP-Server",
|
||||
"sidebar.connected": "Verbunden",
|
||||
"sidebar.disconnected": "Getrennt – verbinde neu…",
|
||||
"sidebar.needsInput": "Wartet auf deine Eingabe",
|
||||
@@ -213,6 +239,9 @@ export const de: Messages = {
|
||||
"wizard.title": "Was möchtest du machen?",
|
||||
"wizard.subtitle": "Aufgabe wählen, beschreiben, was du willst, dann auf Start drücken.",
|
||||
"wizard.whatToDo": "Was möchtest du machen?",
|
||||
"wizard.task.chat": "Einfach chatten",
|
||||
"wizard.task.chatDesc": "Fragen, besprechen, nachschlagen – ohne Projekt und Dateien",
|
||||
"wizard.placeholder.chat": "Worüber möchtest du sprechen?",
|
||||
"wizard.task.craft": "Etwas craften",
|
||||
"wizard.task.craftDesc": "Website, App, Spiel oder altes Programm nachbauen – oder etwas Neues bauen",
|
||||
"wizard.task.build": "Am Code arbeiten",
|
||||
@@ -280,6 +309,9 @@ export const de: Messages = {
|
||||
"rename.generate": "Generieren",
|
||||
"nav.terminal": "Terminal",
|
||||
"terminal.new": "Neues Terminal",
|
||||
"terminal.chatTab": "Chat",
|
||||
"terminal.tab": "Terminal {n}",
|
||||
"terminal.closeConfirm": "{name} schließen? Die Shell und alles, was darin läuft, wird beendet.",
|
||||
"terminal.close": "Terminal schließen",
|
||||
"terminal.here": "Terminal in diesem Ordner öffnen",
|
||||
"terminal.connecting": "Verbinde…",
|
||||
@@ -312,7 +344,7 @@ export const de: Messages = {
|
||||
"agents.refineShort": "Review ×{n}",
|
||||
"agents.deleteFile": "Agenten-Datei {path} löschen?",
|
||||
"nav.jobs": "Automationen",
|
||||
"jobs.intro": "Agenten, die nach Zeitplan starten. Jede Automation ist eine Markdown-Datei (Auftrag + Zeitplan im Frontmatter) – lass sie dir in einem Chat von einem Agenten anlegen (Tool „schedule“) oder registriere unten eine Datei. Jeder Lauf erscheint als normaler Chat; braucht er eine Freigabe, bekommst du eine Push-Benachrichtigung.",
|
||||
"jobs.intro": "Agenten, die nach Zeitplan starten. Die Anweisungen jeder Automatisierung stehen in einer Markdown-Datei; Name, Zeitplan und Einstellungen legst du hier fest. Mit „Neue Automatisierung“ anlegen und die Anweisungen mit dem Assistenten erarbeiten – oder einen Agenten in einem Chat darum bitten (Tool „schedule“). Jeder Lauf erscheint als normaler Chat.",
|
||||
"jobs.none": "Noch keine Automationen.",
|
||||
"jobs.schedule": "Zeitplan",
|
||||
"jobs.next": "Nächster Lauf",
|
||||
@@ -323,13 +355,12 @@ export const de: Messages = {
|
||||
"jobs.enable": "Aktivieren",
|
||||
"jobs.disable": "Deaktivieren",
|
||||
"jobs.runNow": "Jetzt starten",
|
||||
"jobs.editFile": "Datei bearbeiten",
|
||||
"jobs.remove": "Entfernen",
|
||||
"jobs.deleteConfirm": "Automation „{name}“ entfernen? Die Job-Datei bleibt erhalten.",
|
||||
"jobs.openRun": "Lauf öffnen",
|
||||
"jobs.prompt": "Auftrag",
|
||||
"jobs.register": "Job-Datei registrieren",
|
||||
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Zeitplan im Frontmatter:",
|
||||
"jobs.register": "Vorhandene Datei registrieren",
|
||||
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Anweisungen (ältere Dateien mit Zeitplan im Frontmatter werden übernommen):",
|
||||
"jobs.registerBtn": "Registrieren",
|
||||
"jobs.status.running": "läuft",
|
||||
"jobs.status.done": "fertig",
|
||||
@@ -373,6 +404,19 @@ export const de: Messages = {
|
||||
"tokens.title": "API-Tokens",
|
||||
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
|
||||
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
|
||||
"telegram.title": "Telegram",
|
||||
"telegram.intro": "CodeCraft über Telegram mit deinem eigenen Bot nutzen: Nachrichten gehen an deine aktuelle Sitzung, Befehle wechseln Sitzungen, Modi und Modelle, Freigaben und Rückfragen kommen mit Knöpfen. Nur dein Telegram-Konto kann den Bot nutzen.",
|
||||
"telegram.step1": "In Telegram @BotFather öffnen, /newbot senden und den Schritten folgen.",
|
||||
"telegram.step2": "Das Token von BotFather hier einfügen.",
|
||||
"telegram.connect": "Verbinden",
|
||||
"telegram.checking": "Prüfe…",
|
||||
"telegram.running": "läuft",
|
||||
"telegram.stopped": "läuft nicht",
|
||||
"telegram.paired": "gekoppelt",
|
||||
"telegram.pairHint": "Letzter Schritt: deinen Bot öffnen und den Code senden (oder den Link antippen) – danach bedient er nur noch dein Telegram-Konto:",
|
||||
"telegram.commands": "In Telegram: /help zeigt die Befehle – /sessions, /new, /mode, /model, /stop, /status, /costs …",
|
||||
"telegram.disconnect": "Trennen",
|
||||
"telegram.disconnectConfirm": "Telegram-Bot trennen? Er antwortet dann nicht mehr; mit seinem Token kannst du ihn wieder verbinden.",
|
||||
"tokens.copy": "Kopieren",
|
||||
"tokens.copied": "Kopiert",
|
||||
"tokens.name": "Name",
|
||||
@@ -423,6 +467,13 @@ export const de: Messages = {
|
||||
"shareIn.newChat": "Neuen Chat starten",
|
||||
"shareIn.appendTo": "oder an eine letzte Session anhängen",
|
||||
"common.back": "Zurück",
|
||||
"settings.tab.modes": "Meine Modi",
|
||||
"modes.intro": "Deine eigenen Modi: ein Prompt, eine Art (Programmieren oder Unterhaltung), Werkzeuge und optional ein Modell. Sie erscheinen in „Neue Sitzung“ und in der Modus-Auswahl deiner Chats – nur für dich.",
|
||||
"modes.none": "Du hast noch keine eigenen Modi.",
|
||||
"modes.exists": "Ein Modus namens {name} existiert schon.",
|
||||
"modes.promptPlaceholder": "z. B. Du bist ein geduldiger Englischlehrer. Korrigiere meine Sätze und erkläre kurz, warum.",
|
||||
"agents.styleChatShort": "Unterhaltung",
|
||||
"agents.styleCodingShort": "Programmieren",
|
||||
"settings.tab.templates": "Vorlagen",
|
||||
"templates.title": "Prompt-Vorlagen",
|
||||
"templates.filter": "Vorlagen durchsuchen…",
|
||||
@@ -455,6 +506,13 @@ export const de: Messages = {
|
||||
"sidebar.organise": "Session organisieren",
|
||||
"sidebar.pin": "Oben anpinnen",
|
||||
"sidebar.unpin": "Nicht mehr anpinnen",
|
||||
"sidebar.openBeside": "Daneben öffnen",
|
||||
"chat.closePane": "Spalte schließen",
|
||||
"sidebar.groupMenu": "Ordner-Optionen",
|
||||
"sidebar.groupPin": "Oben fixieren",
|
||||
"sidebar.groupUnpin": "Nicht mehr fixieren",
|
||||
"sidebar.groupUp": "Nach oben",
|
||||
"sidebar.groupDown": "Nach unten",
|
||||
"sidebar.moveTo": "In Ordner verschieben…",
|
||||
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
||||
"sidebar.archiveOne": "Archivieren",
|
||||
@@ -464,6 +522,13 @@ export const de: Messages = {
|
||||
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
|
||||
"sidebar.folderName": "Ordnername",
|
||||
"sidebar.move": "Verschieben",
|
||||
"backup.restore": "Wiederherstellen",
|
||||
"backup.restoreFile": "Aus Datei wiederherstellen…",
|
||||
"backup.restoring": "Wird wiederhergestellt…",
|
||||
"backup.restoreTitle": "Backup wiederherstellen?",
|
||||
"backup.restoreConfirm": "Datenbank und Konfiguration durch {file} ersetzen? Alle Sitzungen, Benutzer, Statistiken und Einstellungen werden durch den Stand im Backup ersetzt; Dateien im Workspace bleiben unverändert. Der aktuelle Stand wird vorher gesichert. CodeCraft startet neu – laufende Agents werden beendet.",
|
||||
"backup.restarting": "Backup übernommen – CodeCraft startet neu…",
|
||||
"backup.notBack": "Der Server ist nicht zurückgekommen. Er braucht eine Restart-Policy (Docker: restart: unless-stopped) – bitte von Hand starten.",
|
||||
"backup.now": "Jetzt sichern",
|
||||
"backup.running": "Sichere…",
|
||||
"backup.created": "Backup {file} erstellt.",
|
||||
@@ -484,16 +549,12 @@ export const de: Messages = {
|
||||
"backup.s3Keep": "Im Bucket behalten (0 = alle)",
|
||||
"backup.last": "Letztes Backup",
|
||||
"backup.s3Ok": "hochgeladen",
|
||||
"jobs.fromTemplate": "Neue Automation aus Vorlage",
|
||||
"jobs.tplName": "Name",
|
||||
"jobs.tplProject": "Projektordner",
|
||||
"jobs.tplSchedule": "Wann",
|
||||
"jobs.tplCronHint": "Cron: Minute Stunde Tag Monat Wochentag (Zeitzone des Servers).",
|
||||
"jobs.tplMode": "Modus",
|
||||
"jobs.tplPrompt": "Aufgabe für den Agenten",
|
||||
"jobs.tplCreate": "Automation anlegen",
|
||||
"new.title": "Neue Session",
|
||||
"new.directory": "Projektverzeichnis",
|
||||
"new.invalidFolderName": "Ordnernamen dürfen kein „/“ enthalten.",
|
||||
"new.up": "Hoch",
|
||||
"new.noSubdirs": "Keine Unterverzeichnisse",
|
||||
"new.model": "Modell",
|
||||
@@ -527,6 +588,64 @@ export const de: Messages = {
|
||||
"chat.exportMd": "Markdown (.md)",
|
||||
"chat.exportJson": "JSON (.json)",
|
||||
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
||||
"chat.compactedAuto": "Kontext fast voll ({tokens} Tokens) – Unterhaltung automatisch komprimiert · Zusammenfassung",
|
||||
"chat.compactedManual": "Unterhaltung bei {tokens} Tokens komprimiert · Zusammenfassung",
|
||||
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
||||
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
||||
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
|
||||
"chat.telegram": "Fragen an Telegram senden",
|
||||
"chat.telegramHint": "Freigaben und Rückfragen dieses Chats auch per Telegram schicken (der gerade in Telegram geöffnete Chat meldet sich immer)",
|
||||
"cron.label": "Zeitplan",
|
||||
"cron.mode.minutes": "Alle paar Minuten",
|
||||
"cron.mode.hourly": "Stündlich",
|
||||
"cron.mode.daily": "Täglich",
|
||||
"cron.mode.weekly": "An Wochentagen",
|
||||
"cron.mode.monthly": "Monatlich",
|
||||
"cron.mode.custom": "Cron-Ausdruck",
|
||||
"cron.every": "Alle",
|
||||
"cron.minutes": "Minuten",
|
||||
"cron.atMinute": "Zur Minute",
|
||||
"cron.days": "Wochentage",
|
||||
"cron.workdays": "Mo–Fr",
|
||||
"cron.onDay": "Am",
|
||||
"cron.at": "um",
|
||||
"cron.customHint": "Minute Stunde Tag Monat Wochentag, z. B. „30 6 * * 1-5“ = Mo–Fr 06:30.",
|
||||
"cron.nextRuns": "Nächste Läufe:",
|
||||
"cron.shortMonths": "Monate ohne diesen Tag werden übersprungen.",
|
||||
"cron.serverTz": "Uhrzeiten in Serverzeit ({tz})",
|
||||
"jobEditor.saved": "Gespeichert",
|
||||
"jobEditor.conflict": "Die Anweisungen wurden inzwischen geändert (z. B. vom Assistenten). Mit deiner Fassung überschreiben?",
|
||||
"jobEditor.overwrite": "Überschreiben",
|
||||
"jobEditor.runUnsaved": "Es gibt ungespeicherte Änderungen – der Lauf verwendet die gespeicherte Fassung. Trotzdem starten?",
|
||||
"jobEditor.discard": "Ungespeicherte Änderungen verwerfen?",
|
||||
"jobEditor.back": "Zurück zu den Automatisierungen",
|
||||
"jobEditor.tab.prompt": "Anweisungen",
|
||||
"jobEditor.tab.settings": "Zeitplan",
|
||||
"jobEditor.tab.assistant": "Assistent",
|
||||
"jobEditor.timeout": "Zeitlimit (Min.)",
|
||||
"jobEditor.bypassHint": "Läuft nachts: Mit „Alles erlauben“ wartet der Lauf nie auf eine Freigabe.",
|
||||
"jobEditor.instructions": "Anweisungen",
|
||||
"jobEditor.instructionsHint": "Der Agent sieht nur diesen Text – keinen Chat, niemanden zum Nachfragen.",
|
||||
"jobEditor.changedOnDisk": "Der Assistent hat die Datei geändert, während du bearbeitet hast.",
|
||||
"jobEditor.reload": "Seine Fassung laden",
|
||||
"jobEditor.assistantIntro": "Erarbeite die Anweisungen zusammen mit einem Modell: Beschreib, was die Automatisierung tun soll – es fragt nach und schreibt die Datei nebenan mit.",
|
||||
"jobEditor.assistantStart": "Assistent starten",
|
||||
"jobEditor.defaultModel": "Server-Standard",
|
||||
"jobs.new": "Neue Automatisierung",
|
||||
"jobs.namePlaceholder": "z. B. Nächtlicher Abhängigkeits-Check",
|
||||
"jobs.fileHint": "Die Anweisungen landen in .codecraft/jobs/<name>.md in diesem Ordner.",
|
||||
"jobs.startWith": "Beginnen mit",
|
||||
"jobs.blank": "Leer",
|
||||
"jobs.blankDesc": "Beispielgliederung zum Ausfüllen mit dem Assistenten",
|
||||
"jobs.createAndEdit": "Anlegen und bearbeiten",
|
||||
"jobs.edit": "Bearbeiten",
|
||||
"jobs.permissionMode": "Freigabemodus",
|
||||
"jobs.permissionModeHint": "Gilt ab dem nächsten Lauf. „Alles erlauben“: der Lauf fragt nie nach und wartet nicht auf Freigaben.",
|
||||
"instructions.title": "Anweisungen für diesen Chat",
|
||||
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
|
||||
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
|
||||
"instructions.clear": "Entfernen",
|
||||
"instructions.chip": "Anweisungen",
|
||||
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||
"chat.context": "Kontext",
|
||||
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
||||
@@ -544,6 +663,9 @@ export const de: Messages = {
|
||||
"composer.mode": "Modus",
|
||||
"composer.model": "Modell",
|
||||
"composer.permissionMode": "Berechtigungsmodus",
|
||||
"composer.preview": "Vorschau (Markdown)",
|
||||
"composer.previewEdit": "Zurück zum Bearbeiten",
|
||||
"composer.previewEmpty": "Noch nichts zum Anzeigen.",
|
||||
"composer.attach": "Dateien anhängen",
|
||||
|
||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||
@@ -625,7 +747,7 @@ export const de: Messages = {
|
||||
"settings.baseUrl": "Basis-URL",
|
||||
"settings.apiKey": "API-Key",
|
||||
"settings.apiKeyStored": "Gespeichert – leer lassen zum Beibehalten",
|
||||
"settings.apiKeyHint": "Am besten ${ENV_VAR}-Verweise nutzen; direkte Keys stehen im Klartext in der Config-Datei.",
|
||||
"settings.apiKeyHint": "Wird in der Konfigurationsdatei gespeichert (für Admins lesbar, in Backups enthalten). ${NAME}-Verweise auf Umgebungsvariablen funktionieren weiterhin.",
|
||||
"settings.authPrefix": "Auth-Präfix",
|
||||
"settings.enabled": "Aktiv",
|
||||
"settings.modelCount": "{n} Modelle",
|
||||
@@ -647,6 +769,9 @@ export const de: Messages = {
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.providerUp": "erreichbar · {ms} ms",
|
||||
"settings.providerDown": "nicht erreichbar",
|
||||
"settings.testConnection": "Verbindung testen",
|
||||
"settings.testing": "Teste…",
|
||||
"settings.testOk": "Verbindung klappt: {n} Modelle, {ms} ms. Noch nicht gespeichert.",
|
||||
"settings.providerAuth": "API-Key fehlt oder ungültig",
|
||||
"settings.providerCheck": "Provider prüfen",
|
||||
"settings.providerChecking": "Prüfe…",
|
||||
@@ -800,6 +925,7 @@ export const de: Messages = {
|
||||
"agents.modePrimary": "Modus",
|
||||
"agents.modeSubagent": "Subagent",
|
||||
"agents.source.builtin": "eingebaut",
|
||||
"agents.source.personal": "persönlich",
|
||||
"agents.source.config": "Config",
|
||||
"agents.source.file": "Datei",
|
||||
"agents.model": "Modell",
|
||||
@@ -810,8 +936,16 @@ export const de: Messages = {
|
||||
"agents.description": "Beschreibung",
|
||||
"agents.descriptionHint": "Anhand dieser Beschreibung entscheidet der Hauptagent, wann er delegiert.",
|
||||
"agents.mode": "Art",
|
||||
"agents.addMode": "Neuer Modus",
|
||||
"agents.style": "Art",
|
||||
"agents.styleCoding": "Programmieren – Projekt-Kontext und Coding-Ablauf",
|
||||
"agents.styleChat": "Unterhaltung – nur dieser Prompt, kein Projekt",
|
||||
"agents.styleHint": "Unterhaltungs-Modi bekommen nur deinen Prompt plus Datum und persönliches Gedächtnis, wie der eingebaute Chat-Modus.",
|
||||
"agents.readOnlyLabel": "Nur lesen",
|
||||
"agents.readOnlyHint": "Nur Werkzeuge, die nichts verändern (wie der Plan-Modus)",
|
||||
"agents.toolsSome": "{n} Werkzeuge ausgewählt – nur diese sind verfügbar",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "alle Tools (z. B. read, glob, grep)",
|
||||
"agents.toolsAll": "Nichts ausgewählt = alle Werkzeuge",
|
||||
"agents.prompt": "Anweisungen",
|
||||
"agents.reset": "Zurücksetzen",
|
||||
"agents.resetConfirm": "„{name}“ auf die eingebauten Standardwerte zurücksetzen?",
|
||||
|
||||
+145
-11
@@ -39,6 +39,13 @@ export const en = {
|
||||
"settings.imagesDefault": "Default model",
|
||||
"settings.imagesDir": "Folder in the project",
|
||||
"settings.imagesHint": "Models and prices per image are edited in the config file ([images]).",
|
||||
"budget.plan": "Go plan ({v}/month)",
|
||||
"budget.window.5h": "5 hours",
|
||||
"budget.window.week": "7 days",
|
||||
"budget.window.month": "Month",
|
||||
"settings.planLimit": "Plan limit (USD/month)",
|
||||
"settings.planLimitHint": "opencode Go: the monthly usage limit of your plan. CodeCraft tracks the allowance each response reports (5 hours 20 %, week 50 %, month 100 %) – only usage through CodeCraft.",
|
||||
"status.plan": "Go {pct} ({window})",
|
||||
"budget.title": "Budgets this month",
|
||||
"budget.spent": "Spent",
|
||||
"budget.budget": "Budget",
|
||||
@@ -77,6 +84,9 @@ export const en = {
|
||||
"files.newFolderName": "Folder name:",
|
||||
"files.renameTo": "New name:",
|
||||
"files.copyTo": "Name of the copy:",
|
||||
"files.chatHere": "Open chat here",
|
||||
"files.newTab": "Open file manager",
|
||||
"files.closeTab": "Close file manager",
|
||||
"files.rename": "Rename",
|
||||
"files.duplicate": "Duplicate",
|
||||
"files.download": "Download",
|
||||
@@ -203,6 +213,22 @@ export const en = {
|
||||
"sidebar.yesterday": "Yesterday",
|
||||
"sidebar.week": "Last 7 days",
|
||||
"sidebar.older": "Older",
|
||||
"status.title": "Status",
|
||||
"status.budgets": "Budgets",
|
||||
"status.server": "Server",
|
||||
"status.models": "Models",
|
||||
"status.mcp": "MCP",
|
||||
"status.level.ok": "all fine",
|
||||
"status.level.warn": "partly unavailable",
|
||||
"status.level.err": "unavailable",
|
||||
"status.level.off": "none configured",
|
||||
"status.providerOk": "{n} models · {ms} ms",
|
||||
"status.notChecked": "not checked yet",
|
||||
"status.recheck": "Check again",
|
||||
"status.noProviders": "No providers enabled.",
|
||||
"status.noMcp": "No MCP servers configured.",
|
||||
"status.providerSettings": "Providers",
|
||||
"status.mcpSettings": "MCP servers",
|
||||
"sidebar.connected": "Connected",
|
||||
"sidebar.disconnected": "Disconnected – reconnecting…",
|
||||
"sidebar.needsInput": "Waiting for your input",
|
||||
@@ -212,6 +238,9 @@ export const en = {
|
||||
"wizard.title": "What do you want to do?",
|
||||
"wizard.subtitle": "Pick a task, say what you want, then press Start.",
|
||||
"wizard.whatToDo": "What do you want to do?",
|
||||
"wizard.task.chat": "Just chat",
|
||||
"wizard.task.chatDesc": "Ask, discuss, look things up – no project, no files",
|
||||
"wizard.placeholder.chat": "What would you like to talk about?",
|
||||
"wizard.task.craft": "Craft something",
|
||||
"wizard.task.craftDesc": "Recreate a website, app, game or old program – or build something new",
|
||||
"wizard.task.build": "Work on code",
|
||||
@@ -279,6 +308,9 @@ export const en = {
|
||||
"rename.generate": "Generate",
|
||||
"nav.terminal": "Terminal",
|
||||
"terminal.new": "New terminal",
|
||||
"terminal.chatTab": "Chat",
|
||||
"terminal.tab": "Terminal {n}",
|
||||
"terminal.closeConfirm": "Close {name}? The shell and everything running in it are stopped.",
|
||||
"terminal.close": "Close terminal",
|
||||
"terminal.here": "Open a terminal in this folder",
|
||||
"terminal.connecting": "Connecting…",
|
||||
@@ -311,7 +343,7 @@ export const en = {
|
||||
"agents.refineShort": "Review ×{n}",
|
||||
"agents.deleteFile": "Delete agent file {path}?",
|
||||
"nav.jobs": "Automations",
|
||||
"jobs.intro": "Agents that start on a schedule. Each automation is a Markdown file (prompt + schedule in the frontmatter) – ask an agent in any chat to create one (tool “schedule”) or register a file below. Every run appears as a normal chat; if it needs permission you get a push notification.",
|
||||
"jobs.intro": "Agents that start on a schedule. Each automation has its instructions in a Markdown file; name, schedule and settings are set here. Create one with “New automation” and work out the instructions with the assistant – or ask an agent in any chat (tool “schedule”). Every run appears as a normal chat.",
|
||||
"jobs.none": "No automations yet.",
|
||||
"jobs.schedule": "Schedule",
|
||||
"jobs.next": "Next run",
|
||||
@@ -322,13 +354,12 @@ export const en = {
|
||||
"jobs.enable": "Enable",
|
||||
"jobs.disable": "Disable",
|
||||
"jobs.runNow": "Run now",
|
||||
"jobs.editFile": "Edit file",
|
||||
"jobs.remove": "Remove",
|
||||
"jobs.deleteConfirm": "Remove the automation “{name}”? The job file stays.",
|
||||
"jobs.openRun": "Open run",
|
||||
"jobs.prompt": "Instructions",
|
||||
"jobs.register": "Register a job file",
|
||||
"jobs.registerHint": "Path to a Markdown file with a schedule in its frontmatter:",
|
||||
"jobs.register": "Register an existing file",
|
||||
"jobs.registerHint": "Path to a Markdown file with instructions (older files with a schedule in the frontmatter are taken over):",
|
||||
"jobs.registerBtn": "Register",
|
||||
"jobs.status.running": "running",
|
||||
"jobs.status.done": "done",
|
||||
@@ -372,6 +403,19 @@ export const en = {
|
||||
"tokens.title": "API tokens",
|
||||
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
|
||||
"tokens.copyNow": "Copy the token now – it is shown only once:",
|
||||
"telegram.title": "Telegram",
|
||||
"telegram.intro": "Use CodeCraft from Telegram with your own bot: messages go to your current session, commands switch sessions, modes and models, approvals and questions come with buttons. Only your Telegram account can use the bot.",
|
||||
"telegram.step1": "In Telegram, open @BotFather, send /newbot and follow the steps.",
|
||||
"telegram.step2": "Paste the token BotFather gives you here.",
|
||||
"telegram.connect": "Connect",
|
||||
"telegram.checking": "Checking…",
|
||||
"telegram.running": "running",
|
||||
"telegram.stopped": "not running",
|
||||
"telegram.paired": "paired",
|
||||
"telegram.pairHint": "Last step: open your bot and send the code (or tap the link) – from then on only your Telegram account is served:",
|
||||
"telegram.commands": "In Telegram: /help lists the commands – /sessions, /new, /mode, /model, /stop, /status, /costs …",
|
||||
"telegram.disconnect": "Disconnect",
|
||||
"telegram.disconnectConfirm": "Disconnect the Telegram bot? It stops answering; you can connect it again with its token.",
|
||||
"tokens.copy": "Copy",
|
||||
"tokens.copied": "Copied",
|
||||
"tokens.name": "Name",
|
||||
@@ -422,6 +466,13 @@ export const en = {
|
||||
"shareIn.newChat": "Start a new chat",
|
||||
"shareIn.appendTo": "or add to a recent session",
|
||||
"common.back": "Back",
|
||||
"settings.tab.modes": "My modes",
|
||||
"modes.intro": "Your own modes: a prompt, a kind (coding or conversation), tools and an optional model. They appear in New session and in the mode selector of your chats – only for you.",
|
||||
"modes.none": "You have no modes of your own yet.",
|
||||
"modes.exists": "A mode named {name} already exists.",
|
||||
"modes.promptPlaceholder": "e.g. You are a patient German teacher. Correct my sentences and explain briefly why.",
|
||||
"agents.styleChatShort": "conversation",
|
||||
"agents.styleCodingShort": "coding",
|
||||
"settings.tab.templates": "Templates",
|
||||
"templates.title": "Prompt templates",
|
||||
"templates.filter": "Search templates…",
|
||||
@@ -454,6 +505,13 @@ export const en = {
|
||||
"sidebar.organise": "Organise session",
|
||||
"sidebar.pin": "Pin to top",
|
||||
"sidebar.unpin": "Unpin",
|
||||
"sidebar.openBeside": "Open beside",
|
||||
"chat.closePane": "Close this column",
|
||||
"sidebar.groupMenu": "Folder options",
|
||||
"sidebar.groupPin": "Pin to the top",
|
||||
"sidebar.groupUnpin": "Unpin",
|
||||
"sidebar.groupUp": "Move up",
|
||||
"sidebar.groupDown": "Move down",
|
||||
"sidebar.moveTo": "Move to folder…",
|
||||
"sidebar.removeFromFolder": "Remove from folder",
|
||||
"sidebar.archiveOne": "Archive",
|
||||
@@ -463,6 +521,13 @@ export const en = {
|
||||
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
|
||||
"sidebar.folderName": "Folder name",
|
||||
"sidebar.move": "Move",
|
||||
"backup.restore": "Restore",
|
||||
"backup.restoreFile": "Restore from file…",
|
||||
"backup.restoring": "Restoring…",
|
||||
"backup.restoreTitle": "Restore backup?",
|
||||
"backup.restoreConfirm": "Replace the database and the configuration with {file}? All sessions, users, statistics and settings are replaced by the state in the backup; files in the workspace stay as they are. The current state is backed up first. CodeCraft restarts – running agents are stopped.",
|
||||
"backup.restarting": "Backup accepted – CodeCraft is restarting…",
|
||||
"backup.notBack": "The server did not come back. It needs a restart policy (Docker: restart: unless-stopped) – start it again by hand.",
|
||||
"backup.now": "Back up now",
|
||||
"backup.running": "Backing up…",
|
||||
"backup.created": "Backup {file} created.",
|
||||
@@ -483,16 +548,12 @@ export const en = {
|
||||
"backup.s3Keep": "Keep in bucket (0 = all)",
|
||||
"backup.last": "Last backup",
|
||||
"backup.s3Ok": "uploaded",
|
||||
"jobs.fromTemplate": "New automation from a template",
|
||||
"jobs.tplName": "Name",
|
||||
"jobs.tplProject": "Project folder",
|
||||
"jobs.tplSchedule": "When",
|
||||
"jobs.tplCronHint": "Cron: minute hour day month weekday (server time zone).",
|
||||
"jobs.tplMode": "Mode",
|
||||
"jobs.tplPrompt": "Task for the agent",
|
||||
"jobs.tplCreate": "Create automation",
|
||||
"new.title": "New session",
|
||||
"new.directory": "Project directory",
|
||||
"new.invalidFolderName": "Folder names cannot contain “/”.",
|
||||
"new.up": "Up",
|
||||
"new.noSubdirs": "No subdirectories",
|
||||
"new.model": "Model",
|
||||
@@ -526,6 +587,64 @@ export const en = {
|
||||
"chat.exportMd": "Markdown (.md)",
|
||||
"chat.exportJson": "JSON (.json)",
|
||||
"chat.empty": "Send a message to start.",
|
||||
"chat.compactedAuto": "Context nearly full ({tokens} tokens) – conversation compacted automatically · summary",
|
||||
"chat.compactedManual": "Conversation compacted at {tokens} tokens · summary",
|
||||
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
||||
"chat.compactingManual": "Compacting the conversation",
|
||||
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
|
||||
"chat.telegram": "Send questions to Telegram",
|
||||
"chat.telegramHint": "Also send approvals and questions of this chat via Telegram (the chat currently open in Telegram always reports)",
|
||||
"cron.label": "Schedule",
|
||||
"cron.mode.minutes": "Every few minutes",
|
||||
"cron.mode.hourly": "Hourly",
|
||||
"cron.mode.daily": "Daily",
|
||||
"cron.mode.weekly": "On weekdays",
|
||||
"cron.mode.monthly": "Monthly",
|
||||
"cron.mode.custom": "Cron expression",
|
||||
"cron.every": "Every",
|
||||
"cron.minutes": "minutes",
|
||||
"cron.atMinute": "At minute",
|
||||
"cron.days": "Days of the week",
|
||||
"cron.workdays": "Mon–Fri",
|
||||
"cron.onDay": "On day",
|
||||
"cron.at": "at",
|
||||
"cron.customHint": "Minute hour day month weekday, e.g. “30 6 * * 1-5” = Mon–Fri 06:30.",
|
||||
"cron.nextRuns": "Next runs:",
|
||||
"cron.shortMonths": "Months without this day are skipped.",
|
||||
"cron.serverTz": "Times in server time ({tz})",
|
||||
"jobEditor.saved": "Saved",
|
||||
"jobEditor.conflict": "The instructions were changed on disk meanwhile (e.g. by the assistant). Overwrite them with your version?",
|
||||
"jobEditor.overwrite": "Overwrite",
|
||||
"jobEditor.runUnsaved": "There are unsaved changes – the run uses the saved version. Start anyway?",
|
||||
"jobEditor.discard": "Discard the unsaved changes?",
|
||||
"jobEditor.back": "Back to automations",
|
||||
"jobEditor.tab.prompt": "Instructions",
|
||||
"jobEditor.tab.settings": "Schedule",
|
||||
"jobEditor.tab.assistant": "Assistant",
|
||||
"jobEditor.timeout": "Time limit (min)",
|
||||
"jobEditor.bypassHint": "Runs at night: with “Allow everything” the run never waits for approval.",
|
||||
"jobEditor.instructions": "Instructions",
|
||||
"jobEditor.instructionsHint": "The agent sees only this text – no chat, nobody to ask.",
|
||||
"jobEditor.changedOnDisk": "The assistant changed the file while you were editing.",
|
||||
"jobEditor.reload": "Load its version",
|
||||
"jobEditor.assistantIntro": "Work out the instructions together with a model: describe what the automation should do – it asks back and writes the file next to you.",
|
||||
"jobEditor.assistantStart": "Start assistant",
|
||||
"jobEditor.defaultModel": "Server default",
|
||||
"jobs.new": "New automation",
|
||||
"jobs.namePlaceholder": "e.g. Nightly dependency check",
|
||||
"jobs.fileHint": "The instructions go to .codecraft/jobs/<name>.md in this folder.",
|
||||
"jobs.startWith": "Start with",
|
||||
"jobs.blank": "Blank",
|
||||
"jobs.blankDesc": "Example structure to fill in with the assistant",
|
||||
"jobs.createAndEdit": "Create and edit",
|
||||
"jobs.edit": "Edit",
|
||||
"jobs.permissionMode": "Approval mode",
|
||||
"jobs.permissionModeHint": "Applies from the next run. “Allow everything”: the run never asks and never waits for approval.",
|
||||
"instructions.title": "Instructions for this chat",
|
||||
"instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.",
|
||||
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
|
||||
"instructions.clear": "Remove",
|
||||
"instructions.chip": "Instructions",
|
||||
"chat.compacted": "Conversation compacted – summary",
|
||||
"chat.context": "Context",
|
||||
"chat.contextHint": "{tokens} tokens in the last request",
|
||||
@@ -543,6 +662,9 @@ export const en = {
|
||||
"composer.mode": "Mode",
|
||||
"composer.model": "Model",
|
||||
"composer.permissionMode": "Permission mode",
|
||||
"composer.preview": "Preview (Markdown)",
|
||||
"composer.previewEdit": "Back to editing",
|
||||
"composer.previewEmpty": "Nothing to preview yet.",
|
||||
"composer.attach": "Attach files",
|
||||
|
||||
"command.compact": "Summarize the conversation to free up context",
|
||||
@@ -624,7 +746,7 @@ export const en = {
|
||||
"settings.baseUrl": "Base URL",
|
||||
"settings.apiKey": "API key",
|
||||
"settings.apiKeyStored": "Stored – leave empty to keep",
|
||||
"settings.apiKeyHint": "Prefer ${ENV_VAR} references; literal keys are stored in plain text in the config file.",
|
||||
"settings.apiKeyHint": "Stored in the configuration file (readable for admins, included in backups). ${NAME} references to environment variables still work.",
|
||||
"settings.authPrefix": "Auth prefix",
|
||||
"settings.enabled": "Enabled",
|
||||
"settings.modelCount": "{n} models",
|
||||
@@ -646,6 +768,9 @@ export const en = {
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.providerUp": "reachable · {ms} ms",
|
||||
"settings.providerDown": "unreachable",
|
||||
"settings.testConnection": "Test connection",
|
||||
"settings.testing": "Testing…",
|
||||
"settings.testOk": "Connection works: {n} models, {ms} ms. Not saved yet.",
|
||||
"settings.providerAuth": "API key missing or rejected",
|
||||
"settings.providerCheck": "Check providers",
|
||||
"settings.providerChecking": "Checking…",
|
||||
@@ -799,6 +924,7 @@ export const en = {
|
||||
"agents.modePrimary": "mode",
|
||||
"agents.modeSubagent": "subagent",
|
||||
"agents.source.builtin": "built-in",
|
||||
"agents.source.personal": "personal",
|
||||
"agents.source.config": "config",
|
||||
"agents.source.file": "file",
|
||||
"agents.model": "Model",
|
||||
@@ -809,8 +935,16 @@ export const en = {
|
||||
"agents.description": "Description",
|
||||
"agents.descriptionHint": "The main agent decides by this description when to delegate.",
|
||||
"agents.mode": "Type",
|
||||
"agents.addMode": "New mode",
|
||||
"agents.style": "Kind",
|
||||
"agents.styleCoding": "Coding – project context and coding workflow",
|
||||
"agents.styleChat": "Conversation – only this prompt, no project",
|
||||
"agents.styleHint": "Conversation modes get just your prompt plus date and personal memory, like the built-in chat mode.",
|
||||
"agents.readOnlyLabel": "Read-only",
|
||||
"agents.readOnlyHint": "Only tools that change nothing (like plan mode)",
|
||||
"agents.toolsSome": "{n} tools selected – only these are available",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "all tools (e.g. read, glob, grep)",
|
||||
"agents.toolsAll": "Nothing selected = all tools",
|
||||
"agents.prompt": "Instructions",
|
||||
"agents.reset": "Reset",
|
||||
"agents.resetConfirm": "Reset \"{name}\" to the built-in defaults?",
|
||||
|
||||
@@ -71,13 +71,3 @@ export const JOB_TEMPLATES: JobTemplate[] = [
|
||||
"Compare the README and other documentation with the current code: commands, configuration options, APIs and examples that are outdated, missing or wrong. Do not change files. Report concrete corrections.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Quick schedule choices for the dialog. */
|
||||
export const SCHEDULES: { cron: string; en: string; de: string }[] = [
|
||||
{ cron: "0 3 * * *", en: "daily 03:00", de: "täglich 03:00" },
|
||||
{ cron: "0 8 * * 1-5", en: "weekdays 08:00", de: "werktags 08:00" },
|
||||
{ cron: "0 7 * * 1", en: "Mondays 07:00", de: "montags 07:00" },
|
||||
{ cron: "0 16 * * 5", en: "Fridays 16:00", de: "freitags 16:00" },
|
||||
{ cron: "0 9 1 * *", en: "monthly on the 1st", de: "monatlich am 1." },
|
||||
{ cron: "0 * * * *", en: "hourly", de: "stündlich" },
|
||||
];
|
||||
@@ -10,6 +10,43 @@ import { captureShare } from "./share";
|
||||
applyTheme();
|
||||
captureShare();
|
||||
registerSW({ immediate: true });
|
||||
undoKeyboardShift();
|
||||
|
||||
/**
|
||||
* Browsers that do not resize the page for the on-screen keyboard (iOS Safari, older
|
||||
* Android) scroll the whole page up to show the input – and do not always scroll it back
|
||||
* when the keyboard closes, leaving the top bar (menu) out of reach. The app never
|
||||
* scrolls the page itself (only its panes), so any page offset is undone once the
|
||||
* visible area grows again.
|
||||
*/
|
||||
function undoKeyboardShift() {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
let last = vv.height;
|
||||
const reset = () => {
|
||||
const el = document.scrollingElement ?? document.documentElement;
|
||||
if (window.scrollY !== 0 || el.scrollTop !== 0 || document.body.scrollTop !== 0) {
|
||||
window.scrollTo(0, 0);
|
||||
el.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
vv.addEventListener("resize", () => {
|
||||
// Grown back (keyboard closed or closing): after the browser's own adjustment.
|
||||
if (vv.height > last + 1) {
|
||||
requestAnimationFrame(reset);
|
||||
setTimeout(reset, 350);
|
||||
}
|
||||
last = vv.height;
|
||||
});
|
||||
// Keyboard closed by leaving the input (no resize on some browsers).
|
||||
document.addEventListener("focusout", () =>
|
||||
setTimeout(() => {
|
||||
const a = document.activeElement;
|
||||
if (!a || !(a instanceof HTMLInputElement || a instanceof HTMLTextAreaElement || (a as HTMLElement).isContentEditable)) reset();
|
||||
}, 300),
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -25,3 +25,21 @@ export function useReasoningMode(): ReasoningMode {
|
||||
getReasoningMode,
|
||||
);
|
||||
}
|
||||
|
||||
const TODOS_KEY = "codecraft.todosCollapsed";
|
||||
|
||||
/** Task list above the composer folded to a one-line summary (per device). */
|
||||
export function useTodosCollapsed(): [boolean, (v: boolean) => void] {
|
||||
const v = useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => localStorage.getItem(TODOS_KEY) === "1",
|
||||
);
|
||||
const set = (c: boolean) => {
|
||||
localStorage.setItem(TODOS_KEY, c ? "1" : "0");
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
return [v, set];
|
||||
}
|
||||
@@ -15,3 +15,23 @@ export function useRoute(): string[] {
|
||||
export function navigate(path: string) {
|
||||
location.hash = path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
/** Chats shown side by side: #/s/<id>+<id>+<id> (at most 3). */
|
||||
export const MAX_PANES = 3;
|
||||
|
||||
export function openChats(): string[] {
|
||||
const m = location.hash.match(/^#\/s\/([^/]+)/);
|
||||
return m ? m[1].split("+").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
/** Add a chat as another column (replaces the last one when all columns are used). */
|
||||
export function openBeside(id: string) {
|
||||
const ids = openChats().filter((x) => x !== id);
|
||||
if (ids.length >= MAX_PANES) ids.pop();
|
||||
navigate(`/s/${[...ids, id].join("+")}`);
|
||||
}
|
||||
|
||||
export function closePane(id: string) {
|
||||
const ids = openChats().filter((x) => x !== id);
|
||||
navigate(ids.length ? `/s/${ids.join("+")}` : "/");
|
||||
}
|
||||
@@ -47,6 +47,8 @@ export interface State {
|
||||
queue: Record<string, QueueItem[]>;
|
||||
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
|
||||
waiting: Record<string, string | undefined>;
|
||||
/** Compaction running in a session (auto = context nearly full). */
|
||||
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
|
||||
}
|
||||
|
||||
let state: State = {
|
||||
@@ -67,6 +69,7 @@ let state: State = {
|
||||
queue: {},
|
||||
steps: {},
|
||||
waiting: {},
|
||||
compacting: {},
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -188,6 +191,14 @@ export function applyEvent(ev: ServerEvent) {
|
||||
case "waiting":
|
||||
setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } }));
|
||||
break;
|
||||
case "compaction":
|
||||
setState((s) => ({
|
||||
compacting: {
|
||||
...s.compacting,
|
||||
[ev.session_id]: ev.state === "started" ? { auto: ev.auto, tokens: ev.tokens, limit: ev.limit, since: Date.now() } : undefined,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "mcp_status":
|
||||
setState(() => ({ mcp: ev.servers }));
|
||||
break;
|
||||
|
||||
+704
-7
@@ -132,14 +132,120 @@ textarea:focus {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.conn-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
/* --- status LEDs (sidebar) ------------------------------------------------------ */
|
||||
/* Theme hues, pushed to a vivid chroma and a readable lightness so the state is
|
||||
obvious in every theme; warn takes the red's lightness/chroma at an amber hue. */
|
||||
:root {
|
||||
--led-ok: #22c55e;
|
||||
--led-warn: #f5a700;
|
||||
--led-err: #ef4444;
|
||||
}
|
||||
.conn-dot.on {
|
||||
background: var(--green);
|
||||
@supports (color: oklch(from red l c h)) {
|
||||
:root {
|
||||
--led-ok: oklch(from var(--green) clamp(0.7, l, 0.8) max(c, 0.2) clamp(138, h, 158));
|
||||
--led-warn: oklch(from var(--red) clamp(0.78, l, 0.85) max(c, 0.17) 75);
|
||||
--led-err: oklch(from var(--red) clamp(0.6, l, 0.7) max(c, 0.21) h);
|
||||
}
|
||||
}
|
||||
.status-leds {
|
||||
position: relative;
|
||||
}
|
||||
.status-leds-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg2);
|
||||
}
|
||||
.led {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.led-ok {
|
||||
background: var(--led-ok);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--led-ok) 70%, transparent);
|
||||
}
|
||||
.led-warn {
|
||||
background: var(--led-warn);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--led-warn) 70%, transparent);
|
||||
}
|
||||
.led-err {
|
||||
background: var(--led-err);
|
||||
box-shadow: 0 0 7px color-mix(in srgb, var(--led-err) 80%, transparent);
|
||||
}
|
||||
.status-leds-btn .led-err {
|
||||
animation: led-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
.led-off {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1.5px var(--muted);
|
||||
}
|
||||
@keyframes led-blink {
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-leds-btn .led-err {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.status-pop {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.status-section + .status-section {
|
||||
border-top: 1px solid var(--bg3);
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.status-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 4px;
|
||||
font-weight: 600;
|
||||
color: var(--fg0);
|
||||
}
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px 4px 25px;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.status-row .led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-left: -15px;
|
||||
}
|
||||
.status-row .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-row .hint {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-foot {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--bg3);
|
||||
margin-top: 4px;
|
||||
padding: 6px 4px 2px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@@ -193,6 +299,25 @@ button.session-group:hover {
|
||||
.session-row .session-item {
|
||||
padding-right: 30px;
|
||||
}
|
||||
/* Group headers (folders, projects): menu button, drag and drop. */
|
||||
.group-row .session-group {
|
||||
padding-right: 30px;
|
||||
}
|
||||
.group-row .session-more {
|
||||
top: 4px;
|
||||
}
|
||||
.group-row[draggable="true"] .session-group {
|
||||
cursor: grab;
|
||||
}
|
||||
.group-row.drop-before {
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
.group-row.drop-after {
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
.session-group .group-pin {
|
||||
color: var(--accent);
|
||||
}
|
||||
.session-more {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -402,6 +527,20 @@ button.session-group:hover {
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */
|
||||
.msg-user .markdown {
|
||||
white-space: normal;
|
||||
}
|
||||
.composer-preview {
|
||||
min-height: 44px;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px dashed var(--bg3);
|
||||
background: var(--bg1);
|
||||
cursor: text;
|
||||
}
|
||||
.msg-user .msg-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -615,6 +754,39 @@ button.session-group:hover {
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.todos-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.todos-head:hover {
|
||||
color: var(--fg1);
|
||||
}
|
||||
.todos-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.todos-current {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.todos.collapsed {
|
||||
padding-block: 6px;
|
||||
}
|
||||
.todos ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -3021,6 +3193,11 @@ button.danger-solid {
|
||||
.job-grid > span:nth-child(odd) {
|
||||
color: var(--fg3);
|
||||
}
|
||||
.job-grid select {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.job-prompt {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -3413,6 +3590,10 @@ button.danger-solid {
|
||||
.backup-file .grow {
|
||||
flex: 1;
|
||||
}
|
||||
.backup-file button,
|
||||
.backup-file .button {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
/* --- automation templates ------------------------------------------------------ */
|
||||
.job-tpl {
|
||||
@@ -3459,3 +3640,519 @@ button.danger-solid {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.job-tpl-card.active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.job-tpl .job-tpl-grid {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.job-title {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* --- automation editor ------------------------------------------------------ */
|
||||
.job-tabs {
|
||||
display: none;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
}
|
||||
.job-tabs button {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 8px 6px;
|
||||
color: var(--muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.job-tabs button.active {
|
||||
color: var(--fg0);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.job-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 1fr);
|
||||
}
|
||||
.job-editor-main {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.job-editor-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.job-editor-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.job-editor-row .field {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
margin: 0;
|
||||
}
|
||||
.job-editor-row select,
|
||||
.job-editor-row input {
|
||||
width: 100%;
|
||||
}
|
||||
.job-editor-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.job-editor-prompt {
|
||||
flex: 1;
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.job-editor-prompt-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
background: var(--bg1);
|
||||
}
|
||||
.job-editor-prompt .notice {
|
||||
margin: 8px;
|
||||
}
|
||||
.job-editor-chat {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--bg3);
|
||||
}
|
||||
.job-editor-chat .topbar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.job-assistant-start {
|
||||
margin: auto;
|
||||
max-width: 340px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.job-assistant-start .icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--accent);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.job-tabs {
|
||||
display: flex;
|
||||
}
|
||||
.job-editor {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.job-editor-chat {
|
||||
border-left: none;
|
||||
}
|
||||
.job-editor.tab-prompt .job-editor-settings,
|
||||
.job-editor.tab-settings .job-editor-prompt,
|
||||
.job-editor.tab-prompt .job-editor-chat,
|
||||
.job-editor.tab-settings .job-editor-chat,
|
||||
.job-editor.tab-assistant .job-editor-main {
|
||||
display: none;
|
||||
}
|
||||
.job-editor.tab-prompt .job-editor-main {
|
||||
padding: 0;
|
||||
}
|
||||
.job-editor.tab-prompt .job-editor-prompt {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- schedule builder ---------------------------------------------------------- */
|
||||
.cron-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cron-modes,
|
||||
.cron-days {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.cron-modes .chip-btn,
|
||||
.cron-days .chip-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cron-days .chip-btn {
|
||||
min-width: 44px;
|
||||
}
|
||||
.cron-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.cron-fields label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.cron-fields input[type="number"] {
|
||||
width: 70px;
|
||||
}
|
||||
.cron-custom {
|
||||
flex-direction: column;
|
||||
align-items: stretch !important;
|
||||
flex: 1;
|
||||
}
|
||||
.cron-custom input {
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
}
|
||||
.cron-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
align-items: baseline;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
.cron-preview code {
|
||||
background: var(--bg2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.cron-preview .err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.chip.price-extra {
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.plan-row td {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
.plan-usage {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plan-window {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
min-width: 170px;
|
||||
}
|
||||
.plan-window .budget-bar {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
}
|
||||
.plan-label {
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plan-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- chat tabs (chat + terminals) ---------------------------------------------- */
|
||||
.chat-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 10px 0;
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
overflow-x: auto;
|
||||
flex: none;
|
||||
}
|
||||
.chat-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: none;
|
||||
color: var(--fg2);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
span.chat-tab {
|
||||
padding: 0 4px 0 0;
|
||||
}
|
||||
span.chat-tab > button {
|
||||
padding: 6px 4px 6px 12px;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.chat-tab .close {
|
||||
padding: 2px 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.chat-tab .close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.chat-tab:hover {
|
||||
background: var(--bg1);
|
||||
color: var(--fg0);
|
||||
}
|
||||
.chat-tab.active {
|
||||
color: var(--fg0);
|
||||
border-bottom-color: var(--accent);
|
||||
background: var(--bg1);
|
||||
}
|
||||
.chat-tab.dead {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.chat-tab-add {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.chat-pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.files-embed-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--bg3);
|
||||
flex: none;
|
||||
}
|
||||
.files-embed-bar .path {
|
||||
font-size: 12px;
|
||||
color: var(--fg3);
|
||||
font-family: var(--mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-picker .hint {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 4px 12px;
|
||||
}
|
||||
.tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-item.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.mode-select {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.compacting-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg1);
|
||||
font-size: 13px;
|
||||
}
|
||||
.compacting-card.auto {
|
||||
border-color: color-mix(in srgb, var(--accent) 60%, var(--bg3));
|
||||
}
|
||||
.status-meter .budget-bar {
|
||||
width: 70px;
|
||||
flex: none;
|
||||
}
|
||||
.status-meter .pct {
|
||||
width: 38px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- split view (chats side by side) ------------------------------------------- */
|
||||
.split-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.split-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.split-pane + .split-pane {
|
||||
border-left: 1px solid var(--bg3);
|
||||
}
|
||||
.split-pane .topbar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.split-pane .topbar .grow {
|
||||
min-width: 90px;
|
||||
}
|
||||
.split-pane .topbar h1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Narrow columns: the context chip moves out (the cost chip opens the usage overlay). */
|
||||
.split-pane .topbar span.chip.hide-mobile {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.split-pane + .split-pane {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.instructions-modal {
|
||||
width: min(640px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
/* --- short screens ----------------------------------------------------------
|
||||
* Square phones (e.g. Clicks Communicator, ~393x400 CSS px with a hardware
|
||||
* keyboard) and phones in landscape: height is the scarce dimension, so bars,
|
||||
* the composer and the new-chat wizard get tighter and the chat keeps the room. */
|
||||
@media (max-height: 560px) {
|
||||
.topbar {
|
||||
min-height: 40px;
|
||||
padding: 4px 10px;
|
||||
padding-top: max(4px, env(safe-area-inset-top));
|
||||
gap: 4px;
|
||||
}
|
||||
.topbar button.ghost {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.chat {
|
||||
padding: 10px 12px 14px;
|
||||
}
|
||||
.msg {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.composer-wrap {
|
||||
padding: 6px 10px;
|
||||
padding-bottom: max(6px, env(safe-area-inset-bottom));
|
||||
}
|
||||
.composer textarea {
|
||||
min-height: 38px;
|
||||
max-height: 30vh;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
/* One placeholder line instead of two (the textarea grows with its content). */
|
||||
.composer textarea::placeholder {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.composer-bar {
|
||||
margin-top: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
.composer-bar select {
|
||||
max-width: 150px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.composer-actions button {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.todos {
|
||||
margin: 4px 0;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.page {
|
||||
padding: 12px;
|
||||
}
|
||||
.wiz-title {
|
||||
font-size: 19px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.wiz-sub {
|
||||
display: none;
|
||||
}
|
||||
.wiz-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
.wiz-step {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wiz-step h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wiz-cards,
|
||||
.wiz-cards.five {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.wiz-card {
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.wiz-card.active {
|
||||
padding: 7px 9px;
|
||||
}
|
||||
.wiz-card-text {
|
||||
padding-right: 14px;
|
||||
}
|
||||
.wiz-card-desc {
|
||||
display: none;
|
||||
}
|
||||
.wiz-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.wiz-num {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -2,7 +2,12 @@
|
||||
|
||||
export interface ImageData {
|
||||
media_type: string;
|
||||
data: string;
|
||||
/** Base64 bytes; absent when the server stores the image separately (blob). */
|
||||
data?: string;
|
||||
/** SHA-256 of a stored image: GET /api/blobs/<blob>. */
|
||||
blob?: string;
|
||||
/** Size in bytes of a stored image. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export type Part =
|
||||
@@ -19,7 +24,7 @@ export type Part =
|
||||
is_error: boolean;
|
||||
meta?: any;
|
||||
}
|
||||
| { type: "summary"; text: string };
|
||||
| { type: "summary"; text: string; auto?: boolean; tokens?: number };
|
||||
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
@@ -67,6 +72,10 @@ export interface Session {
|
||||
pinned?: boolean;
|
||||
archived?: boolean;
|
||||
folder?: string | null;
|
||||
/** Instructions for this chat (added to the system prompt). */
|
||||
instructions?: string;
|
||||
/** Approvals and questions of this chat are not sent to Telegram. */
|
||||
telegram_quiet?: boolean;
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
@@ -118,6 +127,7 @@ export type ServerEvent =
|
||||
| { type: "waiting"; session_id: string; reason: string | null }
|
||||
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
||||
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
||||
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
|
||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||
| { type: "resync" };
|
||||
|
||||
@@ -150,9 +160,11 @@ export interface AgentInfo {
|
||||
description: string;
|
||||
mode: "primary" | "subagent";
|
||||
read_only: boolean;
|
||||
/** "coding" or "chat" (plain conversation prompt). */
|
||||
style?: "coding" | "chat";
|
||||
model?: string | null;
|
||||
tools?: string[] | null;
|
||||
source: "builtin" | "config" | "file";
|
||||
source: "builtin" | "config" | "file" | "personal";
|
||||
max_parallel?: number | null;
|
||||
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
|
||||
/** Source file of Markdown agents. */
|
||||
|
||||
+110
-1
@@ -1,5 +1,114 @@
|
||||
/** "What's new" entries per version (shown once after an update). */
|
||||
/**
|
||||
* "What's new" entries per version (shown once after an update). Add new entries
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00017": {
|
||||
en: ["Your messages are shown as Markdown; the eye button next to the text field previews it before sending"],
|
||||
de: ["Deine Nachrichten werden als Markdown dargestellt; das Augen-Symbol neben dem Textfeld zeigt vor dem Senden eine Vorschau"],
|
||||
},
|
||||
"202611-00014": {
|
||||
en: ["Folder picker (new automation, new chat): create a new folder right there"],
|
||||
de: ["Ordnerauswahl (neue Automatisierung, neuer Chat): direkt einen neuen Ordner anlegen"],
|
||||
},
|
||||
"202611-00013": {
|
||||
en: [
|
||||
"New automation editor: instructions, a graphical schedule and an assistant chat that writes the instructions with you",
|
||||
"Chat list: drag folders and projects into order or pin them (⋯ on the folder); new messages no longer move the whole project up",
|
||||
],
|
||||
de: [
|
||||
"Neuer Editor für Automatisierungen: Anweisungen, grafischer Zeitplan und ein Assistent-Chat, der die Anweisungen mit dir schreibt",
|
||||
"Chatliste: Ordner und Projekte per Drag & Drop sortieren oder fixieren (⋯ am Ordner); neue Nachrichten schieben nicht mehr das ganze Projekt nach oben",
|
||||
],
|
||||
},
|
||||
"202611-00012": {
|
||||
en: ["Automations: set the approval mode right in the list – “Allow everything” runs unattended without asking", "⋯ → Send questions to Telegram: per chat, off for new chats"],
|
||||
de: ["Automatisierungen: Freigabemodus direkt in der Liste wählen – „Alles erlauben“ läuft nachts ohne Rückfrage", "⋯ → Fragen an Telegram senden: pro Chat, bei neuen Chats aus"],
|
||||
},
|
||||
"202611-00011": {
|
||||
en: ["The task list of the model can be folded (click on \"Tasks\")", "Better layout on square and short screens (e.g. Clicks Communicator, landscape)"],
|
||||
de: ["Die Aufgabenliste des Modells lässt sich zuklappen (Klick auf „Aufgaben“)", "Besseres Layout auf quadratischen und niedrigen Displays (z. B. Clicks Communicator, Querformat)"],
|
||||
},
|
||||
"202611-00010": {
|
||||
en: ["Chats with many images (screenshots, generated images) open much faster – images load when they scroll into view"],
|
||||
de: ["Chats mit vielen Bildern (Screenshots, generierte Bilder) öffnen viel schneller – Bilder laden erst beim Hinscrollen"],
|
||||
},
|
||||
"202611-00008": {
|
||||
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
|
||||
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],
|
||||
},
|
||||
"202611-00007": {
|
||||
en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"],
|
||||
de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"],
|
||||
},
|
||||
"202611-00006": {
|
||||
en: ["Docker CLI in the image – with the host's Docker socket mounted, admins' agents and terminals can use Docker"],
|
||||
de: ["Docker-CLI im Image – mit eingehängtem Docker-Socket können Admin-Agents und -Terminals Docker nutzen"],
|
||||
},
|
||||
"202611-00005": {
|
||||
en: ["Agents commit with the git name and e-mail from your settings – also in repositories with their own identity"],
|
||||
de: ["Agents committen mit Git-Name und -E-Mail aus deinen Einstellungen – auch in Repos mit eigener Identität"],
|
||||
},
|
||||
"202611-00004": {
|
||||
en: [
|
||||
"Chats side by side: “Open beside” in a chat's menu in the sidebar",
|
||||
"Settings → My modes: your own modes",
|
||||
"Budget warnings as push notifications, budgets in the status overlay",
|
||||
"File manager tabs are the same on every device",
|
||||
],
|
||||
de: [
|
||||
"Chats nebeneinander: „Daneben öffnen“ im Menü eines Chats in der Seitenleiste",
|
||||
"Einstellungen → Meine Modi: eigene Modi",
|
||||
"Budget-Warnungen als Push, Budgets im Status-Overlay",
|
||||
"Dateimanager-Tabs sind auf jedem Gerät gleich",
|
||||
],
|
||||
},
|
||||
"202611-00003": {
|
||||
en: [
|
||||
"Terminals and file managers as tabs in a chat – terminals keep running, also for another device",
|
||||
"New session → “Just chat” for plain conversation",
|
||||
"Own modes: Settings → Agents & commands → New mode",
|
||||
"The chat shows when the conversation is compacted (automatically or on request)",
|
||||
"File manager: “Open chat here” in a folder's menu",
|
||||
"Terminal client downloadable from the server again",
|
||||
],
|
||||
de: [
|
||||
"Terminals und Dateimanager als Tabs im Chat – Terminals laufen weiter, auch für ein anderes Gerät",
|
||||
"Neue Sitzung → „Einfach chatten“ für reine Unterhaltung",
|
||||
"Eigene Modi: Einstellungen → Agents & Befehle → Neuer Modus",
|
||||
"Der Chat zeigt an, wenn die Unterhaltung komprimiert wird (automatisch oder auf Wunsch)",
|
||||
"Dateimanager: „Chat hier öffnen“ im Ordner-Menü",
|
||||
"Terminal-Client wieder direkt vom Server herunterladbar",
|
||||
],
|
||||
},
|
||||
"202611-00002": {
|
||||
en: [
|
||||
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",
|
||||
"Settings → Config file: move keys from environment variables into the configuration",
|
||||
"opencode: real costs per request; Go plan usage (5 hours, week, month) in budgets and status",
|
||||
],
|
||||
de: [
|
||||
"opencode: „Preise holen“ übernimmt Preise inklusive Long-Context- und Spitzenzeit-Preisen",
|
||||
"Einstellungen → Config-Datei: Keys aus Umgebungsvariablen in die Konfiguration übernehmen",
|
||||
"opencode: echte Kosten pro Anfrage; Go-Plan-Verbrauch (5 Stunden, Woche, Monat) in Budget und Status",
|
||||
],
|
||||
},
|
||||
"202611-00001": {
|
||||
en: [
|
||||
"Status LEDs in the sidebar for server, models and MCP – click them for details",
|
||||
"Images: cheap previews first, then the final image with the best model (Nano Banana Pro refines the chosen preview)",
|
||||
"Settings → Audit: restore a backup (stored or uploaded)",
|
||||
"Provider form: test the connection before saving; saving checks the status right away",
|
||||
"opencode Go works again (session header)",
|
||||
],
|
||||
de: [
|
||||
"Status-LEDs in der Seitenleiste für Server, Modelle und MCP – ein Klick zeigt die Details",
|
||||
"Bilder: erst günstige Vorschauen, dann das Endbild mit dem besten Modell (Nano Banana Pro verfeinert die gewählte Vorschau)",
|
||||
"Einstellungen → Audit: Backup wiederherstellen (gespeichert oder hochgeladen)",
|
||||
"Provider-Formular: Verbindung vor dem Speichern testen; Speichern prüft den Status sofort",
|
||||
"opencode Go funktioniert wieder (Session-Header)",
|
||||
],
|
||||
},
|
||||
"0.1.0": {
|
||||
en: [
|
||||
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",
|
||||
|
||||
Reference in new issue
Block a user