Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
084452abf6 | ||
|
|
8b4bd59572 | ||
|
|
c3df4a23cc | ||
|
|
48621f21d6 | ||
|
|
d7baa77922 | ||
|
|
1e8418336d | ||
|
|
33612bf6d4 | ||
|
|
ffecd62b83 | ||
|
|
c77dcf5890 | ||
|
|
9c80f2c857 | ||
|
|
1253430ed4 | ||
|
|
e7d4b9ff76 | ||
|
|
b71dfc49c3 | ||
|
|
3b0764450a | ||
|
|
6e9eda28b5 | ||
|
|
bd896794fd | ||
|
|
ca14ba3338 | ||
|
|
97e66d2118 | ||
|
|
b3a298fb7c | ||
|
|
61219dbca2 | ||
|
|
9fd92d5b39 | ||
|
|
b99f71526a | ||
|
|
90d65ec654 | ||
|
|
7298102e93 | ||
|
|
538aac9122 | ||
|
|
05c215f685 | ||
|
|
872ebb94dd |
No files matched your search
+168
@@ -3,6 +3,174 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [v202611-00025] – 2026-11-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Models did not find built-in tools via `mcp_search`** (e.g. `generate_3d` in a chat that
|
||||
had just been switched from plan to build): the search only looked at MCP servers. It now also
|
||||
lists matching built-in tools and says whether the current mode has them ("available – call it
|
||||
directly", or "not in the read-only mode plan – ask the user to switch to build").
|
||||
|
||||
## [v202611-00024] – 2026-11-12
|
||||
|
||||
### Added
|
||||
|
||||
- **3D models with fal.ai (`generate_3d`):** from an image (a project file or one pasted into the
|
||||
chat, optional back/left/right views), from a text prompt, or from an image steered by a prompt
|
||||
(the image is refined with the prompt first – the 3D models take no text with an image).
|
||||
Styles textured, geometry (untextured, for 3D printing) and low-poly, optional PBR. Uses fal.ai's
|
||||
queue (takes minutes, with progress), saves a GLB plus a preview image, books the cost.
|
||||
Defaults: Hunyuan3D v3 image-to-3D / text-to-3D (`[images] model_3d`, `model_3d_text`); Tripo
|
||||
and Trellis endpoints work too.
|
||||
- The chat card offers "Open in the 3D view"; the media overview lists 3D models (also STL/3MF
|
||||
written by the agent) with their previews.
|
||||
|
||||
## [v202611-00023] – 2026-11-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Tokens and cost under every answer:** a small line under each answer of the model shows the
|
||||
tokens sent/received and the cost of all requests of that turn.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Total cost of chats stopped growing (opencode Go):** opencode now reports plan requests as
|
||||
"nothing charged" (cost 0, usage in `allowance`), and CodeCraft took the 0. Plan requests are
|
||||
valued at the model's list prices again; earlier ones are corrected once on start.
|
||||
- **Saving opencode Go models failed after fetching prices** ("data did not match any variant of
|
||||
untagged enum ModelEntry"): opencode lists cache prices per duration for some models
|
||||
(`{"30m": …, "12h": …}`); the shortest duration is used now.
|
||||
- The chat title in the top bar no longer shrinks to a single letter when there are many chips.
|
||||
|
||||
## [v202611-00022] – 2026-11-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Theme "Tintenblau"** (dark: ink blue with cream text; light: paper with dark ink), from the
|
||||
Tintenblau desktop theme; also in the terminal client.
|
||||
|
||||
## [v202611-00021] – 2026-11-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Commits of agents name the model:** every `git commit` an agent makes gets the trailer
|
||||
`Co-Authored-By: <model> (<provider>) via CodeCraft` (a git wrapper in the agent shell, also in
|
||||
the sandbox; your own commits stay unchanged). The text is set under *Settings → General*
|
||||
(`server.commit_trailer`, `{model}`, `{provider}`, `{model_id}`; empty = off).
|
||||
|
||||
## [v202611-00020] – 2026-11-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Reverse engineering outside craft:** *Settings → Agents & commands* can give any mode the
|
||||
`binary_info` and `decompile` (Ghidra) tools; the clean-room rule stays a craft rule.
|
||||
- **Raw firmware in `decompile`:** optional `processor` (Ghidra language id, e.g.
|
||||
`ARM:LE:32:Cortex`) and `base_address` for images without a file format (.bin, converted .hex).
|
||||
- **Skill "reverse-engineering":** how to identify, decompile and explore programs and firmware
|
||||
(processor table for common chips, HEX conversion, notes in `analysis/`).
|
||||
|
||||
## [v202611-00019] – 2026-11-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Search in a chat:** the magnifier in the chat's top bar searches all of it (messages, tool
|
||||
output) on the server; a hit loads its part of the chat and marks the message.
|
||||
- **Media of a chat:** *⋯ → Media* shows every image, video and audio file of the chat and its
|
||||
subagents – generated images, screenshots, files the agent wrote, media in answers, your
|
||||
attachments – filterable, large view, "show in chat" jumps to the message.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Long chats stay fast:** a chat opens with its messages since the last summary (at most 200);
|
||||
older ones load when scrolling up, the history before a summary on request. Finished turns
|
||||
fold into one line ("42 steps · 3 files", the answer stays visible). Typing, streaming and
|
||||
status changes no longer re-render the whole history, and tool output is only built when a
|
||||
card is opened. Measured with a chat of 5,900 messages: opening 9 s → 0.7 s (phone-like
|
||||
CPU: 22 s → 1 s), typing 440 ms → 25 ms per key.
|
||||
|
||||
## [v202611-00018] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Enforced reviews:** when a review is configured for a mode, every todo the agent marks
|
||||
completed is reviewed; with findings the todo is reopened and the agent has to fix them and
|
||||
complete it again (up to the configured rounds per todo). Ending the turn with open todos sends
|
||||
the agent back to work as long as it makes progress – for unattended runs, e.g. overnight
|
||||
(such modes may take up to 2,000 steps per turn). The chat shows passed reviews and these
|
||||
reminders; *Settings → Modes* can switch it off per mode (then only one review at the end of a
|
||||
turn, as before).
|
||||
- **Max input per model:** providers that limit the input below the context window (e.g. 272k of
|
||||
400k) are taken into account; fetched from the model catalogue, editable in the provider settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Context overflow instead of compaction** (e.g. DeepSeek V4.1 Flash with 384k max output):
|
||||
requests asked for the model's full max output, and providers count input + output against the
|
||||
window, so long chats failed before automatic compaction kicked in. The output budget is now what
|
||||
the window leaves, the chat is compacted when no reasonable room for an answer is left, and a
|
||||
"context too long" error compacts and retries once instead of stopping. A turn that hits the
|
||||
step limit now says so.
|
||||
|
||||
## [v202611-00017] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Markdown in your messages:** your messages are shown formatted like the model's answers
|
||||
(headings, lists, tables, code blocks, quotes, bold/italic); single line breaks stay as typed.
|
||||
The eye button next to the text field shows a preview of the formatted message before sending.
|
||||
|
||||
## [v202611-00016] – 2026-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Phone: page stayed shifted after the keyboard closed** – the top bar (menu) was out of
|
||||
reach. On Android the keyboard now shrinks the page instead of shifting it
|
||||
(`interactive-widget=resizes-content`; the chat stays at its latest message); browsers that
|
||||
still shift the page (iOS Safari) get it scrolled back once the keyboard closes.
|
||||
|
||||
## [v202611-00015] – 2026-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Generated images missing on other devices:** when an agent moved or renamed a generated
|
||||
image afterwards (e.g. sorted it into subfolders), the gallery pointed to the old file – only
|
||||
the device that had it in its browser cache still showed it. Now:
|
||||
- generated images are also kept in the image store and shown from there when the file is gone
|
||||
(existing images get their copy on the first start);
|
||||
- their entries follow moves and renames: after an agent's `mv`/`rename`, when moved with the
|
||||
file manager, and on the next view (found by file name, or by content when renamed);
|
||||
- in the chat, an image that cannot be found falls back to the preview the model saw.
|
||||
|
||||
## [v202611-00014] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **New folder in the folder picker:** creating an automation or a chat, *New folder* creates a
|
||||
subfolder of the shown folder and selects it.
|
||||
|
||||
## [v202611-00013] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Automation editor:** *New automation* asks for name, project and a starting point (blank
|
||||
example structure or a template), creates `.codecraft/jobs/<name>.md` and opens an editor:
|
||||
the instructions in a Markdown editor, schedule and settings next to them, and an assistant
|
||||
chat in which a model works out the instructions with you and edits the file (reloaded after
|
||||
each answer). New automations start switched off.
|
||||
- **Graphical schedule:** every few minutes, hourly, daily, on weekdays, monthly or a cron
|
||||
expression, with the next runs in server time.
|
||||
- **Chat list:** folders and projects can be dragged into any order or moved and pinned to the
|
||||
top via their menu; the order is kept per user on all devices. A new message moves the chat
|
||||
to the top of its folder or project, no longer the whole project.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Automation files hold only the instructions;** name, schedule, mode, model, approval mode
|
||||
and time limit are settings of the automation. Existing job files are taken over on the first
|
||||
start (settings into the database, the frontmatter removed from the file). Registering a
|
||||
plain Markdown file creates a switched-off automation to be scheduled in the editor.
|
||||
|
||||
## [v202611-00012] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -96,6 +96,8 @@ COPY --from=cli /out /app/cli
|
||||
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
||||
COPY --from=dockercli /docker /usr/local/bin/docker
|
||||
COPY server/ghidra /app/ghidra
|
||||
# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer).
|
||||
COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git
|
||||
COPY server/resources /app/resources
|
||||
# The agent's home lives on the data volume: tools it installs in user space
|
||||
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00012" src="https://img.shields.io/badge/version-v202611--00012-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00025" src="https://img.shields.io/badge/version-v202611--00025-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">
|
||||
@@ -176,6 +176,10 @@ output limit, reasoning effort, extra request options and prices per million tok
|
||||
- **Images (fal.ai):** the agent drafts cheap previews (FLUX schnell), asks which one to
|
||||
refine, and creates the final image with the best model for the purpose – Nano Banana Pro
|
||||
refines the chosen draft, Ideogram handles lettering, Recraft design work.
|
||||
- **3D models (fal.ai):** `generate_3d` turns an image (also one pasted into the chat, plus
|
||||
optional back/left/right views), a text prompt, or an image steered by a prompt into a GLB
|
||||
model – textured, untextured for 3D printing, or low-poly (Hunyuan3D v3 by default,
|
||||
`[images] model_3d` / `model_3d_text`). Open it in the 3D view of the file manager.
|
||||
|
||||
## Terminal client
|
||||
|
||||
@@ -202,11 +206,15 @@ platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codec
|
||||
<img src="docs/images/automations.png" alt="Creating an automation from a template: schedule chips, cron expression, mode, permission mode, model and the task for the agent" width="100%">
|
||||
</p>
|
||||
|
||||
An automation is a Markdown file with a cron schedule in its front matter
|
||||
(`.codecraft/jobs/<name>.md`). Each run is a normal chat session, so you can read and continue
|
||||
it; if it needs a permission, you get a push notification. Ask an agent in any chat to set one
|
||||
up, start from a template (dependency check, security review of recent commits, test suite
|
||||
health, daily summary, TODO report, documentation freshness) or write the file yourself.
|
||||
An automation is an agent that starts on a schedule. Its instructions are a Markdown file
|
||||
(`.codecraft/jobs/<name>.md`, only the prompt); name, schedule, mode, model and limits are set
|
||||
in the UI, with a graphical schedule editor (every few minutes, hourly, daily, on weekdays,
|
||||
monthly or a cron expression). *New automation* creates the file – blank with an example
|
||||
structure or from a template (dependency check, security review of recent commits, test suite
|
||||
health, daily summary, TODO report, documentation freshness) – and opens an editor with an
|
||||
assistant chat in which a model works out the instructions with you and writes the file. You
|
||||
can also ask an agent in any chat to set one up. Each run is a normal chat session, so you can
|
||||
read and continue it.
|
||||
|
||||
**Webhooks** start an automation from outside, e.g. on every push: the request is checked with a
|
||||
shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed to the agent.
|
||||
@@ -292,6 +300,7 @@ in turn are based on:
|
||||
| Coffee | Dark, Latte | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) | MIT |
|
||||
| Solarized | Dark, Light | [Solarized by Ethan Schoonover](https://ethanschoonover.com/solarized/) | MIT |
|
||||
| Tokyo Night | Night, Day | [folke/tokyonight.nvim](https://github.com/folke/tokyonight.nvim) | Apache-2.0 |
|
||||
| Tintenblau | Dark, Light | original (zink/tintenblau desktop theme) | private |
|
||||
|
||||
Further standard themes (palettes re-created from the published colour specifications):
|
||||
|
||||
|
||||
@@ -701,6 +701,60 @@
|
||||
"syntax-type": "#b58900",
|
||||
"syntax-variable": "#586e75"
|
||||
},
|
||||
"tintenblau-dark": {
|
||||
"accent": "#7aa0cc",
|
||||
"accent2": "#d4b46a",
|
||||
"aqua": "#96b4bc",
|
||||
"bg0": "#232a44",
|
||||
"bg1": "#2b3352",
|
||||
"bg2": "#353e60",
|
||||
"bg3": "#4a5478",
|
||||
"blue": "#7aa0cc",
|
||||
"fg0": "#f5efe3",
|
||||
"fg1": "#eae4d6",
|
||||
"fg2": "#d6d0c2",
|
||||
"fg3": "#bdcfd4",
|
||||
"green": "#96b4bc",
|
||||
"muted": "#8e96b4",
|
||||
"on-accent": "#232a44",
|
||||
"purple": "#c896b2",
|
||||
"red": "#d47a6a",
|
||||
"syntax-comment": "#8e96b4",
|
||||
"syntax-function": "#7aa0cc",
|
||||
"syntax-keyword": "#c896b2",
|
||||
"syntax-meta": "#baa0c4",
|
||||
"syntax-number": "#96b4bc",
|
||||
"syntax-string": "#d47a6a",
|
||||
"syntax-type": "#d4b46a",
|
||||
"syntax-variable": "#eae4d6"
|
||||
},
|
||||
"tintenblau-light": {
|
||||
"accent": "#2a3f6f",
|
||||
"accent2": "#6a4a2c",
|
||||
"aqua": "#3a5a6a",
|
||||
"bg0": "#f0ead9",
|
||||
"bg1": "#e6dfd0",
|
||||
"bg2": "#dbd3c1",
|
||||
"bg3": "#c9c0ac",
|
||||
"blue": "#3a5a8a",
|
||||
"fg0": "#0f1628",
|
||||
"fg1": "#1e2a4a",
|
||||
"fg2": "#3a3f5a",
|
||||
"fg3": "#5e5a6a",
|
||||
"green": "#4a5a3a",
|
||||
"muted": "#8a8694",
|
||||
"on-accent": "#f0ead9",
|
||||
"purple": "#5a4a72",
|
||||
"red": "#8a3028",
|
||||
"syntax-comment": "#8a8694",
|
||||
"syntax-function": "#3a5a6a",
|
||||
"syntax-keyword": "#2a3f6f",
|
||||
"syntax-meta": "#4a5a3a",
|
||||
"syntax-number": "#5a4a72",
|
||||
"syntax-string": "#8a3028",
|
||||
"syntax-type": "#6a4a2c",
|
||||
"syntax-variable": "#1e2a4a"
|
||||
},
|
||||
"tokyonight-dark": {
|
||||
"accent": "#7aa2f7",
|
||||
"accent2": "#ff9e64",
|
||||
|
||||
+25
-1
@@ -64,6 +64,9 @@ pub struct ModelConfig {
|
||||
pub context: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_output: Option<u64>,
|
||||
/// Input limit when the provider sets one below the context window (e.g. 272k of 400k).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_input: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub reasoning: bool,
|
||||
#[serde(default)]
|
||||
@@ -156,7 +159,13 @@ impl ModelConfig {
|
||||
/// Cost of one request now. Cache prices default to the input price.
|
||||
/// The provider's own figure wins when it reports one (opencode).
|
||||
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
||||
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
|
||||
match u.cost {
|
||||
// A plan request (opencode Go): nothing charged, the usage counts toward the plan –
|
||||
// its value at the model's list prices.
|
||||
Some(c) if c == 0.0 && u.allowance.is_some_and(|a| a > 0.0) => self.cost_at(u, chrono::Utc::now()),
|
||||
Some(c) => c,
|
||||
None => self.cost_at(u, chrono::Utc::now()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cost of one request at a given time: context tiers and time factors applied.
|
||||
@@ -184,6 +193,21 @@ mod price_tests {
|
||||
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_requests_are_valued_at_list_prices() {
|
||||
let m = ModelConfig { input_price: Some(1.0), output_price: Some(2.0), ..Default::default() };
|
||||
let mut u = usage(1_000_000, 1_000_000, 0);
|
||||
u.cost = Some(0.0);
|
||||
u.allowance = Some(0.4);
|
||||
assert_eq!(m.cost(&u), 3.0);
|
||||
// Charged by the provider: that amount; free without a plan: free.
|
||||
u.cost = Some(0.7);
|
||||
assert_eq!(m.cost(&u), 0.7);
|
||||
u.cost = Some(0.0);
|
||||
u.allowance = None;
|
||||
assert_eq!(m.cost(&u), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiers_and_multipliers() {
|
||||
let m = ModelConfig {
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# git for the shells of CodeCraft agents: `git commit` gets the co-author trailer from
|
||||
# $CODECRAFT_COMMIT_TRAILER (model, provider, CodeCraft). Everything else goes to the real
|
||||
# git unchanged. Git itself skips the trailer when the message already ends with it.
|
||||
real=${CODECRAFT_REAL_GIT:-/usr/bin/git}
|
||||
[[ -n $CODECRAFT_COMMIT_TRAILER ]] || exec "$real" "$@"
|
||||
# The subcommand comes after git's global options (-C dir, -c key=value, --git-dir=…).
|
||||
i=1
|
||||
while (( i <= $# )); do
|
||||
case ${!i} in
|
||||
-C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) i=$((i + 2)) ;;
|
||||
--) break ;;
|
||||
-*) i=$((i + 1)) ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
if (( i <= $# )) && [[ ${!i} == commit ]]; then
|
||||
# Right after "commit", so it stays an option even before "-- <paths>".
|
||||
exec "$real" "${@:1:i}" --trailer "$CODECRAFT_COMMIT_TRAILER" "${@:i+1}"
|
||||
fi
|
||||
exec "$real" "$@"
|
||||
+3
-1
@@ -73,7 +73,9 @@ case "$(remote 'uname -m')" in
|
||||
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
|
||||
esac
|
||||
if [[ -n "$arch" ]]; then
|
||||
docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null | grep -q "Platform:.*linux/$arch" \
|
||||
# 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:-?})"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: reverse-engineering
|
||||
description: Analyse programs and firmware – identify the format, inspect with binary_info, decompile with Ghidra (decompile tool, also raw firmware with processor/base address), explore the output and write down findings. For file formats, protocols, bugs and interoperability.
|
||||
---
|
||||
# Reverse engineering of programs and firmware
|
||||
|
||||
You analyse a binary to understand how it works – a file format, a protocol, an algorithm,
|
||||
a bug, or how to talk to a device. Work from cheap to expensive and write down what you learn.
|
||||
|
||||
Only analyse binaries the user owns or may analyse (their own builds, their devices, programs
|
||||
for interoperability). Decompiled code is third-party material: never commit it and never paste
|
||||
it into the user's source – describe behaviour in your own words.
|
||||
|
||||
## 1. Identify
|
||||
- `file <path>` – format, architecture, stripped or not.
|
||||
- Executables and libraries (PE .exe/.dll, ELF, Mach-O): `binary_info` – sections, imports,
|
||||
exports, strings. Imports and strings tell a lot (file names, error messages, protocol
|
||||
keywords, library names) before any decompiling.
|
||||
- Archives and update packages: unpack first (`unzip`, `7z x`), then identify the parts.
|
||||
- Intel HEX / S-record firmware: convert to a raw image –
|
||||
`objcopy -I ihex -O binary fw.hex fw.bin` (the lowest address in the HEX file is the base
|
||||
address). An ELF from the firmware's build (e.g. `zephyr.elf`, `firmware.elf`) is far better
|
||||
than a raw image: symbols and addresses are included – look for it first.
|
||||
- Raw images: look for clues with `strings -n 6 fw.bin | head -200` and
|
||||
`python3 -c` one-liners (vector table, magic numbers, embedded file systems).
|
||||
|
||||
## 2. Raw firmware: processor and load address
|
||||
`decompile` needs both for images without a file format:
|
||||
|
||||
| Chip family | `processor` | typical `base_address` |
|
||||
|---|---|---|
|
||||
| ARM Cortex-M (STM32, nRF52, RP2040, SAMD) | `ARM:LE:32:Cortex` | STM32 `0x08000000`, nRF52 `0x00000000`, RP2040 `0x10000000` |
|
||||
| ARM Cortex-A / ARMv7 | `ARM:LE:32:v7` | from the bootloader / datasheet |
|
||||
| AArch64 | `AARCH64:LE:64:v8A` | |
|
||||
| ESP32 (Xtensa) | `Xtensa:LE:32:default` | per segment (use the ELF if possible) |
|
||||
| AVR (Arduino) | `AVR8:LE:16:atmega256` or `AVR8:LE:16:default` | `0x0` |
|
||||
| MIPS (routers) | `MIPS:BE:32:default` or `MIPS:LE:32:default` | e.g. `0x80000000` |
|
||||
|
||||
Cortex-M check: the first word of the image is the initial stack pointer (0x2000xxxx = RAM),
|
||||
the second the reset vector – it points just behind the base address. If the decompiled output
|
||||
is mostly garbage, the processor or base address is wrong: fix it and run again (results are
|
||||
cached per file *and* processor/address).
|
||||
|
||||
## 3. Decompile
|
||||
- `decompile` with the path (plus `processor`/`base_address` for raw images). Large programs:
|
||||
start with `max_functions` (e.g. 2000), widen when needed.
|
||||
- Output in `.codecraft/decompiled/<name>/`: `functions.tsv` (address, name, size, file,
|
||||
signature), `functions/*.c`, `strings.tsv`, `imports.tsv`, `exports.tsv`.
|
||||
|
||||
## 4. Explore
|
||||
- Start from what you know: a string, an import or an export.
|
||||
`grep -l "SetCommState" .codecraft/decompiled/<name>/functions` finds the functions that use it.
|
||||
- Follow the calls by reading the functions; note addresses (`FUN_00401a30`) as you go.
|
||||
- Broad searches through thousands of functions: delegate them to an `explore` subagent with a
|
||||
precise question and let it report file:line.
|
||||
- File formats: compare the reading/writing code with a sample file (`python3` to dump the bytes)
|
||||
until every field is explained.
|
||||
|
||||
## 5. Write it down
|
||||
Keep findings in `analysis/notes.md` (create it; it is not committed): structure of the
|
||||
program, important functions with addresses, data structures, file formats and protocols with
|
||||
field tables, open questions. Make sure `.gitignore` excludes `analysis/`, `original/` and
|
||||
`.codecraft/decompiled/`. At the end, report what you found and how sure you are – mark guesses
|
||||
as guesses.
|
||||
+265
-21
@@ -81,6 +81,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.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}/messages", get(session_messages))
|
||||
.route("/sessions/{id}/search", get(search_in_session))
|
||||
.route("/sessions/{id}/media", get(session_media))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
.route("/sessions/{id}/abort", post(abort))
|
||||
@@ -137,6 +140,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/images/{id}/file", get(image_file))
|
||||
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
||||
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
||||
.route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
|
||||
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
||||
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
|
||||
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
|
||||
@@ -150,7 +154,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))
|
||||
@@ -810,14 +816,94 @@ async fn export_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
#[derive(Deserialize)]
|
||||
struct WindowQ {
|
||||
/// Only the end of the chat (from the last summary on, at most this many messages);
|
||||
/// without it, all messages (terminal client).
|
||||
limit: Option<i64>,
|
||||
before: Option<String>,
|
||||
after: Option<String>,
|
||||
around: Option<String>,
|
||||
/// Loading older messages stops at a summary (the history before it on request).
|
||||
#[serde(default)]
|
||||
stop_at_summary: bool,
|
||||
}
|
||||
|
||||
impl WindowQ {
|
||||
fn window(&self) -> Option<crate::store::MessageWindow> {
|
||||
use crate::store::MessageWindow as W;
|
||||
let limit = self.limit.unwrap_or(200).clamp(1, 1000);
|
||||
Some(match (&self.before, &self.after, &self.around) {
|
||||
(Some(id), _, _) => W::Before { id: id.clone(), limit, stop_at_summary: self.stop_at_summary },
|
||||
(_, Some(id), _) => W::After { id: id.clone(), limit },
|
||||
(_, _, Some(id)) => W::Around { id: id.clone(), limit },
|
||||
_ if self.limit.is_some() => W::Tail { limit },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The cost of each answer for the chat view (usage.cost: charged, or valued at list prices).
|
||||
fn with_costs(app: &App, mut messages: Vec<crate::types::Message>) -> Vec<crate::types::Message> {
|
||||
let registry = app.registry();
|
||||
for m in &mut messages {
|
||||
if let (Some(u), Some(spec)) = (m.usage.as_mut(), m.model.as_deref()) {
|
||||
if let Ok((_, model)) = registry.resolve(spec) {
|
||||
u.cost = Some(model.cost(u));
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||
let s = readable(&app, &user, &id)?;
|
||||
let messages = app.store.messages(&id)?;
|
||||
let busy = app.is_busy(&id);
|
||||
let can_write = app.access(&user, &id) == Access::Full;
|
||||
let queue = app.store.queue_list(&id)?;
|
||||
let step = app.with_runtime(&id, |r| r.step.clone());
|
||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue, "step": step})))
|
||||
let mut out = json!({"session": s, "busy": busy, "can_write": can_write, "queue": queue, "step": step});
|
||||
match q.window() {
|
||||
Some(w) => {
|
||||
let (messages, older, newer) = app.store.messages_window(&id, &w)?;
|
||||
out["messages"] = json!(with_costs(&app, messages));
|
||||
out["has_older"] = json!(older);
|
||||
out["has_newer"] = json!(newer);
|
||||
}
|
||||
None => out["messages"] = json!(app.store.messages(&id)?),
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// More messages of a chat: before / after / around a message.
|
||||
async fn session_messages(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
|
||||
let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?;
|
||||
Ok(Json(json!({"messages": with_costs(&app, messages), "has_older": older, "has_newer": newer})))
|
||||
}
|
||||
|
||||
/// Images, videos and audio files shown in a chat (all of it, with subagents).
|
||||
async fn session_media(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let a = app.clone();
|
||||
let items = tokio::task::spawn_blocking(move || crate::media::collect(&a, &id)).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
|
||||
Ok(Json(json!(items)))
|
||||
}
|
||||
|
||||
/// Search within one chat (all of it, not only the loaded part).
|
||||
async fn search_in_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<SearchQ>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
if q.q.trim().chars().count() < 2 {
|
||||
return Ok(Json(json!([])));
|
||||
}
|
||||
let hits: Vec<Value> = app
|
||||
.store
|
||||
.search_messages(&id, q.q.trim(), 100)?
|
||||
.into_iter()
|
||||
.map(|(mid, role, at, snippet)| json!({"id": mid, "role": role, "created_at": at, "snippet": snippet}))
|
||||
.collect();
|
||||
Ok(Json(json!(hits)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1560,13 +1646,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 {
|
||||
@@ -1621,15 +1720,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)))
|
||||
}
|
||||
@@ -1638,22 +1746,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)))
|
||||
}
|
||||
@@ -1661,15 +1785,45 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateJob {
|
||||
enabled: Option<bool>,
|
||||
/// Written into the job file's frontmatter.
|
||||
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)?;
|
||||
if let Some(m) = b.permission_mode.as_deref() {
|
||||
crate::jobs::set_permission_mode(std::path::Path::new(&job.path), m).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.audit(Some(&user), "job.permission_mode", &job.path, m);
|
||||
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)?;
|
||||
@@ -1678,6 +1832,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)?;
|
||||
|
||||
@@ -144,6 +144,16 @@ impl App {
|
||||
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
||||
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
||||
}
|
||||
match crate::budget::value_plan_requests(&self) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("valued {n} plan requests (opencode Go) at list prices"),
|
||||
Err(e) => tracing::warn!("valuing plan requests: {e:#}"),
|
||||
}
|
||||
match crate::images::backfill_copies(&self) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
||||
Err(e) => tracing::warn!("copies of generated images: {e:#}"),
|
||||
}
|
||||
match self.store.gc_blobs() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("removed {n} unused images"),
|
||||
|
||||
+2
-1
@@ -56,7 +56,8 @@ pub fn is_hash(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
/// 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()
|
||||
}
|
||||
|
||||
|
||||
+73
-10
@@ -361,6 +361,49 @@ fn opencode_when(w: &Value) -> Option<When> {
|
||||
Some((days, hours, from, until))
|
||||
}
|
||||
|
||||
/// Plan requests (opencode Go) that were booked at 0: value them at the model's list
|
||||
/// prices of the time of the request. Idempotent – valued rows no longer match.
|
||||
pub fn value_plan_requests(app: &App) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for (row, ts, spec, usage) in app.store.plan_requests_without_cost()? {
|
||||
let Ok((_, model)) = app.registry().resolve(&spec) else { continue };
|
||||
let Some(at) = chrono::DateTime::from_timestamp_millis(ts) else { continue };
|
||||
let cost = model.cost_at(&usage, at);
|
||||
if cost > 0.0 {
|
||||
app.store.set_usage_cost(row, cost)?;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// A price from opencode's list: a number, or one price per cache duration
|
||||
/// (`{"30m": 0.125, "12h": 0.25}`) – then the shortest duration (the default caching).
|
||||
fn price_of(v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Number(_) => v.clone(),
|
||||
Value::Object(o) => {
|
||||
let secs = |k: &str| -> Option<f64> {
|
||||
let (num, unit) = k.split_at(k.find(|c: char| c.is_ascii_alphabetic())?);
|
||||
let n: f64 = num.parse().ok()?;
|
||||
Some(n * match unit {
|
||||
"s" => 1.0,
|
||||
"m" => 60.0,
|
||||
"h" => 3600.0,
|
||||
"d" => 86400.0,
|
||||
_ => return None,
|
||||
})
|
||||
};
|
||||
o.iter()
|
||||
.filter(|(_, x)| x.is_number())
|
||||
.min_by(|a, b| secs(a.0).unwrap_or(f64::MAX).total_cmp(&secs(b.0).unwrap_or(f64::MAX)))
|
||||
.map(|(_, x)| x.clone())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
||||
fn opencode_model(m: &Value) -> Value {
|
||||
let cost = &m["cost"]["text"];
|
||||
@@ -371,13 +414,13 @@ fn opencode_model(m: &Value) -> Value {
|
||||
for o in side["overrides"].as_array().into_iter().flatten() {
|
||||
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
||||
let t = tiers.entry(gt).or_default();
|
||||
t.insert(field.into(), o["price"].clone());
|
||||
t.insert(field.into(), price_of(&o["price"]));
|
||||
if field == "input_price" {
|
||||
if o["cache"]["read"].is_number() {
|
||||
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
|
||||
}
|
||||
if o["cache"]["write"].is_number() {
|
||||
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
|
||||
for (key, k) in [("read", "cache_read_price"), ("write", "cache_write_price")] {
|
||||
let p = price_of(&o["cache"][key]);
|
||||
if !p.is_null() {
|
||||
t.insert(k.into(), p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,12 +452,13 @@ fn opencode_model(m: &Value) -> Value {
|
||||
"id": m["id"],
|
||||
"context": m["limit"]["context"],
|
||||
"max_output": m["limit"]["output"],
|
||||
"max_input": m["limit"]["input"],
|
||||
"vision": images,
|
||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||
"input_price": input["price"],
|
||||
"output_price": output["price"],
|
||||
"cache_read_price": input["cache"]["read"],
|
||||
"cache_write_price": input["cache"]["write"],
|
||||
"input_price": price_of(&input["price"]),
|
||||
"output_price": price_of(&output["price"]),
|
||||
"cache_read_price": price_of(&input["cache"]["read"]),
|
||||
"cache_write_price": price_of(&input["cache"]["write"]),
|
||||
"price_tiers": price_tiers,
|
||||
"price_multipliers": price_multipliers,
|
||||
"notes": notes,
|
||||
@@ -480,6 +524,25 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn prices_per_cache_duration() {
|
||||
use super::{opencode_model, price_of};
|
||||
use serde_json::{Value, json};
|
||||
assert_eq!(price_of(&json!(0.5)), json!(0.5));
|
||||
assert_eq!(price_of(&json!({"12h": 0.25, "30m": 0.125})), json!(0.125));
|
||||
assert_eq!(price_of(&json!({"1h": 2.0, "5m": 1.25})), json!(1.25));
|
||||
assert_eq!(price_of(&Value::Null), Value::Null);
|
||||
// A model of opencode's list with per-duration cache write prices.
|
||||
let m = json!({"id": "gpt-6-luna", "limit": {"context": 1050000, "input": 922000, "output": 128000},
|
||||
"cost": {"text": {"input": {"price": 0.1, "cache": {"read": 0.01, "write": {"30m": 0.125, "12h": 0.25}},
|
||||
"overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.2, "cache": {"read": 0.02, "write": {"30m": 0.25, "12h": 0.5}}}]},
|
||||
"output": {"price": 0.5, "overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.75}]}}}});
|
||||
let row = opencode_model(&m);
|
||||
assert_eq!(row["cache_write_price"], json!(0.125));
|
||||
assert_eq!(row["price_tiers"][0]["cache_write_price"], json!(0.25));
|
||||
assert_eq!(row["max_input"], json!(922000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_cost_conversion() {
|
||||
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
||||
|
||||
@@ -231,6 +231,23 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
|
||||
Ok(Json(b))
|
||||
}
|
||||
|
||||
/// Order and pinning of the groups (folders, projects) in the chat list – per user, so
|
||||
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
|
||||
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let v: Value = app.store.user_setting(&user.id, "sidebar")?;
|
||||
Ok(Json(if v.is_null() { json!({"order": [], "pinned": []}) } else { v }))
|
||||
}
|
||||
|
||||
pub async fn put_sidebar(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
|
||||
let ids = |k: &str| b[k].as_array().is_some_and(|a| a.iter().all(|x| x.as_str().is_some_and(|s| s.len() <= 1000)));
|
||||
if !ids("order") || !ids("pinned") || b.to_string().len() > 100_000 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid chat list layout".into()));
|
||||
}
|
||||
let v = json!({"order": b["order"], "pinned": b["pinned"]});
|
||||
app.store.set_user_setting(&user.id, "sidebar", &v)?;
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
/// Personal memory (applies to all projects, web and CLI).
|
||||
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let m: String = app.store.user_setting(&user.id, "memory")?;
|
||||
|
||||
@@ -177,6 +177,19 @@ pub struct ImagesConfig {
|
||||
/// Optional fal.ai Admin key to query the account balance.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub admin_key: String,
|
||||
/// fal.ai model for 3D models from images (generate_3d).
|
||||
#[serde(default = "default_model_3d")]
|
||||
pub model_3d: String,
|
||||
/// fal.ai model for 3D models from a text prompt only.
|
||||
#[serde(default = "default_model_3d_text")]
|
||||
pub model_3d_text: String,
|
||||
}
|
||||
|
||||
fn default_model_3d() -> String {
|
||||
"fal-ai/hunyuan3d-v3/image-to-3d".into()
|
||||
}
|
||||
fn default_model_3d_text() -> String {
|
||||
"fal-ai/hunyuan3d-v3/text-to-3d".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
@@ -215,6 +228,8 @@ impl Default for ImagesConfig {
|
||||
models: vec![ImageModel { id: "fal-ai/flux/schnell".into(), name: Some("FLUX.1 schnell".into()), price: Some(0.003), ..Default::default() }],
|
||||
monthly_budget: None,
|
||||
admin_key: String::new(),
|
||||
model_3d: default_model_3d(),
|
||||
model_3d_text: default_model_3d_text(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,8 +322,13 @@ pub struct ServerConfig {
|
||||
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub metrics_token: String,
|
||||
/// Trailer added to every git commit an agent makes ({model}, {provider}, {model_id});
|
||||
/// empty = off.
|
||||
pub commit_trailer: String,
|
||||
}
|
||||
|
||||
pub const DEFAULT_COMMIT_TRAILER: &str = "Co-Authored-By: {model} ({provider}) via CodeCraft";
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -327,6 +347,7 @@ impl Default for ServerConfig {
|
||||
max_upload_mb: 500,
|
||||
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
|
||||
metrics_token: String::new(),
|
||||
commit_trailer: DEFAULT_COMMIT_TRAILER.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,6 +419,10 @@ pub struct AgentConfig {
|
||||
/// Review loop after each turn of this mode.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub refine: Option<RefineConfig>,
|
||||
/// Reverse engineering tools (binary_info, decompile with Ghidra) in this mode;
|
||||
/// craft has them anyway (deep mode).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reverse_tools: Option<bool>,
|
||||
}
|
||||
|
||||
/// Refinement loop: after the agent finishes, a reviewer checks the result
|
||||
@@ -413,6 +438,9 @@ pub struct RefineConfig {
|
||||
/// Review criteria; empty = the mode's defaults.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub criteria: Option<String>,
|
||||
/// Also review every todo the agent marks completed (reopened when findings come back).
|
||||
#[serde(default = "yes")]
|
||||
pub after_todos: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
@@ -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})))
|
||||
}
|
||||
|
||||
|
||||
+130
-4
@@ -38,7 +38,7 @@ pub struct GenerateRequest {
|
||||
pub references: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> String {
|
||||
pub(crate) fn slug(s: &str) -> String {
|
||||
let mut out: String = s
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
|
||||
@@ -121,7 +121,7 @@ fn pick(all: &[ImageModel], stage: &str, purpose: Option<&str>, with_reference:
|
||||
}
|
||||
|
||||
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
|
||||
async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
||||
pub(crate) async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
|
||||
@@ -135,7 +135,7 @@ async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
|
||||
pub(crate) async fn download(url: &str) -> Result<(Vec<u8>, String)> {
|
||||
if let Some(rest) = url.strip_prefix("data:") {
|
||||
let (meta, data) = rest.split_once(',').context("malformed data URI")?;
|
||||
let ctype = meta.split(';').next().unwrap_or("image/png").to_string();
|
||||
@@ -213,6 +213,8 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
|
||||
let path = req.dir.join(name);
|
||||
tokio::fs::write(&path, &bytes).await?;
|
||||
// A copy in the image store keeps it viewable when the file is moved or deleted.
|
||||
let blob = app.store.put_blob(&ctype, &bytes).map_err(|e| tracing::warn!("image copy: {e:#}")).ok();
|
||||
let rec = ImageRecord {
|
||||
id: new_id(),
|
||||
user_id: req.user_id.clone(),
|
||||
@@ -223,6 +225,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
width: img["width"].as_i64().unwrap_or(0),
|
||||
height: img["height"].as_i64().unwrap_or(0),
|
||||
created_at: now_ms(),
|
||||
blob,
|
||||
};
|
||||
app.store.add_image(&rec)?;
|
||||
records.push(rec);
|
||||
@@ -252,8 +255,100 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// A generated image whose file is gone: the same file name below its original folder
|
||||
/// (or the folder above it), e.g. after an agent sorted the images into subfolders.
|
||||
/// Only inside `roots`; looks at a few thousand entries at most.
|
||||
pub fn find_moved(path: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
|
||||
let name = path.file_name()?;
|
||||
search_near(path, roots, |p, _| p.file_name() == Some(name))
|
||||
}
|
||||
|
||||
/// Like `find_moved`, but by content: an image file with the same bytes (renamed).
|
||||
pub fn find_by_content(path: &Path, roots: &[PathBuf], hash: &str, size: u64) -> Option<PathBuf> {
|
||||
search_near(path, roots, |p, len| {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
len == size && matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg" | "webp")) && std::fs::read(p).is_ok_and(|b| crate::blobs::hash(&b) == hash)
|
||||
})
|
||||
}
|
||||
|
||||
/// Files below the folder of `path` and the folder above it (3 levels, hidden folders
|
||||
/// skipped, inside `roots`) for which `matches(path, size)` holds.
|
||||
fn search_near(path: &Path, roots: &[PathBuf], matches: impl Fn(&Path, u64) -> bool) -> Option<PathBuf> {
|
||||
let dir = path.parent()?;
|
||||
let mut budget = 5000usize;
|
||||
for base in [Some(dir), dir.parent()].into_iter().flatten() {
|
||||
if !roots.iter().any(|r| base.starts_with(r)) {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![(base.to_path_buf(), 0usize)];
|
||||
while let Some((d, depth)) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&d) else { continue };
|
||||
for e in entries.flatten() {
|
||||
if budget == 0 {
|
||||
return None;
|
||||
}
|
||||
budget -= 1;
|
||||
let p = e.path();
|
||||
let Ok(ft) = e.file_type() else { continue };
|
||||
if ft.is_file() && p != path && matches(&p, e.metadata().map(|m| m.len()).unwrap_or(0)) {
|
||||
return Some(p);
|
||||
}
|
||||
let hidden = p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.') || n == "node_modules");
|
||||
if ft.is_dir() && depth < 3 && !hidden {
|
||||
stack.push((p, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where the file of a generated image is now: by name, then (with a copy in the image
|
||||
/// store) by content. Corrects the stored path when found.
|
||||
pub fn relocate(app: &App, rec: &ImageRecord) -> Option<PathBuf> {
|
||||
let path = Path::new(&rec.path);
|
||||
let roots = app.user_roots(rec.user_id.as_deref());
|
||||
let found = find_moved(path, &roots).or_else(|| {
|
||||
let hash = rec.blob.as_deref()?;
|
||||
let (_, bytes) = app.store.blob(hash).ok()??;
|
||||
find_by_content(path, &roots, hash, bytes.len() as u64)
|
||||
})?;
|
||||
let _ = app.store.set_image_path(&rec.id, &found.display().to_string());
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// After an agent moved or renamed files: find the generated images of `owner` from the
|
||||
/// last two weeks whose files are gone and correct their paths. Returns how many.
|
||||
pub fn relocate_missing(app: &App, owner: Option<&str>) -> usize {
|
||||
let since = now_ms() - 14 * 86_400_000;
|
||||
let Ok(list) = app.store.images(owner, 500) else { return 0 };
|
||||
list.iter().filter(|r| r.created_at >= since && !Path::new(&r.path).exists()).filter(|r| relocate(app, r).is_some()).count()
|
||||
}
|
||||
|
||||
/// Copies in the image store for generated images from before they were kept (once per
|
||||
/// image, files up to 25 MB): later moves and renames are then found by content.
|
||||
pub fn backfill_copies(app: &App) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for (id, path) in app.store.images_without_copy()? {
|
||||
let Ok(meta) = std::fs::metadata(&path) else { continue };
|
||||
if !meta.is_file() || meta.len() > 25 * 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||||
let mime = match Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
let hash = app.store.put_blob(mime, &bytes)?;
|
||||
app.store.set_image_blob(&id, &hash)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Downscaled copy for the model's context.
|
||||
fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
pub(crate) fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
let img = image::open(path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
let mut buf = Vec::new();
|
||||
@@ -363,6 +458,37 @@ impl Tool for GenerateImage {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_moved_files_and_keeps_their_copies() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-img-moved-{}", std::process::id()));
|
||||
let art = dir.join("art");
|
||||
std::fs::create_dir_all(art.join("older")).unwrap();
|
||||
std::fs::write(art.join("older/20261110-a.png"), b"x").unwrap();
|
||||
// Moved into a subfolder: found; renamed or outside the roots: not.
|
||||
assert_eq!(find_moved(&art.join("20261110-a.png"), std::slice::from_ref(&dir)), Some(art.join("older/20261110-a.png")));
|
||||
assert_eq!(find_moved(&art.join("20261110-b.png"), std::slice::from_ref(&dir)), None);
|
||||
assert_eq!(find_moved(&art.join("20261110-a.png"), &[dir.join("elsewhere")]), None);
|
||||
// Renamed: found by content.
|
||||
std::fs::write(art.join("older/level-5.png"), b"renamed bytes").unwrap();
|
||||
let h = crate::blobs::hash(b"renamed bytes");
|
||||
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &h, 13), Some(art.join("older/level-5.png")));
|
||||
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &crate::blobs::hash(b"other"), 13), None);
|
||||
// The image store keeps the copy of a generated image.
|
||||
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
|
||||
let hash = store.put_blob("image/png", b"png bytes").unwrap();
|
||||
store
|
||||
.add_image(&ImageRecord { id: "i".into(), user_id: None, session_id: None, prompt: "p".into(), model: "m".into(), path: "/gone.png".into(), width: 1, height: 1, created_at: 0, blob: Some(hash.clone()) })
|
||||
.unwrap();
|
||||
assert_eq!(store.gc_blobs().unwrap(), 0);
|
||||
// Moved with the file manager: the entry follows (file or folder, not look-alikes).
|
||||
assert_eq!(store.move_image_paths("/gone.png", "/art/new.png").unwrap(), 1);
|
||||
assert_eq!(store.move_image_paths("/art", "/bilder/").unwrap(), 1);
|
||||
assert_eq!(store.move_image_paths("/bild", "/x").unwrap(), 0);
|
||||
assert_eq!(store.image("i").unwrap().unwrap().path, "/bilder/new.png");
|
||||
assert_eq!(store.blob(&hash).unwrap().unwrap().1, b"png bytes");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn m(id: &str) -> ImageModel {
|
||||
with_defaults(ImageModel { id: id.into(), ..Default::default() })
|
||||
}
|
||||
|
||||
+234
-117
@@ -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:#}"))?;
|
||||
@@ -204,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 };
|
||||
@@ -236,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()
|
||||
}
|
||||
@@ -262,71 +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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `permission_mode` in the frontmatter of a job file (replaces the line or adds it
|
||||
/// before the closing `---`); the rest of the file stays as it is.
|
||||
pub fn set_permission_mode(path: &Path, mode: &str) -> Result<JobSpec> {
|
||||
if !crate::permission::MODES.contains(&mode) {
|
||||
bail!("invalid permission_mode '{mode}'");
|
||||
}
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let updated = set_frontmatter_key(&text, "permission_mode", mode).ok_or_else(|| anyhow!("{} has no frontmatter", path.display()))?;
|
||||
std::fs::write(path, &updated)?;
|
||||
match parse(path) {
|
||||
Ok(spec) => Ok(spec),
|
||||
Err(e) => {
|
||||
let _ = std::fs::write(path, &text);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_frontmatter_key(text: &str, key: &str, value: &str) -> Option<String> {
|
||||
let bom = if text.starts_with('\u{feff}') { "\u{feff}" } else { "" };
|
||||
let t = &text[bom.len()..];
|
||||
let rest = t.strip_prefix("---")?;
|
||||
let end = rest.find("\n---")?;
|
||||
let (head, tail) = (&rest[..end], &rest[end..]);
|
||||
let prefix = format!("{key}:");
|
||||
let mut found = false;
|
||||
let mut lines: Vec<String> = head
|
||||
.split('\n')
|
||||
.map(|l| {
|
||||
if !found && l.trim_start().starts_with(&prefix) {
|
||||
found = true;
|
||||
format!("{key}: {value}")
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !found {
|
||||
lines.push(format!("{key}: {value}"));
|
||||
}
|
||||
Some(format!("{bom}---{}{tail}", lines.join("\n")))
|
||||
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/`.
|
||||
@@ -348,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."
|
||||
@@ -394,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.",
|
||||
@@ -418,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" => {
|
||||
@@ -491,12 +585,35 @@ mod tests {
|
||||
let s = parse(&p).unwrap();
|
||||
assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto"));
|
||||
assert_eq!(job_cwd(&p, &s), dir);
|
||||
// Added when missing, replaced when present; comments and the prompt stay.
|
||||
assert_eq!(set_permission_mode(&p, "bypass").unwrap().permission_mode, "bypass");
|
||||
std::fs::write(&p, "---\nname: N\npermission_mode: auto # comment\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n").unwrap();
|
||||
assert_eq!(set_permission_mode(&p, "accept_edits").unwrap().permission_mode, "accept_edits");
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "---\nname: N\npermission_mode: accept_edits\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n");
|
||||
assert!(set_permission_mode(&p, "yolo").is_err());
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ mod lsp;
|
||||
mod mentions;
|
||||
mod metrics;
|
||||
mod mcp;
|
||||
mod media;
|
||||
mod mesh3d;
|
||||
mod permission;
|
||||
mod prompts;
|
||||
mod push;
|
||||
|
||||
+41
-2
@@ -558,6 +558,42 @@ impl McpManager {
|
||||
/// Search the on-demand MCP tools by keywords.
|
||||
pub struct McpSearch;
|
||||
|
||||
/// Built-in tools matching the search words, with their availability in the current mode.
|
||||
fn builtin_matches(ctx: &ToolCtx, words: &[&str]) -> String {
|
||||
if words.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let app = &ctx.app;
|
||||
let owner_id = app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
|
||||
let owner_user = owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
||||
let root = app.root_for(owner_id.as_deref(), &ctx.cwd);
|
||||
let agent = crate::prompts::find_agent_for(app, &root, &ctx.cwd, &ctx.agent, owner_id.as_deref());
|
||||
let available: std::collections::HashSet<String> = crate::runner::available_tools(app, &agent, owner_user.as_ref()).iter().map(|t| t.name().to_string()).collect();
|
||||
let mut hits: Vec<(usize, String)> = crate::tools::builtin_tools()
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let name = t.name().to_lowercase();
|
||||
let desc = t.description().to_lowercase();
|
||||
let score: usize = words.iter().map(|w| if name.contains(w) { 3 } else if desc.contains(w) { 1 } else { 0 }).sum();
|
||||
let state = if available.contains(t.name()) {
|
||||
"available – call it directly".to_string()
|
||||
} else if agent.read_only && !t.read_only() {
|
||||
format!("exists, but not in the read-only mode \"{}\" – ask the user to switch to build mode", agent.name)
|
||||
} else {
|
||||
format!("exists, but not available in mode \"{}\" (or disabled in the settings)", agent.name)
|
||||
};
|
||||
(score, format!("- {}: {state}", t.name()))
|
||||
})
|
||||
.filter(|(s, _)| *s >= 2 || words.len() == 1 && *s >= 1)
|
||||
.collect();
|
||||
hits.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
if hits.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let list: Vec<String> = hits.into_iter().take(4).map(|(_, l)| l).collect();
|
||||
format!("Built-in tools (not MCP – call them directly by name, not via mcp_call):\n{}\n\n", list.join("\n"))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for McpSearch {
|
||||
fn name(&self) -> &str {
|
||||
@@ -602,10 +638,13 @@ impl Tool for McpSearch {
|
||||
.filter(|(s, _)| *s > 0 || words.is_empty())
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
// Models also look here for built-in tools (e.g. generate_3d): say that they exist,
|
||||
// whether this mode has them, and that they are called directly.
|
||||
let builtin = if server.is_none() { builtin_matches(ctx, &words) } else { String::new() };
|
||||
if scored.is_empty() {
|
||||
return Ok(ToolOutput::text("No matching tools. Try other keywords or another server."));
|
||||
return Ok(ToolOutput::text(if builtin.is_empty() { "No matching tools. Try other keywords or another server.".to_string() } else { builtin }));
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut out = builtin;
|
||||
for (_, t) in scored.iter().take(8) {
|
||||
let desc: String = t.info.description.chars().take(300).collect();
|
||||
out.push_str(&format!("## {}\n{}\nInput schema: {}\n\n", t.full_name, desc, t.info.input_schema));
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Media overview of a chat: every image, video and audio file that was shown in it –
|
||||
//! generated images, images in tool results (screenshots), media files the agent wrote,
|
||||
//! media referenced in answers, and the user's own attachments. Includes subagents.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::types::{Message, Part, Role};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct MediaItem {
|
||||
/// "image", "video", "audio" or "model" (3D).
|
||||
pub kind: &'static str,
|
||||
pub url: String,
|
||||
pub name: String,
|
||||
/// "generated", "tool", "written", "answer" or "upload".
|
||||
pub source: &'static str,
|
||||
pub created_at: i64,
|
||||
/// Message to jump to (only for messages of the chat itself, not of subagents).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_id: Option<String>,
|
||||
/// 3D models: absolute path (opened in the 3D view) and a preview image.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"];
|
||||
const VIDEO: &[&str] = &["mp4", "webm", "mov", "m4v", "ogv"];
|
||||
const AUDIO: &[&str] = &["mp3", "wav", "ogg", "oga", "m4a", "flac", "opus", "aac"];
|
||||
const MODEL: &[&str] = &["glb", "gltf", "stl", "3mf", "obj", "step", "stp"];
|
||||
|
||||
/// Kind of a media file by its extension.
|
||||
pub fn kind_of(path: &str) -> Option<&'static str> {
|
||||
let ext = Path::new(path.split(['?', '#']).next().unwrap_or(path)).extension()?.to_str()?.to_ascii_lowercase();
|
||||
if IMAGE.contains(&ext.as_str()) {
|
||||
Some("image")
|
||||
} else if VIDEO.contains(&ext.as_str()) {
|
||||
Some("video")
|
||||
} else if AUDIO.contains(&ext.as_str()) {
|
||||
Some("audio")
|
||||
} else if MODEL.contains(&ext.as_str()) {
|
||||
Some("model")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_of_mime(mime: &str) -> &'static str {
|
||||
if mime.starts_with("video/") {
|
||||
"video"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"audio"
|
||||
} else {
|
||||
"image"
|
||||
}
|
||||
}
|
||||
|
||||
/// Like JavaScript's encodeURIComponent.
|
||||
fn encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Absolute, normalised path of `p` (relative to `cwd`).
|
||||
fn resolve(p: &str, cwd: &Path) -> PathBuf {
|
||||
let joined = if p.starts_with('/') { PathBuf::from(p) } else { cwd.join(p) };
|
||||
crate::tools::normalize(&joined)
|
||||
}
|
||||
|
||||
/// Media file paths in a text: Markdown images/links and plain paths with a media extension.
|
||||
fn paths_in(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for token in text.split(|c: char| c.is_whitespace() || "()[]<>\"'`,;".contains(c)) {
|
||||
let t = token.trim_end_matches(['.', ':', '!', '?']);
|
||||
if t.is_empty() || t.contains("://") || t.starts_with("data:") {
|
||||
continue;
|
||||
}
|
||||
if kind_of(t).is_some() && t.chars().any(|c| c == '/' || c == '.') {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// All media of a chat and its subagents, newest first.
|
||||
pub fn collect(app: &App, session_id: &str) -> anyhow::Result<Vec<MediaItem>> {
|
||||
let mut sessions = vec![session_id.to_string()];
|
||||
// Subagents (and theirs).
|
||||
let mut i = 0;
|
||||
while i < sessions.len() && sessions.len() < 500 {
|
||||
let children = app.store.child_sessions(&sessions[i])?;
|
||||
sessions.extend(children);
|
||||
i += 1;
|
||||
}
|
||||
let mut out: Vec<MediaItem> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut push = |item: MediaItem, out: &mut Vec<MediaItem>| {
|
||||
if seen.insert(item.url.clone()) {
|
||||
out.push(item);
|
||||
}
|
||||
};
|
||||
for sid in &sessions {
|
||||
let Some(session) = app.store.get_session(sid)? else { continue };
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let own = sid == session_id;
|
||||
// Tool results are shown in the card of the call: jump to that message.
|
||||
let mut call_msg: Option<String> = None;
|
||||
for m in app.store.messages(sid)? {
|
||||
collect_message(&m, &cwd, own, call_msg.as_deref(), &mut |item| push(item, &mut out));
|
||||
if m.role == Role::Assistant {
|
||||
call_msg = Some(m.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generated images whose tool result was not found (e.g. removed by a rewind).
|
||||
for img in app.store.images_of_sessions(&sessions)? {
|
||||
push(
|
||||
MediaItem { kind: "image", url: format!("/api/images/{}/file", img.id), name: file_name(&img.path), source: "generated", created_at: img.created_at, message_id: None, path: None, preview: None },
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
// Names of generated images (their files).
|
||||
let names: std::collections::HashMap<String, String> = app.store.images_of_sessions(&sessions)?.into_iter().map(|i| (format!("/api/images/{}/file", i.id), file_name(&i.path))).collect();
|
||||
for item in &mut out {
|
||||
if let Some(n) = names.get(&item.url) {
|
||||
item.name = n.clone();
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn raw_url(p: &Path) -> String {
|
||||
format!("/api/files/raw?path={}", encode(&p.display().to_string()))
|
||||
}
|
||||
|
||||
fn file_name(p: &str) -> String {
|
||||
Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p).to_string()
|
||||
}
|
||||
|
||||
fn collect_message(m: &Message, cwd: &Path, own: bool, call_msg: Option<&str>, add: &mut dyn FnMut(MediaItem)) {
|
||||
let mid = own.then(|| m.id.clone());
|
||||
let result_mid = own.then(|| call_msg.unwrap_or(&m.id).to_string());
|
||||
let at = m.created_at;
|
||||
let file = |p: &str, source: &'static str| -> Option<MediaItem> {
|
||||
let kind = kind_of(p)?;
|
||||
let abs = resolve(p, cwd);
|
||||
abs.is_file().then(|| MediaItem { kind, url: raw_url(&abs), name: file_name(p), source, created_at: at, message_id: mid.clone(), path: Some(abs.display().to_string()), preview: None })
|
||||
};
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Image { image } if m.role == Role::User => {
|
||||
if let Some(h) = &image.blob {
|
||||
add(MediaItem { kind: kind_of_mime(&image.media_type), url: format!("/api/blobs/{h}"), name: "image".into(), source: "upload", created_at: at, message_id: mid.clone(), path: None, preview: None });
|
||||
}
|
||||
}
|
||||
// generate_3d: the model with its preview (not the preview as a separate image).
|
||||
Part::ToolResult { meta: Some(meta), .. } if meta["model_file"].is_string() => {
|
||||
let file = PathBuf::from(meta["model_file"].as_str().unwrap_or_default());
|
||||
if file.is_file() {
|
||||
let preview = meta["preview"].as_str().map(PathBuf::from).filter(|p| p.is_file()).map(|p| raw_url(&p));
|
||||
add(MediaItem { kind: "model", url: raw_url(&file), name: file_name(&file.display().to_string()), source: "generated", created_at: at, message_id: result_mid.clone(), path: Some(file.display().to_string()), preview });
|
||||
}
|
||||
}
|
||||
Part::ToolResult { images, meta, .. } => {
|
||||
let ids: Vec<&str> = meta.as_ref().and_then(|v| v["image_ids"].as_array()).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
if !ids.is_empty() {
|
||||
for id in ids {
|
||||
add(MediaItem { kind: "image", url: format!("/api/images/{id}/file"), name: "image".into(), source: "generated", created_at: at, message_id: result_mid.clone(), path: None, preview: None });
|
||||
}
|
||||
} else {
|
||||
for img in images {
|
||||
if let Some(h) = &img.blob {
|
||||
add(MediaItem { kind: kind_of_mime(&img.media_type), url: format!("/api/blobs/{h}"), name: "screenshot".into(), source: "tool", created_at: at, message_id: result_mid.clone(), path: None, preview: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Part::ToolCall { name, input, .. } if matches!(name.as_str(), "write" | "edit") => {
|
||||
if let Some(item) = input["path"].as_str().and_then(|p| file(p, "written")) {
|
||||
add(item);
|
||||
}
|
||||
}
|
||||
Part::Text { text } if m.role == Role::Assistant => {
|
||||
for p in paths_in(text) {
|
||||
if let Some(item) = file(&p, "answer") {
|
||||
add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kinds_paths_and_encoding() {
|
||||
assert_eq!(kind_of("a/b.PNG"), Some("image"));
|
||||
assert_eq!(kind_of("clip.mp4?v=1"), Some("video"));
|
||||
assert_eq!(kind_of("song.flac"), Some("audio"));
|
||||
assert_eq!(kind_of("main.rs"), None);
|
||||
assert_eq!(paths_in("Saved to `art/final/hero.png` and . See https://x.org/y.png"), vec!["art/final/hero.png", "shots/a.webp"]);
|
||||
assert_eq!(encode("/w/ä b.png"), "%2Fw%2F%C3%A4%20b.png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! 3D models with fal.ai (generate_3d): from an image (plus optional back/left/right
|
||||
//! views), from a text prompt, or from an image steered by text – the 3D models take no
|
||||
//! text with an image, so the image is first refined with the prompt (image edit model)
|
||||
//! and then converted. The result is a GLB file (plus a preview image) in the project.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::expand_env;
|
||||
use crate::store::UsageRecord;
|
||||
use crate::tools::{Tool, ToolCtx, ToolOutput};
|
||||
use crate::types::now_ms;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const STYLES: &[&str] = &["textured", "geometry", "lowpoly"];
|
||||
|
||||
/// Request body for a fal.ai 3D endpoint (the families name their fields differently).
|
||||
fn request_body(model: &str, prompt: Option<&str>, images: &Views, style: &str, pbr: bool, faces: Option<u64>) -> Value {
|
||||
let mut b = json!({});
|
||||
if model.contains("tripo") {
|
||||
// Tripo: image_url / prompt, texture + pbr flags, face_limit.
|
||||
if let Some(front) = &images.front {
|
||||
b["image_url"] = json!(front);
|
||||
}
|
||||
if let Some(p) = prompt {
|
||||
b["prompt"] = json!(p);
|
||||
}
|
||||
b["texture"] = json!(style != "geometry");
|
||||
b["pbr"] = json!(style != "geometry" && pbr);
|
||||
if let Some(f) = faces {
|
||||
b["face_limit"] = json!(f);
|
||||
}
|
||||
if style == "lowpoly" {
|
||||
b["face_limit"] = json!(faces.unwrap_or(20_000));
|
||||
}
|
||||
} else if model.contains("trellis") {
|
||||
if let Some(front) = &images.front {
|
||||
b["image_url"] = json!(front);
|
||||
}
|
||||
} else {
|
||||
// Hunyuan3D: front + optional views, generate_type, enable_pbr, face_count.
|
||||
if let Some(front) = &images.front {
|
||||
b["input_image_url"] = json!(front);
|
||||
}
|
||||
for (k, v) in [("back_image_url", &images.back), ("left_image_url", &images.left), ("right_image_url", &images.right)] {
|
||||
if let Some(v) = v {
|
||||
b[k] = json!(v);
|
||||
}
|
||||
}
|
||||
if let Some(p) = prompt {
|
||||
b["prompt"] = json!(p);
|
||||
}
|
||||
b["generate_type"] = json!(match style {
|
||||
"geometry" => "Geometry",
|
||||
"lowpoly" => "LowPoly",
|
||||
_ => "Normal",
|
||||
});
|
||||
b["enable_pbr"] = json!(style != "geometry" && pbr);
|
||||
if let Some(f) = faces {
|
||||
b["face_count"] = json!(f.clamp(40_000, 1_500_000));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Data URIs of the views (front required for image-to-3D).
|
||||
#[derive(Default)]
|
||||
struct Views {
|
||||
front: Option<String>,
|
||||
back: Option<String>,
|
||||
left: Option<String>,
|
||||
right: Option<String>,
|
||||
}
|
||||
|
||||
impl Views {
|
||||
fn extra(&self) -> usize {
|
||||
[&self.back, &self.left, &self.right].iter().filter(|v| v.is_some()).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Price of one generation (USD) by the published fal.ai prices; a price configured for
|
||||
/// the model under [images] models wins.
|
||||
fn price(app: &App, model: &str, style: &str, pbr: bool, multiview: bool, faces: bool) -> f64 {
|
||||
if let Some(p) = app.config().images.models.iter().find(|m| m.id == model).and_then(|m| m.price) {
|
||||
return p;
|
||||
}
|
||||
if model.contains("tripo") {
|
||||
return match style {
|
||||
"geometry" => 0.20,
|
||||
_ => 0.30,
|
||||
};
|
||||
}
|
||||
if model.contains("hunyuan") {
|
||||
let base = match style {
|
||||
"geometry" => 0.225,
|
||||
"lowpoly" => 0.45,
|
||||
_ => 0.375,
|
||||
};
|
||||
let extras = [pbr && style != "geometry", multiview, faces].iter().filter(|x| **x).count() as f64;
|
||||
return base + 0.15 * extras;
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
/// URL of the queue API for fal.ai (requests that take minutes).
|
||||
fn queue_base(app: &App) -> String {
|
||||
let base = expand_env(&app.config().images.base_url);
|
||||
if base.contains("://fal.run") { "https://queue.fal.run".into() } else { base.trim_end_matches('/').to_string() }
|
||||
}
|
||||
|
||||
/// Submit to fal.ai's queue and wait for the result, reporting progress.
|
||||
async fn run_queued(app: &App, ctx: &ToolCtx, model: &str, body: &Value) -> Result<Value> {
|
||||
let key = expand_env(&app.config().images.api_key);
|
||||
let client = crate::provider::http_client();
|
||||
let submit = client
|
||||
.post(format!("{}/{}", queue_base(app), model.trim_matches('/')))
|
||||
.header("Authorization", format!("Key {key}"))
|
||||
.timeout(Duration::from_secs(120))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.context("fal.ai request failed")?;
|
||||
if !submit.status().is_success() {
|
||||
return Err(crate::provider::http_error(submit).await);
|
||||
}
|
||||
let sub: Value = submit.json().await?;
|
||||
let status_url = sub["status_url"].as_str().context("fal.ai: no status_url")?.to_string();
|
||||
let response_url = sub["response_url"].as_str().context("fal.ai: no response_url")?.to_string();
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
if ctx.cancel.is_cancelled() {
|
||||
bail!("cancelled");
|
||||
}
|
||||
if started.elapsed() > Duration::from_secs(20 * 60) {
|
||||
bail!("fal.ai did not finish within 20 minutes");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
||||
let st: Value = client.get(&status_url).header("Authorization", format!("Key {key}")).timeout(Duration::from_secs(30)).send().await?.json().await?;
|
||||
match st["status"].as_str().unwrap_or("") {
|
||||
"COMPLETED" => break,
|
||||
"IN_QUEUE" => ctx.progress(format!("waiting in fal.ai's queue (position {}) – {} s", st["queue_position"].as_u64().unwrap_or(0), started.elapsed().as_secs())),
|
||||
"IN_PROGRESS" => ctx.progress(format!("generating the 3D model – {} s", started.elapsed().as_secs())),
|
||||
other => bail!("fal.ai: unexpected status {other}: {st}"),
|
||||
}
|
||||
}
|
||||
let resp = client.get(&response_url).header("Authorization", format!("Key {key}")).timeout(Duration::from_secs(60)).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(crate::provider::http_error(resp).await);
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// URL of the mesh and of the preview image in a fal.ai 3D result.
|
||||
fn result_urls(out: &Value) -> (Option<String>, Option<String>) {
|
||||
let s = |v: &Value| v.as_str().map(String::from);
|
||||
let mesh = s(&out["model_glb"]["url"])
|
||||
.or_else(|| s(&out["model_mesh"]["url"]))
|
||||
.or_else(|| s(&out["model_urls"]["glb"]["url"]))
|
||||
.or_else(|| s(&out["model_file"]["url"]))
|
||||
.or_else(|| s(&out["mesh"]["url"]));
|
||||
let thumb = s(&out["thumbnail"]["url"]).or_else(|| s(&out["rendered_image"]["url"]));
|
||||
(mesh, thumb)
|
||||
}
|
||||
|
||||
/// File extension of a downloaded mesh: from the URL, else GLB (what the models return).
|
||||
fn ext_of(url: &str) -> &'static str {
|
||||
let path = url.split(['?', '#']).next().unwrap_or(url).to_ascii_lowercase();
|
||||
["glb", "gltf", "fbx", "obj", "usdz", "stl"].into_iter().find(|e| path.ends_with(&format!(".{e}"))).unwrap_or("glb")
|
||||
}
|
||||
|
||||
/// An image the user attached to the chat ("attached" = the first image of the latest
|
||||
/// message with images, "attached:2" = its second), written to `dir` so it can be used
|
||||
/// like a project file.
|
||||
async fn attached_image(app: &App, session_id: &str, spec: &str, dir: &Path) -> Result<PathBuf> {
|
||||
let n: usize = spec.strip_prefix("attached").map(|r| r.trim_start_matches(':').trim()).filter(|r| !r.is_empty()).map(|r| r.parse()).transpose().map_err(|_| anyhow!("use \"attached\" or \"attached:2\""))?.unwrap_or(1);
|
||||
let root = app.root_session_id(session_id);
|
||||
let messages = app.store.messages(&root)?;
|
||||
let images: Vec<crate::types::ImageData> = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|m| m.role == crate::types::Role::User)
|
||||
.map(|m| m.parts.iter().filter_map(|p| if let crate::types::Part::Image { image } = p { Some(image.clone()) } else { None }).collect::<Vec<_>>())
|
||||
.find(|imgs| !imgs.is_empty())
|
||||
.ok_or_else(|| anyhow!("no image attached to this chat"))?;
|
||||
let img = images.get(n.saturating_sub(1)).ok_or_else(|| anyhow!("the latest message has only {} image(s)", images.len()))?;
|
||||
let (mime, bytes) = match &img.blob {
|
||||
Some(h) => app.store.blob(h)?.ok_or_else(|| anyhow!("attached image not found"))?,
|
||||
None => (img.media_type.clone(), base64::Engine::decode(&base64::engine::general_purpose::STANDARD, img.data.as_bytes())?),
|
||||
};
|
||||
let ext = match mime.as_str() {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
_ => "png",
|
||||
};
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
let path = dir.join(format!("{}-attached-{n}.{ext}", chrono::Local::now().format("%Y%m%d-%H%M%S")));
|
||||
tokio::fs::write(&path, &bytes).await?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// An image argument: a project file, or an image attached to the chat.
|
||||
async fn image_arg(ctx: &ToolCtx, spec: &str, dir: &Path) -> Result<PathBuf> {
|
||||
if spec.starts_with("attached") {
|
||||
attached_image(&ctx.app, &ctx.session_id, spec, dir).await
|
||||
} else {
|
||||
ctx.resolve(spec)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Generate3d;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for Generate3d {
|
||||
fn name(&self) -> &str {
|
||||
"generate_3d"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Generate a 3D model (GLB) with fal.ai and save it in the project – from an image of the \
|
||||
object (a file, or image: \"attached\" for an image the user pasted into the chat; best: a \
|
||||
clean front view; optional back/left/right views improve the shape), from a \
|
||||
text prompt alone, or from an image steered by a prompt (the image is refined with the \
|
||||
prompt first, then converted). style: \"textured\" (default), \"geometry\" (untextured – for \
|
||||
3D printing), \"lowpoly\" (games). Takes one to several minutes and costs money (about \
|
||||
$0.25–0.75): generate only what the user asked for. The user can open the model in the 3D \
|
||||
view of the file manager."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "What the model should be (English). With an image: how to change/clean it up before the conversion."},
|
||||
"image": {"type": "string", "description": "Image of the object (front view): a file path, or \"attached\" for the image the user attached to the chat (\"attached:2\" = the second of that message)"},
|
||||
"back": {"type": "string", "description": "Optional back view image (path or \"attached:N\")"},
|
||||
"left": {"type": "string", "description": "Optional left view image (path or \"attached:N\")"},
|
||||
"right": {"type": "string", "description": "Optional right view image (path or \"attached:N\")"},
|
||||
"style": {"type": "string", "enum": STYLES},
|
||||
"pbr": {"type": "boolean", "description": "PBR materials (metal/roughness), textured models only; costs extra"},
|
||||
"face_count": {"type": "integer", "description": "Target number of faces (optional)"},
|
||||
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: <images.output_dir>/3d)"},
|
||||
"model": {"type": "string", "description": "fal.ai model id – only if the user asks for a specific one"}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
let what = input["prompt"].as_str().map(|p| p.chars().take(70).collect::<String>()).or_else(|| input["image"].as_str().map(String::from)).unwrap_or_default();
|
||||
format!("3D model: {what}")
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let app = ctx.app.clone();
|
||||
let cfg = app.config();
|
||||
if !cfg.images.enabled {
|
||||
bail!("fal.ai is disabled – enable image generation in the settings");
|
||||
}
|
||||
if expand_env(&cfg.images.api_key).is_empty() {
|
||||
bail!("no fal.ai API key configured");
|
||||
}
|
||||
let owner = app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
|
||||
crate::budget::check(&app, "fal", owner.as_deref())?;
|
||||
let prompt = input["prompt"].as_str().map(str::trim).filter(|p| !p.is_empty()).map(String::from);
|
||||
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| format!("{}/3d", cfg.images.output_dir.trim_end_matches('/')));
|
||||
let dir = ctx.resolve(&dir_arg)?;
|
||||
let image = match input["image"].as_str() {
|
||||
Some(spec) => Some(image_arg(ctx, spec, &dir).await?),
|
||||
None => None,
|
||||
};
|
||||
if prompt.is_none() && image.is_none() {
|
||||
bail!("give an image of the object, a prompt, or both");
|
||||
}
|
||||
let style = input["style"].as_str().filter(|s| STYLES.contains(s)).unwrap_or("textured");
|
||||
let pbr = input["pbr"].as_bool().unwrap_or(false);
|
||||
let faces = input["face_count"].as_u64();
|
||||
let mut notes = Vec::new();
|
||||
|
||||
// Image + prompt: refine the image with the prompt first (3D models take no text).
|
||||
let mut front = image.clone();
|
||||
if let (Some(img), Some(p)) = (&image, &prompt) {
|
||||
ctx.progress("refining the image with the prompt");
|
||||
let edit = crate::images::choose_model(&app, "final", Some("illustration"), true);
|
||||
if edit.reference_param.is_some() {
|
||||
let records = crate::images::generate(
|
||||
&app,
|
||||
crate::images::GenerateRequest {
|
||||
prompt: format!(
|
||||
"{p}. Show exactly one object, complete and centered, as a clean product shot: plain light neutral background, soft even lighting, no text, no other objects."
|
||||
),
|
||||
model: Some(edit.id.clone()),
|
||||
size: None,
|
||||
count: 1,
|
||||
dir: dir.clone(),
|
||||
user_id: owner.clone(),
|
||||
session_id: Some(ctx.session_id.clone()),
|
||||
references: vec![img.clone()],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("refining the image failed: {e:#}"))?;
|
||||
if let Some(r) = records.first() {
|
||||
front = Some(PathBuf::from(&r.path));
|
||||
notes.push(format!("Refined image: {}", rel(&r.path, &ctx.cwd)));
|
||||
}
|
||||
} else {
|
||||
notes.push("No image edit model configured – the prompt was not applied to the image.".into());
|
||||
}
|
||||
}
|
||||
|
||||
let mut views = Views::default();
|
||||
if let Some(f) = &front {
|
||||
views.front = crate::images::reference_uris(std::slice::from_ref(f)).await?.pop();
|
||||
for (field, slot) in [("back", &mut views.back), ("left", &mut views.left), ("right", &mut views.right)] {
|
||||
if let Some(p) = input[field].as_str() {
|
||||
*slot = crate::images::reference_uris(&[image_arg(ctx, p, &dir).await?]).await?.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
let model = input["model"].as_str().filter(|m| !m.is_empty()).map(String::from).unwrap_or_else(|| if front.is_some() { cfg.images.model_3d.clone() } else { cfg.images.model_3d_text.clone() });
|
||||
if views.extra() > 0 && !model.contains("hunyuan") {
|
||||
notes.push("This model takes only the front view; the other views were not used.".into());
|
||||
}
|
||||
let text = if front.is_none() { prompt.as_deref() } else { None };
|
||||
let body = request_body(&model, text, &views, style, pbr, faces);
|
||||
ctx.progress(format!("sending to {model}"));
|
||||
let out = run_queued(&app, ctx, &model, &body).await?;
|
||||
let (mesh_url, thumb_url) = result_urls(&out);
|
||||
let mesh_url = mesh_url.ok_or_else(|| anyhow!("fal.ai returned no 3D model: {}", out.to_string().chars().take(300).collect::<String>()))?;
|
||||
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||
// Named after the prompt, else the image (without a time stamp it already carries).
|
||||
let stem = image.as_ref().and_then(|p| p.file_stem()).and_then(|s| s.to_str()).unwrap_or("model");
|
||||
let stem = if stem.len() > 16 && stem.as_bytes()[8] == b'-' && stem[..8].bytes().all(|b| b.is_ascii_digit()) { &stem[16..] } else { stem };
|
||||
let base = crate::images::slug(prompt.as_deref().unwrap_or(stem).trim_start_matches('-'));
|
||||
let (bytes, _) = crate::images::download(&mesh_url).await?;
|
||||
let mesh_path = dir.join(format!("{stamp}-{base}.{}", ext_of(&mesh_url)));
|
||||
tokio::fs::write(&mesh_path, &bytes).await?;
|
||||
let mut thumb_path = None;
|
||||
if let Some(t) = thumb_url {
|
||||
if let Ok((tb, _)) = crate::images::download(&t).await {
|
||||
let p = dir.join(format!("{stamp}-{base}-preview.png"));
|
||||
if tokio::fs::write(&p, &tb).await.is_ok() {
|
||||
thumb_path = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cost into the statistics (provider "fal", like images).
|
||||
let cost = price(&app, &model, style, pbr, views.extra() > 0 && model.contains("hunyuan"), faces.is_some());
|
||||
let session = ctx.session_id.clone();
|
||||
let _ = app.store.add_usage(&UsageRecord {
|
||||
ts: now_ms(),
|
||||
session_id: session.clone(),
|
||||
root_session_id: app.root_session_id(&session),
|
||||
provider: "fal".into(),
|
||||
model: model.clone(),
|
||||
agent: "3d".into(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
ttft_ms: 0,
|
||||
duration_ms: 0,
|
||||
cost,
|
||||
error: false,
|
||||
user_id: owner,
|
||||
allowance: None,
|
||||
});
|
||||
|
||||
let mesh_rel = rel(&mesh_path.display().to_string(), &ctx.cwd);
|
||||
let mut text = format!("3D model ({model}, {style}{}): {mesh_rel} ({} KB)", if pbr && style != "geometry" { ", PBR" } else { "" }, bytes.len() / 1024);
|
||||
if let Some(t) = &thumb_path {
|
||||
text.push_str(&format!("\nPreview: {}", rel(&t.display().to_string(), &ctx.cwd)));
|
||||
}
|
||||
if cost > 0.0 {
|
||||
text.push_str(&format!("\nCost: about ${cost:.2}"));
|
||||
}
|
||||
for n in ¬es {
|
||||
text.push_str(&format!("\n{n}"));
|
||||
}
|
||||
text.push_str("\nThe user can open the model in the 3D view of the file manager.");
|
||||
let images = thumb_path.as_deref().and_then(crate::images::preview_for_model).into_iter().collect();
|
||||
Ok(ToolOutput {
|
||||
content: text,
|
||||
images,
|
||||
meta: Some(json!({
|
||||
"model_file": mesh_path.display().to_string(),
|
||||
"preview": thumb_path.map(|p| p.display().to_string()),
|
||||
})),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(path: &str, cwd: &Path) -> String {
|
||||
Path::new(path).strip_prefix(cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| path.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bodies_per_model_family() {
|
||||
let views = Views { front: Some("data:a".into()), back: Some("data:b".into()), ..Default::default() };
|
||||
let h = request_body("fal-ai/hunyuan3d-v3/image-to-3d", None, &views, "geometry", true, Some(10));
|
||||
assert_eq!(h["input_image_url"], "data:a");
|
||||
assert_eq!(h["back_image_url"], "data:b");
|
||||
assert_eq!(h["generate_type"], "Geometry");
|
||||
assert_eq!(h["enable_pbr"], false);
|
||||
assert_eq!(h["face_count"], 40_000);
|
||||
let t = request_body("tripo3d/h3.1/image-to-3d", None, &views, "textured", true, None);
|
||||
assert_eq!((t["image_url"].as_str(), t["texture"].as_bool(), t["pbr"].as_bool()), (Some("data:a"), Some(true), Some(true)));
|
||||
let txt = request_body("fal-ai/hunyuan3d-v3/text-to-3d", Some("a chair"), &Views::default(), "textured", false, None);
|
||||
assert_eq!((txt["prompt"].as_str(), txt["generate_type"].as_str()), (Some("a chair"), Some("Normal")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_urls_of_the_families() {
|
||||
let h = json!({"model_glb": {"url": "https://x/m.glb"}, "thumbnail": {"url": "https://x/t.png"}});
|
||||
assert_eq!(result_urls(&h), (Some("https://x/m.glb".into()), Some("https://x/t.png".into())));
|
||||
let t = json!({"model_mesh": {"url": "https://x/m.glb"}, "rendered_image": {"url": "https://x/r.webp"}});
|
||||
assert_eq!(result_urls(&t).1.as_deref(), Some("https://x/r.webp"));
|
||||
assert_eq!(ext_of("https://x/a.FBX?sig=1"), "fbx");
|
||||
assert_eq!(ext_of("https://x/blob"), "glb");
|
||||
}
|
||||
}
|
||||
+12
-7
@@ -32,6 +32,8 @@ pub struct Agent {
|
||||
pub max_parallel: Option<usize>,
|
||||
/// Review loop after each turn (None/0 rounds = off).
|
||||
pub refine: Option<crate::config::RefineConfig>,
|
||||
/// binary_info and decompile available (besides craft).
|
||||
pub reverse: bool,
|
||||
/// Source file of Markdown agents.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
@@ -43,12 +45,12 @@ pub use codecraft_core::prompts::*;
|
||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||
let craft = craft_prompt(clean_room);
|
||||
vec![
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -121,7 +123,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||
source: "file".into(),
|
||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }),
|
||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
|
||||
reverse: meta.get("reverse_tools").is_some_and(|v| v == "true"),
|
||||
path: Some(p.display().to_string()),
|
||||
});
|
||||
}
|
||||
@@ -141,6 +144,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
source: "config".into(),
|
||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
||||
reverse: a.reverse_tools.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.reverse)),
|
||||
path: None,
|
||||
});
|
||||
}
|
||||
@@ -173,6 +177,7 @@ pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) ->
|
||||
source: "personal".into(),
|
||||
max_parallel: None,
|
||||
refine: a.refine,
|
||||
reverse: a.reverse_tools.unwrap_or(false),
|
||||
path: None,
|
||||
});
|
||||
}
|
||||
|
||||
+30
-4
@@ -142,7 +142,10 @@ impl Tool for Decompile {
|
||||
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
|
||||
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
|
||||
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
|
||||
large programs; results are cached per file."
|
||||
large programs; results are cached per file. Raw firmware images (.bin without a file \
|
||||
format) need `processor` (Ghidra language id, e.g. \"ARM:LE:32:Cortex\" for Cortex-M, \
|
||||
\"ARM:LE:32:v8\", \"AVR8:LE:16:atmega256\", \"Xtensa:LE:32:default\" for ESP32) and usually \
|
||||
`base_address` (load address, e.g. \"0x08000000\" for STM32 flash)."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
@@ -151,7 +154,9 @@ impl Tool for Decompile {
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
|
||||
"force": {"type": "boolean", "description": "Re-run even if cached"}
|
||||
"force": {"type": "boolean", "description": "Re-run even if cached"},
|
||||
"processor": {"type": "string", "description": "Ghidra language id for raw images, e.g. ARM:LE:32:Cortex (default: detected from the file format)"},
|
||||
"base_address": {"type": "string", "description": "Load address of a raw image, e.g. 0x08000000"}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
@@ -171,7 +176,20 @@ impl Tool for Decompile {
|
||||
bail!("{} is not a file", path.display());
|
||||
}
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
|
||||
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}", file_hash(&path)?));
|
||||
let processor = input["processor"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||
let base = input["base_address"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||
if processor.as_deref().is_some_and(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || ":_-.".contains(c))) {
|
||||
bail!("invalid processor id (expected e.g. ARM:LE:32:Cortex)");
|
||||
}
|
||||
if base.as_deref().is_some_and(|b| !b.trim_start_matches("0x").chars().all(|c| c.is_ascii_hexdigit())) {
|
||||
bail!("invalid base_address (expected e.g. 0x08000000)");
|
||||
}
|
||||
// Another processor or load address is another analysis.
|
||||
let variant = match (&processor, &base) {
|
||||
(None, None) => String::new(),
|
||||
(p, b) => format!("-{}", crate::blobs::hash(format!("{p:?}{b:?}").as_bytes()).get(..8).unwrap_or("")),
|
||||
};
|
||||
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}{variant}", file_hash(&path)?));
|
||||
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
|
||||
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
|
||||
}
|
||||
@@ -183,7 +201,15 @@ impl Tool for Decompile {
|
||||
let scripts = scripts_dir().display().to_string();
|
||||
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
|
||||
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
|
||||
let args = vec![proj.as_str(), "cc", "-import", p.as_str(), "-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"];
|
||||
let mut args = vec![proj.as_str(), "cc", "-import", p.as_str()];
|
||||
// Raw firmware: the binary loader with the given processor and load address.
|
||||
if let Some(proc_id) = processor.as_deref() {
|
||||
args.extend(["-processor", proc_id]);
|
||||
}
|
||||
if let Some(addr) = base.as_deref() {
|
||||
args.extend(["-loader", "BinaryLoader", "-loader-baseAddr", addr]);
|
||||
}
|
||||
args.extend(["-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]);
|
||||
let java = tools.join("jdk").display().to_string();
|
||||
let mut cmd = match &ctx.sandbox {
|
||||
Some(spec) => {
|
||||
|
||||
+288
-24
@@ -18,6 +18,8 @@ use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const MAX_STEPS: usize = 250;
|
||||
/// Modes with enforced reviews work through their todo list (e.g. overnight).
|
||||
const MAX_STEPS_ENFORCED: usize = 2_000;
|
||||
const MAX_SUBAGENT_DEPTH: usize = 3;
|
||||
|
||||
/// What happened to a prompt.
|
||||
@@ -213,7 +215,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
||||
let cfg = app.config();
|
||||
let mut all = tools::builtin_tools();
|
||||
if !cfg.images.enabled {
|
||||
all.retain(|t| t.name() != "generate_image");
|
||||
all.retain(|t| t.name() != "generate_image" && t.name() != "generate_3d");
|
||||
}
|
||||
let (direct, deferred) = app.mcp.tools(&cfg, owner);
|
||||
all.extend(direct);
|
||||
@@ -224,7 +226,12 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
||||
all.into_iter()
|
||||
.filter(|t| !agent.read_only || t.read_only())
|
||||
// Craft tools only where they are useful.
|
||||
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some())
|
||||
.filter(|t| {
|
||||
agent.name == "craft"
|
||||
|| agent.tools.is_some()
|
||||
|| (agent.reverse && matches!(t.name(), "binary_info" | "decompile"))
|
||||
|| !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase")
|
||||
})
|
||||
.filter(|t| match &agent.tools {
|
||||
None => true,
|
||||
Some(list) => {
|
||||
@@ -260,7 +267,15 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
let mut stop_hooks = 0;
|
||||
let mut refine_done = 0u32;
|
||||
let turn_start = now_ms();
|
||||
for _ in 0..MAX_STEPS {
|
||||
// Compacted for the current step (no second compaction before a request went through).
|
||||
let mut compacted_now = false;
|
||||
// Mandatory reviews of completed todos: rounds per todo, and where the last one ended.
|
||||
let mut todo_reviews: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
let mut review_since = turn_start;
|
||||
// Reminders about open todos at the end of a turn (see MAX_IDLE_CONTINUES).
|
||||
let mut last_reminder: Option<Value> = None;
|
||||
let mut idle_continues = 0u32;
|
||||
for step_no in 0..=MAX_STEPS_ENFORCED {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
@@ -275,40 +290,65 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
agent.prompt = prompts::craft_prompt(clean_room);
|
||||
}
|
||||
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
|
||||
let step_limit = if enforced_review(&agent) > 0 { MAX_STEPS_ENFORCED } else { MAX_STEPS };
|
||||
if step_no >= step_limit {
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("Stopped after {step_limit} steps (limit of a turn) – send a message to continue.") });
|
||||
break;
|
||||
}
|
||||
let (provider, mut model) = app.registry().resolve(&model_spec)?;
|
||||
if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) {
|
||||
model.effort = Some(effort);
|
||||
}
|
||||
|
||||
// Auto-compaction when the last request used most of the context window.
|
||||
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
|
||||
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||
// Providers count input + max_tokens against the context window: the answer needs
|
||||
// room too. Estimated input = last request (input + its answer) + what came since.
|
||||
let est_input = estimate_input(session.last_usage.as_ref(), &history);
|
||||
let want_output = model.max_output.unwrap_or(16_000);
|
||||
if let Some(ctx_len) = model.context {
|
||||
let threshold = app.config().server.auto_compact_threshold;
|
||||
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
|
||||
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
|
||||
// Input limit: the context window, or the provider's lower input limit.
|
||||
let input_limit = model.max_input.map_or(ctx_len, |m| m.min(ctx_len));
|
||||
let room = ctx_len.saturating_sub(est_input);
|
||||
// Most of the input limit used, or no room left for a reasonable answer.
|
||||
if threshold > 0.0 && (est_input as f64 > input_limit as f64 * threshold || room < want_output.min(MIN_ANSWER_ROOM)) && !compacted_now {
|
||||
compacted_now = true;
|
||||
compact(app.clone(), session_id, cancel.clone(), Some((est_input, input_limit))).await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let max_tokens = answer_budget(want_output, model.context, est_input);
|
||||
|
||||
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
||||
let tools = available_tools(&app, &agent, owner.as_ref());
|
||||
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||
let req = ChatRequest {
|
||||
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
|
||||
messages: history,
|
||||
tools: tools.iter().map(|t| t.def()).collect(),
|
||||
max_tokens: model.max_output.unwrap_or(16_000),
|
||||
max_tokens,
|
||||
reasoning: true,
|
||||
session_id: Some(session_id.to_string()),
|
||||
model: model.clone(),
|
||||
};
|
||||
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
|
||||
Ok(step) => step,
|
||||
Ok(step) => {
|
||||
compacted_now = false;
|
||||
step
|
||||
}
|
||||
// The estimate was off and the provider says the input is too long: compact
|
||||
// once and try again instead of stopping.
|
||||
Err(e) if is_context_overflow(&e) && !compacted_now && !cancel.is_cancelled() => {
|
||||
compacted_now = true;
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("context window full ({}) – compacting the conversation and continuing", short_error(&e)) });
|
||||
compact(app.clone(), session_id, cancel.clone(), Some((est_input, model.context.unwrap_or(0)))).await?;
|
||||
continue;
|
||||
}
|
||||
// Host down or budget used up: switch to the model's fallback for this step.
|
||||
Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => {
|
||||
let fb = model.fallback.clone().unwrap_or_default();
|
||||
let (fb_provider, fb_model) = app.registry().resolve(&fb)?;
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("{model_spec} is not available ({}) – using the fallback model {fb}", short_error(&e)) });
|
||||
let req = ChatRequest { model: fb_model.clone(), max_tokens: fb_model.max_output.unwrap_or(16_000), ..req };
|
||||
let req = ChatRequest { model: fb_model.clone(), max_tokens: answer_budget(fb_model.max_output.unwrap_or(16_000), fb_model.context, est_input), ..req };
|
||||
stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
@@ -353,12 +393,37 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
}
|
||||
}
|
||||
let normal_end = matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "");
|
||||
// Enforced reviews also mean: work until the todo list is done. Ending the turn with
|
||||
// open todos sends the agent back (as long as it makes progress).
|
||||
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() && enforced_review(&agent) > 0 {
|
||||
let todos_now = app.session(session_id)?.todos;
|
||||
let open = open_todos(&todos_now);
|
||||
if !open.is_empty() {
|
||||
let progressed = last_reminder.as_ref() != Some(&todos_now);
|
||||
idle_continues = if progressed { 0 } else { idle_continues + 1 };
|
||||
if idle_continues < MAX_IDLE_CONTINUES {
|
||||
last_reminder = Some(todos_now);
|
||||
save_user_parts(
|
||||
&app,
|
||||
session_id,
|
||||
vec![Part::Text {
|
||||
text: format!(
|
||||
"<todo-reminder open=\"{}\">\n{}\n</todo-reminder>\nThese todos are still open. Continue working until all of them are completed (each completed todo is reviewed). Only if you are truly blocked and need the user, say clearly what you need and stop.",
|
||||
open.len(),
|
||||
open.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
|
||||
),
|
||||
}],
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() {
|
||||
// Refinement loop: a reviewer checks the turn; findings send the agent back to work.
|
||||
let rounds = agent.refine.as_ref().map(|r| r.rounds).unwrap_or(0);
|
||||
if refine_done < rounds {
|
||||
refine_done += 1;
|
||||
match review_turn(&app, &session, &agent, &model_spec, turn_start, refine_done, rounds, &cancel).await {
|
||||
match review_turn(&app, &session, &agent, &model_spec, turn_start, None, refine_done, rounds, &cancel).await {
|
||||
Ok(Some(feedback)) => {
|
||||
save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?;
|
||||
continue;
|
||||
@@ -396,7 +461,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
agent: agent.name.clone(),
|
||||
roots: roots.clone(),
|
||||
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
||||
clean_room,
|
||||
// Clean room is a craft rule; other modes with reverse tools may analyse binaries.
|
||||
clean_room: clean_room && agent.name == "craft",
|
||||
owner_id: None,
|
||||
};
|
||||
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
||||
@@ -412,6 +478,40 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await);
|
||||
}
|
||||
}
|
||||
// Mode with a review: every todo marked completed is reviewed; with findings it is
|
||||
// reopened and the agent has to fix them and complete it again (max. `rounds` per todo).
|
||||
// The verdict goes into the same message as the tool results.
|
||||
let rounds = enforced_review(&agent);
|
||||
if rounds > 0 && depth == 0 && !cancel.is_cancelled() && calls.iter().any(|(_, n, _)| n == "todo_write") {
|
||||
let todos_now = app.session(session_id)?.todos;
|
||||
let due: Vec<String> = newly_completed(&session.todos, &todos_now)
|
||||
.into_iter()
|
||||
.filter(|t| todo_reviews.get(t).copied().unwrap_or(0) < rounds)
|
||||
.collect();
|
||||
if !due.is_empty() {
|
||||
for t in &due {
|
||||
*todo_reviews.entry(t.clone()).or_default() += 1;
|
||||
}
|
||||
let round = due.iter().filter_map(|t| todo_reviews.get(t)).copied().max().unwrap_or(1);
|
||||
let fresh = app.session(session_id)?;
|
||||
let reviewed = review_turn(&app, &fresh, &agent, &model_spec, review_since, Some(&due), round, rounds, &cancel).await;
|
||||
review_since = now_ms();
|
||||
match reviewed {
|
||||
Ok(Some(feedback)) => {
|
||||
app.set_todos(session_id, reopen_todos(&todos_now, &due))?;
|
||||
results.push(Part::Text { text: feedback });
|
||||
}
|
||||
Ok(None) => {
|
||||
let open = open_todos(&todos_now);
|
||||
let next = if open.is_empty() { "All todos are done – check the whole request once more, then finish." } else { "Continue with the next open todo." };
|
||||
results.push(Part::Text {
|
||||
text: format!("<refine-approved reviewer=\"{}\">\n{}\n</refine-approved>\nReview passed. {next}", review_model(&agent, &model_spec), due.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")),
|
||||
});
|
||||
}
|
||||
Err(e) => tracing::warn!("todo review of {session_id} failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deliver messages the user queued while we were working (with attachments).
|
||||
results.extend(take_queue(&app, session_id));
|
||||
save_user_parts(&app, session_id, results)?;
|
||||
@@ -419,6 +519,55 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
Ok(final_text)
|
||||
}
|
||||
|
||||
/// Review rounds when the mode enforces reviews of completed todos (0 = not enforced).
|
||||
fn enforced_review(agent: &prompts::Agent) -> u32 {
|
||||
agent.refine.as_ref().filter(|r| r.after_todos).map(|r| r.rounds).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The model that reviews for this mode.
|
||||
fn review_model(agent: &prompts::Agent, model_spec: &str) -> String {
|
||||
agent.refine.as_ref().and_then(|r| r.model.clone()).filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string())
|
||||
}
|
||||
|
||||
/// Contents of the todos that are not completed yet.
|
||||
fn open_todos(todos: &Value) -> Vec<String> {
|
||||
todos.as_array().into_iter().flatten().filter(|t| t["status"] != "completed").filter_map(|t| t["content"].as_str()).map(String::from).collect()
|
||||
}
|
||||
|
||||
/// Times in a row the agent may end its turn with open todos without making progress.
|
||||
const MAX_IDLE_CONTINUES: u32 = 10;
|
||||
|
||||
/// Contents of the todos that are completed in `after` but were not in `before`.
|
||||
fn newly_completed(before: &Value, after: &Value) -> Vec<String> {
|
||||
let done_before: std::collections::HashSet<&str> = before
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|t| t["status"] == "completed")
|
||||
.filter_map(|t| t["content"].as_str())
|
||||
.collect();
|
||||
after
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|t| t["status"] == "completed")
|
||||
.filter_map(|t| t["content"].as_str())
|
||||
.filter(|c| !done_before.contains(c))
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The todo list with the given items open again (in progress).
|
||||
fn reopen_todos(todos: &Value, items: &[String]) -> Value {
|
||||
let mut out = todos.clone();
|
||||
for t in out.as_array_mut().into_iter().flatten() {
|
||||
if t["content"].as_str().is_some_and(|c| items.iter().any(|i| i == c)) {
|
||||
t["status"] = Value::from("in_progress");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tool_result(call_id: &str, name: &str, content: String, is_error: bool, meta: Option<Value>) -> Part {
|
||||
Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta }
|
||||
}
|
||||
@@ -563,7 +712,15 @@ async fn execute_call(
|
||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||
run_task(app, &ctx, input, depth).await
|
||||
} else {
|
||||
tool.run(input, &ctx).await
|
||||
let moves = name == "bash" && input["command"].as_str().is_some_and(moves_files);
|
||||
let out = tool.run(input, &ctx).await;
|
||||
if moves {
|
||||
// Generated images the command moved or renamed keep their entries.
|
||||
let a = app.clone();
|
||||
let owner = session.owner_id.clone();
|
||||
tokio::task::spawn_blocking(move || crate::images::relocate_missing(&a, owner.as_deref()));
|
||||
}
|
||||
out
|
||||
};
|
||||
// Diagnostics: report new errors of the changed file right away.
|
||||
let res = match res {
|
||||
@@ -609,6 +766,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();
|
||||
@@ -766,6 +928,47 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
|
||||
|
||||
/// Errors after which the fallback model takes over: the host is unreachable
|
||||
/// (connection, timeout, gateway errors) or the provider's budget is used up.
|
||||
/// Room an answer gets at least before the conversation is compacted.
|
||||
const MIN_ANSWER_ROOM: u64 = 32_000;
|
||||
|
||||
/// Estimated input tokens of the next request: the last request (its input and answer)
|
||||
/// plus the messages that came after that answer (tool results, user messages), at about
|
||||
/// 3 characters per token; without usage (fresh or just compacted) the whole history.
|
||||
fn estimate_input(last: Option<&crate::types::Usage>, history: &[Message]) -> u64 {
|
||||
let chars_of = |m: &Message| -> u64 {
|
||||
m.parts
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => text.len() as u64,
|
||||
Part::ToolCall { input, .. } => input.to_string().len() as u64,
|
||||
Part::ToolResult { content, images, .. } => content.len() as u64 + images.len() as u64 * 4_500,
|
||||
Part::Image { .. } => 4_500,
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
match last {
|
||||
Some(u) => {
|
||||
let after = history.iter().rposition(|m| m.role == Role::Assistant).map(|i| &history[i + 1..]).unwrap_or(&[]);
|
||||
u.context_tokens() + after.iter().map(chars_of).sum::<u64>() / 3
|
||||
}
|
||||
None => history.iter().map(chars_of).sum::<u64>() / 3 + 8_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// max_tokens for a request: the model's maximum, but no more than the window leaves
|
||||
/// (with a margin for the estimate), and never below a small floor.
|
||||
fn answer_budget(max_output: u64, context: Option<u64>, est_input: u64) -> u64 {
|
||||
let Some(ctx) = context else { return max_output };
|
||||
let room = ctx.saturating_sub(est_input + est_input / 50 + 2_000);
|
||||
max_output.min(room).max(4_096.min(max_output))
|
||||
}
|
||||
|
||||
/// The provider rejected the request because input + output exceed the context window.
|
||||
fn is_context_overflow(e: &anyhow::Error) -> bool {
|
||||
let s = format!("{e:#}").to_lowercase();
|
||||
["input too long", "context length", "context_length", "maximum context", "context window", "prompt is too long", "too many tokens", "reduce the length"].iter().any(|k| s.contains(k))
|
||||
}
|
||||
|
||||
fn needs_fallback(e: &anyhow::Error) -> bool {
|
||||
let s = format!("{e:#}").to_lowercase();
|
||||
["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k))
|
||||
@@ -910,7 +1113,8 @@ async fn stream_step(
|
||||
let mut message = Message::new(Role::Assistant, parts);
|
||||
message.id = msg_id;
|
||||
message.model = Some(model_spec.to_string());
|
||||
message.usage = Some(usage.clone());
|
||||
// With its cost (charged, or valued at list prices) for the line under the answer.
|
||||
message.usage = Some(Usage { cost: Some(req.model.cost(&usage)), ..usage.clone() });
|
||||
if !message.parts.is_empty() {
|
||||
app.store.add_message(&session.id, &message)?;
|
||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||
@@ -1120,7 +1324,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
|
||||
/// Review the last turn against the user's request. Returns feedback for the
|
||||
/// agent, or None when the reviewer approves.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, turn_start: i64, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
|
||||
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, since: i64, todos: Option<&[String]>, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
|
||||
let refine = agent.refine.clone().unwrap_or_default();
|
||||
let spec = refine.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string());
|
||||
let (provider, mut model) = app.registry().resolve(&spec)?;
|
||||
@@ -1134,7 +1338,12 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
.rposition(|m| matches!(m.role, Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { text } if !text.starts_with("<refine-feedback") && !text.starts_with("<hook-"))))
|
||||
.unwrap_or(0);
|
||||
let request: String = history.get(req_idx).map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")).unwrap_or_default();
|
||||
let mut work = transcript_of(&history[(req_idx + 1).min(history.len())..]);
|
||||
// A todo review looks at the work since the last review, the end-of-turn review at the whole turn.
|
||||
let from = match todos {
|
||||
Some(_) => history.iter().position(|m| m.created_at >= since).unwrap_or(history.len()).max(req_idx + 1),
|
||||
None => req_idx + 1,
|
||||
};
|
||||
let mut work = transcript_of(&history[from.min(history.len())..]);
|
||||
if work.len() > 30_000 {
|
||||
let mut start = work.len() - 30_000;
|
||||
while !work.is_char_boundary(start) {
|
||||
@@ -1145,7 +1354,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
// Actual file changes of this turn (checkpoint vs. current content).
|
||||
let mut changes = String::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (path, before) in app.store.checkpoints_since(&session.id, turn_start)? {
|
||||
for (path, before) in app.store.checkpoints_since(&session.id, since)? {
|
||||
if !seen.insert(path.clone()) || changes.len() > 30_000 {
|
||||
continue;
|
||||
}
|
||||
@@ -1159,10 +1368,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
let system = "You review the work of an AI coding agent for the user. Be strict about what matters for the user's request and ignore style nitpicks. \
|
||||
Reply in exactly this format: the first line is APPROVED or CHANGES_NEEDED. For CHANGES_NEEDED, follow with at most 8 concrete, actionable findings as a bullet list \
|
||||
(with file:line where possible). If the important work is done and correct, reply APPROVED.";
|
||||
let focus = match todos {
|
||||
Some(items) => format!(
|
||||
"<completed-todos>\n{}\n</completed-todos>\nThe agent just marked these todo items as completed. Review only whether they are really done and correct; other parts of the request may still be in progress.\n\n",
|
||||
items.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
let body = format!(
|
||||
"<request>\n{request}\n</request>\n\n<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
|
||||
"<request>\n{request}\n</request>\n\n{focus}<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
|
||||
agent.name,
|
||||
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
|
||||
if changes.is_empty() { "(no files changed since the last review)" } else { changes.as_str() }
|
||||
);
|
||||
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) };
|
||||
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
|
||||
@@ -1175,10 +1391,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
}
|
||||
let findings: String = lines.collect::<Vec<_>>().join("\n");
|
||||
let findings = if findings.trim().is_empty() { text.clone() } else { findings };
|
||||
Ok(Some(format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
|
||||
findings.trim()
|
||||
)))
|
||||
Ok(Some(match todos {
|
||||
Some(items) => format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\" todos=\"{}\">\n{}\n</refine-feedback>\nThe review of the todo(s) you just completed found problems, so they are open again. Fix these findings (or explain briefly why one does not apply), verify, then mark the todo(s) completed again – that triggers the next review.",
|
||||
items.len(),
|
||||
findings.trim()
|
||||
),
|
||||
None => format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
|
||||
findings.trim()
|
||||
),
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
|
||||
@@ -1301,6 +1524,47 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn todo_completion_and_reopening() {
|
||||
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||
let after = serde_json::json!([{"content": "A", "status": "completed"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||
assert_eq!(newly_completed(&before, &after), vec!["A".to_string()]);
|
||||
assert_eq!(newly_completed(&Value::Null, &after), vec!["A".to_string(), "B".to_string()]);
|
||||
assert_eq!(open_todos(&after), vec!["C".to_string()]);
|
||||
let reopened = reopen_todos(&after, &["A".to_string()]);
|
||||
assert_eq!(reopened[0]["status"], "in_progress");
|
||||
assert_eq!(reopened[1]["status"], "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_room_and_overflow() {
|
||||
// The case from production: DeepSeek V4.1 Flash, 1M window, 384K output, 720K input.
|
||||
let (ctx, out) = (1_048_576u64, 393_216u64);
|
||||
let b = answer_budget(out, Some(ctx), 720_000);
|
||||
assert!(720_000 + b <= ctx && b > 300_000, "{b}");
|
||||
assert_eq!(answer_budget(out, Some(ctx), 100_000), out);
|
||||
assert_eq!(answer_budget(16_000, None, 5_000_000), 16_000);
|
||||
assert_eq!(answer_budget(out, Some(ctx), 1_040_000), 4_096);
|
||||
assert!(is_context_overflow(&anyhow!("HTTP 400: [invalid_request_error] Input too long: model limit 1048576 tokens, requested 1108739 tokens")));
|
||||
assert!(is_context_overflow(&anyhow!("This model's maximum context length is 128000 tokens")));
|
||||
assert!(!is_context_overflow(&anyhow!("HTTP 429 rate limited")));
|
||||
// Estimate: last request + what came after its answer (about 3 chars per token).
|
||||
let u = crate::types::Usage { input_tokens: 1_000, cache_read_tokens: 9_000, output_tokens: 500, ..Default::default() };
|
||||
let mut call = Message::new(Role::Assistant, vec![call("c1")]);
|
||||
call.id = "a".into();
|
||||
let result = Message::new(Role::User, vec![Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "x".repeat(3_000), images: vec![], is_error: false, meta: None }]);
|
||||
assert_eq!(estimate_input(Some(&u), &[call.clone(), result.clone()]), 10_500 + 1_000);
|
||||
assert!(estimate_input(None, &[call, result]) >= 1_000 + 8_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_commands_that_move_files() {
|
||||
assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/"));
|
||||
assert!(moves_files("git mv a.png b.png"));
|
||||
assert!(moves_files("cd art;mv x y"));
|
||||
assert!(!moves_files("ls -la movies && cat remove.txt"));
|
||||
}
|
||||
|
||||
fn call(id: &str) -> Part {
|
||||
Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None }
|
||||
}
|
||||
|
||||
+266
-29
@@ -1,6 +1,6 @@
|
||||
//! SQLite persistence for sessions and messages.
|
||||
|
||||
use crate::types::{ImageData, Message, Part, Session, now_ms};
|
||||
use crate::types::{ImageData, Message, Part, Role, Session, now_ms};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use anyhow::Result;
|
||||
@@ -169,6 +169,8 @@ impl Store {
|
||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
||||
("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings TEXT"),
|
||||
("images", "blob", "ALTER TABLE images ADD COLUMN blob TEXT"),
|
||||
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
||||
] {
|
||||
let exists: bool = conn
|
||||
@@ -242,28 +244,7 @@ impl Store {
|
||||
let (sdata, mdata) = row?;
|
||||
let session: Session = serde_json::from_str(&sdata)?;
|
||||
// Snippet from the visible text of the matching message (not JSON keys).
|
||||
let mut snippet = String::new();
|
||||
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
|
||||
for p in &m.parts {
|
||||
let text = match p {
|
||||
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::ToolResult { content, .. } => content.as_str(),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(i) = text.to_lowercase().find(&needle) {
|
||||
let mut a = i.saturating_sub(60);
|
||||
while !text.is_char_boundary(a) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut b = (i + needle.len() + 100).min(text.len());
|
||||
while !text.is_char_boundary(b) {
|
||||
b += 1;
|
||||
}
|
||||
snippet = format!("{}{}{}", if a > 0 { "…" } else { "" }, text[a..b].replace('\n', " "), if b < text.len() { "…" } else { "" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let snippet = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()).and_then(|m| snippet_in(&m, &needle)).unwrap_or_default();
|
||||
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
|
||||
continue; // matched only JSON structure (keys, ids)
|
||||
}
|
||||
@@ -272,6 +253,121 @@ impl Store {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A part of a session's messages for the chat view (long chats are not loaded at
|
||||
/// once). Returns the messages (oldest first) and whether older / newer ones exist.
|
||||
pub fn messages_window(&self, session_id: &str, w: &MessageWindow) -> Result<(Vec<Message>, bool, bool)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let seq_of = |id: &str| -> Result<i64> {
|
||||
conn.query_row("SELECT seq FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, id], |r| r.get(0))
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow::anyhow!("message {id} not found"))
|
||||
};
|
||||
let (sql, a, limit): (&str, i64, i64) = match w {
|
||||
// From the last summary (compaction) on, at most `limit`.
|
||||
MessageWindow::Tail { limit } => {
|
||||
let from: i64 = conn
|
||||
.query_row(
|
||||
"SELECT seq FROM messages WHERE session_id = ?1 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
|
||||
[session_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(i64::MIN);
|
||||
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq DESC LIMIT ?3", from, *limit)
|
||||
}
|
||||
// Scrolling up stops at a summary (compaction); the history before it on request.
|
||||
MessageWindow::Before { id, limit, stop_at_summary } => {
|
||||
let before = seq_of(id)?;
|
||||
let summary: Option<i64> = if *stop_at_summary {
|
||||
conn.query_row(
|
||||
"SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
|
||||
params![session_id, before],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match summary {
|
||||
Some(from) => {
|
||||
let mut stmt = conn.prepare("SELECT seq, data FROM messages WHERE session_id = ?1 AND seq >= ?2 AND seq < ?3 ORDER BY seq DESC LIMIT ?4")?;
|
||||
let mut rows: Vec<(i64, Message)> = Vec::new();
|
||||
for r in stmt.query_map(params![session_id, from, before, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
|
||||
let (seq, data) = r?;
|
||||
rows.push((seq, serde_json::from_str(&data)?));
|
||||
}
|
||||
drop(stmt);
|
||||
return Self::finish_window(&conn, session_id, rows, false);
|
||||
}
|
||||
None => ("SELECT data FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3", before, *limit),
|
||||
}
|
||||
}
|
||||
MessageWindow::After { id, limit } => ("SELECT data FROM messages WHERE session_id = ?1 AND seq > ?2 ORDER BY seq ASC LIMIT ?3", seq_of(id)?, *limit),
|
||||
// The message and about half the limit before it.
|
||||
MessageWindow::Around { id, limit } => {
|
||||
let seq = seq_of(id)?;
|
||||
let before: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MIN(seq), ?2) FROM (SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3)",
|
||||
params![session_id, seq, limit / 2],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq ASC LIMIT ?3", before, *limit)
|
||||
}
|
||||
};
|
||||
let mut rows: Vec<(i64, Message)> = Vec::new();
|
||||
let mut stmt = conn.prepare(&sql.replace("SELECT data", "SELECT seq, data"))?;
|
||||
for r in stmt.query_map(params![session_id, a, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
|
||||
let (seq, data) = r?;
|
||||
rows.push((seq, serde_json::from_str(&data)?));
|
||||
}
|
||||
drop(stmt);
|
||||
Self::finish_window(&conn, session_id, rows, matches!(w, MessageWindow::Tail { .. }))
|
||||
}
|
||||
|
||||
/// Sort a window and tell whether older / newer messages exist around it.
|
||||
fn finish_window(conn: &Connection, session_id: &str, mut rows: Vec<(i64, Message)>, tail: bool) -> Result<(Vec<Message>, bool, bool)> {
|
||||
rows.sort_by_key(|(seq, _)| *seq);
|
||||
let exists = |q: &str, seq: i64| -> Result<bool> { Ok(conn.prepare(q)?.exists(params![session_id, seq])?) };
|
||||
let older = match rows.first() {
|
||||
Some((first, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq < ?2", *first)?,
|
||||
None => tail && exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", i64::MIN)?,
|
||||
};
|
||||
let newer = match rows.last() {
|
||||
Some((last, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", *last)?,
|
||||
None => false,
|
||||
};
|
||||
Ok((rows.into_iter().map(|(_, m)| m).collect(), older, newer))
|
||||
}
|
||||
|
||||
/// Messages of one session containing `q` (case-insensitive), newest first:
|
||||
/// (message id, role, created_at, snippet).
|
||||
pub fn search_messages(&self, session_id: &str, q: &str, limit: usize) -> Result<Vec<(String, Role, i64, String)>> {
|
||||
let needle = q.to_lowercase();
|
||||
let like = format!("%{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT data FROM messages WHERE session_id = ?1 AND data LIKE ?2 ESCAPE '\\' ORDER BY seq DESC")?;
|
||||
let mut out = Vec::new();
|
||||
for r in stmt.query_map(params![session_id, like], |r| r.get::<_, String>(0))? {
|
||||
let Ok(m) = serde_json::from_str::<Message>(&r?) else { continue };
|
||||
// Only visible text counts (not JSON keys or ids).
|
||||
if let Some(snippet) = snippet_in(&m, &needle) {
|
||||
out.push((m.id.clone(), m.role, m.created_at, snippet));
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ids of the subagent sessions started by a session.
|
||||
pub fn child_sessions(&self, id: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM sessions WHERE parent_id = ?1")?;
|
||||
let rows = stmt.query_map([id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn delete_session(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Child sessions (subagents) go with their parent.
|
||||
@@ -393,6 +489,10 @@ impl Store {
|
||||
}
|
||||
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)) {
|
||||
@@ -553,6 +653,39 @@ impl Store {
|
||||
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
|
||||
}
|
||||
|
||||
/// Plan requests booked at 0 (opencode Go reports nothing charged, only `allowance`):
|
||||
/// (row id, ts, "provider/model", usage).
|
||||
pub fn plan_requests_without_cost(&self) -> Result<Vec<(i64, i64, String, crate::types::Usage)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, ts, provider || '/' || model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, allowance
|
||||
FROM usage WHERE cost = 0 AND allowance > 0",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
r.get(1)?,
|
||||
r.get(2)?,
|
||||
crate::types::Usage {
|
||||
input_tokens: r.get::<_, i64>(3)? as u64,
|
||||
output_tokens: r.get::<_, i64>(4)? as u64,
|
||||
cache_read_tokens: r.get::<_, i64>(5)? as u64,
|
||||
cache_write_tokens: r.get::<_, i64>(6)? as u64,
|
||||
allowance: r.get(7)?,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn set_usage_cost(&self, row: i64, cost: f64) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE usage SET cost = ?2 WHERE id = ?1", params![row, cost])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||
/// into the viewer's local time.
|
||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
|
||||
@@ -965,6 +1098,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> {
|
||||
@@ -978,22 +1115,74 @@ fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
|
||||
width: r.get(6)?,
|
||||
height: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
blob: r.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at";
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at, blob";
|
||||
|
||||
impl Store {
|
||||
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at],
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at, img.blob],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store bytes in the image store; returns their hash.
|
||||
pub fn put_blob(&self, media_type: &str, bytes: &[u8]) -> Result<String> {
|
||||
let hash = crate::blobs::hash(bytes);
|
||||
self.conn.lock().unwrap().execute(
|
||||
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![hash, media_type, bytes, now_ms()],
|
||||
)?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Generated images from before the copies in the image store (id, path).
|
||||
pub fn images_without_copy(&self) -> Result<Vec<(String, String)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, path FROM images WHERE blob IS NULL")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn set_image_blob(&self, id: &str, hash: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE images SET blob = ?2 WHERE id = ?1", params![id, hash])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file or folder was moved: generated images in it follow.
|
||||
pub fn move_image_paths(&self, from: &str, to: &str) -> Result<usize> {
|
||||
let from = from.trim_end_matches('/');
|
||||
let to = to.trim_end_matches('/');
|
||||
Ok(self.conn.lock().unwrap().execute(
|
||||
"UPDATE images SET path = ?2 || substr(path, length(?1) + 1)
|
||||
WHERE path = ?1 OR substr(path, 1, length(?1) + 1) = ?1 || '/'",
|
||||
params![from, to],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// The file of a generated image was found somewhere else.
|
||||
pub fn set_image_path(&self, id: &str, path: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE images SET path = ?2 WHERE id = ?1", params![id, path])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Latest images; `user = None` lists everybody's (admins).
|
||||
/// Generated images of these sessions.
|
||||
pub fn images_of_sessions(&self, sessions: &[String]) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!("SELECT {IMAGE_COLS} FROM images WHERE session_id = ?1"))?;
|
||||
let mut out = Vec::new();
|
||||
for s in sessions {
|
||||
out.extend(stmt.query_map([s], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
@@ -1135,13 +1324,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)?;
|
||||
@@ -1151,7 +1341,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()?)
|
||||
}
|
||||
|
||||
@@ -1167,7 +1357,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,
|
||||
)?)
|
||||
@@ -1200,6 +1390,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(())
|
||||
@@ -1335,3 +1530,45 @@ pub struct PushSub {
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
/// Which messages of a session the chat view loads (see Store::messages_window).
|
||||
pub enum MessageWindow {
|
||||
Tail { limit: i64 },
|
||||
Before { id: String, limit: i64, stop_at_summary: bool },
|
||||
After { id: String, limit: i64 },
|
||||
Around { id: String, limit: i64 },
|
||||
}
|
||||
|
||||
/// Text around the first occurrence of `needle` (lowercase) in the visible text of a
|
||||
/// message: its text, reasoning, summaries, tool calls and tool output.
|
||||
fn snippet_in(m: &Message, needle: &str) -> Option<String> {
|
||||
for p in &m.parts {
|
||||
let owned;
|
||||
let text = match p {
|
||||
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::ToolResult { content, .. } => content.as_str(),
|
||||
Part::ToolCall { input, .. } => {
|
||||
owned = input.to_string();
|
||||
owned.as_str()
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let lower = text.to_lowercase();
|
||||
// Lowercasing can change byte lengths (rare): then cut the lowercase text.
|
||||
let (src, i) = match lower.find(needle) {
|
||||
Some(i) if lower.len() == text.len() => (text, i),
|
||||
Some(i) => (lower.as_str(), i),
|
||||
None => continue,
|
||||
};
|
||||
let mut a = i.saturating_sub(60);
|
||||
while !src.is_char_boundary(a) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut b = (i + needle.len() + 100).min(src.len());
|
||||
while !src.is_char_boundary(b) {
|
||||
b += 1;
|
||||
}
|
||||
return Some(format!("{}{}{}", if a > 0 { "…" } else { "" }, src[a..b].replace('\n', " "), if b < src.len() { "…" } else { "" }));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -260,7 +260,8 @@ fn user(app: &App, user_id: &str) -> Result<User> {
|
||||
app.store.user(user_id)?.filter(|u| !u.disabled).ok_or_else(|| anyhow!("account disabled"))
|
||||
}
|
||||
|
||||
fn german(app: &App, user_id: &str) -> bool {
|
||||
/// The user reads German (language preference of the web UI).
|
||||
pub fn german(app: &App, user_id: &str) -> bool {
|
||||
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
|
||||
prefs["lang"].as_str().unwrap_or("en").starts_with("de")
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Tool for Bash {
|
||||
/// The bash command for a script, sandboxed for non-admin users.
|
||||
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
|
||||
// stderr of every command (not just the last one) goes into the output.
|
||||
let script = format!("exec 2>&1\n{command}");
|
||||
let script = format!("exec 2>&1\n{}{command}", commit_attribution(ctx));
|
||||
Ok(match &ctx.sandbox {
|
||||
Some(spec) => {
|
||||
if !crate::sandbox::available() {
|
||||
@@ -161,6 +161,36 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
||||
})
|
||||
}
|
||||
|
||||
/// Directory with the git wrapper that adds the co-author trailer to commits.
|
||||
fn git_wrapper_dir() -> String {
|
||||
std::env::var("CODECRAFT_GIT_WRAPPER").unwrap_or_else(|_| "/usr/local/lib/codecraft/bin".into())
|
||||
}
|
||||
|
||||
/// Shell lines that make `git commit` in this command carry the co-author trailer of
|
||||
/// the session's model (see docker/git-coauthor). Set in the script itself, so it
|
||||
/// works inside the sandbox (which clears the environment) too.
|
||||
fn commit_attribution(ctx: &ToolCtx) -> String {
|
||||
let template = ctx.app.config().server.commit_trailer.clone();
|
||||
let dir = git_wrapper_dir();
|
||||
if template.trim().is_empty() || !std::path::Path::new(&dir).join("git").exists() {
|
||||
return String::new();
|
||||
}
|
||||
let Ok(session) = ctx.app.session(&ctx.session_id) else { return String::new() };
|
||||
let (provider, model_id) = session.model.split_once('/').unwrap_or(("", session.model.as_str()));
|
||||
let name = ctx.app.registry().resolve(&session.model).ok().and_then(|(_, m)| m.name).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| model_id.to_string());
|
||||
let trailer = commit_trailer(&template, &name, provider, model_id);
|
||||
format!("export PATH={}:\"$PATH\" CODECRAFT_COMMIT_TRAILER={}\n", shell_quote(&dir), shell_quote(&trailer))
|
||||
}
|
||||
|
||||
/// The trailer text for a model ("Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft").
|
||||
pub fn commit_trailer(template: &str, model: &str, provider: &str, model_id: &str) -> String {
|
||||
template.replace("{model}", model).replace("{provider}", provider).replace("{model_id}", model_id).replace(['\n', '\r'], " ").trim().to_string()
|
||||
}
|
||||
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
fn tail(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
@@ -171,3 +201,18 @@ fn tail(s: &str, max: usize) -> String {
|
||||
}
|
||||
s[start..].to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn commit_trailer_text() {
|
||||
assert_eq!(
|
||||
commit_trailer(crate::config::DEFAULT_COMMIT_TRAILER, "DeepSeek V4.1 Flash", "opencode-go", "deepseek-v4.1-flash"),
|
||||
"Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft"
|
||||
);
|
||||
assert_eq!(commit_trailer("By {model_id}\nx", "M", "p", "m-1"), "By m-1 x");
|
||||
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(craft::ImageCompare),
|
||||
Arc::new(crate::prompts::SkillTool),
|
||||
Arc::new(crate::images::GenerateImage),
|
||||
Arc::new(crate::mesh3d::Generate3d),
|
||||
Arc::new(crate::reverse::BinaryInfo),
|
||||
Arc::new(crate::reverse::Decompile),
|
||||
]
|
||||
|
||||
+3
-1
@@ -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" />
|
||||
|
||||
@@ -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";
|
||||
@@ -95,6 +96,7 @@ export function App() {
|
||||
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 = (
|
||||
|
||||
+449
-72
@@ -1,12 +1,12 @@
|
||||
import { takePrefill } from "../share";
|
||||
import { Icon } from "./Icon";
|
||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { del, get, patch, post } from "../api";
|
||||
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 { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store";
|
||||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||
@@ -26,8 +26,10 @@ import { ShellsChip } from "./ShellsPanel";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||
import { MediaGallery } from "./MediaGallery";
|
||||
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||
|
||||
const EMPTY: Message[] = [];
|
||||
|
||||
@@ -115,7 +117,36 @@ function BackgroundResult({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)">\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
||||
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)"(?: todos="\d+")?>\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
||||
/** Enforced reviews: a completed todo passed, or a reminder about open todos. */
|
||||
const NOTE_RE = /^<(refine-approved|todo-reminder)(?: reviewer="([^"]*)")?(?: open="\d+")?>\n?([\s\S]*?)\n?<\/(?:refine-approved|todo-reminder)>/;
|
||||
/** Messages from the harness shown as cards instead of user bubbles. */
|
||||
const isHarnessNote = (text: string) => BG_RE.test(text) || REFINE_RE.test(text) || NOTE_RE.test(text);
|
||||
|
||||
/** A todo passed its review, or the agent was sent back to its open todos. */
|
||||
function HarnessNote({ text }: { text: string }) {
|
||||
const t = useT();
|
||||
const m = text.match(NOTE_RE);
|
||||
if (!m) return null;
|
||||
const [, kind, reviewer, body] = m;
|
||||
const items = body.split("\n").map((l) => l.replace(/^- /, "").trim()).filter(Boolean);
|
||||
const approved = kind === "refine-approved";
|
||||
return (
|
||||
<div className={`harness-note ${approved ? "ok" : ""}`}>
|
||||
<Icon name={approved ? "check" : "refresh"} />
|
||||
<span className="grow">
|
||||
<b>{t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })}</b> {items.join(" · ")}
|
||||
</span>
|
||||
{reviewer && <span className="chip">{reviewer}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HarnessMessage({ text }: { text: string }) {
|
||||
if (REFINE_RE.test(text)) return <RefineFeedback text={text} />;
|
||||
if (NOTE_RE.test(text)) return <HarnessNote text={text} />;
|
||||
return <BackgroundResult text={text} />;
|
||||
}
|
||||
|
||||
/** Findings of the review loop, sent back to the agent. */
|
||||
function RefineFeedback({ text }: { text: string }) {
|
||||
@@ -137,10 +168,340 @@ function RefineFeedback({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Search the whole chat on the server; a hit loads its part of the chat. */
|
||||
function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const [q, setQ] = useState("");
|
||||
const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>();
|
||||
useEffect(() => {
|
||||
const query = q.trim();
|
||||
if (query.length < 2) return setHits(undefined);
|
||||
let alive = true;
|
||||
const h = setTimeout(() => {
|
||||
get<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
|
||||
.then((r) => alive && setHits(r ?? []))
|
||||
.catch(() => alive && setHits([]));
|
||||
}, 250);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(h);
|
||||
};
|
||||
}, [q, sessionId]);
|
||||
return (
|
||||
<div className="chat-search" role="search">
|
||||
<div className="row">
|
||||
<Icon name="search" />
|
||||
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
|
||||
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
{hits && (
|
||||
<div className="chat-search-hits">
|
||||
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
|
||||
{hits.map((h) => (
|
||||
<button key={h.id} onClick={() => onPick(h.id)}>
|
||||
<span className="who">
|
||||
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||
</span>
|
||||
<span className="snippet">{h.snippet}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A part of the chat: a user message, a summary, or the work of the agent after it. */
|
||||
type Block = { kind: "user" | "summary" | "work"; messages: Message[] };
|
||||
|
||||
const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image");
|
||||
|
||||
/** Group the messages into user messages and the agent's work between them. */
|
||||
function blocksOf(messages: Message[]): Block[] {
|
||||
const out: Block[] = [];
|
||||
for (const m of messages) {
|
||||
const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work";
|
||||
const last = out[out.length - 1];
|
||||
if (kind === "work" && last?.kind === "work") last.messages.push(m);
|
||||
else out.push({ kind, messages: [m] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages of a chat. Memoised: typing, streaming and status changes of the
|
||||
* chat view do not touch it – only new messages, tool results or the busy state.
|
||||
*/
|
||||
const MessageList = memo(function MessageList({
|
||||
messages,
|
||||
results,
|
||||
busy,
|
||||
canEdit,
|
||||
canRegenerate,
|
||||
sessionId,
|
||||
model,
|
||||
baseDir,
|
||||
onRewind,
|
||||
highlight,
|
||||
currency,
|
||||
}: {
|
||||
messages: Message[];
|
||||
currency: string;
|
||||
results: Map<string, ToolResult>;
|
||||
busy: boolean;
|
||||
canEdit: boolean;
|
||||
canRegenerate: boolean;
|
||||
sessionId: string;
|
||||
model: string;
|
||||
baseDir: string;
|
||||
onRewind: (m: Message) => void;
|
||||
highlight?: string;
|
||||
}) {
|
||||
const blocks = useMemo(() => blocksOf(messages), [messages]);
|
||||
const lastWork = blocks.map((b) => b.kind).lastIndexOf("work");
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id;
|
||||
return (
|
||||
<>
|
||||
{blocks.map((b, i) => {
|
||||
// Stable while older messages are loaded above (they may join the first block):
|
||||
// the last message; the block still growing at the end keeps its first one.
|
||||
const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id;
|
||||
if (b.kind !== "work") return <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
|
||||
const items = b.messages.map((m) => (
|
||||
<AssistantOrNote
|
||||
key={m.id}
|
||||
m={m}
|
||||
results={results}
|
||||
busy={busy}
|
||||
baseDir={baseDir}
|
||||
highlight={highlight}
|
||||
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
|
||||
/>
|
||||
));
|
||||
// Finished turns with tool steps fold into one line (the answer stays visible).
|
||||
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
||||
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
|
||||
if (i === lastWork || steps.length < 2)
|
||||
return (
|
||||
<div key={key}>
|
||||
{items}
|
||||
{usage}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={key}>
|
||||
<WorkBlock messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>
|
||||
{items}
|
||||
</WorkBlock>
|
||||
{usage}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/** Tokens (in/out) and cost of one answer: all requests of the turn together. */
|
||||
function TurnUsage({ messages, currency }: { messages: Message[]; currency: string }) {
|
||||
const t = useT();
|
||||
let inp = 0;
|
||||
let out = 0;
|
||||
let cost = 0;
|
||||
let requests = 0;
|
||||
for (const m of messages) {
|
||||
const u = m.role === "assistant" ? m.usage : undefined;
|
||||
if (!u) continue;
|
||||
requests++;
|
||||
inp += u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||||
out += u.output_tokens;
|
||||
cost += u.cost ?? 0;
|
||||
}
|
||||
if (!requests) return null;
|
||||
return (
|
||||
<div className="turn-usage" title={t("chat.turnUsageHint", { n: requests })}>
|
||||
{fmtCompact(inp)}
|
||||
<Icon name="arrow-up" className="tiny" /> {fmtCompact(out)}
|
||||
<Icon name="arrow-down" className="tiny" /> · {fmtCost(cost, currency)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
|
||||
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||||
// The answer: text of the last assistant message of the turn.
|
||||
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim()));
|
||||
const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : "";
|
||||
if (open || forced) {
|
||||
return (
|
||||
<div className="work-block open">
|
||||
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
|
||||
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="msg work-block" data-mid={answer?.id}>
|
||||
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
|
||||
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||
</button>
|
||||
{answerText && (
|
||||
<>
|
||||
<Markdown text={answerText} baseDir={baseDir} />
|
||||
<ImageRefs text={answerText} baseDir={baseDir} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An assistant message, or a user message that only carries tool results / harness notes. */
|
||||
const AssistantOrNote = memo(function AssistantOrNote({
|
||||
m,
|
||||
results,
|
||||
busy,
|
||||
baseDir,
|
||||
highlight,
|
||||
regenerate,
|
||||
}: {
|
||||
m: Message;
|
||||
results: Map<string, ToolResult>;
|
||||
busy: boolean;
|
||||
baseDir: string;
|
||||
highlight?: string;
|
||||
regenerate?: { sessionId: string; model: string };
|
||||
}) {
|
||||
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
||||
if (m.role === "assistant") {
|
||||
return (
|
||||
<div className={cls} data-mid={m.id}>
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||||
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text));
|
||||
if (!notes.length) return null;
|
||||
return (
|
||||
<div className={cls} data-mid={m.id}>
|
||||
{notes.map((p, i) => (
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** A user message or a summary of the history before it. */
|
||||
const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight }: { m: Message; canEdit: boolean; onRewind: (m: Message) => void; highlight?: string }) {
|
||||
const t = useT();
|
||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
const summary = m.parts.find((p) => p.type === "summary");
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
||||
<summary>
|
||||
{summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
{summaryOpen && <Markdown text={summary.text} />}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => isHarnessNote(p.text));
|
||||
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
return (
|
||||
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
|
||||
{background.map((p, i) => (
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
<div className="msg-user">
|
||||
{canEdit && (
|
||||
<div className="msg-actions">
|
||||
<button className="small ghost" onClick={() => onRewind(m)} title={t("chat.rewind")}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Older messages: loaded automatically when scrolled to the top; the history before a
|
||||
* summary only on request. Keeps the scroll position while the list grows upwards.
|
||||
*/
|
||||
function OlderLoader({ sessionId, afterSummary, scrollRef }: { sessionId: string; afterSummary?: boolean; scrollRef: RefObject<HTMLDivElement | null> }) {
|
||||
const t = useT();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const sentinel = useRef<HTMLDivElement>(null);
|
||||
const load = useCallback(async () => {
|
||||
const el = scrollRef.current;
|
||||
if (loading || !el) return;
|
||||
setLoading(true);
|
||||
const before = { height: el.scrollHeight, top: el.scrollTop };
|
||||
try {
|
||||
// Scrolling stops at a summary; its button loads the history before it.
|
||||
await loadOlder(sessionId, !afterSummary);
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = before.top + (el.scrollHeight - before.height);
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading, sessionId, scrollRef, afterSummary]);
|
||||
useEffect(() => {
|
||||
const node = sentinel.current;
|
||||
if (!node || afterSummary || typeof IntersectionObserver === "undefined") return;
|
||||
const io = new IntersectionObserver((e) => e[0]?.isIntersecting && void load(), { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" });
|
||||
io.observe(node);
|
||||
return () => io.disconnect();
|
||||
}, [afterSummary, load, scrollRef]);
|
||||
return (
|
||||
<div ref={sentinel} className="older-loader">
|
||||
{loading ? (
|
||||
<Spinner small />
|
||||
) : (
|
||||
<button className="small ghost" onClick={() => void load()}>
|
||||
<Icon name="arrow-up" /> {t(afterSummary ? "chat.showBeforeSummary" : "chat.loadOlder")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
||||
const t = useT();
|
||||
const session = useStore((s) => s.sessionById[sessionId]);
|
||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||
const win = useStore((s) => s.windows[sessionId]);
|
||||
// Message to show (search hit, media): scrolled into view and marked.
|
||||
const [highlight, setHighlight] = useState<string>();
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [gallery, setGallery] = useState(false);
|
||||
const draft = useStore((s) => s.drafts[sessionId]);
|
||||
const busy = useStore((s) => !!s.busy[sessionId]);
|
||||
const errors = useStore((s) => s.errors[sessionId]);
|
||||
@@ -197,11 +558,47 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
return m;
|
||||
}, [messages]);
|
||||
|
||||
// Keep scrolled to the bottom while the user hasn't scrolled up.
|
||||
// Keep scrolled to the bottom while the user hasn't scrolled up (only when the
|
||||
// content changes – reading the height forces a layout).
|
||||
const permCount = permissions.length + questions.length;
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, [messages, draft, busy, waiting, compacting, permCount, errors]);
|
||||
|
||||
// Stable callbacks for the (memoised) message list.
|
||||
const rewindRef = useRef<(m: Message) => void>(() => undefined);
|
||||
const onRewind = useCallback((m: Message) => rewindRef.current(m), []);
|
||||
const showLatest = useCallback(async () => {
|
||||
setHighlight(undefined);
|
||||
await loadSession(sessionId);
|
||||
follow(true);
|
||||
}, [sessionId]);
|
||||
/** Load the part of the chat around a message and scroll to it. */
|
||||
const jumpTo = useCallback(
|
||||
async (messageId: string) => {
|
||||
follow(false);
|
||||
if (!getState().messages[sessionId]?.some((m) => m.id === messageId)) await loadAround(sessionId, messageId);
|
||||
setHighlight(messageId);
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!highlight) return;
|
||||
const h = setTimeout(() => document.querySelector(`[data-mid="${highlight}"]`)?.scrollIntoView({ block: "center" }), 50);
|
||||
return () => clearTimeout(h);
|
||||
}, [highlight, messages]);
|
||||
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||||
const hasSession = !!session;
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [hasSession, sessionId]);
|
||||
|
||||
if (loadError) return <div className="empty">{loadError}</div>;
|
||||
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
||||
@@ -240,9 +637,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
}
|
||||
};
|
||||
|
||||
const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1);
|
||||
|
||||
const [rewinding, setRewinding] = useState<Message>();
|
||||
const rewind = (m: Message) => setRewinding(m);
|
||||
rewindRef.current = rewind;
|
||||
const rewindDone = async () => {
|
||||
const m = rewinding!;
|
||||
setRewinding(undefined);
|
||||
@@ -331,6 +729,9 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
>
|
||||
<Icon name="terminal" />
|
||||
</button>
|
||||
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
|
||||
<Icon name="search" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||
<Icon name="folder" />
|
||||
</button>
|
||||
@@ -354,6 +755,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
>
|
||||
<Icon name="edit" /> {t("instructions.title")}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||
setGallery(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="image" /> {t("media.title")}
|
||||
</a>
|
||||
{telegramPaired && !session.parent_id && (
|
||||
<a
|
||||
href="#"
|
||||
@@ -407,6 +818,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{gallery && (
|
||||
<MediaGallery
|
||||
sessionId={sessionId}
|
||||
onClose={() => setGallery(false)}
|
||||
onJump={(id) => {
|
||||
setGallery(false);
|
||||
void jumpTo(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
|
||||
<ChatTabBar state={tabs} />
|
||||
<ChatTabPanes state={tabs} />
|
||||
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
||||
@@ -432,71 +854,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
<div className="chat">
|
||||
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
||||
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
||||
{messages.map((m, idx) => {
|
||||
if (m.role === "assistant") {
|
||||
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
|
||||
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const summary = m.parts.find((p) => p.type === "summary");
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details key={m.id} className="summary-divider">
|
||||
<summary>
|
||||
{summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
<Markdown text={summary.text} />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => BG_RE.test(p.text) || REFINE_RE.test(p.text));
|
||||
const texts = allTexts.filter((p) => !BG_RE.test(p.text) && !REFINE_RE.test(p.text));
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
if (texts.length === 0 && imgs.length === 0) {
|
||||
return background.length ? (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
|
||||
))}
|
||||
<div className="msg-user">
|
||||
{!busy && !readOnly && (
|
||||
<div className="msg-actions">
|
||||
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
results={results}
|
||||
busy={busy}
|
||||
canEdit={!busy && !readOnly}
|
||||
canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
|
||||
sessionId={sessionId}
|
||||
model={session.model}
|
||||
baseDir={session.cwd}
|
||||
onRewind={onRewind}
|
||||
highlight={highlight}
|
||||
currency={stats?.currency ?? "USD"}
|
||||
/>
|
||||
{win?.newer && (
|
||||
<button className="back-to-latest" onClick={() => void showLatest()}>
|
||||
<Icon name="arrow-down" /> {t("chat.backToLatest")}
|
||||
</button>
|
||||
)}
|
||||
{draft && (
|
||||
<div className="msg">
|
||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||
@@ -597,9 +973,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) => (
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -125,6 +125,12 @@ const P: Record<string, ReactNode> = {
|
||||
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4.5 4.5" />
|
||||
</>
|
||||
),
|
||||
eye: (
|
||||
<>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
||||
|
||||
@@ -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
-40
@@ -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,22 +111,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
</span>
|
||||
<span>{t("jobs.mode")}</span>
|
||||
<span>
|
||||
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · max {j.spec?.timeout_minutes} min
|
||||
</span>
|
||||
<span>{t("jobs.permissionMode")}</span>
|
||||
<span>
|
||||
<select
|
||||
value={j.spec?.permission_mode ?? "auto"}
|
||||
disabled={busy === j.id}
|
||||
title={t("jobs.permissionModeHint")}
|
||||
aria-label={t("jobs.permissionMode")}
|
||||
onChange={(e) => act(j.id, () => patch(`/jobs/${j.id}`, { 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>
|
||||
{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>
|
||||
)}
|
||||
@@ -178,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
|
||||
@@ -205,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,155 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get } from "../api";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import { Spinner } from "./Activity";
|
||||
import { Icon } from "./Icon";
|
||||
import { navigate } from "../router";
|
||||
|
||||
/** Open a 3D model in the 3D view of the file manager. */
|
||||
const open3d = (path: string) => {
|
||||
sessionStorage.setItem("codecraft.openFile", path);
|
||||
navigate("/files");
|
||||
};
|
||||
|
||||
interface MediaItem {
|
||||
kind: "image" | "video" | "audio" | "model";
|
||||
url: string;
|
||||
name: string;
|
||||
source: "generated" | "tool" | "written" | "answer" | "upload";
|
||||
created_at: number;
|
||||
message_id?: string;
|
||||
/** 3D models: file (opened in the 3D view) and preview image. */
|
||||
path?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
type Filter = "all" | MediaItem["kind"];
|
||||
|
||||
/**
|
||||
* All images, videos and audio files of a chat (with its subagents), newest first.
|
||||
* An item opens large; "show in chat" jumps to the message it came with.
|
||||
*/
|
||||
export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string; onClose: () => void; onJump: (messageId: string) => void }) {
|
||||
const t = useT();
|
||||
const [items, setItems] = useState<MediaItem[]>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [open, setOpen] = useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
get<MediaItem[]>(`/sessions/${sessionId}/media`)
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e.message));
|
||||
}, [sessionId]);
|
||||
|
||||
const shown = (items ?? []).filter((i) => filter === "all" || i.kind === filter);
|
||||
const count = (k: Filter) => (items ?? []).filter((i) => k === "all" || i.kind === k).length;
|
||||
const current = open !== undefined ? shown[open] : undefined;
|
||||
|
||||
// Keyboard: Esc closes, arrows step through the open item.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") return current ? setOpen(undefined) : onClose();
|
||||
if (current && e.key === "ArrowRight") setOpen((o) => Math.min((o ?? 0) + 1, shown.length - 1));
|
||||
if (current && e.key === "ArrowLeft") setOpen((o) => Math.max((o ?? 0) - 1, 0));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [current, shown.length, onClose]);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal media-gallery" role="dialog" aria-label={t("media.title")} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="media-head">
|
||||
<h3>
|
||||
<Icon name="image" /> {t("media.title")}
|
||||
</h3>
|
||||
<div className="media-filters" role="radiogroup">
|
||||
{(["all", "image", "video", "audio", "model"] as Filter[]).map((k) => (
|
||||
<button key={k} role="radio" aria-checked={filter === k} className={`chip-btn ${filter === k ? "active" : ""}`} onClick={() => (setFilter(k), setOpen(undefined))} disabled={!!items && count(k) === 0 && k !== "all"}>
|
||||
{t(`media.filter.${k}` as Key)} {items && <small>{count(k)}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!items && !error && (
|
||||
<div className="empty">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{items && shown.length === 0 && <div className="empty">{t("media.none")}</div>}
|
||||
{current ? (
|
||||
<div className="media-viewer">
|
||||
<div className="media-stage">
|
||||
{current.kind === "image" && <img src={current.url} alt={current.name} />}
|
||||
{current.kind === "video" && <video src={current.url} controls autoPlay />}
|
||||
{current.kind === "audio" && <audio src={current.url} controls autoPlay />}
|
||||
{current.kind === "model" && (
|
||||
<div className="media-model">
|
||||
{current.preview ? <img src={current.preview} alt={current.name} /> : <Icon name="cube" />}
|
||||
{current.path && (
|
||||
<button className="primary" onClick={() => open3d(current.path!)}>
|
||||
<Icon name="cube" /> {t("tool.open3d")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="media-bar">
|
||||
<button className="small ghost" disabled={open === 0} onClick={() => setOpen((o) => (o ?? 1) - 1)} aria-label={t("media.prev")}>
|
||||
<Icon name="arrow-left" />
|
||||
</button>
|
||||
<span className="grow">
|
||||
{current.name} · {t(`media.source.${current.source}` as Key)} · {new Date(current.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||
</span>
|
||||
{current.message_id && (
|
||||
<button className="small" onClick={() => onJump(current.message_id!)}>
|
||||
{t("media.showInChat")}
|
||||
</button>
|
||||
)}
|
||||
<a className="small button" href={current.url} target="_blank" rel="noreferrer">
|
||||
<Icon name="external" /> {t("media.open")}
|
||||
</a>
|
||||
<button className="small ghost" disabled={open === shown.length - 1} onClick={() => setOpen((o) => (o ?? 0) + 1)} aria-label={t("media.next")}>
|
||||
<Icon name="arrow-right" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={() => setOpen(undefined)} aria-label={t("media.back")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="media-grid">
|
||||
{shown.map((m, i) => (
|
||||
<button key={m.url} className={`media-tile ${m.kind}`} onClick={() => setOpen(i)} title={`${m.name} · ${t(`media.source.${m.source}` as Key)}`}>
|
||||
{m.kind === "image" && <img src={m.url} alt="" loading="lazy" decoding="async" />}
|
||||
{m.kind === "video" && <video src={`${m.url}#t=0.1`} preload="metadata" muted />}
|
||||
{m.kind === "audio" && (
|
||||
<span className="media-audio">
|
||||
<Icon name="music" />
|
||||
<span>{m.name}</span>
|
||||
</span>
|
||||
)}
|
||||
{m.kind === "video" && <Icon name="play" className="media-play" />}
|
||||
{m.kind === "model" &&
|
||||
(m.preview ? (
|
||||
<img src={m.preview} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<span className="media-audio">
|
||||
<Icon name="cube" />
|
||||
<span>{m.name}</span>
|
||||
</span>
|
||||
))}
|
||||
{m.kind === "model" && <span className="media-badge">3D</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
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";
|
||||
@@ -602,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 && (
|
||||
@@ -622,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>}
|
||||
|
||||
@@ -248,6 +248,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
small_model: s.small_model ?? "",
|
||||
currency: s.currency ?? "USD",
|
||||
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
||||
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
|
||||
max_parallel_subagents: s.max_parallel_subagents ?? 4,
|
||||
monthly_budget: s.monthly_budget ?? 0,
|
||||
budget_warning: s.budget_warning ?? 0.8,
|
||||
@@ -271,6 +272,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
{ path: ["server", "small_model"], value: form.small_model },
|
||||
{ path: ["server", "currency"], value: form.currency },
|
||||
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
||||
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
|
||||
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
||||
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
||||
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
||||
@@ -305,6 +307,9 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<label>{t("settings.autoCompact")}</label>
|
||||
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
||||
<div className="hint">{t("settings.autoCompactHint")}</div>
|
||||
<label>{t("settings.commitTrailer")}</label>
|
||||
<input className="mono" value={form.commit_trailer} placeholder={t("settings.commitTrailerOff")} onChange={(e) => setForm({ ...form, commit_trailer: e.target.value })} />
|
||||
<div className="hint">{t("settings.commitTrailerHint")}</div>
|
||||
<label>{t("settings.monthlyBudgetTotal")}</label>
|
||||
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
||||
@@ -376,6 +381,7 @@ interface ModelRow {
|
||||
name?: string;
|
||||
context?: number;
|
||||
max_output?: number;
|
||||
max_input?: number;
|
||||
reasoning?: boolean;
|
||||
vision?: boolean;
|
||||
effort?: string;
|
||||
@@ -454,6 +460,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
price_multipliers: f.price_multipliers ?? m.price_multipliers,
|
||||
context: m.context ?? f.context,
|
||||
max_output: m.max_output ?? f.max_output,
|
||||
max_input: m.max_input ?? f.max_input ?? undefined,
|
||||
vision: m.vision || !!f.vision,
|
||||
reasoning: m.reasoning || !!f.reasoning,
|
||||
};
|
||||
@@ -616,6 +623,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<th>ID</th>
|
||||
<th>{t("settings.context")}</th>
|
||||
<th>{t("settings.maxOutput")}</th>
|
||||
<th title={t("settings.maxInputHint")}>{t("settings.maxInput")}</th>
|
||||
<th title={t("settings.reasoning")}><Icon name="brain" /></th>
|
||||
<th title={t("settings.vision")}><Icon name="eye" /></th>
|
||||
<th>{t("settings.effort")}</th>
|
||||
@@ -640,6 +648,9 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<td>
|
||||
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={m.max_input ?? ""} placeholder="–" onChange={(e) => setModel(i, { max_input: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
|
||||
</td>
|
||||
|
||||
+197
-11
@@ -2,7 +2,7 @@ import { Icon } from "./Icon";
|
||||
import { StatusLeds } from "./StatusLeds";
|
||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { get, patch, post } from "../api";
|
||||
import { get, patch, post, put } from "../api";
|
||||
import { promptDialog, toast } from "../dialogs";
|
||||
import { fmtRelative, useT, type Key } from "../i18n";
|
||||
import { navigate, openBeside } from "../router";
|
||||
@@ -102,6 +102,113 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
||||
);
|
||||
}
|
||||
|
||||
/** Order and pinning of folders and projects in the list (per user, all devices). */
|
||||
interface Layout {
|
||||
order: string[];
|
||||
pinned: string[];
|
||||
}
|
||||
|
||||
/** Folders and projects can be moved and pinned; time groups and the archive cannot. */
|
||||
const movable = (id: string) => id.startsWith("folder:") || id.startsWith("project:");
|
||||
|
||||
const DRAG_TYPE = "application/x-codecraft-group";
|
||||
|
||||
/** Header of a group: collapses it; folders and projects also have a menu and can be dragged. */
|
||||
function GroupHeader({
|
||||
g,
|
||||
collapsed,
|
||||
pinned,
|
||||
onToggle,
|
||||
onPin,
|
||||
onMove,
|
||||
onDrop,
|
||||
}: {
|
||||
g: { id: string; label: string; icon?: string; items: Session[] };
|
||||
collapsed: boolean;
|
||||
pinned: boolean;
|
||||
onToggle: () => void;
|
||||
onPin?: () => void;
|
||||
onMove?: (dir: -1 | 1) => void;
|
||||
onDrop?: (dragged: string, after: boolean) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [over, setOver] = useState<"before" | "after">();
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => setMenu(false);
|
||||
window.addEventListener("click", close);
|
||||
window.addEventListener("keydown", close);
|
||||
return () => {
|
||||
window.removeEventListener("click", close);
|
||||
window.removeEventListener("keydown", close);
|
||||
};
|
||||
}, [menu]);
|
||||
const canMove = !!onMove;
|
||||
return (
|
||||
<div
|
||||
className={`session-row group-row ${menu ? "menu-open" : ""} ${over ? `drop-${over}` : ""}`}
|
||||
draggable={canMove}
|
||||
onDragStart={(e) => {
|
||||
if (!canMove) return;
|
||||
e.dataTransfer.setData(DRAG_TYPE, g.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!onDrop || !e.dataTransfer.types.includes(DRAG_TYPE)) return;
|
||||
e.preventDefault();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setOver(e.clientY > r.top + r.height / 2 ? "after" : "before");
|
||||
}}
|
||||
onDragLeave={() => setOver(undefined)}
|
||||
onDrop={(e) => {
|
||||
const id = e.dataTransfer.getData(DRAG_TYPE);
|
||||
const after = over === "after";
|
||||
setOver(undefined);
|
||||
if (id && id !== g.id && onDrop) {
|
||||
e.preventDefault();
|
||||
onDrop(id, after);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="session-group" onClick={onToggle} aria-expanded={!collapsed}>
|
||||
<Icon name={collapsed ? "chevron-right" : "chevron-down"} className="tiny" />
|
||||
{g.icon && <Icon name={g.icon} className="tiny" />}
|
||||
<span className="grow">{g.label}</span>
|
||||
{pinned && <Icon name="pin" className="tiny group-pin" />}
|
||||
{collapsed && <span className="count">{g.items.length}</span>}
|
||||
</button>
|
||||
{canMove && (
|
||||
<button
|
||||
className="session-more ghost"
|
||||
aria-label={t("sidebar.groupMenu")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenu(!menu);
|
||||
}}
|
||||
>
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
)}
|
||||
{menu && (
|
||||
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
|
||||
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(-1))}>
|
||||
<Icon name="arrow-up" /> {t("sidebar.groupUp")}
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(1))}>
|
||||
<Icon name="arrow-down" /> {t("sidebar.groupDown")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
|
||||
const t = useT();
|
||||
const sessions = useStore((s) => s.sessions);
|
||||
@@ -164,14 +271,15 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
|
||||
const live = sessions.filter((s) => !s.archived);
|
||||
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
|
||||
const rest = live.filter((s) => !s.pinned);
|
||||
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
|
||||
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])];
|
||||
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
|
||||
const loose = rest.filter((s) => !s.folder);
|
||||
if (groupBy === "project") {
|
||||
const byProject = new Map<string, Session[]>();
|
||||
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
|
||||
// Projects with the most recent activity first.
|
||||
for (const [cwd, items] of [...byProject].sort((a, b) => b[1][0].updated_at - a[1][0].updated_at)) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
|
||||
// Order of the projects: see `ordered` below (not by activity – a new message
|
||||
// moves the chat to the top of its project, not the project).
|
||||
for (const [cwd, items] of byProject) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
|
||||
} else {
|
||||
for (const s of loose) {
|
||||
const key = groupKey(s.updated_at);
|
||||
@@ -181,6 +289,81 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
|
||||
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
|
||||
return out;
|
||||
}, [sessions, groupBy, t]);
|
||||
// Folders and projects: pinned first, then in the user's order. Groups not ordered yet
|
||||
// (new folder or project) start at the top and are then kept where they are.
|
||||
const [layout, setLayout] = useState<Layout>();
|
||||
useEffect(() => {
|
||||
get<Layout>("/account/sidebar")
|
||||
.then((l) => setLayout({ order: l.order ?? [], pinned: l.pinned ?? [] }))
|
||||
.catch(() => setLayout({ order: [], pinned: [] }));
|
||||
}, []);
|
||||
const ordered = useMemo(() => {
|
||||
const l = layout ?? { order: [], pinned: [] };
|
||||
const head = groups.filter((g) => g.id === "pinned");
|
||||
const tail = groups.filter((g) => g.id !== "pinned" && !movable(g.id));
|
||||
const rank = (id: string) => l.order.indexOf(id);
|
||||
const mid = groups
|
||||
.filter((g) => movable(g.id))
|
||||
.sort((a, b) => {
|
||||
const pa = l.pinned.includes(a.id);
|
||||
const pb = l.pinned.includes(b.id);
|
||||
if (pa !== pb) return pa ? -1 : 1;
|
||||
const ra = rank(a.id);
|
||||
const rb = rank(b.id);
|
||||
if (ra < 0 && rb < 0) return b.items[0].updated_at - a.items[0].updated_at;
|
||||
if (ra < 0 || rb < 0) return ra < 0 ? -1 : 1;
|
||||
return ra - rb;
|
||||
});
|
||||
return { head, mid, tail };
|
||||
}, [groups, layout]);
|
||||
const saveLayout = (next: Layout) => {
|
||||
// Forget groups that no longer exist (no chat in that folder or project).
|
||||
const known = new Set(sessions.flatMap((s) => [`project:${s.cwd}`, ...(s.folder ? [`folder:${s.folder}`] : [])]));
|
||||
const clean = { order: next.order.filter((id) => known.has(id)), pinned: next.pinned.filter((id) => known.has(id)) };
|
||||
setLayout(clean);
|
||||
void put("/account/sidebar", clean).catch((e) => toast(e.message));
|
||||
};
|
||||
// Freeze the position of groups that appeared since the last visit.
|
||||
useEffect(() => {
|
||||
if (!layout) return;
|
||||
const fresh = ordered.mid.map((g) => g.id).filter((id) => !layout.order.includes(id));
|
||||
if (fresh.length) saveLayout({ ...layout, order: [...fresh, ...layout.order] });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ordered, layout]);
|
||||
/** Order of the shown groups with `id` moved: by a step, or next to another group. */
|
||||
const reorder = (ids: string[]) => {
|
||||
if (!layout) return;
|
||||
const shown = new Set(ids);
|
||||
saveLayout({ ...layout, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||||
};
|
||||
const moveGroup = (id: string, dir: -1 | 1) => {
|
||||
if (!layout) return;
|
||||
const ids = ordered.mid.map((g) => g.id);
|
||||
const i = ids.indexOf(id);
|
||||
const j = i + dir;
|
||||
// Only within the pinned or the unpinned part.
|
||||
if (j < 0 || j >= ids.length || layout.pinned.includes(ids[j]) !== layout.pinned.includes(id)) return;
|
||||
[ids[i], ids[j]] = [ids[j], ids[i]];
|
||||
reorder(ids);
|
||||
};
|
||||
const dropGroup = (dragged: string, target: string, after: boolean) => {
|
||||
if (!layout || !movable(dragged)) return;
|
||||
const ids = ordered.mid.map((g) => g.id).filter((id) => id !== dragged);
|
||||
const at = ids.indexOf(target);
|
||||
if (at < 0) return;
|
||||
ids.splice(after ? at + 1 : at, 0, dragged);
|
||||
// Dropped among pinned groups: pinned too (and the other way round).
|
||||
const pinTarget = layout.pinned.includes(target);
|
||||
const pinned = pinTarget ? [...new Set([...layout.pinned, dragged])] : layout.pinned.filter((p) => p !== dragged);
|
||||
const shown = new Set(ids);
|
||||
saveLayout({ pinned, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||||
};
|
||||
const pinGroup = (id: string) => {
|
||||
if (!layout) return;
|
||||
const pinned = layout.pinned.includes(id) ? layout.pinned.filter((p) => p !== id) : [...layout.pinned, id];
|
||||
saveLayout({ ...layout, pinned });
|
||||
};
|
||||
|
||||
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
|
||||
|
||||
return (
|
||||
@@ -235,14 +418,17 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
|
||||
</div>
|
||||
)}
|
||||
{!hits &&
|
||||
groups.map((g) => (
|
||||
[...ordered.head, ...ordered.mid, ...ordered.tail].map((g) => (
|
||||
<div key={g.id}>
|
||||
<button className="session-group" onClick={() => toggle(g.id)} aria-expanded={!collapsed.has(g.id)}>
|
||||
<Icon name={collapsed.has(g.id) ? "chevron-right" : "chevron-down"} className="tiny" />
|
||||
{g.icon && <Icon name={g.icon} className="tiny" />}
|
||||
<span className="grow">{g.label}</span>
|
||||
{collapsed.has(g.id) && <span className="count">{g.items.length}</span>}
|
||||
</button>
|
||||
<GroupHeader
|
||||
g={g}
|
||||
collapsed={collapsed.has(g.id)}
|
||||
pinned={!!layout?.pinned.includes(g.id)}
|
||||
onToggle={() => toggle(g.id)}
|
||||
onPin={movable(g.id) ? () => pinGroup(g.id) : undefined}
|
||||
onMove={movable(g.id) ? (dir) => moveGroup(g.id, dir) : undefined}
|
||||
onDrop={movable(g.id) ? (dragged, after) => dropGroup(dragged, g.id, after) : undefined}
|
||||
/>
|
||||
{!collapsed.has(g.id) &&
|
||||
g.items.map((s) => (
|
||||
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useState } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -68,6 +69,8 @@ export function TodoList({ todos }: { todos: Todo[] }) {
|
||||
|
||||
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
|
||||
const t = useT();
|
||||
// The body (diff, output) is only built while the card is open – long chats have thousands.
|
||||
const [toggled, setToggled] = useState<boolean | null>(null);
|
||||
const progress = useStore((s) => s.progress[call.id]);
|
||||
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||||
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
|
||||
@@ -118,10 +121,14 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||||
const openByDefault =
|
||||
(status === "err" && !!result) ||
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d"));
|
||||
const open = toggled ?? openByDefault;
|
||||
|
||||
return (
|
||||
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}>
|
||||
<details className={`tool ${status === "run" ? "running" : ""}`} open={open} onToggle={(e) => {
|
||||
const now = (e.currentTarget as HTMLDetailsElement).open;
|
||||
if (now !== open) setToggled(now);
|
||||
}}>
|
||||
<summary>
|
||||
<span className="tool-name">{call.name}</span>
|
||||
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
|
||||
@@ -130,6 +137,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
|
||||
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
||||
</summary>
|
||||
{open && (
|
||||
<div className="tool-body">
|
||||
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||||
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
||||
@@ -156,13 +164,39 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
<pre className="mono">{result.content}</pre>
|
||||
</>
|
||||
)}
|
||||
{typeof meta?.model_file === "string" && (
|
||||
<button
|
||||
className="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("codecraft.openFile", meta.model_file);
|
||||
navigate("/files");
|
||||
}}
|
||||
>
|
||||
<Icon name="cube" /> {t("tool.open3d")}
|
||||
</button>
|
||||
)}
|
||||
{Array.isArray(meta?.image_ids) ? (
|
||||
<div className="gallery small">
|
||||
{meta.image_ids.map((id: string) => (
|
||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||
<img src={`/api/images/${id}/file`} alt="" />
|
||||
</FileFigure>
|
||||
))}
|
||||
{meta.image_ids.map((id: string, i: number) => {
|
||||
// The file may be gone (moved or deleted later): fall back to the preview the model saw.
|
||||
const preview = result?.images?.[i];
|
||||
return (
|
||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||
<img
|
||||
src={`/api/images/${id}/file`}
|
||||
alt=""
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (preview && !img.dataset.fallback) {
|
||||
img.dataset.fallback = "1";
|
||||
img.src = imageSrc(preview);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileFigure>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
result?.images?.map((img, i) => (
|
||||
@@ -172,6 +206,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,8 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
||||
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
||||
refine_model: raw?.refine?.model ?? "",
|
||||
refine_criteria: raw?.refine?.criteria ?? "",
|
||||
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
||||
reverse_tools: !!(raw?.reverse_tools ?? a.reverse),
|
||||
});
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||
|
||||
@@ -74,9 +76,10 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
||||
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
|
||||
patches.push({
|
||||
path: [...base, "refine"],
|
||||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null } : null,
|
||||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
|
||||
});
|
||||
}
|
||||
if (a.name !== "craft") patches.push({ path: [...base, "reverse_tools"], value: form.reverse_tools || null });
|
||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||
if (!builtin) {
|
||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||
@@ -158,6 +161,20 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
||||
<label>{t("agents.refineCriteria")}</label>
|
||||
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
|
||||
<div className="hint">{t("agents.refineCriteriaHint")}</div>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={form.refine_after_todos} onChange={(e) => setForm({ ...form, refine_after_todos: e.target.checked })} />
|
||||
{t("agents.refineAfterTodos")}
|
||||
</label>
|
||||
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
|
||||
</>
|
||||
)}
|
||||
{a.name !== "craft" && (
|
||||
<>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={form.reverse_tools} onChange={(e) => setForm({ ...form, reverse_tools: e.target.checked })} />
|
||||
{t("agents.reverseTools")}
|
||||
</label>
|
||||
<div className="hint">{t("agents.reverseToolsHint")}</div>
|
||||
</>
|
||||
)}
|
||||
<label>{t("agents.description")}</label>
|
||||
|
||||
+99
-11
@@ -189,7 +189,7 @@ export const de: Messages = {
|
||||
"tools.install": "Ghidra installieren",
|
||||
"tools.reinstall": "Installation prüfen / reparieren",
|
||||
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…",
|
||||
"tools.ghidraHint": "Mit Ghidra können Agenten im Craft-Deep-Modus Programme dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren. Wird samt eigenem JDK ins Daten-Volume installiert.",
|
||||
"tools.ghidraHint": "Mit Ghidra können Agenten Programme und Firmware dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren – im Craft-Deep-Modus und in Modi mit Reverse-Engineering-Werkzeugen (Einstellungen → Agenten & Befehle). Wird samt eigenem JDK ins Daten-Volume installiert.",
|
||||
"rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.",
|
||||
"rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
|
||||
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
|
||||
@@ -334,6 +334,41 @@ export const de: Messages = {
|
||||
"wizard.idea.cadBox": "Eine Box mit Schnappdeckel, Innenmaß 80 × 50 × 30 mm",
|
||||
"wizard.idea.cadBracket": "Eine Wandhalterung für ein 25-mm-Rohr mit zwei M4-Schraublöchern",
|
||||
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
||||
"chat.reviewPassed": "Review bestanden:",
|
||||
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
||||
"agents.reverseTools": "Reverse-Engineering-Werkzeuge (binary_info, decompile mit Ghidra)",
|
||||
"agents.reverseToolsHint": "Der Agent kann in diesem Modus Programme und Firmware untersuchen und dekompilieren (Ghidra muss unter Einstellungen → Werkzeuge installiert sein). Craft hat sie im Deep-Modus ohnehin.",
|
||||
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
||||
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
||||
"chat.workSteps": "{n} Schritte",
|
||||
"chat.workFiles": "{n} Dateien",
|
||||
"chat.loadOlder": "Ältere Nachrichten laden",
|
||||
"chat.showBeforeSummary": "Verlauf vor der Zusammenfassung anzeigen",
|
||||
"chat.backToLatest": "Zu den neuesten Nachrichten",
|
||||
"chat.search": "Im Chat suchen",
|
||||
"chat.searchPlaceholder": "Nachrichten, Tool-Ausgaben … durchsuchen",
|
||||
"chat.searchNone": "Keine Treffer",
|
||||
"chat.searchHits": "{n} Treffer",
|
||||
"chat.you": "Du",
|
||||
"chat.agent": "Agent",
|
||||
"media.title": "Medien",
|
||||
"media.none": "Noch keine Bilder, Videos oder Audiodateien in diesem Chat.",
|
||||
"media.filter.all": "Alle",
|
||||
"media.filter.image": "Bilder",
|
||||
"media.filter.video": "Videos",
|
||||
"media.filter.model": "3D",
|
||||
"media.filter.audio": "Audio",
|
||||
"media.source.generated": "generiert",
|
||||
"media.source.tool": "Tool-Ausgabe",
|
||||
"media.source.written": "vom Agenten geschrieben",
|
||||
"media.source.answer": "in einer Antwort",
|
||||
"media.source.upload": "dein Anhang",
|
||||
"media.showInChat": "Im Chat zeigen",
|
||||
"media.open": "Öffnen",
|
||||
"media.prev": "Vorheriges",
|
||||
"media.next": "Nächstes",
|
||||
"media.back": "Zurück zur Übersicht",
|
||||
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
|
||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||
"agents.refine": "Review-Schleife",
|
||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||
@@ -344,7 +379,7 @@ export const de: Messages = {
|
||||
"agents.refineShort": "Review ×{n}",
|
||||
"agents.deleteFile": "Agenten-Datei {path} löschen?",
|
||||
"nav.jobs": "Automationen",
|
||||
"jobs.intro": "Agenten, die nach Zeitplan starten. Jede Automation ist eine Markdown-Datei (Auftrag + Zeitplan im Frontmatter) – lass sie dir in einem Chat von einem Agenten anlegen (Tool „schedule“) oder registriere unten eine Datei. Jeder Lauf erscheint als normaler Chat; braucht er eine Freigabe, bekommst du eine Push-Benachrichtigung.",
|
||||
"jobs.intro": "Agenten, die nach Zeitplan starten. Die Anweisungen jeder Automatisierung stehen in einer Markdown-Datei; Name, Zeitplan und Einstellungen legst du hier fest. Mit „Neue Automatisierung“ anlegen und die Anweisungen mit dem Assistenten erarbeiten – oder einen Agenten in einem Chat darum bitten (Tool „schedule“). Jeder Lauf erscheint als normaler Chat.",
|
||||
"jobs.none": "Noch keine Automationen.",
|
||||
"jobs.schedule": "Zeitplan",
|
||||
"jobs.next": "Nächster Lauf",
|
||||
@@ -355,13 +390,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",
|
||||
@@ -509,6 +543,11 @@ export const de: Messages = {
|
||||
"sidebar.unpin": "Nicht mehr anpinnen",
|
||||
"sidebar.openBeside": "Daneben öffnen",
|
||||
"chat.closePane": "Spalte schließen",
|
||||
"sidebar.groupMenu": "Ordner-Optionen",
|
||||
"sidebar.groupPin": "Oben fixieren",
|
||||
"sidebar.groupUnpin": "Nicht mehr fixieren",
|
||||
"sidebar.groupUp": "Nach oben",
|
||||
"sidebar.groupDown": "Nach unten",
|
||||
"sidebar.moveTo": "In Ordner verschieben…",
|
||||
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
||||
"sidebar.archiveOne": "Archivieren",
|
||||
@@ -545,16 +584,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",
|
||||
@@ -595,8 +630,52 @@ export const de: Messages = {
|
||||
"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 (wird in die Datei geschrieben). „Alles erlauben“: der Lauf fragt nie nach und wartet nicht auf Freigaben.",
|
||||
"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.",
|
||||
@@ -619,6 +698,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",
|
||||
@@ -632,6 +714,7 @@ export const de: Messages = {
|
||||
"plan.implementPrompt": "Setze den Plan um.",
|
||||
|
||||
"tool.input": "Eingabe",
|
||||
"tool.open3d": "In 3D-Ansicht öffnen",
|
||||
"tool.output": "Ausgabe",
|
||||
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
|
||||
"tool.todos": "Aufgaben",
|
||||
@@ -690,6 +773,9 @@ export const de: Messages = {
|
||||
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
||||
"settings.currency": "Währung",
|
||||
"settings.autoCompact": "Auto-Komprimierung bei",
|
||||
"settings.commitTrailer": "Commit-Vermerk der Agenten",
|
||||
"settings.commitTrailerOff": "aus",
|
||||
"settings.commitTrailerHint": "Wird als Trailer an jeden Git-Commit eines Agenten angehängt. {model} = Modellname, {provider} = Anbieter, {model_id} = Modell-ID. Leer = aus. Deine eigenen Commits bleiben unverändert.",
|
||||
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
||||
"settings.craft": "Craft-Modus",
|
||||
"settings.cleanRoom": "Clean Room",
|
||||
@@ -710,6 +796,8 @@ export const de: Messages = {
|
||||
"settings.pricesHint": "Preise pro 1 Mio. Tokens in der eingestellten Währung. Cache-Preise fallen auf den Input-Preis zurück.",
|
||||
"settings.modelsEmptyHint": "Keine Modelle eingetragen: Sie werden automatisch beim Provider abgefragt.",
|
||||
"settings.context": "Kontext",
|
||||
"settings.maxInput": "Max. Eingabe",
|
||||
"settings.maxInputHint": "Eingabe-Limit, falls der Anbieter eines unterhalb des Kontextfensters setzt (leer: das Kontextfenster). Der Chat wird zusammengefasst, bevor die Eingabe es erreicht.",
|
||||
"settings.maxOutput": "Max. Ausgabe",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Bilder",
|
||||
|
||||
+99
-11
@@ -188,7 +188,7 @@ export const en = {
|
||||
"tools.install": "Install Ghidra",
|
||||
"tools.reinstall": "Check / repair installation",
|
||||
"tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…",
|
||||
"tools.ghidraHint": "Ghidra lets agents in craft Deep mode decompile programs (Windows .exe/.dll, Linux, DOS, …) to study how they work. Installed into the data volume together with its own JDK.",
|
||||
"tools.ghidraHint": "Ghidra lets agents decompile programs and firmware (Windows .exe/.dll, Linux, DOS, …) to study how they work – in craft Deep mode and in modes with reverse engineering tools (Settings → Agents & commands). Installed into the data volume together with its own JDK.",
|
||||
"rewind.intro": "The conversation is reset to before this message; you can edit and resend it.",
|
||||
"rewind.noFiles": "The agent did not change files since then.",
|
||||
"rewind.restore": "Also restore the {n} file(s) the agent changed",
|
||||
@@ -333,6 +333,41 @@ export const en = {
|
||||
"wizard.idea.cadBox": "A box with a snap-on lid, inner size 80 × 50 × 30 mm",
|
||||
"wizard.idea.cadBracket": "A wall bracket for a 25 mm pipe with two M4 screw holes",
|
||||
"activity.reviewing": "Reviewing the result (round {round})…",
|
||||
"chat.reviewPassed": "Review passed:",
|
||||
"chat.todoReminder": "Back to work – {n} open todos:",
|
||||
"agents.reverseTools": "Reverse engineering tools (binary_info, decompile with Ghidra)",
|
||||
"agents.reverseToolsHint": "The agent can inspect and decompile programs and firmware in this mode (Ghidra must be installed under Settings → Tools). Craft has them anyway in deep mode.",
|
||||
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
||||
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
||||
"chat.workSteps": "{n} steps",
|
||||
"chat.workFiles": "{n} files",
|
||||
"chat.loadOlder": "Load older messages",
|
||||
"chat.showBeforeSummary": "Show the history before the summary",
|
||||
"chat.backToLatest": "Back to the latest messages",
|
||||
"chat.search": "Search in this chat",
|
||||
"chat.searchPlaceholder": "Search messages, tool output …",
|
||||
"chat.searchNone": "No matches",
|
||||
"chat.searchHits": "{n} matches",
|
||||
"chat.you": "You",
|
||||
"chat.agent": "Agent",
|
||||
"media.title": "Media",
|
||||
"media.none": "No images, videos or audio in this chat yet.",
|
||||
"media.filter.all": "All",
|
||||
"media.filter.image": "Images",
|
||||
"media.filter.video": "Videos",
|
||||
"media.filter.model": "3D",
|
||||
"media.filter.audio": "Audio",
|
||||
"media.source.generated": "generated",
|
||||
"media.source.tool": "tool output",
|
||||
"media.source.written": "written by the agent",
|
||||
"media.source.answer": "in an answer",
|
||||
"media.source.upload": "your attachment",
|
||||
"media.showInChat": "Show in chat",
|
||||
"media.open": "Open",
|
||||
"media.prev": "Previous",
|
||||
"media.next": "Next",
|
||||
"media.back": "Back to the overview",
|
||||
"chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)",
|
||||
"chat.reviewRound": "Review {round}: changes requested",
|
||||
"agents.refine": "Review loop",
|
||||
"agents.refineRounds": "Review rounds per task",
|
||||
@@ -343,7 +378,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",
|
||||
@@ -354,13 +389,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",
|
||||
@@ -508,6 +542,11 @@ export const en = {
|
||||
"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",
|
||||
@@ -544,16 +583,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",
|
||||
@@ -594,8 +629,52 @@ export const en = {
|
||||
"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 (written to the file). “Allow everything”: the run never asks and never waits for approval.",
|
||||
"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.",
|
||||
@@ -618,6 +697,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",
|
||||
@@ -631,6 +713,7 @@ export const en = {
|
||||
"plan.implementPrompt": "Implement the plan.",
|
||||
|
||||
"tool.input": "Input",
|
||||
"tool.open3d": "Open in the 3D view",
|
||||
"tool.output": "Output",
|
||||
"tool.todosUpdated": "Tasks updated ({done}/{total} done)",
|
||||
"tool.todos": "Tasks",
|
||||
@@ -689,6 +772,9 @@ export const en = {
|
||||
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
||||
"settings.currency": "Currency",
|
||||
"settings.autoCompact": "Auto-compact at",
|
||||
"settings.commitTrailer": "Commit note of agents",
|
||||
"settings.commitTrailerOff": "off",
|
||||
"settings.commitTrailerHint": "Added as a trailer to every git commit an agent makes. {model} = model name, {provider} = provider, {model_id} = model id. Empty = off. Your own commits stay unchanged.",
|
||||
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
||||
"settings.craft": "Craft mode",
|
||||
"settings.cleanRoom": "Clean room",
|
||||
@@ -709,6 +795,8 @@ export const en = {
|
||||
"settings.pricesHint": "Prices per 1M tokens in the configured currency. Cache prices default to the input price.",
|
||||
"settings.modelsEmptyHint": "No models listed: they are discovered automatically from the provider.",
|
||||
"settings.context": "Context",
|
||||
"settings.maxInput": "Max input",
|
||||
"settings.maxInputHint": "Input limit, if the provider sets one below the context window (empty: the context window). The chat is compacted before the input reaches it.",
|
||||
"settings.maxOutput": "Max output",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Vision",
|
||||
|
||||
@@ -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>
|
||||
|
||||
+39
-2
@@ -32,6 +32,8 @@ export interface State {
|
||||
/** Includes subagent sessions loaded on demand. */
|
||||
sessionById: Record<string, Session>;
|
||||
messages: Record<string, Message[]>;
|
||||
/** Long chats are loaded in parts: are there older / newer messages than the loaded ones? */
|
||||
windows: Record<string, { older: boolean; newer: boolean } | undefined>;
|
||||
/** Sessions the current user may only read (shared with them). */
|
||||
readOnly: Record<string, boolean>;
|
||||
drafts: Record<string, Draft | undefined>;
|
||||
@@ -58,6 +60,7 @@ let state: State = {
|
||||
sessions: [],
|
||||
sessionById: {},
|
||||
messages: {},
|
||||
windows: {},
|
||||
readOnly: {},
|
||||
drafts: {},
|
||||
busy: {},
|
||||
@@ -130,7 +133,8 @@ export function applyEvent(ev: ServerEvent) {
|
||||
setState((s) => {
|
||||
const list = s.messages[ev.session_id];
|
||||
const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts;
|
||||
if (!list) return { drafts };
|
||||
// Viewing an older part of the chat: the new message comes with "back to the end".
|
||||
if (!list || s.windows[ev.session_id]?.newer) return { drafts };
|
||||
if (list.some((m) => m.id === ev.message.id)) return { drafts };
|
||||
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
||||
});
|
||||
@@ -231,9 +235,17 @@ export async function loadSessions() {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Messages loaded when a chat opens (from the last summary on, at most this many). */
|
||||
export const PAGE = 200;
|
||||
|
||||
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
|
||||
|
||||
export async function loadSession(id: string) {
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any }>(`/sessions/${id}`);
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>(
|
||||
`/sessions/${id}?limit=${PAGE}`,
|
||||
);
|
||||
setState((s) => ({
|
||||
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
|
||||
...upsertSession(s, data.session),
|
||||
sessionById: { ...s.sessionById, [id]: data.session },
|
||||
messages: { ...s.messages, [id]: data.messages },
|
||||
@@ -247,6 +259,31 @@ export async function loadSession(id: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Older messages before the loaded ones (scrolling up). Returns how many came. */
|
||||
export async function loadOlder(id: string, stopAtSummary = true): Promise<number> {
|
||||
const first = getState().messages[id]?.[0];
|
||||
if (!first) return 0;
|
||||
const page = await get<Page>(`/sessions/${id}/messages?before=${encodeURIComponent(first.id)}&limit=${PAGE}${stopAtSummary ? "&stop_at_summary=true" : ""}`);
|
||||
setState((s) => {
|
||||
const cur = s.messages[id] ?? [];
|
||||
if (cur[0]?.id !== first.id) return {}; // changed meanwhile
|
||||
return {
|
||||
messages: { ...s.messages, [id]: [...page.messages, ...cur] },
|
||||
windows: { ...s.windows, [id]: { older: page.has_older, newer: s.windows[id]?.newer ?? false } },
|
||||
};
|
||||
});
|
||||
return page.messages.length;
|
||||
}
|
||||
|
||||
/** Show the part of the chat around a message (search hit, media). */
|
||||
export async function loadAround(id: string, messageId: string) {
|
||||
const page = await get<Page>(`/sessions/${id}/messages?around=${encodeURIComponent(messageId)}&limit=${PAGE}`);
|
||||
setState((s) => ({
|
||||
messages: { ...s.messages, [id]: page.messages },
|
||||
windows: { ...s.windows, [id]: { older: page.has_older, newer: page.has_newer } },
|
||||
}));
|
||||
}
|
||||
|
||||
/** After reconnects: reload everything that could have changed. */
|
||||
export async function resync() {
|
||||
const isAdmin = getState().me?.role === "admin";
|
||||
|
||||
+528
-1
@@ -299,6 +299,25 @@ button.session-group:hover {
|
||||
.session-row .session-item {
|
||||
padding-right: 30px;
|
||||
}
|
||||
/* Group headers (folders, projects): menu button, drag and drop. */
|
||||
.group-row .session-group {
|
||||
padding-right: 30px;
|
||||
}
|
||||
.group-row .session-more {
|
||||
top: 4px;
|
||||
}
|
||||
.group-row[draggable="true"] .session-group {
|
||||
cursor: grab;
|
||||
}
|
||||
.group-row.drop-before {
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
.group-row.drop-after {
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
.session-group .group-pin {
|
||||
color: var(--accent);
|
||||
}
|
||||
.session-more {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -437,7 +456,8 @@ button.session-group:hover {
|
||||
}
|
||||
.topbar .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* The title stays readable: with many chips they wrap into a second row instead. */
|
||||
min-width: min(160px, 40vw);
|
||||
}
|
||||
.topbar h1 {
|
||||
font-size: 15px;
|
||||
@@ -508,6 +528,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;
|
||||
@@ -1035,6 +1069,13 @@ button.session-group:hover {
|
||||
.form label {
|
||||
color: var(--fg2);
|
||||
}
|
||||
.form label.check {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.form .hint {
|
||||
grid-column: 2;
|
||||
font-size: 12px;
|
||||
@@ -3131,6 +3172,28 @@ button.danger-solid {
|
||||
.refine-card summary .icon {
|
||||
color: var(--purple);
|
||||
}
|
||||
.harness-note {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 6px 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid var(--blue);
|
||||
border-radius: 4px;
|
||||
background: var(--bg1);
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.harness-note .icon {
|
||||
color: var(--blue);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.harness-note.ok {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
.harness-note.ok .icon {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* --- automations ---------------------------------------------------------------- */
|
||||
.job.off {
|
||||
@@ -3607,6 +3670,217 @@ button.danger-solid {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.job-tpl-card.active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.job-tpl .job-tpl-grid {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.job-title {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* --- automation editor ------------------------------------------------------ */
|
||||
.job-tabs {
|
||||
display: none;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
}
|
||||
.job-tabs button {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 8px 6px;
|
||||
color: var(--muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.job-tabs button.active {
|
||||
color: var(--fg0);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.job-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 1fr);
|
||||
}
|
||||
.job-editor-main {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.job-editor-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.job-editor-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.job-editor-row .field {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
margin: 0;
|
||||
}
|
||||
.job-editor-row select,
|
||||
.job-editor-row input {
|
||||
width: 100%;
|
||||
}
|
||||
.job-editor-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.job-editor-prompt {
|
||||
flex: 1;
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.job-editor-prompt-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
background: var(--bg1);
|
||||
}
|
||||
.job-editor-prompt .notice {
|
||||
margin: 8px;
|
||||
}
|
||||
.job-editor-chat {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--bg3);
|
||||
}
|
||||
.job-editor-chat .topbar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.job-assistant-start {
|
||||
margin: auto;
|
||||
max-width: 340px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.job-assistant-start .icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--accent);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.job-tabs {
|
||||
display: flex;
|
||||
}
|
||||
.job-editor {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.job-editor-chat {
|
||||
border-left: none;
|
||||
}
|
||||
.job-editor.tab-prompt .job-editor-settings,
|
||||
.job-editor.tab-settings .job-editor-prompt,
|
||||
.job-editor.tab-prompt .job-editor-chat,
|
||||
.job-editor.tab-settings .job-editor-chat,
|
||||
.job-editor.tab-assistant .job-editor-main {
|
||||
display: none;
|
||||
}
|
||||
.job-editor.tab-prompt .job-editor-main {
|
||||
padding: 0;
|
||||
}
|
||||
.job-editor.tab-prompt .job-editor-prompt {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- schedule builder ---------------------------------------------------------- */
|
||||
.cron-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cron-modes,
|
||||
.cron-days {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.cron-modes .chip-btn,
|
||||
.cron-days .chip-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cron-days .chip-btn {
|
||||
min-width: 44px;
|
||||
}
|
||||
.cron-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.cron-fields label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.cron-fields input[type="number"] {
|
||||
width: 70px;
|
||||
}
|
||||
.cron-custom {
|
||||
flex-direction: column;
|
||||
align-items: stretch !important;
|
||||
flex: 1;
|
||||
}
|
||||
.cron-custom input {
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
}
|
||||
.cron-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
align-items: baseline;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
.cron-preview code {
|
||||
background: var(--bg2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.cron-preview .err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.chip.price-extra {
|
||||
margin-left: 4px;
|
||||
@@ -3912,3 +4186,256 @@ span.chat-tab > button {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- long chats ------------------------------------------------------------------
|
||||
* Finished turns fold into one line, older messages load on demand, search hits. */
|
||||
.work-block > .work-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px 0;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.work-block > .work-head:hover {
|
||||
color: var(--fg1);
|
||||
}
|
||||
.work-block.open {
|
||||
border-left: 2px solid var(--bg2);
|
||||
padding-left: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.older-loader {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0 12px;
|
||||
}
|
||||
.back-to-latest {
|
||||
position: sticky;
|
||||
bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 10px auto 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.msg.highlight,
|
||||
.summary-divider[data-mid].highlight {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.chat-search {
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
padding: 6px 12px;
|
||||
background: var(--bg0);
|
||||
}
|
||||
.chat-search > .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-search > .row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-search-hits {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.chat-search-hits button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.chat-search-hits button:hover {
|
||||
background: var(--bg1);
|
||||
}
|
||||
.chat-search-hits .who {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.chat-search-hits .snippet {
|
||||
font-size: 13px;
|
||||
color: var(--fg1);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* --- media overview of a chat --------------------------------------------------- */
|
||||
.media-gallery {
|
||||
width: min(1100px, calc(100vw - 24px));
|
||||
height: min(820px, calc(100dvh - 24px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.media-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.media-head h3 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.media-filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
.media-filters .chip-btn {
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.media-filters small {
|
||||
color: var(--muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.media-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-auto-rows: 150px;
|
||||
gap: 8px;
|
||||
}
|
||||
.media-tile {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--bg3);
|
||||
}
|
||||
.media-tile img,
|
||||
.media-tile video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.media-tile .media-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
.media-audio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--fg2);
|
||||
word-break: break-all;
|
||||
}
|
||||
.media-audio .icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.media-viewer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.media-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.media-stage img,
|
||||
.media-stage video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.media-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.media-bar .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Tokens and cost under each answer. */
|
||||
.turn-usage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin: -6px 0 12px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg0);
|
||||
color: var(--accent);
|
||||
}
|
||||
.media-model {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-height: 100%;
|
||||
}
|
||||
.media-model img {
|
||||
max-height: calc(100% - 50px);
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.media-model > .icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const THEMES = [
|
||||
{ id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" },
|
||||
{ id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" },
|
||||
{ id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" },
|
||||
{ id: "tintenblau", name: "Tintenblau", source: "zink/tintenblau", license: "private" },
|
||||
] as const;
|
||||
|
||||
export type Theme = (typeof THEMES)[number]["id"];
|
||||
|
||||
@@ -30,3 +30,5 @@ import "./everforest-dark.css";
|
||||
import "./everforest-light.css";
|
||||
import "./kanagawa-dark.css";
|
||||
import "./kanagawa-light.css";
|
||||
import "./tintenblau-dark.css";
|
||||
import "./tintenblau-light.css";
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Tintenblau Dark
|
||||
* Palette: zink/tintenblau (colors.toml, neovim.lua, zed.json of the desktop theme).
|
||||
*/
|
||||
:root[data-theme="tintenblau-dark"] {
|
||||
color-scheme: dark;
|
||||
--theme-color: #232a44;
|
||||
|
||||
--bg0: #232a44;
|
||||
--bg1: #2b3352;
|
||||
--bg2: #353e60;
|
||||
--bg3: #4a5478;
|
||||
--fg0: #f5efe3;
|
||||
--fg1: #eae4d6;
|
||||
--fg2: #d6d0c2;
|
||||
--fg3: #bdcfd4;
|
||||
--muted: #8e96b4;
|
||||
|
||||
--accent: #7aa0cc;
|
||||
--accent2: #d4b46a;
|
||||
--on-accent: #232a44;
|
||||
--red: #d47a6a;
|
||||
--green: #96b4bc;
|
||||
--aqua: #96b4bc;
|
||||
--blue: #7aa0cc;
|
||||
--purple: #c896b2;
|
||||
|
||||
--diff-add: rgba(150, 180, 188, 0.16);
|
||||
--diff-del: rgba(212, 122, 106, 0.16);
|
||||
--shadow: 0 6px 24px rgba(10, 14, 30, 0.45);
|
||||
|
||||
--syntax-keyword: #c896b2;
|
||||
--syntax-string: #d47a6a;
|
||||
--syntax-number: #96b4bc;
|
||||
--syntax-comment: #8e96b4;
|
||||
--syntax-function: #7aa0cc;
|
||||
--syntax-type: #d4b46a;
|
||||
--syntax-variable: #eae4d6;
|
||||
--syntax-meta: #baa0c4;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Tintenblau (light)
|
||||
* Palette: zink/tintenblau (colors.toml, neovim.lua, zed.json of the desktop theme).
|
||||
*/
|
||||
:root[data-theme="tintenblau-light"] {
|
||||
color-scheme: light;
|
||||
--theme-color: #f0ead9;
|
||||
|
||||
--bg0: #f0ead9;
|
||||
--bg1: #e6dfd0;
|
||||
--bg2: #dbd3c1;
|
||||
--bg3: #c9c0ac;
|
||||
--fg0: #0f1628;
|
||||
--fg1: #1e2a4a;
|
||||
--fg2: #3a3f5a;
|
||||
--fg3: #5e5a6a;
|
||||
--muted: #8a8694;
|
||||
|
||||
--accent: #2a3f6f;
|
||||
--accent2: #6a4a2c;
|
||||
--on-accent: #f0ead9;
|
||||
--red: #8a3028;
|
||||
--green: #4a5a3a;
|
||||
--aqua: #3a5a6a;
|
||||
--blue: #3a5a8a;
|
||||
--purple: #5a4a72;
|
||||
|
||||
--diff-add: rgba(74, 90, 58, 0.14);
|
||||
--diff-del: rgba(138, 48, 40, 0.12);
|
||||
--shadow: 0 6px 24px rgba(30, 42, 74, 0.16);
|
||||
|
||||
--syntax-keyword: #2a3f6f;
|
||||
--syntax-string: #8a3028;
|
||||
--syntax-number: #5a4a72;
|
||||
--syntax-comment: #8a8694;
|
||||
--syntax-function: #3a5a6a;
|
||||
--syntax-type: #6a4a2c;
|
||||
--syntax-variable: #1e2a4a;
|
||||
--syntax-meta: #4a5a3a;
|
||||
}
|
||||
+6
-1
@@ -31,6 +31,8 @@ export interface Usage {
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
/** Cost of the request (charged, or valued at list prices for plan requests). */
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -141,6 +143,7 @@ export interface ModelInfo {
|
||||
name: string;
|
||||
context?: number;
|
||||
max_output?: number | null;
|
||||
max_input?: number | null;
|
||||
reasoning: boolean;
|
||||
vision: boolean;
|
||||
effort?: string | null;
|
||||
@@ -166,7 +169,9 @@ export interface AgentInfo {
|
||||
tools?: string[] | null;
|
||||
source: "builtin" | "config" | "file" | "personal";
|
||||
max_parallel?: number | null;
|
||||
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
|
||||
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
|
||||
/** binary_info and decompile (Ghidra) available in this mode. */
|
||||
reverse?: boolean;
|
||||
/** Source file of Markdown agents. */
|
||||
path?: string | null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,58 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00024": {
|
||||
en: ["3D models with fal.ai: from an image (also pasted into the chat), a text prompt or both – open them in the 3D view"],
|
||||
de: ["3D-Modelle mit fal.ai: aus einem Bild (auch eingefügt), einem Text oder beidem – öffnen in der 3D-Ansicht"],
|
||||
},
|
||||
"202611-00023": {
|
||||
en: ["Tokens and cost under every answer; the total cost of chats with opencode Go is shown correctly again"],
|
||||
de: ["Tokens und Kosten unter jeder Antwort; der Gesamtpreis von Chats mit opencode Go stimmt wieder"],
|
||||
},
|
||||
"202611-00022": {
|
||||
en: ["New theme “Tintenblau” (Settings → General → Theme)"],
|
||||
de: ["Neues Theme „Tintenblau“ (Einstellungen → Allgemein → Theme)"],
|
||||
},
|
||||
"202611-00021": {
|
||||
en: ["Git commits of agents name the model: “Co-Authored-By: <model> (<provider>) via CodeCraft” (Settings → General)"],
|
||||
de: ["Git-Commits von Agenten nennen das Modell: „Co-Authored-By: <Modell> (<Anbieter>) via CodeCraft“ (Einstellungen → Allgemein)"],
|
||||
},
|
||||
"202611-00020": {
|
||||
en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"],
|
||||
de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"],
|
||||
},
|
||||
"202611-00019": {
|
||||
en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"],
|
||||
de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"],
|
||||
},
|
||||
"202611-00018": {
|
||||
en: [
|
||||
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
|
||||
"Long chats are compacted in time instead of failing with “input too long”",
|
||||
],
|
||||
de: [
|
||||
"Modi mit Review: jedes erledigte Todo wird geprüft, und der Agent arbeitet weiter, bis die Liste abgehakt ist – für Läufe über Nacht",
|
||||
"Lange Chats werden rechtzeitig zusammengefasst, statt mit „Eingabe zu lang“ abzubrechen",
|
||||
],
|
||||
},
|
||||
"202611-00017": {
|
||||
en: ["Your messages are shown as Markdown; the eye button next to the text field previews it before sending"],
|
||||
de: ["Deine Nachrichten werden als Markdown dargestellt; das Augen-Symbol neben dem Textfeld zeigt vor dem Senden eine Vorschau"],
|
||||
},
|
||||
"202611-00014": {
|
||||
en: ["Folder picker (new automation, new chat): create a new folder right there"],
|
||||
de: ["Ordnerauswahl (neue Automatisierung, neuer Chat): direkt einen neuen Ordner anlegen"],
|
||||
},
|
||||
"202611-00013": {
|
||||
en: [
|
||||
"New automation editor: instructions, a graphical schedule and an assistant chat that writes the instructions with you",
|
||||
"Chat list: drag folders and projects into order or pin them (⋯ on the folder); new messages no longer move the whole project up",
|
||||
],
|
||||
de: [
|
||||
"Neuer Editor für Automatisierungen: Anweisungen, grafischer Zeitplan und ein Assistent-Chat, der die Anweisungen mit dir schreibt",
|
||||
"Chatliste: Ordner und Projekte per Drag & Drop sortieren oder fixieren (⋯ am Ordner); neue Nachrichten schieben nicht mehr das ganze Projekt nach oben",
|
||||
],
|
||||
},
|
||||
"202611-00012": {
|
||||
en: ["Automations: set the approval mode right in the list – “Allow everything” runs unattended without asking", "⋯ → Send questions to Telegram: per chat, off for new chats"],
|
||||
de: ["Automatisierungen: Freigabemodus direkt in der Liste wählen – „Alles erlauben“ läuft nachts ohne Rückfrage", "⋯ → Fragen an Telegram senden: pro Chat, bei neuen Chats aus"],
|
||||
|
||||
Reference in new issue
Block a user