Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34a0c7a73 | ||
|
|
62fe5bf467 | ||
|
|
084452abf6 | ||
|
|
8b4bd59572 | ||
|
|
c3df4a23cc | ||
|
|
48621f21d6 | ||
|
|
d7baa77922 | ||
|
|
1e8418336d | ||
|
|
33612bf6d4 | ||
|
|
ffecd62b83 | ||
|
|
c77dcf5890 | ||
|
|
9c80f2c857 | ||
|
|
1253430ed4 | ||
|
|
e7d4b9ff76 | ||
|
|
b71dfc49c3 | ||
|
|
3b0764450a | ||
|
|
6e9eda28b5 | ||
|
|
bd896794fd |
No files matched your search
+137
@@ -3,6 +3,143 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00026] – 2026-11-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Claude Code as an engine:** chats with a "Claude Code" model (Opus, Sonnet, Haiku, default) are
|
||||||
|
run by the `claude` CLI. Its events become normal chat messages; its tool approvals go through
|
||||||
|
CodeCraft's permission system (cards, push buttons, "allow for this project", diffs, sandbox).
|
||||||
|
Sign in from the web UI (Settings → Account → Claude Code: open the sign-in URL, paste the code),
|
||||||
|
see the CLI version and install updates (admins; installed into the persistent home). The Docker
|
||||||
|
image now contains the CLI.
|
||||||
|
- **Small models (8k context) for side tasks:** `small_model` jobs cut their input to the model's
|
||||||
|
context window. New with it: a one-line status per chat in the session list, "What does this
|
||||||
|
do?" on approvals, and "Plan as automation" (drafts job instructions from a chat).
|
||||||
|
- **Mobile:** the top bar no longer pushes the page sideways (extra actions moved into the ⋯ menu);
|
||||||
|
an image viewer for all images (zoom, swipe between images, mark up and attach to the message,
|
||||||
|
use in the message); jump to the previous own message; message menu (copy, quote, fork from
|
||||||
|
here, edit and resend); date and time under every message.
|
||||||
|
- **Approvals:** push notifications can allow or reject right away; "always allow in this project"
|
||||||
|
is remembered per folder (never for dangerous commands) and can be revoked in the ⋯ menu.
|
||||||
|
- **Changes per turn:** files changed by a turn with diffs and "roll back to before this turn".
|
||||||
|
- **Check after each turn:** a command per project (e.g. `cargo test`) runs after turns that
|
||||||
|
changed files; green/red badge with output and "let the agent fix it".
|
||||||
|
- **Colour tags for chats:** tinted chat background and a badge in the list, drawn in the colours
|
||||||
|
of the active theme.
|
||||||
|
- **Project defaults, project page, offline queue:** new chats in a folder inherit model, mode,
|
||||||
|
colour, instructions and folder; a timeline of a project's chats (sidebar → project → ⋯);
|
||||||
|
messages written without a connection are sent when it is back.
|
||||||
|
|
||||||
|
## [v202611-00025] – 2026-11-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Models did not find built-in tools via `mcp_search`** (e.g. `generate_3d` in a chat that
|
||||||
|
had just been switched from plan to build): the search only looked at MCP servers. It now also
|
||||||
|
lists matching built-in tools and says whether the current mode has them ("available – call it
|
||||||
|
directly", or "not in the read-only mode plan – ask the user to switch to build").
|
||||||
|
|
||||||
|
## [v202611-00024] – 2026-11-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **3D models with fal.ai (`generate_3d`):** from an image (a project file or one pasted into the
|
||||||
|
chat, optional back/left/right views), from a text prompt, or from an image steered by a prompt
|
||||||
|
(the image is refined with the prompt first – the 3D models take no text with an image).
|
||||||
|
Styles textured, geometry (untextured, for 3D printing) and low-poly, optional PBR. Uses fal.ai's
|
||||||
|
queue (takes minutes, with progress), saves a GLB plus a preview image, books the cost.
|
||||||
|
Defaults: Hunyuan3D v3 image-to-3D / text-to-3D (`[images] model_3d`, `model_3d_text`); Tripo
|
||||||
|
and Trellis endpoints work too.
|
||||||
|
- The chat card offers "Open in the 3D view"; the media overview lists 3D models (also STL/3MF
|
||||||
|
written by the agent) with their previews.
|
||||||
|
|
||||||
|
## [v202611-00023] – 2026-11-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Tokens and cost under every answer:** a small line under each answer of the model shows the
|
||||||
|
tokens sent/received and the cost of all requests of that turn.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Total cost of chats stopped growing (opencode Go):** opencode now reports plan requests as
|
||||||
|
"nothing charged" (cost 0, usage in `allowance`), and CodeCraft took the 0. Plan requests are
|
||||||
|
valued at the model's list prices again; earlier ones are corrected once on start.
|
||||||
|
- **Saving opencode Go models failed after fetching prices** ("data did not match any variant of
|
||||||
|
untagged enum ModelEntry"): opencode lists cache prices per duration for some models
|
||||||
|
(`{"30m": …, "12h": …}`); the shortest duration is used now.
|
||||||
|
- The chat title in the top bar no longer shrinks to a single letter when there are many chips.
|
||||||
|
|
||||||
|
## [v202611-00022] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Theme "Tintenblau"** (dark: ink blue with cream text; light: paper with dark ink), from the
|
||||||
|
Tintenblau desktop theme; also in the terminal client.
|
||||||
|
|
||||||
|
## [v202611-00021] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Commits of agents name the model:** every `git commit` an agent makes gets the trailer
|
||||||
|
`Co-Authored-By: <model> (<provider>) via CodeCraft` (a git wrapper in the agent shell, also in
|
||||||
|
the sandbox; your own commits stay unchanged). The text is set under *Settings → General*
|
||||||
|
(`server.commit_trailer`, `{model}`, `{provider}`, `{model_id}`; empty = off).
|
||||||
|
|
||||||
|
## [v202611-00020] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Reverse engineering outside craft:** *Settings → Agents & commands* can give any mode the
|
||||||
|
`binary_info` and `decompile` (Ghidra) tools; the clean-room rule stays a craft rule.
|
||||||
|
- **Raw firmware in `decompile`:** optional `processor` (Ghidra language id, e.g.
|
||||||
|
`ARM:LE:32:Cortex`) and `base_address` for images without a file format (.bin, converted .hex).
|
||||||
|
- **Skill "reverse-engineering":** how to identify, decompile and explore programs and firmware
|
||||||
|
(processor table for common chips, HEX conversion, notes in `analysis/`).
|
||||||
|
|
||||||
|
## [v202611-00019] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Search in a chat:** the magnifier in the chat's top bar searches all of it (messages, tool
|
||||||
|
output) on the server; a hit loads its part of the chat and marks the message.
|
||||||
|
- **Media of a chat:** *⋯ → Media* shows every image, video and audio file of the chat and its
|
||||||
|
subagents – generated images, screenshots, files the agent wrote, media in answers, your
|
||||||
|
attachments – filterable, large view, "show in chat" jumps to the message.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Long chats stay fast:** a chat opens with its messages since the last summary (at most 200);
|
||||||
|
older ones load when scrolling up, the history before a summary on request. Finished turns
|
||||||
|
fold into one line ("42 steps · 3 files", the answer stays visible). Typing, streaming and
|
||||||
|
status changes no longer re-render the whole history, and tool output is only built when a
|
||||||
|
card is opened. Measured with a chat of 5,900 messages: opening 9 s → 0.7 s (phone-like
|
||||||
|
CPU: 22 s → 1 s), typing 440 ms → 25 ms per key.
|
||||||
|
|
||||||
|
## [v202611-00018] – 2026-11-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Enforced reviews:** when a review is configured for a mode, every todo the agent marks
|
||||||
|
completed is reviewed; with findings the todo is reopened and the agent has to fix them and
|
||||||
|
complete it again (up to the configured rounds per todo). Ending the turn with open todos sends
|
||||||
|
the agent back to work as long as it makes progress – for unattended runs, e.g. overnight
|
||||||
|
(such modes may take up to 2,000 steps per turn). The chat shows passed reviews and these
|
||||||
|
reminders; *Settings → Modes* can switch it off per mode (then only one review at the end of a
|
||||||
|
turn, as before).
|
||||||
|
- **Max input per model:** providers that limit the input below the context window (e.g. 272k of
|
||||||
|
400k) are taken into account; fetched from the model catalogue, editable in the provider settings.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Context overflow instead of compaction** (e.g. DeepSeek V4.1 Flash with 384k max output):
|
||||||
|
requests asked for the model's full max output, and providers count input + output against the
|
||||||
|
window, so long chats failed before automatic compaction kicked in. The output budget is now what
|
||||||
|
the window leaves, the chat is compacted when no reasonable room for an answer is left, and a
|
||||||
|
"context too long" error compacts and retries once instead of stopping. A turn that hits the
|
||||||
|
step limit now says so.
|
||||||
|
|
||||||
## [v202611-00017] – 2026-11-10
|
## [v202611-00017] – 2026-11-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+3
-1
@@ -89,13 +89,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
|
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
|
||||||
# installed by agents: rustup component add rust-analyzer, go install …).
|
# installed by agents: rustup component add rust-analyzer, go install …).
|
||||||
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
|
RUN npm install -g typescript@5 typescript-language-server pyright @anthropic-ai/claude-code && npm cache clean --force
|
||||||
COPY --from=server /codecraft /usr/local/bin/codecraft
|
COPY --from=server /codecraft /usr/local/bin/codecraft
|
||||||
COPY --from=web /web/dist /app/web
|
COPY --from=web /web/dist /app/web
|
||||||
COPY --from=cli /out /app/cli
|
COPY --from=cli /out /app/cli
|
||||||
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
||||||
COPY --from=dockercli /docker /usr/local/bin/docker
|
COPY --from=dockercli /docker /usr/local/bin/docker
|
||||||
COPY server/ghidra /app/ghidra
|
COPY server/ghidra /app/ghidra
|
||||||
|
# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer).
|
||||||
|
COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git
|
||||||
COPY server/resources /app/resources
|
COPY server/resources /app/resources
|
||||||
# The agent's home lives on the data volume: tools it installs in user space
|
# The agent's home lives on the data volume: tools it installs in user space
|
||||||
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
|
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00017" src="https://img.shields.io/badge/version-v202611--00017-d69e2e?style=flat-square">
|
<img alt="Version v202611-00026" src="https://img.shields.io/badge/version-v202611--00026-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
@@ -176,6 +176,10 @@ output limit, reasoning effort, extra request options and prices per million tok
|
|||||||
- **Images (fal.ai):** the agent drafts cheap previews (FLUX schnell), asks which one to
|
- **Images (fal.ai):** the agent drafts cheap previews (FLUX schnell), asks which one to
|
||||||
refine, and creates the final image with the best model for the purpose – Nano Banana Pro
|
refine, and creates the final image with the best model for the purpose – Nano Banana Pro
|
||||||
refines the chosen draft, Ideogram handles lettering, Recraft design work.
|
refines the chosen draft, Ideogram handles lettering, Recraft design work.
|
||||||
|
- **3D models (fal.ai):** `generate_3d` turns an image (also one pasted into the chat, plus
|
||||||
|
optional back/left/right views), a text prompt, or an image steered by a prompt into a GLB
|
||||||
|
model – textured, untextured for 3D printing, or low-poly (Hunyuan3D v3 by default,
|
||||||
|
`[images] model_3d` / `model_3d_text`). Open it in the 3D view of the file manager.
|
||||||
|
|
||||||
## Terminal client
|
## Terminal client
|
||||||
|
|
||||||
@@ -296,6 +300,7 @@ in turn are based on:
|
|||||||
| Coffee | Dark, Latte | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) | MIT |
|
| Coffee | Dark, Latte | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) | MIT |
|
||||||
| Solarized | Dark, Light | [Solarized by Ethan Schoonover](https://ethanschoonover.com/solarized/) | MIT |
|
| Solarized | Dark, Light | [Solarized by Ethan Schoonover](https://ethanschoonover.com/solarized/) | MIT |
|
||||||
| Tokyo Night | Night, Day | [folke/tokyonight.nvim](https://github.com/folke/tokyonight.nvim) | Apache-2.0 |
|
| Tokyo Night | Night, Day | [folke/tokyonight.nvim](https://github.com/folke/tokyonight.nvim) | Apache-2.0 |
|
||||||
|
| Tintenblau | Dark, Light | original (zink/tintenblau desktop theme) | private |
|
||||||
|
|
||||||
Further standard themes (palettes re-created from the published colour specifications):
|
Further standard themes (palettes re-created from the published colour specifications):
|
||||||
|
|
||||||
|
|||||||
@@ -701,6 +701,60 @@
|
|||||||
"syntax-type": "#b58900",
|
"syntax-type": "#b58900",
|
||||||
"syntax-variable": "#586e75"
|
"syntax-variable": "#586e75"
|
||||||
},
|
},
|
||||||
|
"tintenblau-dark": {
|
||||||
|
"accent": "#7aa0cc",
|
||||||
|
"accent2": "#d4b46a",
|
||||||
|
"aqua": "#96b4bc",
|
||||||
|
"bg0": "#232a44",
|
||||||
|
"bg1": "#2b3352",
|
||||||
|
"bg2": "#353e60",
|
||||||
|
"bg3": "#4a5478",
|
||||||
|
"blue": "#7aa0cc",
|
||||||
|
"fg0": "#f5efe3",
|
||||||
|
"fg1": "#eae4d6",
|
||||||
|
"fg2": "#d6d0c2",
|
||||||
|
"fg3": "#bdcfd4",
|
||||||
|
"green": "#96b4bc",
|
||||||
|
"muted": "#8e96b4",
|
||||||
|
"on-accent": "#232a44",
|
||||||
|
"purple": "#c896b2",
|
||||||
|
"red": "#d47a6a",
|
||||||
|
"syntax-comment": "#8e96b4",
|
||||||
|
"syntax-function": "#7aa0cc",
|
||||||
|
"syntax-keyword": "#c896b2",
|
||||||
|
"syntax-meta": "#baa0c4",
|
||||||
|
"syntax-number": "#96b4bc",
|
||||||
|
"syntax-string": "#d47a6a",
|
||||||
|
"syntax-type": "#d4b46a",
|
||||||
|
"syntax-variable": "#eae4d6"
|
||||||
|
},
|
||||||
|
"tintenblau-light": {
|
||||||
|
"accent": "#2a3f6f",
|
||||||
|
"accent2": "#6a4a2c",
|
||||||
|
"aqua": "#3a5a6a",
|
||||||
|
"bg0": "#f0ead9",
|
||||||
|
"bg1": "#e6dfd0",
|
||||||
|
"bg2": "#dbd3c1",
|
||||||
|
"bg3": "#c9c0ac",
|
||||||
|
"blue": "#3a5a8a",
|
||||||
|
"fg0": "#0f1628",
|
||||||
|
"fg1": "#1e2a4a",
|
||||||
|
"fg2": "#3a3f5a",
|
||||||
|
"fg3": "#5e5a6a",
|
||||||
|
"green": "#4a5a3a",
|
||||||
|
"muted": "#8a8694",
|
||||||
|
"on-accent": "#f0ead9",
|
||||||
|
"purple": "#5a4a72",
|
||||||
|
"red": "#8a3028",
|
||||||
|
"syntax-comment": "#8a8694",
|
||||||
|
"syntax-function": "#3a5a6a",
|
||||||
|
"syntax-keyword": "#2a3f6f",
|
||||||
|
"syntax-meta": "#4a5a3a",
|
||||||
|
"syntax-number": "#5a4a72",
|
||||||
|
"syntax-string": "#8a3028",
|
||||||
|
"syntax-type": "#6a4a2c",
|
||||||
|
"syntax-variable": "#1e2a4a"
|
||||||
|
},
|
||||||
"tokyonight-dark": {
|
"tokyonight-dark": {
|
||||||
"accent": "#7aa2f7",
|
"accent": "#7aa2f7",
|
||||||
"accent2": "#ff9e64",
|
"accent2": "#ff9e64",
|
||||||
|
|||||||
+25
-1
@@ -64,6 +64,9 @@ pub struct ModelConfig {
|
|||||||
pub context: Option<u64>,
|
pub context: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub max_output: Option<u64>,
|
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)]
|
#[serde(default)]
|
||||||
pub reasoning: bool,
|
pub reasoning: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -156,7 +159,13 @@ impl ModelConfig {
|
|||||||
/// Cost of one request now. Cache prices default to the input price.
|
/// Cost of one request now. Cache prices default to the input price.
|
||||||
/// The provider's own figure wins when it reports one (opencode).
|
/// The provider's own figure wins when it reports one (opencode).
|
||||||
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
||||||
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
|
match u.cost {
|
||||||
|
// A plan request (opencode Go): nothing charged, the usage counts toward the plan –
|
||||||
|
// its value at the model's list prices.
|
||||||
|
Some(c) if c == 0.0 && u.allowance.is_some_and(|a| a > 0.0) => self.cost_at(u, chrono::Utc::now()),
|
||||||
|
Some(c) => c,
|
||||||
|
None => self.cost_at(u, chrono::Utc::now()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cost of one request at a given time: context tiers and time factors applied.
|
/// Cost of one request at a given time: context tiers and time factors applied.
|
||||||
@@ -184,6 +193,21 @@ mod price_tests {
|
|||||||
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_requests_are_valued_at_list_prices() {
|
||||||
|
let m = ModelConfig { input_price: Some(1.0), output_price: Some(2.0), ..Default::default() };
|
||||||
|
let mut u = usage(1_000_000, 1_000_000, 0);
|
||||||
|
u.cost = Some(0.0);
|
||||||
|
u.allowance = Some(0.4);
|
||||||
|
assert_eq!(m.cost(&u), 3.0);
|
||||||
|
// Charged by the provider: that amount; free without a plan: free.
|
||||||
|
u.cost = Some(0.7);
|
||||||
|
assert_eq!(m.cost(&u), 0.7);
|
||||||
|
u.cost = Some(0.0);
|
||||||
|
u.allowance = None;
|
||||||
|
assert_eq!(m.cost(&u), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tiers_and_multipliers() {
|
fn tiers_and_multipliers() {
|
||||||
let m = ModelConfig {
|
let m = ModelConfig {
|
||||||
|
|||||||
+13
-1
@@ -187,8 +187,17 @@ pub struct Session {
|
|||||||
/// unless it is the chat currently open in Telegram.
|
/// unless it is the chat currently open in Telegram.
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub telegram_quiet: bool,
|
pub telegram_quiet: bool,
|
||||||
|
/// One-line "where things stand", written by the small model after a turn (session list).
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub summary: String,
|
||||||
|
/// Colour tag of the chat (a palette name, see `SESSION_COLORS`); the client maps it to the theme.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Names of the colour tags: the client draws them with the colours of the active theme.
|
||||||
|
pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua", "blue", "purple"];
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn clean_room(&self, default: bool) -> bool {
|
pub fn clean_room(&self, default: bool) -> bool {
|
||||||
self.clean_room.unwrap_or(default)
|
self.clean_room.unwrap_or(default)
|
||||||
@@ -243,6 +252,8 @@ pub enum Event {
|
|||||||
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
|
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
|
||||||
/// a nearly full context (tokens of limit).
|
/// a nearly full context (tokens of limit).
|
||||||
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
|
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
|
||||||
|
/// The project's check command ran after a turn: status "running" | "passed" | "failed" | "timeout".
|
||||||
|
CheckResult { session_id: String, message_id: String, status: String, exit_code: Option<i32>, duration_ms: u64, output: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Event {
|
impl Event {
|
||||||
@@ -265,7 +276,8 @@ impl Event {
|
|||||||
| Waiting { session_id, .. }
|
| Waiting { session_id, .. }
|
||||||
| QueueUpdated { session_id, .. }
|
| QueueUpdated { session_id, .. }
|
||||||
| StepStarted { session_id, .. }
|
| StepStarted { session_id, .. }
|
||||||
| Compaction { session_id, .. } => Some(session_id),
|
| Compaction { session_id, .. }
|
||||||
|
| CheckResult { session_id, .. } => Some(session_id),
|
||||||
McpStatus { .. } => None,
|
McpStatus { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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" "$@"
|
||||||
@@ -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.
|
||||||
+348
-7
@@ -81,11 +81,20 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
||||||
.route("/sessions/{id}/export", get(export_session))
|
.route("/sessions/{id}/export", get(export_session))
|
||||||
.route("/blobs/{hash}", get(get_blob))
|
.route("/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}", get(get_session).patch(update_session).delete(delete_session))
|
||||||
.route("/sessions/{id}/prompt", post(prompt))
|
.route("/sessions/{id}/prompt", post(prompt))
|
||||||
.route("/sessions/{id}/abort", post(abort))
|
.route("/sessions/{id}/abort", post(abort))
|
||||||
.route("/sessions/{id}/compact", post(compact))
|
.route("/sessions/{id}/compact", post(compact))
|
||||||
.route("/sessions/{id}/rewind", post(rewind))
|
.route("/sessions/{id}/rewind", post(rewind))
|
||||||
|
.route("/sessions/{id}/job-draft", post(job_draft))
|
||||||
|
.route("/projects/overview", get(project_overview))
|
||||||
|
.route("/sessions/{id}/project-defaults", get(get_project_defaults).put(put_project_defaults).delete(delete_project_defaults))
|
||||||
|
.route("/sessions/{id}/check", get(get_check).put(put_check))
|
||||||
|
.route("/sessions/{id}/checks", get(list_checks))
|
||||||
|
.route("/sessions/{id}/check/run", post(run_check))
|
||||||
.route("/sessions/{id}/changes", get(changed_files))
|
.route("/sessions/{id}/changes", get(changed_files))
|
||||||
.route("/sessions/{id}/shells", get(list_shells))
|
.route("/sessions/{id}/shells", get(list_shells))
|
||||||
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
|
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
|
||||||
@@ -95,6 +104,13 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions/{id}/queue/flush", post(flush_queue))
|
.route("/sessions/{id}/queue/flush", post(flush_queue))
|
||||||
.route("/sessions/{id}/queue/{item}", delete(delete_queue_item))
|
.route("/sessions/{id}/queue/{item}", delete(delete_queue_item))
|
||||||
.route("/permissions/{id}", post(reply_permission))
|
.route("/permissions/{id}", post(reply_permission))
|
||||||
|
.route("/permissions/{id}/explain", post(explain_permission))
|
||||||
|
.route("/claude/status", get(claude_status))
|
||||||
|
.route("/claude/login", post(claude_login_start).delete(claude_login_cancel))
|
||||||
|
.route("/claude/login/code", post(claude_login_code))
|
||||||
|
.route("/claude/logout", post(claude_logout))
|
||||||
|
.route("/claude/update", post(claude_update))
|
||||||
|
.route("/sessions/{id}/approvals", get(project_approvals).delete(delete_project_approval))
|
||||||
.route("/questions/{id}", post(answer_question))
|
.route("/questions/{id}", post(answer_question))
|
||||||
.route("/pending", get(pending))
|
.route("/pending", get(pending))
|
||||||
.route("/mcp", get(mcp_status))
|
.route("/mcp", get(mcp_status))
|
||||||
@@ -592,13 +608,17 @@ fn validate_cwd(app: &App, user: &User, cwd: Option<String>) -> Result<PathBuf,
|
|||||||
async fn create_session(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
|
async fn create_session(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
|
||||||
let cwd = validate_cwd(&app, &user, body.cwd)?;
|
let cwd = validate_cwd(&app, &user, body.cwd)?;
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
|
// What the project's chats start with (⋯ menu "use as project default").
|
||||||
|
let defaults = app.store.project_defaults(&cwd.display().to_string()).ok().flatten().unwrap_or(Value::Null);
|
||||||
|
let dstr = |k: &str| defaults[k].as_str().filter(|v| !v.is_empty()).map(String::from);
|
||||||
let model = body
|
let model = body
|
||||||
.model
|
.model
|
||||||
.filter(|m| !m.is_empty())
|
.filter(|m| !m.is_empty())
|
||||||
|
.or_else(|| dstr("model").filter(|m| app.registry().resolve(m).is_ok()))
|
||||||
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
|
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
|
||||||
.or_else(|| app.registry().first_model())
|
.or_else(|| app.registry().first_model())
|
||||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
|
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
|
||||||
let mode = body.permission_mode.unwrap_or_else(|| "auto".into());
|
let mode = body.permission_mode.or_else(|| dstr("permission_mode")).unwrap_or_else(|| "auto".into());
|
||||||
if !crate::permission::MODES.contains(&mode.as_str()) {
|
if !crate::permission::MODES.contains(&mode.as_str()) {
|
||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||||
}
|
}
|
||||||
@@ -621,10 +641,12 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
worktree: None,
|
worktree: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: dstr("folder"),
|
||||||
instructions: String::new(),
|
instructions: dstr("instructions").unwrap_or_default(),
|
||||||
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
||||||
telegram_quiet: true,
|
telegram_quiet: true,
|
||||||
|
summary: String::new(),
|
||||||
|
color: dstr("color").filter(|c| codecraft_core::types::SESSION_COLORS.contains(&c.as_str())),
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
@@ -813,14 +835,94 @@ async fn export_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
.into_response())
|
.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 s = readable(&app, &user, &id)?;
|
||||||
let messages = app.store.messages(&id)?;
|
|
||||||
let busy = app.is_busy(&id);
|
let busy = app.is_busy(&id);
|
||||||
let can_write = app.access(&user, &id) == Access::Full;
|
let can_write = app.access(&user, &id) == Access::Full;
|
||||||
let queue = app.store.queue_list(&id)?;
|
let queue = app.store.queue_list(&id)?;
|
||||||
let step = app.with_runtime(&id, |r| r.step.clone());
|
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)]
|
#[derive(Deserialize)]
|
||||||
@@ -838,6 +940,8 @@ struct UpdateSession {
|
|||||||
instructions: Option<String>,
|
instructions: Option<String>,
|
||||||
/// Approvals and questions of this chat are not sent to Telegram.
|
/// Approvals and questions of this chat are not sent to Telegram.
|
||||||
telegram_quiet: Option<bool>,
|
telegram_quiet: Option<bool>,
|
||||||
|
/// Colour tag ("" removes it).
|
||||||
|
color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||||
@@ -850,6 +954,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if let Some(m) = &body.model {
|
if let Some(m) = &body.model {
|
||||||
app.registry().resolve(m)?;
|
app.registry().resolve(m)?;
|
||||||
}
|
}
|
||||||
|
if body.color.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_COLORS.contains(&c)) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid colour".into()));
|
||||||
|
}
|
||||||
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
|
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
|
||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||||
}
|
}
|
||||||
@@ -889,6 +996,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if let Some(q) = body.telegram_quiet {
|
if let Some(q) = body.telegram_quiet {
|
||||||
s.telegram_quiet = q;
|
s.telegram_quiet = q;
|
||||||
}
|
}
|
||||||
|
if let Some(c) = body.color {
|
||||||
|
s.color = (!c.is_empty()).then_some(c);
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
}
|
}
|
||||||
@@ -1148,13 +1258,47 @@ async fn kill_shell(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ChangesQuery {
|
struct ChangesQuery {
|
||||||
since_message: String,
|
since_message: String,
|
||||||
|
/// Include a unified diff per file.
|
||||||
|
#[serde(default)]
|
||||||
|
diff: bool,
|
||||||
|
/// Only changes before this message (one turn instead of everything since).
|
||||||
|
until_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Files the agent changed since a message (for the rewind dialog).
|
/// Files the agent changed since a message (for the rewind dialog).
|
||||||
async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult {
|
async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult {
|
||||||
readable(&app, &user, &id)?;
|
readable(&app, &user, &id)?;
|
||||||
let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX);
|
let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX);
|
||||||
let files: Vec<Value> = app.store.checkpoints_since(&id, since)?.into_iter().map(|(p, c)| json!({"path": p, "created": c.is_none()})).collect();
|
let until = match &q.until_message {
|
||||||
|
Some(m) => app.store.message_created_at(&id, m)?.unwrap_or(i64::MAX),
|
||||||
|
None => i64::MAX,
|
||||||
|
};
|
||||||
|
let files: Vec<Value> = app
|
||||||
|
.store
|
||||||
|
.checkpoints_between(&id, since, until)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(p, c)| {
|
||||||
|
let mut v = json!({"path": p, "created": c.is_none()});
|
||||||
|
if q.diff {
|
||||||
|
// Checkpoint (state before the agent's first change) against the file now.
|
||||||
|
let old = c.map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default();
|
||||||
|
let new = std::fs::read_to_string(&p);
|
||||||
|
v["deleted"] = json!(new.is_err());
|
||||||
|
let new = new.unwrap_or_default();
|
||||||
|
let mut diff = similar::TextDiff::from_lines(&old, &new).unified_diff().context_radius(3).header(&p, &p).to_string();
|
||||||
|
if diff.len() > 60_000 {
|
||||||
|
let mut end = 60_000;
|
||||||
|
while !diff.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
diff.truncate(end);
|
||||||
|
v["truncated"] = json!(true);
|
||||||
|
}
|
||||||
|
v["diff"] = json!(diff);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
Ok(Json(json!(files)))
|
Ok(Json(json!(files)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,6 +1352,201 @@ async fn reply_permission(Extension(user): Extension<User>, State(app): State<Ap
|
|||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Instructions for an automation drafted from this chat (small model); the client creates the job.
|
||||||
|
async fn job_draft(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
let (name, prompt) = runner::draft_job(&app, &s).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
Ok(Json(json!({"name": name, "prompt": prompt, "cwd": s.cwd, "agent": s.agent})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Claude Code: sign-in from the web UI ---------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ClaudeStatusQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
refresh: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_status(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<ClaudeStatusQuery>) -> Json<Value> {
|
||||||
|
if q.refresh {
|
||||||
|
crate::claude_code::refresh_latest().await;
|
||||||
|
}
|
||||||
|
Json(crate::claude_code::status(&app, &user.id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ClaudeLoginBody {
|
||||||
|
/// Anthropic Console (API billing) instead of the Claude subscription.
|
||||||
|
#[serde(default)]
|
||||||
|
console: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_login_start(Extension(user): Extension<User>, State(app): State<AppState>, body: Option<Json<ClaudeLoginBody>>) -> ApiResult {
|
||||||
|
let console = body.map(|b| b.0.console).unwrap_or(false);
|
||||||
|
let url = crate::claude_code::login_start(&app, &user.id, console).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
app.audit(Some(&user), "claude.login_start", "", "");
|
||||||
|
Ok(Json(json!({"url": url})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ClaudeCodeBody {
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_login_code(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ClaudeCodeBody>) -> ApiResult {
|
||||||
|
crate::claude_code::login_code(&user.id, &b.code).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
|
app.audit(Some(&user), "claude.login", "", "");
|
||||||
|
// New models may have appeared (the CLI is signed in now): clients reload them.
|
||||||
|
Ok(Json(crate::claude_code::status(&app, &user.id).await))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_login_cancel(Extension(user): Extension<User>) -> Json<Value> {
|
||||||
|
crate::claude_code::login_cancel(&user.id);
|
||||||
|
Json(json!({"ok": true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the latest Claude Code CLI on the server (admins: it is shared by all accounts).
|
||||||
|
async fn claude_update(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
require_admin(&user)?;
|
||||||
|
let output = crate::claude_code::update().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
app.audit(Some(&user), "claude.update", "", "");
|
||||||
|
let mut v = crate::claude_code::status(&app, &user.id).await;
|
||||||
|
v["output"] = json!(output);
|
||||||
|
Ok(Json(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_logout(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
crate::claude_code::logout(&app, &user.id).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
app.audit(Some(&user), "claude.logout", "", "");
|
||||||
|
Ok(Json(crate::claude_code::status(&app, &user.id).await))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- project page ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OverviewQuery {
|
||||||
|
cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chats of a project folder with what they did (files, checks, cost) plus the folder's settings.
|
||||||
|
async fn project_overview(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<OverviewQuery>) -> ApiResult {
|
||||||
|
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
|
||||||
|
let sessions: Vec<Session> = app.store.list_sessions(filter)?.into_iter().filter(|s| s.cwd == q.cwd).collect();
|
||||||
|
let ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
|
||||||
|
let check = app.store.project_check(&q.cwd)?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"sessions": app.store.project_overview(&ids)?,
|
||||||
|
"check": check.map(|c| json!({"command": c.0, "enabled": c.1})),
|
||||||
|
"defaults": app.store.project_defaults(&q.cwd)?,
|
||||||
|
"currency": app.config().server.currency,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- defaults of new chats per project ---------------------------------------------------------
|
||||||
|
|
||||||
|
async fn get_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = readable(&app, &user, &id)?;
|
||||||
|
Ok(Json(app.store.project_defaults(&s.cwd)?.unwrap_or(Value::Null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember this chat's colour, model, mode, instructions and folder for new chats in the project folder.
|
||||||
|
async fn put_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
let data = json!({
|
||||||
|
"model": s.model, "agent": s.agent, "permission_mode": s.permission_mode,
|
||||||
|
"color": s.color, "instructions": s.instructions, "folder": s.folder,
|
||||||
|
});
|
||||||
|
app.store.set_project_defaults(&s.cwd, Some(&data))?;
|
||||||
|
Ok(Json(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
app.store.set_project_defaults(&s.cwd, None)?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- check command per project ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn get_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = readable(&app, &user, &id)?;
|
||||||
|
let c = app.store.project_check(&s.cwd)?;
|
||||||
|
Ok(Json(json!({"command": c.as_ref().map(|c| c.0.clone()).unwrap_or_default(), "enabled": c.map(|c| c.1).unwrap_or(false)})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CheckBody {
|
||||||
|
command: String,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<CheckBody>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
if b.command.len() > 2000 {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "command too long".into()));
|
||||||
|
}
|
||||||
|
app.store.set_project_check(&s.cwd, &b.command, b.enabled)?;
|
||||||
|
app.audit(Some(&user), "project.check", &s.cwd, &b.command);
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_checks(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
Ok(Json(json!(app.store.turn_checks(&id)?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the check now for the latest answer.
|
||||||
|
async fn run_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
let last = app.store.messages(&id)?.into_iter().rev().find(|m| m.role == crate::types::Role::Assistant).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no answer yet".into()))?;
|
||||||
|
let app2 = app.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = crate::checks::run(&app2, &s, &last.id).await;
|
||||||
|
});
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ExplainBody {
|
||||||
|
lang: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain-words explanation of a pending approval (small model).
|
||||||
|
async fn explain_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<ExplainBody>>) -> ApiResult {
|
||||||
|
check_pending(&app, &user, &id)?;
|
||||||
|
let ev = app.pending_events().into_iter().find(|e| matches!(e, Event::PermissionRequest { id: i, .. } if *i == id)).ok_or_else(|| not_found("request"))?;
|
||||||
|
let Event::PermissionRequest { session_id, tool, title, input, diff, .. } = ev else { return Err(not_found("request")) };
|
||||||
|
let session = app.session(&session_id)?;
|
||||||
|
let lang = body.and_then(|b| b.0.lang).unwrap_or_default();
|
||||||
|
let text = crate::runner::explain_tool_call(&app, &session, &tool, &title, &input, diff.as_deref(), &lang)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
Ok(Json(json!({"text": text})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is allowed "for this project" (remembered across chats).
|
||||||
|
async fn project_approvals(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = readable(&app, &user, &id)?;
|
||||||
|
Ok(Json(json!(app.store.project_approvals(&s.cwd)?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ApprovalKey {
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_project_approval(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<ApprovalKey>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
app.store.delete_project_approval(&s.cwd, &body.key)?;
|
||||||
|
app.audit(Some(&user), "permission.project_revoked", body.key, "");
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct QuestionBody {
|
struct QuestionBody {
|
||||||
/// question text → answer (string or array of strings); null = dismissed
|
/// question text → answer (string or array of strings); null = dismissed
|
||||||
@@ -1804,6 +2143,8 @@ async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppSt
|
|||||||
folder: None,
|
folder: None,
|
||||||
instructions,
|
instructions,
|
||||||
telegram_quiet: true,
|
telegram_quiet: true,
|
||||||
|
summary: String::new(),
|
||||||
|
color: None,
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
st.assistant_session = Some(s.id.clone());
|
st.assistant_session = Some(s.id.clone());
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub enum PermissionReply {
|
|||||||
Once,
|
Once,
|
||||||
/// Allow this tool (or bash command prefix) for the rest of the session.
|
/// Allow this tool (or bash command prefix) for the rest of the session.
|
||||||
Always,
|
Always,
|
||||||
|
/// Like `Always`, and remembered for the project folder (all chats, survives restarts).
|
||||||
|
Project,
|
||||||
Reject,
|
Reject,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +146,11 @@ impl App {
|
|||||||
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
||||||
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
||||||
}
|
}
|
||||||
|
match crate::budget::value_plan_requests(&self) {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!("valued {n} plan requests (opencode Go) at list prices"),
|
||||||
|
Err(e) => tracing::warn!("valuing plan requests: {e:#}"),
|
||||||
|
}
|
||||||
match crate::images::backfill_copies(&self) {
|
match crate::images::backfill_copies(&self) {
|
||||||
Ok(0) => {}
|
Ok(0) => {}
|
||||||
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
||||||
|
|||||||
+73
-10
@@ -361,6 +361,49 @@ fn opencode_when(w: &Value) -> Option<When> {
|
|||||||
Some((days, hours, from, until))
|
Some((days, hours, from, until))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plan requests (opencode Go) that were booked at 0: value them at the model's list
|
||||||
|
/// prices of the time of the request. Idempotent – valued rows no longer match.
|
||||||
|
pub fn value_plan_requests(app: &App) -> Result<usize> {
|
||||||
|
let mut n = 0;
|
||||||
|
for (row, ts, spec, usage) in app.store.plan_requests_without_cost()? {
|
||||||
|
let Ok((_, model)) = app.registry().resolve(&spec) else { continue };
|
||||||
|
let Some(at) = chrono::DateTime::from_timestamp_millis(ts) else { continue };
|
||||||
|
let cost = model.cost_at(&usage, at);
|
||||||
|
if cost > 0.0 {
|
||||||
|
app.store.set_usage_cost(row, cost)?;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A price from opencode's list: a number, or one price per cache duration
|
||||||
|
/// (`{"30m": 0.125, "12h": 0.25}`) – then the shortest duration (the default caching).
|
||||||
|
fn price_of(v: &Value) -> Value {
|
||||||
|
match v {
|
||||||
|
Value::Number(_) => v.clone(),
|
||||||
|
Value::Object(o) => {
|
||||||
|
let secs = |k: &str| -> Option<f64> {
|
||||||
|
let (num, unit) = k.split_at(k.find(|c: char| c.is_ascii_alphabetic())?);
|
||||||
|
let n: f64 = num.parse().ok()?;
|
||||||
|
Some(n * match unit {
|
||||||
|
"s" => 1.0,
|
||||||
|
"m" => 60.0,
|
||||||
|
"h" => 3600.0,
|
||||||
|
"d" => 86400.0,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
o.iter()
|
||||||
|
.filter(|(_, x)| x.is_number())
|
||||||
|
.min_by(|a, b| secs(a.0).unwrap_or(f64::MAX).total_cmp(&secs(b.0).unwrap_or(f64::MAX)))
|
||||||
|
.map(|(_, x)| x.clone())
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
}
|
||||||
|
_ => Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
||||||
fn opencode_model(m: &Value) -> Value {
|
fn opencode_model(m: &Value) -> Value {
|
||||||
let cost = &m["cost"]["text"];
|
let cost = &m["cost"]["text"];
|
||||||
@@ -371,13 +414,13 @@ fn opencode_model(m: &Value) -> Value {
|
|||||||
for o in side["overrides"].as_array().into_iter().flatten() {
|
for o in side["overrides"].as_array().into_iter().flatten() {
|
||||||
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
||||||
let t = tiers.entry(gt).or_default();
|
let t = tiers.entry(gt).or_default();
|
||||||
t.insert(field.into(), o["price"].clone());
|
t.insert(field.into(), price_of(&o["price"]));
|
||||||
if field == "input_price" {
|
if field == "input_price" {
|
||||||
if o["cache"]["read"].is_number() {
|
for (key, k) in [("read", "cache_read_price"), ("write", "cache_write_price")] {
|
||||||
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
|
let p = price_of(&o["cache"][key]);
|
||||||
}
|
if !p.is_null() {
|
||||||
if o["cache"]["write"].is_number() {
|
t.insert(k.into(), p);
|
||||||
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,12 +452,13 @@ fn opencode_model(m: &Value) -> Value {
|
|||||||
"id": m["id"],
|
"id": m["id"],
|
||||||
"context": m["limit"]["context"],
|
"context": m["limit"]["context"],
|
||||||
"max_output": m["limit"]["output"],
|
"max_output": m["limit"]["output"],
|
||||||
|
"max_input": m["limit"]["input"],
|
||||||
"vision": images,
|
"vision": images,
|
||||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||||
"input_price": input["price"],
|
"input_price": price_of(&input["price"]),
|
||||||
"output_price": output["price"],
|
"output_price": price_of(&output["price"]),
|
||||||
"cache_read_price": input["cache"]["read"],
|
"cache_read_price": price_of(&input["cache"]["read"]),
|
||||||
"cache_write_price": input["cache"]["write"],
|
"cache_write_price": price_of(&input["cache"]["write"]),
|
||||||
"price_tiers": price_tiers,
|
"price_tiers": price_tiers,
|
||||||
"price_multipliers": price_multipliers,
|
"price_multipliers": price_multipliers,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
@@ -480,6 +524,25 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn prices_per_cache_duration() {
|
||||||
|
use super::{opencode_model, price_of};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
assert_eq!(price_of(&json!(0.5)), json!(0.5));
|
||||||
|
assert_eq!(price_of(&json!({"12h": 0.25, "30m": 0.125})), json!(0.125));
|
||||||
|
assert_eq!(price_of(&json!({"1h": 2.0, "5m": 1.25})), json!(1.25));
|
||||||
|
assert_eq!(price_of(&Value::Null), Value::Null);
|
||||||
|
// A model of opencode's list with per-duration cache write prices.
|
||||||
|
let m = json!({"id": "gpt-6-luna", "limit": {"context": 1050000, "input": 922000, "output": 128000},
|
||||||
|
"cost": {"text": {"input": {"price": 0.1, "cache": {"read": 0.01, "write": {"30m": 0.125, "12h": 0.25}},
|
||||||
|
"overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.2, "cache": {"read": 0.02, "write": {"30m": 0.25, "12h": 0.5}}}]},
|
||||||
|
"output": {"price": 0.5, "overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.75}]}}}});
|
||||||
|
let row = opencode_model(&m);
|
||||||
|
assert_eq!(row["cache_write_price"], json!(0.125));
|
||||||
|
assert_eq!(row["price_tiers"][0]["cache_write_price"], json!(0.25));
|
||||||
|
assert_eq!(row["max_input"], json!(922000));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn opencode_cost_conversion() {
|
fn opencode_cost_conversion() {
|
||||||
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//! A check command per project folder (tests, build, lint) that runs after every turn that changed
|
||||||
|
//! files. The result is a green/red badge on the turn; a failed run can be handed to the agent.
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::types::{Event, Role, Session, now_ms};
|
||||||
|
use anyhow::{Result, anyhow, bail};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||||
|
const MAX_OUTPUT: usize = 12_000;
|
||||||
|
|
||||||
|
/// Last `max` bytes of the output (failures show at the end).
|
||||||
|
fn tail(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let mut start = s.len() - max;
|
||||||
|
while !s.is_char_boundary(start) {
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
format!("…\n{}", &s[start..])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(app: &App, session: &Session, script: &str) -> Result<tokio::process::Command> {
|
||||||
|
let script = format!("exec 2>&1\n{script}");
|
||||||
|
let cwd = std::path::Path::new(&session.cwd);
|
||||||
|
if let Some(spec) = crate::sandbox::spec_for(app, session.owner_id.as_deref()) {
|
||||||
|
if !crate::sandbox::available() {
|
||||||
|
bail!("the sandbox (bubblewrap) is not available");
|
||||||
|
}
|
||||||
|
return Ok(crate::sandbox::command(&spec, cwd, "bash", &["-c", &script]));
|
||||||
|
}
|
||||||
|
let mut c = tokio::process::Command::new("bash");
|
||||||
|
c.arg("-c").arg(&script).current_dir(cwd).env("CODECRAFT", "1").env("CI", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the project's check for the turn that ended with `message_id` and publish the result.
|
||||||
|
pub async fn run(app: &Arc<App>, session: &Session, message_id: &str) -> Result<()> {
|
||||||
|
let (cmd, _) = app.store.project_check(&session.cwd)?.ok_or_else(|| anyhow!("no check command set for this project"))?;
|
||||||
|
let sid = session.id.clone();
|
||||||
|
let emit = |status: &str, code: Option<i32>, ms: u64, out: &str| {
|
||||||
|
let _ = app.store.set_turn_check(&sid, message_id, status, code, ms, out);
|
||||||
|
app.emit(Event::CheckResult { session_id: sid.clone(), message_id: message_id.into(), status: status.into(), exit_code: code, duration_ms: ms, output: out.into() });
|
||||||
|
};
|
||||||
|
emit("running", None, 0, "");
|
||||||
|
let started = now_ms();
|
||||||
|
let mut c = match command(app, session, &cmd) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
emit("failed", None, 0, &format!("{e:#}"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let res = tokio::time::timeout(TIMEOUT, c.stdin(Stdio::null()).kill_on_drop(true).output()).await;
|
||||||
|
let ms = (now_ms() - started).max(0) as u64;
|
||||||
|
match res {
|
||||||
|
Err(_) => emit("timeout", None, ms, &format!("Stopped after {} minutes.", TIMEOUT.as_secs() / 60)),
|
||||||
|
Ok(Err(e)) => emit("failed", None, ms, &format!("could not run the command: {e}")),
|
||||||
|
Ok(Ok(o)) => {
|
||||||
|
let mut text = String::from_utf8_lossy(&o.stdout).to_string();
|
||||||
|
text.push_str(&String::from_utf8_lossy(&o.stderr));
|
||||||
|
emit(if o.status.success() { "passed" } else { "failed" }, o.status.code(), ms, &tail(text.trim(), MAX_OUTPUT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a turn: run the check if the project has one and the turn changed files.
|
||||||
|
pub fn spawn_after_turn(app: Arc<App>, session_id: String, turn_started: i64) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let Ok(session) = app.session(&session_id) else { return };
|
||||||
|
if session.parent_id.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(Some((_, true))) = app.store.project_check(&session.cwd) else { return };
|
||||||
|
if app.store.checkpoints_since(&session_id, turn_started).map(|c| c.is_empty()).unwrap_or(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(last) = app.store.messages(&session_id).ok().and_then(|m| m.into_iter().rev().find(|m| m.role == Role::Assistant)) else { return };
|
||||||
|
if let Err(e) = run(&app, &session, &last.id).await {
|
||||||
|
tracing::debug!("check failed to start: {e:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,883 @@
|
|||||||
|
//! Claude Code as an engine: a chat whose model is "claude-code/<alias>" is not run by our own
|
||||||
|
//! agent loop but by the `claude` CLI (`-p`, stream-json). Its events become normal chat
|
||||||
|
//! messages; its tool approvals (`can_use_tool`) go through our permission system, so cards,
|
||||||
|
//! push actions, "allow for this project" and the sandbox work as for the native agent.
|
||||||
|
//!
|
||||||
|
//! Login is done in the web UI: `claude auth login` runs in a PTY, the sign-in URL is shown
|
||||||
|
//! and the code the Anthropic page displays is pasted back (see the `login_*` functions).
|
||||||
|
|
||||||
|
use crate::app::{App, PermissionReply};
|
||||||
|
use crate::config::ModelConfig;
|
||||||
|
use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command};
|
||||||
|
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
||||||
|
use crate::store::UsageRecord;
|
||||||
|
use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
|
||||||
|
use anyhow::{Result, anyhow, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
pub const PROVIDER: &str = "claude-code";
|
||||||
|
|
||||||
|
/// The `claude` binary of the server user (outside the sandbox); None = not installed.
|
||||||
|
/// Where updates from the web UI are installed (npm prefix in the persistent home); it is first in
|
||||||
|
/// `binary()`'s search and is made readable inside the sandbox, so one update serves all users.
|
||||||
|
fn managed_dir() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".npm-global"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn managed_binary() -> Option<PathBuf> {
|
||||||
|
managed_dir().map(|d| d.join("bin/claude")).filter(|p| p.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn binary() -> Option<PathBuf> {
|
||||||
|
if let Some(m) = managed_binary() {
|
||||||
|
return Some(m);
|
||||||
|
}
|
||||||
|
let mut dirs: Vec<PathBuf> = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
|
||||||
|
if let Some(h) = std::env::var_os("HOME") {
|
||||||
|
dirs.push(PathBuf::from(&h).join(".local/bin"));
|
||||||
|
dirs.push(PathBuf::from(&h).join(".npm-global/bin"));
|
||||||
|
}
|
||||||
|
dirs.into_iter().map(|d| d.join("claude")).find(|p| p.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registered like a provider so the model picker, validation and statistics need no special cases;
|
||||||
|
/// requests never reach it (the runner hands such chats to `run`).
|
||||||
|
pub struct Marker;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for Marker {
|
||||||
|
async fn stream(&self, _req: ChatRequest, _tx: mpsc::Sender<StreamEvent>, _cancel: CancellationToken) -> Result<()> {
|
||||||
|
bail!("this model runs through Claude Code and cannot be used for background tasks – set a small_model in the configuration")
|
||||||
|
}
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelConfig>> {
|
||||||
|
Ok(models())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn models() -> Vec<ModelConfig> {
|
||||||
|
[("default", "Claude Code (default model)"), ("opus", "Claude Code · Opus"), ("sonnet", "Claude Code · Sonnet"), ("haiku", "Claude Code · Haiku")]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, name)| ModelConfig { id: id.into(), name: Some(name.into()), vision: true, reasoning: true, ..Default::default() })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_claude_code(model_spec: &str) -> bool {
|
||||||
|
model_spec.starts_with("claude-code/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command running `claude` for this user: in the user's sandbox, or directly with a minimal
|
||||||
|
/// environment (no server secrets; the CLI uses its own sign-in from HOME).
|
||||||
|
fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Result<tokio::process::Command> {
|
||||||
|
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||||
|
if let Some(spec) = crate::sandbox::spec_for(app, owner) {
|
||||||
|
if !crate::sandbox::available() {
|
||||||
|
bail!("Claude Code is disabled for this account: the sandbox (bubblewrap) is not available");
|
||||||
|
}
|
||||||
|
// An update installed from the web UI lives outside the image: mounted read-only, called by path.
|
||||||
|
if let (Some(bin), Some(dir)) = (managed_binary(), managed_dir()) {
|
||||||
|
let program = bin.display().to_string();
|
||||||
|
return Ok(crate::sandbox::command_with(&spec, cwd, &[dir.as_path()], &program, &refs));
|
||||||
|
}
|
||||||
|
return Ok(crate::sandbox::command(&spec, cwd, "claude", &refs));
|
||||||
|
}
|
||||||
|
let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?;
|
||||||
|
let mut c = tokio::process::Command::new(bin);
|
||||||
|
c.current_dir(cwd).env_clear();
|
||||||
|
for k in ["PATH", "HOME", "LANG", "USER", "SHELL"] {
|
||||||
|
if let Some(v) = std::env::var_os(k) {
|
||||||
|
c.env(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").args(args);
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tool mapping -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
|
||||||
|
fn map_tool(name: &str, input: &Value) -> (String, Value) {
|
||||||
|
let mut v = input.clone();
|
||||||
|
let rename = |v: &mut Value, from: &str, to: &str| {
|
||||||
|
if let Some(x) = v.get(from).cloned() {
|
||||||
|
v[to] = x;
|
||||||
|
if let Some(o) = v.as_object_mut() {
|
||||||
|
o.remove(from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let ours = match name {
|
||||||
|
"Bash" => "bash",
|
||||||
|
"Read" => "read",
|
||||||
|
"Write" => "write",
|
||||||
|
"Edit" | "MultiEdit" | "NotebookEdit" => "edit",
|
||||||
|
"Glob" => "glob",
|
||||||
|
"Grep" => "grep",
|
||||||
|
"WebFetch" => "web_fetch",
|
||||||
|
"WebSearch" => "web_search",
|
||||||
|
"Task" | "Agent" => "task",
|
||||||
|
other => return (other.to_lowercase(), v),
|
||||||
|
};
|
||||||
|
rename(&mut v, "file_path", "path");
|
||||||
|
rename(&mut v, "notebook_path", "path");
|
||||||
|
(ours.to_string(), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CcTool {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolInfo for CcTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list")
|
||||||
|
}
|
||||||
|
fn is_edit(&self) -> bool {
|
||||||
|
matches!(self.name.as_str(), "write" | "edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unified(path: &str, old: &str, new: &str) -> String {
|
||||||
|
similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(path, path).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff of a pending write/edit (shown on the approval card and kept as the result's diff).
|
||||||
|
fn preview_diff(name: &str, input: &Value) -> Option<String> {
|
||||||
|
let path = input["path"].as_str()?;
|
||||||
|
let old = std::fs::read_to_string(path).unwrap_or_default();
|
||||||
|
match name {
|
||||||
|
"write" => Some(unified(path, &old, input["content"].as_str()?)),
|
||||||
|
"edit" => {
|
||||||
|
let (from, to) = (input["old_string"].as_str()?, input["new_string"].as_str()?);
|
||||||
|
let new = if input["replace_all"].as_bool() == Some(true) { old.replace(from, to) } else { old.replacen(from, to, 1) };
|
||||||
|
Some(unified(path, &old, &new))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
|
||||||
|
match name {
|
||||||
|
"bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()),
|
||||||
|
"write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(),
|
||||||
|
_ => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
|
||||||
|
async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
|
||||||
|
let (name, input) = map_tool(claude_name, claude_input);
|
||||||
|
if claude_name == "AskUserQuestion" {
|
||||||
|
return (false, Some("Questions to the user are not available in this mode – decide yourself or state your assumption.".into()));
|
||||||
|
}
|
||||||
|
let info = CcTool { name: name.clone() };
|
||||||
|
let cfg = app.config();
|
||||||
|
let root = app.root_session_id(&session.id);
|
||||||
|
let (mut decision, approved) = app.with_runtime(&root, |r| {
|
||||||
|
(Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(&info, &input), r.approved.clone())
|
||||||
|
});
|
||||||
|
if decision == Decision::Ask {
|
||||||
|
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
|
||||||
|
if !project.is_empty() {
|
||||||
|
let mut all = approved;
|
||||||
|
all.extend(project);
|
||||||
|
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(&info, &input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match decision {
|
||||||
|
Decision::Deny => return (false, Some("Denied by the permission rules in the configuration.".into())),
|
||||||
|
Decision::Allow => {}
|
||||||
|
Decision::Ask => {
|
||||||
|
let diff = preview_diff(&name, &input);
|
||||||
|
let (reply, message, _) = app.request_permission(&session.id, &name, title_of(&name, &input, claude_input), input.clone(), diff, cancel.clone()).await;
|
||||||
|
match reply {
|
||||||
|
PermissionReply::Reject => {
|
||||||
|
let mut text = "The user rejected this tool call.".to_string();
|
||||||
|
if let Some(m) = message.filter(|m| !m.trim().is_empty()) {
|
||||||
|
text.push_str(&format!(" User feedback: {m}"));
|
||||||
|
}
|
||||||
|
return (false, Some(text));
|
||||||
|
}
|
||||||
|
PermissionReply::Always | PermissionReply::Project => {
|
||||||
|
let mut keys: Vec<(String, bool)> = Vec::new();
|
||||||
|
if name == "bash" {
|
||||||
|
for part in split_command(input["command"].as_str().unwrap_or("")).unwrap_or_default() {
|
||||||
|
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keys.push((name.clone(), true));
|
||||||
|
}
|
||||||
|
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
|
||||||
|
if reply == PermissionReply::Project {
|
||||||
|
for (k, ok) in &keys {
|
||||||
|
if *ok {
|
||||||
|
let _ = app.store.add_project_approval(&session.cwd, k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PermissionReply::Once => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The state before the agent's change (turn changes, rewind).
|
||||||
|
if matches!(name.as_str(), "write" | "edit") {
|
||||||
|
if let Some(path) = input["path"].as_str() {
|
||||||
|
let before = std::fs::read(path).ok();
|
||||||
|
let _ = app.store.add_checkpoint(&session.id, path, before.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(true, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the turn ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Cur {
|
||||||
|
id: String,
|
||||||
|
api_id: String,
|
||||||
|
parts: Vec<Part>,
|
||||||
|
usage: Usage,
|
||||||
|
model: Option<String>,
|
||||||
|
started: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokens(u: &Value) -> Usage {
|
||||||
|
Usage {
|
||||||
|
input_tokens: u["input_tokens"].as_u64().unwrap_or(0),
|
||||||
|
output_tokens: u["output_tokens"].as_u64().unwrap_or(0),
|
||||||
|
cache_read_tokens: u["cache_read_input_tokens"].as_u64().unwrap_or(0),
|
||||||
|
cache_write_tokens: u["cache_creation_input_tokens"].as_u64().unwrap_or(0),
|
||||||
|
cost: None,
|
||||||
|
allowance: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(app: &App, session_id: &str, cur: &mut Option<Cur>, model_id: &str) {
|
||||||
|
let Some(c) = cur.take() else { return };
|
||||||
|
if c.parts.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut m = Message::new(Role::Assistant, c.parts);
|
||||||
|
m.id = c.id;
|
||||||
|
m.created_at = c.started;
|
||||||
|
m.model = Some(format!("{PROVIDER}/{}", c.model.as_deref().unwrap_or(model_id)));
|
||||||
|
m.usage = Some(c.usage.clone());
|
||||||
|
if app.store.add_message(session_id, &m).is_ok() {
|
||||||
|
app.emit(Event::MessageAdded { session_id: session_id.into(), message: m });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text of a tool result: a string, or a list of blocks.
|
||||||
|
fn result_text(content: &Value) -> String {
|
||||||
|
match content {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Array(a) => a.iter().filter_map(|b| b["text"].as_str()).collect::<Vec<_>>().join("\n"),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Images of the prompt as files the CLI can read (it has no inline image input in this mode).
|
||||||
|
fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
|
||||||
|
use base64::Engine;
|
||||||
|
let dir = Path::new(cwd).join(".codecraft/uploads");
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (i, p) in parts.iter().enumerate() {
|
||||||
|
let Part::Image { image } = p else { continue };
|
||||||
|
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue };
|
||||||
|
let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png");
|
||||||
|
let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms()));
|
||||||
|
if std::fs::create_dir_all(&dir).is_ok() {
|
||||||
|
let _ = std::fs::write(dir.join(".gitignore"), "*\n");
|
||||||
|
if std::fs::write(&path, bytes).is_ok() {
|
||||||
|
out.push(path.display().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one turn of a "claude-code/…" chat. Returns the final text.
|
||||||
|
pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<String> {
|
||||||
|
let session = app.session(session_id)?;
|
||||||
|
let owner = session.owner_id.clone();
|
||||||
|
let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string();
|
||||||
|
let history = crate::runner::active_history(app.store.messages(session_id)?);
|
||||||
|
let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else {
|
||||||
|
bail!("nothing to do: no user message");
|
||||||
|
};
|
||||||
|
let mut prompt = history[last_user].parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n\n");
|
||||||
|
let images = save_images(&session.cwd, &history[last_user].parts);
|
||||||
|
if !images.is_empty() {
|
||||||
|
prompt.push_str(&format!("\n\nAttached images (read them with the Read tool):\n{}", images.iter().map(|p| format!("- {p}")).collect::<Vec<_>>().join("\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue the CLI's own conversation; without one (new chat, fork, model switch) give it the history as context.
|
||||||
|
let key = format!("claude_sid:{session_id}");
|
||||||
|
let resume: Option<String> = owner.as_deref().and_then(|u| app.store.user_setting::<Option<String>>(u, &key).ok().flatten());
|
||||||
|
if resume.is_none() && last_user > 0 {
|
||||||
|
let earlier = crate::runner::transcript_of(&history[..last_user]);
|
||||||
|
prompt = format!("<previous-conversation>\n{}\n</previous-conversation>\n\n{prompt}", crate::runner::fit(&earlier, 20_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = match (session.agent.as_str(), session.permission_mode.as_str()) {
|
||||||
|
("plan", _) => "plan",
|
||||||
|
_ => "default",
|
||||||
|
};
|
||||||
|
let mut args: Vec<String> = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-prompt-tool", "stdio", "--permission-mode", mode]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
if model_id != "default" {
|
||||||
|
args.extend(["--model".into(), model_id.clone()]);
|
||||||
|
}
|
||||||
|
if let Some(r) = &resume {
|
||||||
|
args.extend(["--resume".into(), r.clone()]);
|
||||||
|
}
|
||||||
|
if !session.instructions.trim().is_empty() {
|
||||||
|
args.extend(["--append-system-prompt".into(), session.instructions.trim().to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?;
|
||||||
|
let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true).spawn().map_err(|e| anyhow!("could not start Claude Code: {e}"))?;
|
||||||
|
let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
|
||||||
|
let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
|
||||||
|
let stderr_tail = Arc::new(Mutex::new(String::new()));
|
||||||
|
{
|
||||||
|
let tail = stderr_tail.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stderr).lines();
|
||||||
|
while let Ok(Some(l)) = lines.next_line().await {
|
||||||
|
let mut t = tail.lock().unwrap();
|
||||||
|
t.push_str(&l);
|
||||||
|
t.push('\n');
|
||||||
|
if t.len() > 4000 {
|
||||||
|
let cut = t.len() - 3000;
|
||||||
|
let cut = (cut..t.len()).find(|i| t.is_char_boundary(*i)).unwrap_or(t.len());
|
||||||
|
t.drain(..cut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let first = json!({"type": "user", "message": {"role": "user", "content": prompt}});
|
||||||
|
stdin.write_all(format!("{first}\n").as_bytes()).await?;
|
||||||
|
let mut lines = BufReader::new(child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?).lines();
|
||||||
|
|
||||||
|
let started = now_ms();
|
||||||
|
let mut cur: Option<Cur> = None;
|
||||||
|
let mut calls: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut diffs: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut final_text = String::new();
|
||||||
|
let mut got_result = false;
|
||||||
|
let mut failure: Option<String> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let line = tokio::select! {
|
||||||
|
l = lines.next_line() => l?,
|
||||||
|
_ = cancel.cancelled() => { let _ = child.start_kill(); break; }
|
||||||
|
};
|
||||||
|
let Some(line) = line else { break };
|
||||||
|
let Ok(ev) = serde_json::from_str::<Value>(&line) else { continue };
|
||||||
|
let sub = ev["parent_tool_use_id"].as_str().is_some();
|
||||||
|
match ev["type"].as_str().unwrap_or("") {
|
||||||
|
"system" if ev["subtype"] == "init" => {
|
||||||
|
if let (Some(sid), Some(u)) = (ev["session_id"].as_str(), owner.as_deref()) {
|
||||||
|
let _ = app.store.set_user_setting(u, &key, &Some(sid.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"stream_event" if !sub => {
|
||||||
|
let e = &ev["event"];
|
||||||
|
match e["type"].as_str().unwrap_or("") {
|
||||||
|
"message_start" => {
|
||||||
|
finalize(&app, session_id, &mut cur, &model_id);
|
||||||
|
let u = tokens(&e["message"]["usage"]);
|
||||||
|
let id = new_id();
|
||||||
|
app.emit(Event::StepStarted { session_id: session_id.into(), message_id: id.clone(), started_at: now_ms(), prompt_tokens: u.input_tokens + u.cache_read_tokens + u.cache_write_tokens, estimated: false });
|
||||||
|
cur = Some(Cur { id, api_id: e["message"]["id"].as_str().unwrap_or("").into(), parts: vec![], usage: u, model: e["message"]["model"].as_str().map(String::from), started: now_ms() });
|
||||||
|
}
|
||||||
|
"content_block_delta" => {
|
||||||
|
if let Some(c) = &cur {
|
||||||
|
match e["delta"]["type"].as_str().unwrap_or("") {
|
||||||
|
"text_delta" => app.emit(Event::TextDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["text"].as_str().unwrap_or("").into() }),
|
||||||
|
"thinking_delta" => app.emit(Event::ReasoningDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["thinking"].as_str().unwrap_or("").into() }),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"message_delta" => {
|
||||||
|
if let Some(c) = &mut cur {
|
||||||
|
if let Some(o) = e["usage"]["output_tokens"].as_u64() {
|
||||||
|
c.usage.output_tokens = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"message_stop" => finalize(&app, session_id, &mut cur, &model_id),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"assistant" if !sub => {
|
||||||
|
let api_id = ev["message"]["id"].as_str().unwrap_or("").to_string();
|
||||||
|
if cur.as_ref().is_none_or(|c| c.api_id != api_id) {
|
||||||
|
finalize(&app, session_id, &mut cur, &model_id);
|
||||||
|
cur = Some(Cur { id: new_id(), api_id, parts: vec![], usage: tokens(&ev["message"]["usage"]), model: ev["message"]["model"].as_str().map(String::from), started: now_ms() });
|
||||||
|
}
|
||||||
|
let c = cur.as_mut().unwrap();
|
||||||
|
for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() {
|
||||||
|
let part = match b["type"].as_str().unwrap_or("") {
|
||||||
|
"text" => Part::Text { text: b["text"].as_str().unwrap_or("").to_string() },
|
||||||
|
"thinking" => Part::Reasoning { text: b["thinking"].as_str().unwrap_or("").to_string(), signature: b["signature"].as_str().map(String::from) },
|
||||||
|
"tool_use" => {
|
||||||
|
let (name, input) = map_tool(b["name"].as_str().unwrap_or(""), &b["input"]);
|
||||||
|
let id = b["id"].as_str().unwrap_or("").to_string();
|
||||||
|
calls.insert(id.clone(), name.clone());
|
||||||
|
Part::ToolCall { id, name, input, extra: None }
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
if !c.parts.contains(&part) && !matches!(&part, Part::ToolCall { id, .. } if c.parts.iter().any(|p| matches!(p, Part::ToolCall { id: i, .. } if i == id))) {
|
||||||
|
c.parts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"user" if !sub => {
|
||||||
|
finalize(&app, session_id, &mut cur, &model_id);
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() {
|
||||||
|
if b["type"] != "tool_result" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
|
||||||
|
let is_error = b["is_error"].as_bool().unwrap_or(false);
|
||||||
|
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) };
|
||||||
|
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: vec![], is_error, meta });
|
||||||
|
}
|
||||||
|
if !parts.is_empty() {
|
||||||
|
let m = Message::new(Role::User, parts);
|
||||||
|
if app.store.add_message(session_id, &m).is_ok() {
|
||||||
|
app.emit(Event::MessageAdded { session_id: session_id.into(), message: m });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"control_request" if ev["request"]["subtype"] == "can_use_tool" => {
|
||||||
|
finalize(&app, session_id, &mut cur, &model_id);
|
||||||
|
let req = &ev["request"];
|
||||||
|
let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
|
||||||
|
if allow {
|
||||||
|
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
|
||||||
|
if let (Some(d), Some(id)) = (preview_diff(&name, &input), req["tool_use_id"].as_str()) {
|
||||||
|
diffs.insert(id.to_string(), d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let response = if allow { json!({"behavior": "allow", "updatedInput": req["input"]}) } else { json!({"behavior": "deny", "message": message.unwrap_or_default()}) };
|
||||||
|
let reply = json!({"type": "control_response", "response": {"subtype": "success", "request_id": ev["request_id"], "response": response}});
|
||||||
|
if stdin.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"result" => {
|
||||||
|
finalize(&app, session_id, &mut cur, &model_id);
|
||||||
|
got_result = true;
|
||||||
|
final_text = ev["result"].as_str().unwrap_or("").to_string();
|
||||||
|
if ev["is_error"].as_bool().unwrap_or(false) || ev["subtype"].as_str().is_some_and(|s| s != "success") {
|
||||||
|
failure = Some(if final_text.trim().is_empty() { format!("Claude Code stopped: {}", ev["subtype"].as_str().unwrap_or("error")) } else { final_text.clone() });
|
||||||
|
}
|
||||||
|
let u = tokens(&ev["usage"]);
|
||||||
|
let (p, m) = (PROVIDER, model_id.as_str());
|
||||||
|
let _ = app.store.add_usage(&UsageRecord {
|
||||||
|
ts: now_ms(),
|
||||||
|
session_id: session_id.into(),
|
||||||
|
root_session_id: app.root_session_id(session_id),
|
||||||
|
provider: p.into(),
|
||||||
|
model: m.into(),
|
||||||
|
agent: "claude-code".into(),
|
||||||
|
input_tokens: u.input_tokens,
|
||||||
|
output_tokens: u.output_tokens,
|
||||||
|
cache_read_tokens: u.cache_read_tokens,
|
||||||
|
cache_write_tokens: u.cache_write_tokens,
|
||||||
|
ttft_ms: 0,
|
||||||
|
duration_ms: (now_ms() - started).max(0) as u64,
|
||||||
|
cost: ev["total_cost_usd"].as_f64().unwrap_or(0.0),
|
||||||
|
error: failure.is_some(),
|
||||||
|
user_id: None,
|
||||||
|
allowance: None,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalize(&app, session_id, &mut cur, &model_id);
|
||||||
|
drop(stdin);
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
|
||||||
|
let _ = child.start_kill();
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
return Ok(final_text);
|
||||||
|
}
|
||||||
|
if let Some(f) = failure {
|
||||||
|
// An expired sign-in is the common case: say where to fix it.
|
||||||
|
if f.to_lowercase().contains("login") || f.to_lowercase().contains("authenticat") || f.contains("401") {
|
||||||
|
bail!("Claude Code is not signed in (or the sign-in expired): Settings → Claude Code → Sign in. ({f})");
|
||||||
|
}
|
||||||
|
bail!("{f}");
|
||||||
|
}
|
||||||
|
if !got_result {
|
||||||
|
let tail = stderr_tail.lock().unwrap().trim().to_string();
|
||||||
|
if tail.to_lowercase().contains("login") || tail.to_lowercase().contains("authenticat") {
|
||||||
|
bail!("Claude Code is not signed in: Settings → Claude Code → Sign in.");
|
||||||
|
}
|
||||||
|
bail!("Claude Code ended without an answer{}", if tail.is_empty() { String::new() } else { format!(": {tail}") });
|
||||||
|
}
|
||||||
|
Ok(final_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sign-in from the web UI ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `claude auth login` running in a PTY for one user: its output (with the sign-in URL) and stdin.
|
||||||
|
struct Login {
|
||||||
|
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
|
||||||
|
out: Arc<Mutex<String>>,
|
||||||
|
child: Mutex<Option<tokio::process::Child>>,
|
||||||
|
exited: Arc<Mutex<Option<i32>>>,
|
||||||
|
started: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
static LOGINS: OnceLock<Mutex<HashMap<String, Arc<Login>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn logins() -> &'static Mutex<HashMap<String, Arc<Login>>> {
|
||||||
|
LOGINS.get_or_init(Default::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal escape sequences removed.
|
||||||
|
fn strip_ansi(s: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '\x1b' {
|
||||||
|
match chars.peek() {
|
||||||
|
Some('[') => {
|
||||||
|
chars.next();
|
||||||
|
for n in chars.by_ref() {
|
||||||
|
if ('@'..='~').contains(&n) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(']') => {
|
||||||
|
chars.next();
|
||||||
|
for n in chars.by_ref() {
|
||||||
|
if n == '\x07' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if c != '\r' {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_url(text: &str) -> Option<String> {
|
||||||
|
let t = strip_ansi(text);
|
||||||
|
let start = t.find("https://")?;
|
||||||
|
let url: String = t[start..].chars().take_while(|c| !c.is_whitespace()).collect();
|
||||||
|
// Complete once whitespace follows it (the output arrives in chunks); the wide PTY avoids wrapping.
|
||||||
|
let complete = t.len() > start + url.len();
|
||||||
|
(complete && (url.contains("oauth") || url.contains("authorize"))).then_some(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_login(user_id: &str) {
|
||||||
|
if let Some(l) = logins().lock().unwrap().remove(user_id) {
|
||||||
|
if let Some(mut c) = l.child.lock().unwrap().take() {
|
||||||
|
let _ = c.start_kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start `claude auth login` for the user and return the sign-in URL to open in any browser.
|
||||||
|
/// `console` = Anthropic Console (API billing) instead of the Claude subscription.
|
||||||
|
pub async fn login_start(app: &Arc<App>, user_id: &str, console: bool) -> Result<String> {
|
||||||
|
stop_login(user_id);
|
||||||
|
let (pty, pts) = pty_process::open()?;
|
||||||
|
pty.resize(pty_process::Size::new(24, 500))?;
|
||||||
|
let mode = if console { "--console" } else { "--claudeai" };
|
||||||
|
let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
let cmd = match crate::sandbox::spec_for(app, Some(user_id)) {
|
||||||
|
Some(spec) => {
|
||||||
|
if !crate::sandbox::available() {
|
||||||
|
bail!("the sandbox (bubblewrap) is not available");
|
||||||
|
}
|
||||||
|
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, &cwd, &[], false, "env", &["BROWSER=/bin/false", "claude", "auth", "login", mode]))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?;
|
||||||
|
let mut c = pty_process::Command::new(bin).args(["auth", "login", mode]).current_dir(&cwd).env_clear().env("BROWSER", "/bin/false").env("TERM", "xterm-256color");
|
||||||
|
for k in ["PATH", "HOME", "LANG", "USER"] {
|
||||||
|
if let Some(v) = std::env::var_os(k) {
|
||||||
|
c = c.env(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let child = cmd.kill_on_drop(true).spawn(pts).map_err(|e| anyhow!("could not start `claude auth login`: {e}"))?;
|
||||||
|
let (mut reader, writer) = pty.into_split();
|
||||||
|
let login = Arc::new(Login { writer: tokio::sync::Mutex::new(writer), out: Arc::default(), child: Mutex::new(Some(child)), exited: Arc::default(), started: Instant::now() });
|
||||||
|
{
|
||||||
|
let (out, exited, l) = (login.out.clone(), login.exited.clone(), login.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; 8192];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buf).await {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
let mut o = out.lock().unwrap();
|
||||||
|
o.push_str(&String::from_utf8_lossy(&buf[..n]));
|
||||||
|
if o.len() > 64_000 {
|
||||||
|
let cut = o.len() - 32_000;
|
||||||
|
let cut = (cut..o.len()).find(|i| o.is_char_boundary(*i)).unwrap_or(o.len());
|
||||||
|
o.drain(..cut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let code = l.child.lock().unwrap().as_mut().and_then(|c| c.try_wait().ok().flatten()).and_then(|s| s.code()).unwrap_or(0);
|
||||||
|
*exited.lock().unwrap() = Some(code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logins().lock().unwrap().insert(user_id.to_string(), login.clone());
|
||||||
|
// The URL shows up within a few seconds.
|
||||||
|
for _ in 0..60 {
|
||||||
|
if let Some(u) = find_url(&login.out.lock().unwrap()) {
|
||||||
|
return Ok(u);
|
||||||
|
}
|
||||||
|
if login.exited.lock().unwrap().is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
let out = strip_ansi(&login.out.lock().unwrap());
|
||||||
|
stop_login(user_id);
|
||||||
|
bail!("no sign-in URL received from `claude auth login`{}", if out.trim().is_empty() { String::new() } else { format!(": {}", out.trim().chars().take(300).collect::<String>()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the code from the sign-in page to the waiting CLI; true once it reports success.
|
||||||
|
pub async fn login_code(user_id: &str, code: &str) -> Result<bool> {
|
||||||
|
let login = logins().lock().unwrap().get(user_id).cloned().ok_or_else(|| anyhow!("no sign-in in progress – start it again"))?;
|
||||||
|
if login.started.elapsed() > Duration::from_secs(15 * 60) {
|
||||||
|
stop_login(user_id);
|
||||||
|
bail!("the sign-in expired – start it again");
|
||||||
|
}
|
||||||
|
let code: String = code.chars().filter(|c| !c.is_control()).collect::<String>().trim().to_string();
|
||||||
|
if code.is_empty() || code.len() > 2000 {
|
||||||
|
bail!("empty or invalid code");
|
||||||
|
}
|
||||||
|
login.writer.lock().await.write_all(format!("{code}\r").as_bytes()).await?;
|
||||||
|
for _ in 0..80 {
|
||||||
|
if login.exited.lock().unwrap().is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
let exited = *login.exited.lock().unwrap();
|
||||||
|
let out = strip_ansi(&login.out.lock().unwrap());
|
||||||
|
stop_login(user_id);
|
||||||
|
match exited {
|
||||||
|
Some(0) => Ok(true),
|
||||||
|
_ => bail!("sign-in failed: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("no answer from the CLI").trim()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_cancel(user_id: &str) {
|
||||||
|
stop_login(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `claude auth status` for the user (their sandbox home or the server's): the CLI's JSON.
|
||||||
|
pub async fn status(app: &App, user_id: &str) -> Value {
|
||||||
|
let mut v = status_inner(app, user_id).await;
|
||||||
|
if v["installed"] == true {
|
||||||
|
if let (Some(o), Some(i)) = (v.as_object_mut(), version_info(false).await.as_object()) {
|
||||||
|
o.extend(i.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status_inner(app: &App, user_id: &str) -> Value {
|
||||||
|
let Some(_) = binary().or_else(|| crate::sandbox::spec_for(app, Some(user_id)).map(|_| PathBuf::from("claude"))) else {
|
||||||
|
return json!({"installed": false});
|
||||||
|
};
|
||||||
|
let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
let mut cmd = match command(app, Some(user_id), &cwd, &["auth".into(), "status".into(), "--json".into()]) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return json!({"installed": true, "error": format!("{e:#}")}),
|
||||||
|
};
|
||||||
|
let out = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await;
|
||||||
|
let in_progress = logins().lock().unwrap().contains_key(user_id);
|
||||||
|
match out {
|
||||||
|
Ok(Ok(o)) => {
|
||||||
|
let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);
|
||||||
|
json!({
|
||||||
|
"installed": true,
|
||||||
|
"logged_in": v["loggedIn"].as_bool().unwrap_or(false),
|
||||||
|
"method": v["authMethod"], "email": v["email"], "org": v["orgName"], "expires": v["loginExpiresAt"],
|
||||||
|
"login_in_progress": in_progress,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => json!({"installed": false, "error": e.to_string()}),
|
||||||
|
Err(_) => json!({"installed": true, "error": "timeout", "login_in_progress": in_progress}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(app: &App, user_id: &str) -> Result<()> {
|
||||||
|
let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
let mut cmd = command(app, Some(user_id), &cwd, &["auth".into(), "logout".into()])?;
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- version and update -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// "2.1.325" or "2.1.325 (Claude Code)" → [2, 1, 325].
|
||||||
|
fn version_key(v: &str) -> Vec<u64> {
|
||||||
|
v.split_whitespace().next().unwrap_or("").trim_start_matches('v').split('.').map(|p| p.parse().unwrap_or(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newer_version(latest: &str, current: &str) -> bool {
|
||||||
|
version_key(latest) > version_key(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version of the installed CLI (what `claude --version` prints).
|
||||||
|
async fn installed_version() -> Option<String> {
|
||||||
|
let bin = binary()?;
|
||||||
|
let out = tokio::time::timeout(Duration::from_secs(10), tokio::process::Command::new(bin).arg("--version").stdin(Stdio::null()).kill_on_drop(true).output()).await.ok()?.ok()?;
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let v = text.split_whitespace().next()?.to_string();
|
||||||
|
(!v.is_empty()).then_some(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
static LATEST: OnceLock<Mutex<Option<(Instant, String)>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Latest release on npm (cached for 15 minutes; `force` asks again).
|
||||||
|
async fn latest_version(force: bool) -> Option<String> {
|
||||||
|
let cache = LATEST.get_or_init(Default::default);
|
||||||
|
if !force {
|
||||||
|
if let Some((t, v)) = cache.lock().unwrap().as_ref() {
|
||||||
|
if t.elapsed() < Duration::from_secs(15 * 60) {
|
||||||
|
return Some(v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let v: Value = crate::provider::http_client().get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest").timeout(Duration::from_secs(10)).send().await.ok()?.json().await.ok()?;
|
||||||
|
let version = v["version"].as_str()?.to_string();
|
||||||
|
*cache.lock().unwrap() = Some((Instant::now(), version.clone()));
|
||||||
|
Some(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the registry for the latest version now (the "check" button).
|
||||||
|
pub async fn refresh_latest() {
|
||||||
|
let _ = latest_version(true).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {version, latest, update_available} of the CLI on the server.
|
||||||
|
pub async fn version_info(force: bool) -> Value {
|
||||||
|
let (current, latest) = tokio::join!(installed_version(), latest_version(force));
|
||||||
|
let update = match (¤t, &latest) {
|
||||||
|
(Some(c), Some(l)) => newer_version(l, c),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
json!({"version": current, "latest": latest, "update_available": update})
|
||||||
|
}
|
||||||
|
|
||||||
|
static UPDATING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Install the latest version: a native install updates itself (`claude update`), otherwise npm installs
|
||||||
|
/// into the persistent home (`~/.npm-global`, ahead of the image's copy). Returns the tool output.
|
||||||
|
pub async fn update() -> Result<String> {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
if UPDATING.swap(true, Ordering::SeqCst) {
|
||||||
|
bail!("an update is already running");
|
||||||
|
}
|
||||||
|
struct Reset;
|
||||||
|
impl Drop for Reset {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
UPDATING.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _reset = Reset;
|
||||||
|
let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server"))?;
|
||||||
|
let native = std::fs::canonicalize(&bin).is_ok_and(|p| p.to_string_lossy().contains("/.local/share/claude/"));
|
||||||
|
let mut cmd = if native {
|
||||||
|
let mut c = tokio::process::Command::new(&bin);
|
||||||
|
c.arg("update");
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
let prefix = managed_dir().ok_or_else(|| anyhow!("no HOME"))?;
|
||||||
|
let mut c = tokio::process::Command::new("npm");
|
||||||
|
c.args(["install", "-g", "--no-audit", "--no-fund", "@anthropic-ai/claude-code@latest"]).env("NPM_CONFIG_PREFIX", prefix);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
cmd.stdin(Stdio::null()).kill_on_drop(true);
|
||||||
|
for k in ["PATH", "HOME", "LANG", "USER"] {
|
||||||
|
if let Some(v) = std::env::var_os(k) {
|
||||||
|
cmd.env(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let out = tokio::time::timeout(Duration::from_secs(300), cmd.output()).await.map_err(|_| anyhow!("the update timed out after 5 minutes"))?.map_err(|e| anyhow!("could not run the updater: {e}"))?;
|
||||||
|
let mut text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
text.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||||
|
let tail: String = { let t = text.trim(); t.chars().rev().take(1500).collect::<Vec<_>>().into_iter().rev().collect() };
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("the update failed: {tail}");
|
||||||
|
}
|
||||||
|
// Ask the registry again next time.
|
||||||
|
if let Some(c) = LATEST.get() {
|
||||||
|
*c.lock().unwrap() = None;
|
||||||
|
}
|
||||||
|
Ok(tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_the_login_url_only_when_complete() {
|
||||||
|
let partial = "Opening browser…\r\nIf the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&cli";
|
||||||
|
assert_eq!(find_url(partial), None);
|
||||||
|
let full = format!("{partial}ent_id=1&state=x\r\nPaste code here if prompted > ");
|
||||||
|
assert_eq!(find_url(&full).as_deref(), Some("https://claude.com/cai/oauth/authorize?code=true&client_id=1&state=x"));
|
||||||
|
assert_eq!(strip_ansi("\x1b[2Khello\x1b[0m\r\n"), "hello\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compares_cli_versions() {
|
||||||
|
assert!(newer_version("2.1.325", "2.1.311"));
|
||||||
|
assert!(newer_version("2.2.0", "2.1.999"));
|
||||||
|
assert!(!newer_version("2.1.325", "2.1.325 (Claude Code)"));
|
||||||
|
assert!(!newer_version("2.1.9", "2.1.10"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_claude_tools_to_ours() {
|
||||||
|
let (n, i) = map_tool("Write", &json!({"file_path": "/a/b.txt", "content": "x"}));
|
||||||
|
assert_eq!((n.as_str(), i["path"].as_str()), ("write", Some("/a/b.txt")));
|
||||||
|
assert!(i.get("file_path").is_none());
|
||||||
|
assert_eq!(map_tool("Bash", &json!({"command": "ls"})).0, "bash");
|
||||||
|
assert_eq!(map_tool("mcp__x__y", &json!({})).0, "mcp__x__y");
|
||||||
|
assert!(CcTool { name: "edit".into() }.is_edit() && CcTool { name: "read".into() }.read_only());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,6 +177,19 @@ pub struct ImagesConfig {
|
|||||||
/// Optional fal.ai Admin key to query the account balance.
|
/// Optional fal.ai Admin key to query the account balance.
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub admin_key: String,
|
pub admin_key: String,
|
||||||
|
/// fal.ai model for 3D models from images (generate_3d).
|
||||||
|
#[serde(default = "default_model_3d")]
|
||||||
|
pub model_3d: String,
|
||||||
|
/// fal.ai model for 3D models from a text prompt only.
|
||||||
|
#[serde(default = "default_model_3d_text")]
|
||||||
|
pub model_3d_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_model_3d() -> String {
|
||||||
|
"fal-ai/hunyuan3d-v3/image-to-3d".into()
|
||||||
|
}
|
||||||
|
fn default_model_3d_text() -> String {
|
||||||
|
"fal-ai/hunyuan3d-v3/text-to-3d".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
@@ -215,6 +228,8 @@ impl Default for ImagesConfig {
|
|||||||
models: vec![ImageModel { id: "fal-ai/flux/schnell".into(), name: Some("FLUX.1 schnell".into()), price: Some(0.003), ..Default::default() }],
|
models: vec![ImageModel { id: "fal-ai/flux/schnell".into(), name: Some("FLUX.1 schnell".into()), price: Some(0.003), ..Default::default() }],
|
||||||
monthly_budget: None,
|
monthly_budget: None,
|
||||||
admin_key: String::new(),
|
admin_key: String::new(),
|
||||||
|
model_3d: default_model_3d(),
|
||||||
|
model_3d_text: default_model_3d_text(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,8 +322,13 @@ pub struct ServerConfig {
|
|||||||
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
|
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
|
||||||
#[serde(skip_serializing_if = "String::is_empty")]
|
#[serde(skip_serializing_if = "String::is_empty")]
|
||||||
pub metrics_token: String,
|
pub metrics_token: String,
|
||||||
|
/// Trailer added to every git commit an agent makes ({model}, {provider}, {model_id});
|
||||||
|
/// empty = off.
|
||||||
|
pub commit_trailer: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const DEFAULT_COMMIT_TRAILER: &str = "Co-Authored-By: {model} ({provider}) via CodeCraft";
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -327,6 +347,7 @@ impl Default for ServerConfig {
|
|||||||
max_upload_mb: 500,
|
max_upload_mb: 500,
|
||||||
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
|
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
|
||||||
metrics_token: String::new(),
|
metrics_token: String::new(),
|
||||||
|
commit_trailer: DEFAULT_COMMIT_TRAILER.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,6 +419,10 @@ pub struct AgentConfig {
|
|||||||
/// Review loop after each turn of this mode.
|
/// Review loop after each turn of this mode.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub refine: Option<RefineConfig>,
|
pub refine: Option<RefineConfig>,
|
||||||
|
/// Reverse engineering tools (binary_info, decompile with Ghidra) in this mode;
|
||||||
|
/// craft has them anyway (deep mode).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reverse_tools: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refinement loop: after the agent finishes, a reviewer checks the result
|
/// Refinement loop: after the agent finishes, a reviewer checks the result
|
||||||
@@ -413,6 +438,9 @@ pub struct RefineConfig {
|
|||||||
/// Review criteria; empty = the mode's defaults.
|
/// Review criteria; empty = the mode's defaults.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub criteria: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub struct GenerateRequest {
|
|||||||
pub references: Vec<PathBuf>,
|
pub references: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn slug(s: &str) -> String {
|
pub(crate) fn slug(s: &str) -> String {
|
||||||
let mut out: String = s
|
let mut out: String = s
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
|
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
|
||||||
@@ -121,7 +121,7 @@ fn pick(all: &[ImageModel], stage: &str, purpose: Option<&str>, with_reference:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
|
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
|
||||||
async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
pub(crate) async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for p in paths {
|
for p in paths {
|
||||||
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
|
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
|
||||||
@@ -135,7 +135,7 @@ async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
|
pub(crate) async fn download(url: &str) -> Result<(Vec<u8>, String)> {
|
||||||
if let Some(rest) = url.strip_prefix("data:") {
|
if let Some(rest) = url.strip_prefix("data:") {
|
||||||
let (meta, data) = rest.split_once(',').context("malformed data URI")?;
|
let (meta, data) = rest.split_once(',').context("malformed data URI")?;
|
||||||
let ctype = meta.split(';').next().unwrap_or("image/png").to_string();
|
let ctype = meta.split(';').next().unwrap_or("image/png").to_string();
|
||||||
@@ -348,7 +348,7 @@ pub fn backfill_copies(app: &App) -> Result<usize> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Downscaled copy for the model's context.
|
/// Downscaled copy for the model's context.
|
||||||
fn preview_for_model(path: &Path) -> Option<ImageData> {
|
pub(crate) fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||||
let img = image::open(path).ok()?;
|
let img = image::open(path).ok()?;
|
||||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
|
|||||||
@@ -321,6 +321,8 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
||||||
telegram_quiet: true,
|
telegram_quiet: true,
|
||||||
|
summary: String::new(),
|
||||||
|
color: None,
|
||||||
};
|
};
|
||||||
let session = app.save_session(session)?;
|
let session = app.save_session(session)?;
|
||||||
app.store.job_started(&job.id, &session.id, now)?;
|
app.store.job_started(&job.id, &session.id, now)?;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ mod app;
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod backup;
|
mod backup;
|
||||||
mod blobs;
|
mod blobs;
|
||||||
|
mod checks;
|
||||||
|
mod claude_code;
|
||||||
mod cli_api;
|
mod cli_api;
|
||||||
mod budget;
|
mod budget;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -22,6 +24,8 @@ mod lsp;
|
|||||||
mod mentions;
|
mod mentions;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod media;
|
||||||
|
mod mesh3d;
|
||||||
mod permission;
|
mod permission;
|
||||||
mod prompts;
|
mod prompts;
|
||||||
mod push;
|
mod push;
|
||||||
|
|||||||
+41
-2
@@ -558,6 +558,42 @@ impl McpManager {
|
|||||||
/// Search the on-demand MCP tools by keywords.
|
/// Search the on-demand MCP tools by keywords.
|
||||||
pub struct McpSearch;
|
pub struct McpSearch;
|
||||||
|
|
||||||
|
/// Built-in tools matching the search words, with their availability in the current mode.
|
||||||
|
fn builtin_matches(ctx: &ToolCtx, words: &[&str]) -> String {
|
||||||
|
if words.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let app = &ctx.app;
|
||||||
|
let owner_id = app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
|
||||||
|
let owner_user = owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
||||||
|
let root = app.root_for(owner_id.as_deref(), &ctx.cwd);
|
||||||
|
let agent = crate::prompts::find_agent_for(app, &root, &ctx.cwd, &ctx.agent, owner_id.as_deref());
|
||||||
|
let available: std::collections::HashSet<String> = crate::runner::available_tools(app, &agent, owner_user.as_ref()).iter().map(|t| t.name().to_string()).collect();
|
||||||
|
let mut hits: Vec<(usize, String)> = crate::tools::builtin_tools()
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
let name = t.name().to_lowercase();
|
||||||
|
let desc = t.description().to_lowercase();
|
||||||
|
let score: usize = words.iter().map(|w| if name.contains(w) { 3 } else if desc.contains(w) { 1 } else { 0 }).sum();
|
||||||
|
let state = if available.contains(t.name()) {
|
||||||
|
"available – call it directly".to_string()
|
||||||
|
} else if agent.read_only && !t.read_only() {
|
||||||
|
format!("exists, but not in the read-only mode \"{}\" – ask the user to switch to build mode", agent.name)
|
||||||
|
} else {
|
||||||
|
format!("exists, but not available in mode \"{}\" (or disabled in the settings)", agent.name)
|
||||||
|
};
|
||||||
|
(score, format!("- {}: {state}", t.name()))
|
||||||
|
})
|
||||||
|
.filter(|(s, _)| *s >= 2 || words.len() == 1 && *s >= 1)
|
||||||
|
.collect();
|
||||||
|
hits.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
if hits.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let list: Vec<String> = hits.into_iter().take(4).map(|(_, l)| l).collect();
|
||||||
|
format!("Built-in tools (not MCP – call them directly by name, not via mcp_call):\n{}\n\n", list.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for McpSearch {
|
impl Tool for McpSearch {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
@@ -602,10 +638,13 @@ impl Tool for McpSearch {
|
|||||||
.filter(|(s, _)| *s > 0 || words.is_empty())
|
.filter(|(s, _)| *s > 0 || words.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
// Models also look here for built-in tools (e.g. generate_3d): say that they exist,
|
||||||
|
// whether this mode has them, and that they are called directly.
|
||||||
|
let builtin = if server.is_none() { builtin_matches(ctx, &words) } else { String::new() };
|
||||||
if scored.is_empty() {
|
if scored.is_empty() {
|
||||||
return Ok(ToolOutput::text("No matching tools. Try other keywords or another server."));
|
return Ok(ToolOutput::text(if builtin.is_empty() { "No matching tools. Try other keywords or another server.".to_string() } else { builtin }));
|
||||||
}
|
}
|
||||||
let mut out = String::new();
|
let mut out = builtin;
|
||||||
for (_, t) in scored.iter().take(8) {
|
for (_, t) in scored.iter().take(8) {
|
||||||
let desc: String = t.info.description.chars().take(300).collect();
|
let desc: String = t.info.description.chars().take(300).collect();
|
||||||
out.push_str(&format!("## {}\n{}\nInput schema: {}\n\n", t.full_name, desc, t.info.input_schema));
|
out.push_str(&format!("## {}\n{}\nInput schema: {}\n\n", t.full_name, desc, t.info.input_schema));
|
||||||
|
|||||||
@@ -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>,
|
pub max_parallel: Option<usize>,
|
||||||
/// Review loop after each turn (None/0 rounds = off).
|
/// Review loop after each turn (None/0 rounds = off).
|
||||||
pub refine: Option<crate::config::RefineConfig>,
|
pub refine: Option<crate::config::RefineConfig>,
|
||||||
|
/// binary_info and decompile available (besides craft).
|
||||||
|
pub reverse: bool,
|
||||||
/// Source file of Markdown agents.
|
/// Source file of Markdown agents.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
@@ -43,12 +45,12 @@ pub use codecraft_core::prompts::*;
|
|||||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||||
let craft = craft_prompt(clean_room);
|
let craft = craft_prompt(clean_room);
|
||||||
vec![
|
vec![
|
||||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +123,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||||
source: "file".into(),
|
source: "file".into(),
|
||||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }),
|
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
|
||||||
|
reverse: meta.get("reverse_tools").is_some_and(|v| v == "true"),
|
||||||
path: Some(p.display().to_string()),
|
path: Some(p.display().to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
source: "config".into(),
|
source: "config".into(),
|
||||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||||
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
||||||
|
reverse: a.reverse_tools.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.reverse)),
|
||||||
path: None,
|
path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -173,6 +177,7 @@ pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) ->
|
|||||||
source: "personal".into(),
|
source: "personal".into(),
|
||||||
max_parallel: None,
|
max_parallel: None,
|
||||||
refine: a.refine,
|
refine: a.refine,
|
||||||
|
reverse: a.reverse_tools.unwrap_or(false),
|
||||||
path: None,
|
path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,21 @@ impl Registry {
|
|||||||
ProviderEntry { name: name.clone(), provider, models },
|
ProviderEntry { name: name.clone(), provider, models },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Claude Code (the CLI's own agent) appears as a model list when it is installed.
|
||||||
|
if !providers.contains_key(crate::claude_code::PROVIDER) && crate::claude_code::binary().is_some() {
|
||||||
|
providers.insert(
|
||||||
|
crate::claude_code::PROVIDER.to_string(),
|
||||||
|
ProviderEntry { name: crate::claude_code::PROVIDER.to_string(), provider: Arc::new(crate::claude_code::Marker), models: crate::claude_code::models() },
|
||||||
|
);
|
||||||
|
}
|
||||||
Self { providers, limits }
|
Self { providers, limits }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// First model of any provider but `skip`.
|
||||||
|
pub fn first_model_excluding(&self, skip: &str) -> Option<String> {
|
||||||
|
self.providers.values().filter(|e| e.name != skip).find_map(|e| e.models.first().map(|m| format!("{}/{}", e.name, m.id)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Wait for a free slot on the model and its provider. Always acquired in the
|
/// Wait for a free slot on the model and its provider. Always acquired in the
|
||||||
/// same order (model, then provider), so concurrent requests cannot deadlock.
|
/// same order (model, then provider), so concurrent requests cannot deadlock.
|
||||||
pub async fn acquire(&self, spec: &str) -> Slot {
|
pub async fn acquire(&self, spec: &str) -> Slot {
|
||||||
|
|||||||
+7
-1
@@ -200,8 +200,12 @@ pub fn spawn_listener(app: Arc<App>) {
|
|||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
};
|
};
|
||||||
|
let mut permission_id = None;
|
||||||
let (session_id, kind, body) = match &ev {
|
let (session_id, kind, body) = match &ev {
|
||||||
Event::PermissionRequest { session_id, title, tool, .. } => (session_id.clone(), "permission", format!("{tool}: {title}")),
|
Event::PermissionRequest { id, session_id, title, tool, .. } => {
|
||||||
|
permission_id = Some(id.clone());
|
||||||
|
(session_id.clone(), "permission", format!("{tool}: {title}"))
|
||||||
|
}
|
||||||
Event::QuestionRequest { session_id, questions, .. } => {
|
Event::QuestionRequest { session_id, questions, .. } => {
|
||||||
let q = questions.first().map(|q| q.question.clone()).unwrap_or_default();
|
let q = questions.first().map(|q| q.question.clone()).unwrap_or_default();
|
||||||
(session_id.clone(), "question", q)
|
(session_id.clone(), "question", q)
|
||||||
@@ -228,6 +232,8 @@ pub fn spawn_listener(app: Arc<App>) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
|
// Lets the notification itself approve or reject (service worker → POST /api/permissions/<id>).
|
||||||
|
"permission_id": permission_id,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"title": title,
|
"title": title,
|
||||||
"body": body.chars().take(180).collect::<String>(),
|
"body": body.chars().take(180).collect::<String>(),
|
||||||
|
|||||||
+30
-4
@@ -142,7 +142,10 @@ impl Tool for Decompile {
|
|||||||
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
|
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
|
||||||
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
|
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
|
||||||
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
|
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
|
||||||
large programs; results are cached per file."
|
large programs; results are cached per file. Raw firmware images (.bin without a file \
|
||||||
|
format) need `processor` (Ghidra language id, e.g. \"ARM:LE:32:Cortex\" for Cortex-M, \
|
||||||
|
\"ARM:LE:32:v8\", \"AVR8:LE:16:atmega256\", \"Xtensa:LE:32:default\" for ESP32) and usually \
|
||||||
|
`base_address` (load address, e.g. \"0x08000000\" for STM32 flash)."
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
@@ -151,7 +154,9 @@ impl Tool for Decompile {
|
|||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string"},
|
"path": {"type": "string"},
|
||||||
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
|
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
|
||||||
"force": {"type": "boolean", "description": "Re-run even if cached"}
|
"force": {"type": "boolean", "description": "Re-run even if cached"},
|
||||||
|
"processor": {"type": "string", "description": "Ghidra language id for raw images, e.g. ARM:LE:32:Cortex (default: detected from the file format)"},
|
||||||
|
"base_address": {"type": "string", "description": "Load address of a raw image, e.g. 0x08000000"}
|
||||||
},
|
},
|
||||||
"required": ["path"]
|
"required": ["path"]
|
||||||
})
|
})
|
||||||
@@ -171,7 +176,20 @@ impl Tool for Decompile {
|
|||||||
bail!("{} is not a file", path.display());
|
bail!("{} is not a file", path.display());
|
||||||
}
|
}
|
||||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
|
||||||
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}", file_hash(&path)?));
|
let processor = input["processor"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||||
|
let base = input["base_address"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||||
|
if processor.as_deref().is_some_and(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || ":_-.".contains(c))) {
|
||||||
|
bail!("invalid processor id (expected e.g. ARM:LE:32:Cortex)");
|
||||||
|
}
|
||||||
|
if base.as_deref().is_some_and(|b| !b.trim_start_matches("0x").chars().all(|c| c.is_ascii_hexdigit())) {
|
||||||
|
bail!("invalid base_address (expected e.g. 0x08000000)");
|
||||||
|
}
|
||||||
|
// Another processor or load address is another analysis.
|
||||||
|
let variant = match (&processor, &base) {
|
||||||
|
(None, None) => String::new(),
|
||||||
|
(p, b) => format!("-{}", crate::blobs::hash(format!("{p:?}{b:?}").as_bytes()).get(..8).unwrap_or("")),
|
||||||
|
};
|
||||||
|
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}{variant}", file_hash(&path)?));
|
||||||
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
|
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
|
||||||
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
|
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
|
||||||
}
|
}
|
||||||
@@ -183,7 +201,15 @@ impl Tool for Decompile {
|
|||||||
let scripts = scripts_dir().display().to_string();
|
let scripts = scripts_dir().display().to_string();
|
||||||
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
|
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
|
||||||
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
|
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
|
||||||
let args = vec![proj.as_str(), "cc", "-import", p.as_str(), "-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"];
|
let mut args = vec![proj.as_str(), "cc", "-import", p.as_str()];
|
||||||
|
// Raw firmware: the binary loader with the given processor and load address.
|
||||||
|
if let Some(proc_id) = processor.as_deref() {
|
||||||
|
args.extend(["-processor", proc_id]);
|
||||||
|
}
|
||||||
|
if let Some(addr) = base.as_deref() {
|
||||||
|
args.extend(["-loader", "BinaryLoader", "-loader-baseAddr", addr]);
|
||||||
|
}
|
||||||
|
args.extend(["-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]);
|
||||||
let java = tools.join("jdk").display().to_string();
|
let java = tools.join("jdk").display().to_string();
|
||||||
let mut cmd = match &ctx.sandbox {
|
let mut cmd = match &ctx.sandbox {
|
||||||
Some(spec) => {
|
Some(spec) => {
|
||||||
|
|||||||
+437
-31
@@ -2,7 +2,7 @@
|
|||||||
//! checks), feed results back, repeat until the model stops.
|
//! checks), feed results back, repeat until the model stops.
|
||||||
|
|
||||||
use crate::app::{App, PermissionReply};
|
use crate::app::{App, PermissionReply};
|
||||||
use crate::permission::{Decision, Evaluator, bash_prefix, split_command};
|
use crate::permission::{Decision, Evaluator, bash_prefix, is_dangerous, split_command};
|
||||||
use crate::prompts::{self, Agent};
|
use crate::prompts::{self, Agent};
|
||||||
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
||||||
use crate::store::UsageRecord;
|
use crate::store::UsageRecord;
|
||||||
@@ -18,6 +18,8 @@ use tokio::sync::mpsc;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
const MAX_STEPS: usize = 250;
|
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;
|
const MAX_SUBAGENT_DEPTH: usize = 3;
|
||||||
|
|
||||||
/// What happened to a prompt.
|
/// What happened to a prompt.
|
||||||
@@ -71,6 +73,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
|
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
|
||||||
let source = source.to_string();
|
let source = source.to_string();
|
||||||
|
let turn_started = now_ms();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let res = async {
|
let res = async {
|
||||||
@@ -112,6 +115,10 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
|||||||
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
|
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
|
||||||
});
|
});
|
||||||
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
|
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
|
||||||
|
if leftover.is_empty() && !cancel.is_cancelled() {
|
||||||
|
spawn_summary(app.clone(), session_id.clone());
|
||||||
|
crate::checks::spawn_after_turn(app.clone(), session_id.clone(), turn_started);
|
||||||
|
}
|
||||||
if !leftover.is_empty() {
|
if !leftover.is_empty() {
|
||||||
let _ = start_prompt(app, session_id, leftover);
|
let _ = start_prompt(app, session_id, leftover);
|
||||||
}
|
}
|
||||||
@@ -143,7 +150,7 @@ pub fn abort(app: &App, session_id: &str) {
|
|||||||
/// Messages since the last compaction summary. Tool calls left without a
|
/// Messages since the last compaction summary. Tool calls left without a
|
||||||
/// result (run interrupted, e.g. by a server restart) get a synthetic result:
|
/// result (run interrupted, e.g. by a server restart) get a synthetic result:
|
||||||
/// providers reject histories with unanswered tool calls.
|
/// providers reject histories with unanswered tool calls.
|
||||||
fn active_history(all: Vec<Message>) -> Vec<Message> {
|
pub(crate) fn active_history(all: Vec<Message>) -> Vec<Message> {
|
||||||
let start = all
|
let start = all
|
||||||
.iter()
|
.iter()
|
||||||
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
|
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
|
||||||
@@ -213,7 +220,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
|||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let mut all = tools::builtin_tools();
|
let mut all = tools::builtin_tools();
|
||||||
if !cfg.images.enabled {
|
if !cfg.images.enabled {
|
||||||
all.retain(|t| t.name() != "generate_image");
|
all.retain(|t| t.name() != "generate_image" && t.name() != "generate_3d");
|
||||||
}
|
}
|
||||||
let (direct, deferred) = app.mcp.tools(&cfg, owner);
|
let (direct, deferred) = app.mcp.tools(&cfg, owner);
|
||||||
all.extend(direct);
|
all.extend(direct);
|
||||||
@@ -224,7 +231,12 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
|||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|t| !agent.read_only || t.read_only())
|
.filter(|t| !agent.read_only || t.read_only())
|
||||||
// Craft tools only where they are useful.
|
// Craft tools only where they are useful.
|
||||||
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some())
|
.filter(|t| {
|
||||||
|
agent.name == "craft"
|
||||||
|
|| agent.tools.is_some()
|
||||||
|
|| (agent.reverse && matches!(t.name(), "binary_info" | "decompile"))
|
||||||
|
|| !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase")
|
||||||
|
})
|
||||||
.filter(|t| match &agent.tools {
|
.filter(|t| match &agent.tools {
|
||||||
None => true,
|
None => true,
|
||||||
Some(list) => {
|
Some(list) => {
|
||||||
@@ -256,11 +268,23 @@ fn run_session_boxed(
|
|||||||
|
|
||||||
/// Run the loop for a session until the model stops. Returns the final text.
|
/// Run the loop for a session until the model stops. Returns the final text.
|
||||||
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
|
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
|
||||||
|
// Chats on a "claude-code/…" model are run by the Claude Code CLI.
|
||||||
|
if depth == 0 && crate::claude_code::is_claude_code(&app.session(session_id)?.model) {
|
||||||
|
return crate::claude_code::run(app, session_id, cancel).await;
|
||||||
|
}
|
||||||
let mut final_text = String::new();
|
let mut final_text = String::new();
|
||||||
let mut stop_hooks = 0;
|
let mut stop_hooks = 0;
|
||||||
let mut refine_done = 0u32;
|
let mut refine_done = 0u32;
|
||||||
let turn_start = now_ms();
|
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() {
|
if cancel.is_cancelled() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -275,40 +299,65 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
agent.prompt = prompts::craft_prompt(clean_room);
|
agent.prompt = prompts::craft_prompt(clean_room);
|
||||||
}
|
}
|
||||||
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
|
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)?;
|
let (provider, mut model) = app.registry().resolve(&model_spec)?;
|
||||||
if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) {
|
if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) {
|
||||||
model.effort = Some(effort);
|
model.effort = Some(effort);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-compaction when the last request used most of the context window.
|
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||||
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
|
// 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;
|
let threshold = app.config().server.auto_compact_threshold;
|
||||||
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
|
// Input limit: the context window, or the provider's lower input limit.
|
||||||
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
|
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;
|
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 owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
||||||
let tools = available_tools(&app, &agent, owner.as_ref());
|
let tools = available_tools(&app, &agent, owner.as_ref());
|
||||||
let history = active_history(app.store.messages_for_model(session_id)?);
|
|
||||||
let req = ChatRequest {
|
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),
|
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
|
||||||
messages: history,
|
messages: history,
|
||||||
tools: tools.iter().map(|t| t.def()).collect(),
|
tools: tools.iter().map(|t| t.def()).collect(),
|
||||||
max_tokens: model.max_output.unwrap_or(16_000),
|
max_tokens,
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
session_id: Some(session_id.to_string()),
|
session_id: Some(session_id.to_string()),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
};
|
};
|
||||||
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
|
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.
|
// 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) => {
|
Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => {
|
||||||
let fb = model.fallback.clone().unwrap_or_default();
|
let fb = model.fallback.clone().unwrap_or_default();
|
||||||
let (fb_provider, fb_model) = app.registry().resolve(&fb)?;
|
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)) });
|
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?
|
stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await?
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
@@ -353,12 +402,37 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let normal_end = matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "");
|
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() {
|
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.
|
// 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);
|
let rounds = agent.refine.as_ref().map(|r| r.rounds).unwrap_or(0);
|
||||||
if refine_done < rounds {
|
if refine_done < rounds {
|
||||||
refine_done += 1;
|
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)) => {
|
Ok(Some(feedback)) => {
|
||||||
save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?;
|
save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?;
|
||||||
continue;
|
continue;
|
||||||
@@ -396,7 +470,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
agent: agent.name.clone(),
|
agent: agent.name.clone(),
|
||||||
roots: roots.clone(),
|
roots: roots.clone(),
|
||||||
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
||||||
clean_room,
|
// Clean room is a craft rule; other modes with reverse tools may analyse binaries.
|
||||||
|
clean_room: clean_room && agent.name == "craft",
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
};
|
};
|
||||||
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
||||||
@@ -412,6 +487,40 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await);
|
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).
|
// Deliver messages the user queued while we were working (with attachments).
|
||||||
results.extend(take_queue(&app, session_id));
|
results.extend(take_queue(&app, session_id));
|
||||||
save_user_parts(&app, session_id, results)?;
|
save_user_parts(&app, session_id, results)?;
|
||||||
@@ -419,6 +528,55 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
Ok(final_text)
|
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 {
|
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 }
|
Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta }
|
||||||
}
|
}
|
||||||
@@ -490,9 +648,18 @@ async fn execute_call(
|
|||||||
// Permission check. Approvals are tracked on the top-level session.
|
// Permission check. Approvals are tracked on the top-level session.
|
||||||
let root = app.root_session_id(&session.id);
|
let root = app.root_session_id(&session.id);
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let decision = app.with_runtime(&root, |r| {
|
let (mut decision, session_approved) = app.with_runtime(&root, |r| {
|
||||||
Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input)
|
(Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input), r.approved.clone())
|
||||||
});
|
});
|
||||||
|
// What the user allowed "for this project" earlier counts as well.
|
||||||
|
if decision == Decision::Ask {
|
||||||
|
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
|
||||||
|
if !project.is_empty() {
|
||||||
|
let mut all = session_approved;
|
||||||
|
all.extend(project);
|
||||||
|
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(tool.as_ref(), &input);
|
||||||
|
}
|
||||||
|
}
|
||||||
match decision {
|
match decision {
|
||||||
Decision::Deny => {
|
Decision::Deny => {
|
||||||
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
|
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
|
||||||
@@ -510,16 +677,27 @@ async fn execute_call(
|
|||||||
}
|
}
|
||||||
return tool_result(call_id, call_name, text, true, None);
|
return tool_result(call_id, call_name, text, true, None);
|
||||||
}
|
}
|
||||||
PermissionReply::Always => app.with_runtime(&root, |r| {
|
PermissionReply::Always | PermissionReply::Project => {
|
||||||
|
// (key, may be remembered for the whole project)
|
||||||
|
let mut keys: Vec<(String, bool)> = Vec::new();
|
||||||
if name == "bash" {
|
if name == "bash" {
|
||||||
let cmd = input["command"].as_str().unwrap_or("");
|
let cmd = input["command"].as_str().unwrap_or("");
|
||||||
for part in split_command(cmd).unwrap_or_default() {
|
for part in split_command(cmd).unwrap_or_default() {
|
||||||
r.approved.insert(format!("bash:{}", bash_prefix(&part)));
|
// Dangerous commands (rm -r, git push, docker …) are never remembered beyond this chat.
|
||||||
|
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
r.approved.insert(name.to_string());
|
keys.push((name.to_string(), true));
|
||||||
}
|
}
|
||||||
}),
|
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
|
||||||
|
if reply == PermissionReply::Project {
|
||||||
|
for (k, ok) in &keys {
|
||||||
|
if *ok {
|
||||||
|
let _ = app.store.add_project_approval(&session.cwd, k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
PermissionReply::Once => {}
|
PermissionReply::Once => {}
|
||||||
}
|
}
|
||||||
if let Some(sel) = hunks {
|
if let Some(sel) = hunks {
|
||||||
@@ -719,6 +897,8 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
telegram_quiet: false,
|
telegram_quiet: false,
|
||||||
|
summary: String::new(),
|
||||||
|
color: None,
|
||||||
};
|
};
|
||||||
let child = app.save_session(child)?;
|
let child = app.save_session(child)?;
|
||||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||||
@@ -779,6 +959,47 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
|
|||||||
|
|
||||||
/// Errors after which the fallback model takes over: the host is unreachable
|
/// Errors after which the fallback model takes over: the host is unreachable
|
||||||
/// (connection, timeout, gateway errors) or the provider's budget is used up.
|
/// (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 {
|
fn needs_fallback(e: &anyhow::Error) -> bool {
|
||||||
let s = format!("{e:#}").to_lowercase();
|
let s = format!("{e:#}").to_lowercase();
|
||||||
["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k))
|
["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k))
|
||||||
@@ -923,7 +1144,8 @@ async fn stream_step(
|
|||||||
let mut message = Message::new(Role::Assistant, parts);
|
let mut message = Message::new(Role::Assistant, parts);
|
||||||
message.id = msg_id;
|
message.id = msg_id;
|
||||||
message.model = Some(model_spec.to_string());
|
message.model = Some(model_spec.to_string());
|
||||||
message.usage = Some(usage.clone());
|
// With its cost (charged, or valued at list prices) for the line under the answer.
|
||||||
|
message.usage = Some(Usage { cost: Some(req.model.cost(&usage)), ..usage.clone() });
|
||||||
if !message.parts.is_empty() {
|
if !message.parts.is_empty() {
|
||||||
app.store.add_message(&session.id, &message)?;
|
app.store.add_message(&session.id, &message)?;
|
||||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||||
@@ -958,6 +1180,9 @@ fn small_model(app: &App, session: &Session) -> String {
|
|||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
|
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
|
||||||
cfg.server.small_model.clone()
|
cfg.server.small_model.clone()
|
||||||
|
} else if crate::claude_code::is_claude_code(&session.model) {
|
||||||
|
// Claude Code runs itself; side tasks need an ordinary model.
|
||||||
|
app.registry().first_model_excluding(crate::claude_code::PROVIDER).unwrap_or_else(|| session.model.clone())
|
||||||
} else {
|
} else {
|
||||||
session.model.clone()
|
session.model.clone()
|
||||||
}
|
}
|
||||||
@@ -985,6 +1210,122 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cut a text down to `max` bytes keeping the beginning (the goal) and the end (where it went).
|
||||||
|
pub(crate) fn fit(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let head = clip(s, max * 2 / 3);
|
||||||
|
let mut start = s.len() - max / 3;
|
||||||
|
while !s.is_char_boundary(start) {
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
format!("{}\n…\n{}", head.split(" […").next().unwrap_or(&head), &s[start..])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short, tool-free job for the small model (summaries, explanations). The input is cut to the
|
||||||
|
/// model's context window, so models with 8k tokens work as well as large ones: tokens left for
|
||||||
|
/// the input = context − answer − prompt, at a conservative 2.5 bytes per token.
|
||||||
|
async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input: &str, max_out: u64) -> Result<String> {
|
||||||
|
let spec = small_model(app, session);
|
||||||
|
let (provider, mut model) = app.registry().resolve(&spec)?;
|
||||||
|
model.effort = Some("low".into());
|
||||||
|
let ctx = model.context.unwrap_or(32_000);
|
||||||
|
let room_tokens = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 400).max(600);
|
||||||
|
let input = fit(input, (room_tokens as usize * 5 / 2).min(60_000));
|
||||||
|
let req = ChatRequest {
|
||||||
|
system: system.into(),
|
||||||
|
messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])],
|
||||||
|
tools: vec![],
|
||||||
|
max_tokens: max_out,
|
||||||
|
reasoning: false,
|
||||||
|
session_id: Some(session.id.clone()),
|
||||||
|
model: model.clone(),
|
||||||
|
};
|
||||||
|
let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?;
|
||||||
|
record_side_usage(app, session, &spec, &model, &usage, kind);
|
||||||
|
Ok(strip_think(&text))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_text(m: &Message) -> String {
|
||||||
|
m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a turn: one line "where things stand" for the session list (small model, ~1k tokens of input).
|
||||||
|
fn spawn_summary(app: Arc<App>, session_id: String) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let Ok(session) = app.session(&session_id) else { return };
|
||||||
|
if session.parent_id.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(history) = app.store.messages(&session_id) else { return };
|
||||||
|
let history = active_history(history);
|
||||||
|
let last_user = history.iter().rev().find(|m| matches!(m.role, Role::User) && !message_text(m).trim().is_empty() && !message_text(m).trim_start().starts_with('<'));
|
||||||
|
let last_answer = history.iter().rev().find(|m| matches!(m.role, Role::Assistant) && !message_text(m).trim().is_empty());
|
||||||
|
let (Some(u), Some(a)) = (last_user, last_answer) else { return };
|
||||||
|
let input = format!(
|
||||||
|
"<previous-line>{}</previous-line>\n<user-request>\n{}\n</user-request>\n<agent-answer>\n{}\n</agent-answer>",
|
||||||
|
session.summary,
|
||||||
|
fit(&message_text(u), 1500),
|
||||||
|
fit(&message_text(a), 2500)
|
||||||
|
);
|
||||||
|
let system = "You write the status line of a coding chat for a list. One line, at most 14 words: what was done or where things stand, concrete (file/feature names). \
|
||||||
|
Use the language of the user's request. No quotes, no markdown, no trailing period. Reply with the line only.";
|
||||||
|
match side_task(&app, &session, "summary", system, &input, 120).await {
|
||||||
|
Ok(text) => {
|
||||||
|
let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ', '.']).chars().take(140).collect::<String>();
|
||||||
|
if !line.is_empty() {
|
||||||
|
let _ = app.modify_session(&session_id, false, |s| s.summary = line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::debug!("summary failed: {e:#}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "What does this do?" for a pending approval, in plain words (for phones: approve without reading code).
|
||||||
|
pub async fn explain_tool_call(app: &App, session: &Session, tool: &str, title: &str, input: &Value, diff: Option<&str>, lang: &str) -> Result<String> {
|
||||||
|
let language = if lang.starts_with("de") { "German" } else { "English" };
|
||||||
|
let system = format!(
|
||||||
|
"You explain a pending action of an AI coding agent to the user who has to approve it. Answer in {language}, plain words, at most 3 short sentences: \
|
||||||
|
what it does, and whether anything is risky (deletes or overwrites data, network access, secrets, changes outside the project, hard to undo). \
|
||||||
|
If it is harmless, say so. No markdown headings, no code blocks."
|
||||||
|
);
|
||||||
|
let mut body = format!("<tool>{tool}</tool>\n<title>{title}</title>\n<input>\n{}\n</input>", clip(&input.to_string(), 3000));
|
||||||
|
if let Some(d) = diff {
|
||||||
|
body.push_str(&format!("\n<diff>\n{}\n</diff>", clip(d, 3000)));
|
||||||
|
}
|
||||||
|
side_task(app, session, "explain", &system, &body, 300).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a chat into the instructions of a repeatable automation: (name, instructions in Markdown).
|
||||||
|
/// Works with a small model: the transcript is cut to its context window.
|
||||||
|
pub async fn draft_job(app: &App, session: &Session) -> Result<(String, String)> {
|
||||||
|
let history = active_history(app.store.messages(&session.id)?);
|
||||||
|
let transcript = transcript_of(&history);
|
||||||
|
if transcript.trim().is_empty() {
|
||||||
|
bail!("the session has no messages yet");
|
||||||
|
}
|
||||||
|
let system = "You turn a finished coding-assistant conversation into instructions for an AUTOMATION that repeats the useful work on a schedule, without a human in the loop. \
|
||||||
|
Reply in exactly this format. First line: NAME: <at most 5 words>. Then the instructions in Markdown with the headings '## Goal', '## Steps' (numbered), '## Result' and '## Limits'. \
|
||||||
|
Write them standalone: never refer to 'the conversation' or 'above'; name concrete paths, commands and checks that were used. \
|
||||||
|
Use the language of the user's messages. Keep it under 300 words.";
|
||||||
|
let text = side_task(app, session, "job-draft", system, &format!("<conversation>\n{transcript}\n</conversation>"), 1200).await?;
|
||||||
|
let mut lines = text.lines().skip_while(|l| l.trim().is_empty());
|
||||||
|
let first = lines.next().unwrap_or("");
|
||||||
|
let (name, body) = match first.trim().strip_prefix("NAME:").or_else(|| first.trim().strip_prefix("Name:")) {
|
||||||
|
Some(n) => (n.trim().trim_matches(['"', '*', '#', ' ']).to_string(), lines.collect::<Vec<_>>().join("\n")),
|
||||||
|
None => (String::new(), text.clone()),
|
||||||
|
};
|
||||||
|
let body = body.trim().to_string();
|
||||||
|
if body.is_empty() {
|
||||||
|
bail!("the model returned no instructions");
|
||||||
|
}
|
||||||
|
let name = if name.is_empty() { session.title.clone() } else { name };
|
||||||
|
let name: String = if name.trim().is_empty() { "Automation".into() } else { name.chars().take(60).collect() };
|
||||||
|
Ok((name.clone(), format!("# {name}\n\n{body}\n")))
|
||||||
|
}
|
||||||
|
|
||||||
fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||||
let Ok(session) = app.session(session_id) else { return };
|
let Ok(session) = app.session(session_id) else { return };
|
||||||
if session.parent_id.is_some() || !session.title.is_empty() {
|
if session.parent_id.is_some() || !session.title.is_empty() {
|
||||||
@@ -1133,7 +1474,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
|
/// Review the last turn against the user's request. Returns feedback for the
|
||||||
/// agent, or None when the reviewer approves.
|
/// agent, or None when the reviewer approves.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[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 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 spec = refine.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string());
|
||||||
let (provider, mut model) = app.registry().resolve(&spec)?;
|
let (provider, mut model) = app.registry().resolve(&spec)?;
|
||||||
@@ -1147,7 +1488,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-"))))
|
.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);
|
.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 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 {
|
if work.len() > 30_000 {
|
||||||
let mut start = work.len() - 30_000;
|
let mut start = work.len() - 30_000;
|
||||||
while !work.is_char_boundary(start) {
|
while !work.is_char_boundary(start) {
|
||||||
@@ -1158,7 +1504,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
|||||||
// Actual file changes of this turn (checkpoint vs. current content).
|
// Actual file changes of this turn (checkpoint vs. current content).
|
||||||
let mut changes = String::new();
|
let mut changes = String::new();
|
||||||
let mut seen = std::collections::HashSet::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 {
|
if !seen.insert(path.clone()) || changes.len() > 30_000 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1172,10 +1518,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. \
|
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 \
|
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.";
|
(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!(
|
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,
|
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 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?;
|
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
|
||||||
@@ -1188,10 +1541,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
|||||||
}
|
}
|
||||||
let findings: String = lines.collect::<Vec<_>>().join("\n");
|
let findings: String = lines.collect::<Vec<_>>().join("\n");
|
||||||
let findings = if findings.trim().is_empty() { text.clone() } else { findings };
|
let findings = if findings.trim().is_empty() { text.clone() } else { findings };
|
||||||
Ok(Some(format!(
|
Ok(Some(match todos {
|
||||||
"<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.",
|
Some(items) => format!(
|
||||||
findings.trim()
|
"<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;
|
.await;
|
||||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
|
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
|
||||||
@@ -1314,6 +1674,52 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fit_keeps_head_and_tail_within_budget() {
|
||||||
|
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
|
||||||
|
let out = fit(&text, 3000);
|
||||||
|
assert!(out.len() <= 3100, "{}", out.len());
|
||||||
|
assert!(out.starts_with("AAAA") && out.ends_with("ZZZZ") && out.contains('…'));
|
||||||
|
// Multi-byte text is cut on character boundaries.
|
||||||
|
let de = "äöüß".repeat(2000);
|
||||||
|
assert!(fit(&de, 1000).is_char_boundary(0));
|
||||||
|
assert_eq!(fit("short", 100), "short");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn todo_completion_and_reopening() {
|
||||||
|
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||||
|
let after = serde_json::json!([{"content": "A", "status": "completed"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||||
|
assert_eq!(newly_completed(&before, &after), vec!["A".to_string()]);
|
||||||
|
assert_eq!(newly_completed(&Value::Null, &after), vec!["A".to_string(), "B".to_string()]);
|
||||||
|
assert_eq!(open_todos(&after), vec!["C".to_string()]);
|
||||||
|
let reopened = reopen_todos(&after, &["A".to_string()]);
|
||||||
|
assert_eq!(reopened[0]["status"], "in_progress");
|
||||||
|
assert_eq!(reopened[1]["status"], "completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn answer_room_and_overflow() {
|
||||||
|
// The case from production: DeepSeek V4.1 Flash, 1M window, 384K output, 720K input.
|
||||||
|
let (ctx, out) = (1_048_576u64, 393_216u64);
|
||||||
|
let b = answer_budget(out, Some(ctx), 720_000);
|
||||||
|
assert!(720_000 + b <= ctx && b > 300_000, "{b}");
|
||||||
|
assert_eq!(answer_budget(out, Some(ctx), 100_000), out);
|
||||||
|
assert_eq!(answer_budget(16_000, None, 5_000_000), 16_000);
|
||||||
|
assert_eq!(answer_budget(out, Some(ctx), 1_040_000), 4_096);
|
||||||
|
assert!(is_context_overflow(&anyhow!("HTTP 400: [invalid_request_error] Input too long: model limit 1048576 tokens, requested 1108739 tokens")));
|
||||||
|
assert!(is_context_overflow(&anyhow!("This model's maximum context length is 128000 tokens")));
|
||||||
|
assert!(!is_context_overflow(&anyhow!("HTTP 429 rate limited")));
|
||||||
|
// Estimate: last request + what came after its answer (about 3 chars per token).
|
||||||
|
let u = crate::types::Usage { input_tokens: 1_000, cache_read_tokens: 9_000, output_tokens: 500, ..Default::default() };
|
||||||
|
let mut call = Message::new(Role::Assistant, vec![call("c1")]);
|
||||||
|
call.id = "a".into();
|
||||||
|
let result = Message::new(Role::User, vec![Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "x".repeat(3_000), images: vec![], is_error: false, meta: None }]);
|
||||||
|
assert_eq!(estimate_input(Some(&u), &[call.clone(), result.clone()]), 10_500 + 1_000);
|
||||||
|
assert!(estimate_input(None, &[call, result]) >= 1_000 + 8_000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_commands_that_move_files() {
|
fn detects_commands_that_move_files() {
|
||||||
assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/"));
|
assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/"));
|
||||||
|
|||||||
+344
-26
@@ -1,6 +1,6 @@
|
|||||||
//! SQLite persistence for sessions and messages.
|
//! 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::Serialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -157,6 +157,31 @@ impl Store {
|
|||||||
content BLOB
|
content BLOB
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts);
|
CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts);
|
||||||
|
CREATE TABLE IF NOT EXISTS project_approvals (
|
||||||
|
cwd TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (cwd, key)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS project_defaults (
|
||||||
|
cwd TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS project_checks (
|
||||||
|
cwd TEXT PRIMARY KEY,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS turn_checks (
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
exit_code INTEGER,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output TEXT NOT NULL DEFAULT '',
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (session_id, message_id)
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS session_shares (
|
CREATE TABLE IF NOT EXISTS session_shares (
|
||||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -244,28 +269,7 @@ impl Store {
|
|||||||
let (sdata, mdata) = row?;
|
let (sdata, mdata) = row?;
|
||||||
let session: Session = serde_json::from_str(&sdata)?;
|
let session: Session = serde_json::from_str(&sdata)?;
|
||||||
// Snippet from the visible text of the matching message (not JSON keys).
|
// Snippet from the visible text of the matching message (not JSON keys).
|
||||||
let mut snippet = String::new();
|
let snippet = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()).and_then(|m| snippet_in(&m, &needle)).unwrap_or_default();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
|
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
|
||||||
continue; // matched only JSON structure (keys, ids)
|
continue; // matched only JSON structure (keys, ids)
|
||||||
}
|
}
|
||||||
@@ -274,6 +278,121 @@ impl Store {
|
|||||||
Ok(out)
|
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<()> {
|
pub fn delete_session(&self, id: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
// Child sessions (subagents) go with their parent.
|
// Child sessions (subagents) go with their parent.
|
||||||
@@ -559,6 +678,39 @@ impl Store {
|
|||||||
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
|
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plan requests booked at 0 (opencode Go reports nothing charged, only `allowance`):
|
||||||
|
/// (row id, ts, "provider/model", usage).
|
||||||
|
pub fn plan_requests_without_cost(&self) -> Result<Vec<(i64, i64, String, crate::types::Usage)>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, ts, provider || '/' || model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, allowance
|
||||||
|
FROM usage WHERE cost = 0 AND allowance > 0",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
Ok((
|
||||||
|
r.get(0)?,
|
||||||
|
r.get(1)?,
|
||||||
|
r.get(2)?,
|
||||||
|
crate::types::Usage {
|
||||||
|
input_tokens: r.get::<_, i64>(3)? as u64,
|
||||||
|
output_tokens: r.get::<_, i64>(4)? as u64,
|
||||||
|
cache_read_tokens: r.get::<_, i64>(5)? as u64,
|
||||||
|
cache_write_tokens: r.get::<_, i64>(6)? as u64,
|
||||||
|
allowance: r.get(7)?,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_usage_cost(&self, row: i64, cost: f64) -> Result<()> {
|
||||||
|
self.conn.lock().unwrap().execute("UPDATE usage SET cost = ?2 WHERE id = ?1", params![row, cost])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||||
/// into the viewer's local time.
|
/// into the viewer's local time.
|
||||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
|
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
|
||||||
@@ -1045,6 +1197,17 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Latest images; `user = None` lists everybody's (admins).
|
/// 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>> {
|
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
@@ -1343,6 +1506,114 @@ impl Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- project overview ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// Per chat: files the agent changed, the latest check result and the cost.
|
||||||
|
pub fn project_overview(&self, session_ids: &[String]) -> Result<Value> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut out = serde_json::Map::new();
|
||||||
|
for id in session_ids {
|
||||||
|
let files: i64 = conn.query_row("SELECT COUNT(DISTINCT path) FROM checkpoints WHERE session_id = ?1", params![id], |r| r.get(0))?;
|
||||||
|
let check: Option<String> = conn
|
||||||
|
.query_row("SELECT status FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 1", params![id], |r| r.get(0))
|
||||||
|
.optional()?;
|
||||||
|
let failed: i64 = conn.query_row("SELECT COUNT(*) FROM turn_checks WHERE session_id = ?1 AND status IN ('failed','timeout')", params![id], |r| r.get(0))?;
|
||||||
|
let cost: Option<f64> = conn.query_row("SELECT SUM(cost) FROM usage WHERE root_session_id = ?1", params![id], |r| r.get(0))?;
|
||||||
|
out.insert(id.clone(), json!({"files": files, "check": check, "checks_failed": failed, "cost": cost.unwrap_or(0.0)}));
|
||||||
|
}
|
||||||
|
Ok(Value::Object(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- defaults of new chats in a project -------------------------------------------------------
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// {model, agent, permission_mode, color, instructions, folder} new chats of the folder start with.
|
||||||
|
pub fn project_defaults(&self, cwd: &str) -> Result<Option<Value>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let v: Option<String> = conn.query_row("SELECT data FROM project_defaults WHERE cwd = ?1", params![cwd], |r| r.get(0)).optional()?;
|
||||||
|
Ok(v.and_then(|v| serde_json::from_str(&v).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_project_defaults(&self, cwd: &str, data: Option<&Value>) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
match data {
|
||||||
|
Some(d) => conn.execute("INSERT INTO project_defaults (cwd, data) VALUES (?1, ?2) ON CONFLICT(cwd) DO UPDATE SET data = ?2", params![cwd, d.to_string()])?,
|
||||||
|
None => conn.execute("DELETE FROM project_defaults WHERE cwd = ?1", params![cwd])?,
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- checks after a turn ------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// (command, enabled) of a project folder.
|
||||||
|
pub fn project_check(&self, cwd: &str) -> Result<Option<(String, bool)>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
Ok(conn.query_row("SELECT command, enabled FROM project_checks WHERE cwd = ?1", params![cwd], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? != 0))).optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_project_check(&self, cwd: &str, command: &str, enabled: bool) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
if command.trim().is_empty() {
|
||||||
|
conn.execute("DELETE FROM project_checks WHERE cwd = ?1", params![cwd])?;
|
||||||
|
} else {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_checks (cwd, command, enabled) VALUES (?1, ?2, ?3) ON CONFLICT(cwd) DO UPDATE SET command = ?2, enabled = ?3",
|
||||||
|
params![cwd, command.trim(), enabled as i64],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_turn_check(&self, session_id: &str, message_id: &str, status: &str, exit_code: Option<i32>, duration_ms: u64, output: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO turn_checks (session_id, message_id, status, exit_code, duration_ms, output, ts) VALUES (?1,?2,?3,?4,?5,?6,?7)
|
||||||
|
ON CONFLICT(session_id, message_id) DO UPDATE SET status = ?3, exit_code = ?4, duration_ms = ?5, output = ?6, ts = ?7",
|
||||||
|
params![session_id, message_id, status, exit_code, duration_ms as i64, output, now_ms()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn turn_checks(&self, session_id: &str) -> Result<Vec<Value>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT message_id, status, exit_code, duration_ms, output FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 300")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![session_id], |r| {
|
||||||
|
Ok(json!({"message_id": r.get::<_, String>(0)?, "status": r.get::<_, String>(1)?, "exit_code": r.get::<_, Option<i32>>(2)?, "duration_ms": r.get::<_, i64>(3)?, "output": r.get::<_, String>(4)?}))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- project-wide approvals ---------------------------------------------------------
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// Approvals ("bash:<prefix>" or a tool name) remembered for a project folder.
|
||||||
|
pub fn project_approvals(&self, cwd: &str) -> Result<Vec<String>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT key FROM project_approvals WHERE cwd = ?1 ORDER BY key")?;
|
||||||
|
let rows = stmt.query_map(params![cwd], |r| r.get::<_, String>(0))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_project_approval(&self, cwd: &str, key: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("INSERT OR IGNORE INTO project_approvals (cwd, key, created_at) VALUES (?1, ?2, ?3)", params![cwd, key, now_ms()])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_project_approval(&self, cwd: &str, key: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("DELETE FROM project_approvals WHERE cwd = ?1 AND key = ?2", params![cwd, key])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- file checkpoints ------------------------------------------------------------
|
// --- file checkpoints ------------------------------------------------------------
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
@@ -1358,13 +1629,18 @@ impl Store {
|
|||||||
|
|
||||||
/// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore.
|
/// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore.
|
||||||
pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
|
pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
|
||||||
|
self.checkpoints_between(session_id, since, i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per file the state before its first change in the time range [since, until).
|
||||||
|
pub fn checkpoints_between(&self, session_id: &str, since: i64, until: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2
|
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3
|
||||||
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND path = c.path)",
|
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3 AND path = c.path)",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![session_id, since], |r| {
|
.query_map(params![session_id, since, until], |r| {
|
||||||
let existed: i64 = r.get(1)?;
|
let existed: i64 = r.get(1)?;
|
||||||
Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None }))
|
Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None }))
|
||||||
})?
|
})?
|
||||||
@@ -1392,3 +1668,45 @@ pub struct PushSub {
|
|||||||
pub p256dh: String,
|
pub p256dh: String,
|
||||||
pub auth: 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
|
||||||
|
}
|
||||||
@@ -541,6 +541,8 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
|
|||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
// Started from Telegram: keeps reporting there after switching to another chat.
|
// Started from Telegram: keeps reporting there after switching to another chat.
|
||||||
telegram_quiet: false,
|
telegram_quiet: false,
|
||||||
|
summary: String::new(),
|
||||||
|
color: None,
|
||||||
})?;
|
})?;
|
||||||
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
||||||
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ impl Tool for Bash {
|
|||||||
/// The bash command for a script, sandboxed for non-admin users.
|
/// The bash command for a script, sandboxed for non-admin users.
|
||||||
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
|
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
|
||||||
// stderr of every command (not just the last one) goes into the output.
|
// stderr of every command (not just the last one) goes into the output.
|
||||||
let script = format!("exec 2>&1\n{command}");
|
let script = format!("exec 2>&1\n{}{command}", commit_attribution(ctx));
|
||||||
Ok(match &ctx.sandbox {
|
Ok(match &ctx.sandbox {
|
||||||
Some(spec) => {
|
Some(spec) => {
|
||||||
if !crate::sandbox::available() {
|
if !crate::sandbox::available() {
|
||||||
@@ -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 {
|
fn tail(s: &str, max: usize) -> String {
|
||||||
if s.len() <= max {
|
if s.len() <= max {
|
||||||
return s.to_string();
|
return s.to_string();
|
||||||
@@ -171,3 +201,18 @@ fn tail(s: &str, max: usize) -> String {
|
|||||||
}
|
}
|
||||||
s[start..].to_string()
|
s[start..].to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod commit_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_trailer_text() {
|
||||||
|
assert_eq!(
|
||||||
|
commit_trailer(crate::config::DEFAULT_COMMIT_TRAILER, "DeepSeek V4.1 Flash", "opencode-go", "deepseek-v4.1-flash"),
|
||||||
|
"Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft"
|
||||||
|
);
|
||||||
|
assert_eq!(commit_trailer("By {model_id}\nx", "M", "p", "m-1"), "By m-1 x");
|
||||||
|
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,6 +177,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
|||||||
Arc::new(craft::ImageCompare),
|
Arc::new(craft::ImageCompare),
|
||||||
Arc::new(crate::prompts::SkillTool),
|
Arc::new(crate::prompts::SkillTool),
|
||||||
Arc::new(crate::images::GenerateImage),
|
Arc::new(crate::images::GenerateImage),
|
||||||
|
Arc::new(crate::mesh3d::Generate3d),
|
||||||
Arc::new(crate::reverse::BinaryInfo),
|
Arc::new(crate::reverse::BinaryInfo),
|
||||||
Arc::new(crate::reverse::Decompile),
|
Arc::new(crate::reverse::Decompile),
|
||||||
]
|
]
|
||||||
|
|||||||
+34
-1
@@ -7,6 +7,12 @@ self.addEventListener("push", (event) => {
|
|||||||
data = { body: event.data ? event.data.text() : "" };
|
data = { body: event.data ? event.data.text() : "" };
|
||||||
}
|
}
|
||||||
const title = data.title || "CodeCraft";
|
const title = data.title || "CodeCraft";
|
||||||
|
const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de");
|
||||||
|
// Approvals can be answered right from the notification.
|
||||||
|
const actions = data.kind === "permission" && data.permission_id ? [
|
||||||
|
{ action: "allow", title: de ? "Erlauben" : "Allow" },
|
||||||
|
{ action: "reject", title: de ? "Ablehnen" : "Reject" },
|
||||||
|
] : [];
|
||||||
const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || "";
|
const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || "";
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(prefix + title, {
|
self.registration.showNotification(prefix + title, {
|
||||||
@@ -15,13 +21,40 @@ self.addEventListener("push", (event) => {
|
|||||||
renotify: true,
|
renotify: true,
|
||||||
icon: "/icon-192.png",
|
icon: "/icon-192.png",
|
||||||
badge: "/icon-192.png",
|
badge: "/icon-192.png",
|
||||||
data: { url: data.url || "/" },
|
actions,
|
||||||
|
requireInteraction: data.kind === "permission",
|
||||||
|
data: { url: data.url || "/", permission_id: data.permission_id || null },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("notificationclick", (event) => {
|
self.addEventListener("notificationclick", (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
const info = event.notification.data || {};
|
||||||
|
if ((event.action === "allow" || event.action === "reject") && info.permission_id) {
|
||||||
|
const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de");
|
||||||
|
const target = new URL(info.url || "/", self.location.origin).href;
|
||||||
|
event.waitUntil(
|
||||||
|
fetch("/api/permissions/" + encodeURIComponent(info.permission_id), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reply: event.action === "allow" ? "once" : "reject" }),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
// Short confirmation, so the tap does not feel lost.
|
||||||
|
return self.registration.showNotification(event.action === "allow" ? (de ? "Erlaubt" : "Allowed") : (de ? "Abgelehnt" : "Rejected"), {
|
||||||
|
tag: "perm-" + info.permission_id,
|
||||||
|
icon: "/icon-192.png",
|
||||||
|
silent: true,
|
||||||
|
data: { url: info.url },
|
||||||
|
}).then(() => new Promise((res) => setTimeout(res, 2500))).then(() => self.registration.getNotifications({ tag: "perm-" + info.permission_id })).then((l) => l.forEach((n) => n.close()));
|
||||||
|
})
|
||||||
|
.catch(() => self.clients.openWindow(target)), // expired, answered elsewhere or signed out: open the app
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
|
const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => {
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useT } from "./i18n";
|
|||||||
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
|
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
|
||||||
import { applyEvent, loadSessions, resync, setState } from "./store";
|
import { applyEvent, loadSessions, resync, setState } from "./store";
|
||||||
import type { Info } from "./types";
|
import type { Info } from "./types";
|
||||||
|
import { ImageViewer } from "./components/ImageViewer";
|
||||||
|
import { ProjectPage } from "./components/ProjectPage";
|
||||||
import { Sidebar } from "./components/Sidebar";
|
import { Sidebar } from "./components/Sidebar";
|
||||||
import { Login } from "./components/Login";
|
import { Login } from "./components/Login";
|
||||||
import { ChatView } from "./components/ChatView";
|
import { ChatView } from "./components/ChatView";
|
||||||
@@ -94,6 +96,7 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
||||||
|
else if (section === "p" && arg) content = <ProjectPage key={arg} cwd={decodeURIComponent(arg)} onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
|
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
|
||||||
@@ -119,6 +122,7 @@ export function App() {
|
|||||||
<main className="main">{content}</main>
|
<main className="main">{content}</main>
|
||||||
<WhatsNew version={info.version} />
|
<WhatsNew version={info.version} />
|
||||||
<Palette />
|
<Palette />
|
||||||
|
<ImageViewer />
|
||||||
{shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />}
|
{shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />}
|
||||||
<DialogHost />
|
<DialogHost />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+594
-86
@@ -1,12 +1,12 @@
|
|||||||
import { takePrefill } from "../share";
|
import { takePrefill } from "../share";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||||
import { 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 { del, get, patch, post, put } from "../api";
|
||||||
import { useFetch, useModels } from "../hooks";
|
import { useFetch, useModels } from "../hooks";
|
||||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
import { fmtCompact, fmtCost, fmtMsgTime, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
import { dismissError, getState, loadAround, loadChecks, loadOlder, loadSession, loadSessions, useStore } from "../store";
|
||||||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||||
@@ -26,8 +26,17 @@ import { ShellsChip } from "./ShellsPanel";
|
|||||||
import { TodoList, ToolCard } from "./ToolCard";
|
import { TodoList, ToolCard } from "./ToolCard";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||||
|
import { ColorSwatches } from "./ColorSwatches";
|
||||||
|
import { ProjectPermissions } from "./ProjectPermissions";
|
||||||
|
import { OutboxBar } from "./OutboxBar";
|
||||||
|
import { addToOutbox, isOffline } from "../outbox";
|
||||||
|
import { TurnCheck } from "./TurnCheck";
|
||||||
|
import { TurnChanges } from "./TurnChanges";
|
||||||
|
import { ChatCtx, MessageMenu } from "./MessageMenu";
|
||||||
|
import { MediaGallery } from "./MediaGallery";
|
||||||
|
|
||||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||||
|
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||||
|
|
||||||
const EMPTY: Message[] = [];
|
const EMPTY: Message[] = [];
|
||||||
|
|
||||||
@@ -115,7 +124,36 @@ function BackgroundResult({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)">\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)"(?: todos="\d+")?>\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
||||||
|
/** Enforced reviews: a completed todo passed, or a reminder about open todos. */
|
||||||
|
const NOTE_RE = /^<(refine-approved|todo-reminder)(?: reviewer="([^"]*)")?(?: open="\d+")?>\n?([\s\S]*?)\n?<\/(?:refine-approved|todo-reminder)>/;
|
||||||
|
/** Messages from the harness shown as cards instead of user bubbles. */
|
||||||
|
const isHarnessNote = (text: string) => BG_RE.test(text) || REFINE_RE.test(text) || NOTE_RE.test(text);
|
||||||
|
|
||||||
|
/** A todo passed its review, or the agent was sent back to its open todos. */
|
||||||
|
function HarnessNote({ text }: { text: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const m = text.match(NOTE_RE);
|
||||||
|
if (!m) return null;
|
||||||
|
const [, kind, reviewer, body] = m;
|
||||||
|
const items = body.split("\n").map((l) => l.replace(/^- /, "").trim()).filter(Boolean);
|
||||||
|
const approved = kind === "refine-approved";
|
||||||
|
return (
|
||||||
|
<div className={`harness-note ${approved ? "ok" : ""}`}>
|
||||||
|
<Icon name={approved ? "check" : "refresh"} />
|
||||||
|
<span className="grow">
|
||||||
|
<b>{t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })}</b> {items.join(" · ")}
|
||||||
|
</span>
|
||||||
|
{reviewer && <span className="chip">{reviewer}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HarnessMessage({ text }: { text: string }) {
|
||||||
|
if (REFINE_RE.test(text)) return <RefineFeedback text={text} />;
|
||||||
|
if (NOTE_RE.test(text)) return <HarnessNote text={text} />;
|
||||||
|
return <BackgroundResult text={text} />;
|
||||||
|
}
|
||||||
|
|
||||||
/** Findings of the review loop, sent back to the agent. */
|
/** Findings of the review loop, sent back to the agent. */
|
||||||
function RefineFeedback({ text }: { text: string }) {
|
function RefineFeedback({ text }: { text: string }) {
|
||||||
@@ -137,10 +175,371 @@ function RefineFeedback({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Search the whole chat on the server; a hit loads its part of the chat. */
|
||||||
|
function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>();
|
||||||
|
useEffect(() => {
|
||||||
|
const query = q.trim();
|
||||||
|
if (query.length < 2) return setHits(undefined);
|
||||||
|
let alive = true;
|
||||||
|
const h = setTimeout(() => {
|
||||||
|
get<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
|
||||||
|
.then((r) => alive && setHits(r ?? []))
|
||||||
|
.catch(() => alive && setHits([]));
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearTimeout(h);
|
||||||
|
};
|
||||||
|
}, [q, sessionId]);
|
||||||
|
return (
|
||||||
|
<div className="chat-search" role="search">
|
||||||
|
<div className="row">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
|
||||||
|
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{hits && (
|
||||||
|
<div className="chat-search-hits">
|
||||||
|
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
|
||||||
|
{hits.map((h) => (
|
||||||
|
<button key={h.id} onClick={() => onPick(h.id)}>
|
||||||
|
<span className="who">
|
||||||
|
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||||
|
</span>
|
||||||
|
<span className="snippet">{h.snippet}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A part of the chat: a user message, a summary, or the work of the agent after it. */
|
||||||
|
type Block = { kind: "user" | "summary" | "work"; messages: Message[] };
|
||||||
|
|
||||||
|
const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image");
|
||||||
|
|
||||||
|
/** Group the messages into user messages and the agent's work between them. */
|
||||||
|
function blocksOf(messages: Message[]): Block[] {
|
||||||
|
const out: Block[] = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work";
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (kind === "work" && last?.kind === "work") last.messages.push(m);
|
||||||
|
else out.push({ kind, messages: [m] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The messages of a chat. Memoised: typing, streaming and status changes of the
|
||||||
|
* chat view do not touch it – only new messages, tool results or the busy state.
|
||||||
|
*/
|
||||||
|
const MessageList = memo(function MessageList({
|
||||||
|
messages,
|
||||||
|
results,
|
||||||
|
busy,
|
||||||
|
canEdit,
|
||||||
|
canRegenerate,
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
baseDir,
|
||||||
|
onRewind,
|
||||||
|
highlight,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
messages: Message[];
|
||||||
|
currency: string;
|
||||||
|
results: Map<string, ToolResult>;
|
||||||
|
busy: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canRegenerate: boolean;
|
||||||
|
sessionId: string;
|
||||||
|
model: string;
|
||||||
|
baseDir: string;
|
||||||
|
onRewind: (m: Message) => void;
|
||||||
|
highlight?: string;
|
||||||
|
}) {
|
||||||
|
const blocks = useMemo(() => blocksOf(messages), [messages]);
|
||||||
|
const lastWork = blocks.map((b) => b.kind).lastIndexOf("work");
|
||||||
|
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blocks.map((b, i) => {
|
||||||
|
// Stable while older messages are loaded above (they may join the first block):
|
||||||
|
// the last message; the block still growing at the end keeps its first one.
|
||||||
|
const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id;
|
||||||
|
if (b.kind !== "work") return <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
|
||||||
|
const items = b.messages.map((m) => (
|
||||||
|
<AssistantOrNote
|
||||||
|
key={m.id}
|
||||||
|
m={m}
|
||||||
|
results={results}
|
||||||
|
busy={busy}
|
||||||
|
baseDir={baseDir}
|
||||||
|
highlight={highlight}
|
||||||
|
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
// Finished turns with tool steps fold into one line (the answer stays visible).
|
||||||
|
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
||||||
|
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
|
||||||
|
// Files the agent wrote or edited in this turn: diffs and "roll back" (not while it is still working).
|
||||||
|
const changed = new Set(steps.filter((c) => (c.name === "write" || c.name === "edit") && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||||||
|
const lastAnswer = [...b.messages].reverse().find((m) => m.role === "assistant");
|
||||||
|
const checkBadge = lastAnswer && !(busy && i === lastWork) && <TurnCheck sessionId={sessionId} messageId={lastAnswer.id} canSend={canEdit} />;
|
||||||
|
const prev = blocks[i - 1];
|
||||||
|
const changes = changed.size > 0 && !(busy && i === lastWork) && (
|
||||||
|
<TurnChanges
|
||||||
|
sessionId={sessionId}
|
||||||
|
fromId={b.messages[0].id}
|
||||||
|
untilId={blocks[i + 1]?.messages[0].id}
|
||||||
|
fileCount={changed.size}
|
||||||
|
userMessage={canEdit && prev?.kind === "user" ? prev.messages[0] : undefined}
|
||||||
|
onRewind={onRewind}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (i === lastWork || steps.length < 2)
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
{items}
|
||||||
|
{usage}
|
||||||
|
{checkBadge}
|
||||||
|
{changes}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<WorkBlock messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>
|
||||||
|
{items}
|
||||||
|
</WorkBlock>
|
||||||
|
{usage}
|
||||||
|
{checkBadge}
|
||||||
|
{changes}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Tokens (in/out) and cost of one answer: all requests of the turn together. */
|
||||||
|
function TurnUsage({ messages, currency }: { messages: Message[]; currency: string }) {
|
||||||
|
const t = useT();
|
||||||
|
let inp = 0;
|
||||||
|
let out = 0;
|
||||||
|
let cost = 0;
|
||||||
|
let requests = 0;
|
||||||
|
for (const m of messages) {
|
||||||
|
const u = m.role === "assistant" ? m.usage : undefined;
|
||||||
|
if (!u) continue;
|
||||||
|
requests++;
|
||||||
|
inp += u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||||||
|
out += u.output_tokens;
|
||||||
|
cost += u.cost ?? 0;
|
||||||
|
}
|
||||||
|
if (!requests) return null;
|
||||||
|
return (
|
||||||
|
<div className="turn-usage" title={t("chat.turnUsageHint", { n: requests })}>
|
||||||
|
{fmtCompact(inp)}
|
||||||
|
<Icon name="arrow-up" className="tiny" /> {fmtCompact(out)}
|
||||||
|
<Icon name="arrow-down" className="tiny" /> · {fmtCost(cost, currency)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
|
||||||
|
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
||||||
|
const t = useT();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||||||
|
// The answer: text of the last assistant message of the turn.
|
||||||
|
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim()));
|
||||||
|
const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : "";
|
||||||
|
if (open || forced) {
|
||||||
|
return (
|
||||||
|
<div className="work-block open">
|
||||||
|
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
|
||||||
|
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||||
|
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="msg work-block" 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 && (
|
||||||
|
<>
|
||||||
|
{answer && <MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />}
|
||||||
|
<Markdown text={answerText} baseDir={baseDir} />
|
||||||
|
<ImageRefs text={answerText} baseDir={baseDir} />
|
||||||
|
{answer && <MsgTime ms={answer.created_at} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text parts of a message. */
|
||||||
|
const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n");
|
||||||
|
|
||||||
|
/** Small date/time line under a message. */
|
||||||
|
function MsgTime({ ms }: { ms: number }) {
|
||||||
|
if (!ms) return null;
|
||||||
|
return (
|
||||||
|
<time className="msg-time" dateTime={new Date(ms).toISOString()} title={new Date(ms).toLocaleString()}>
|
||||||
|
{fmtMsgTime(ms)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An assistant message, or a user message that only carries tool results / harness notes. */
|
||||||
|
const AssistantOrNote = memo(function AssistantOrNote({
|
||||||
|
m,
|
||||||
|
results,
|
||||||
|
busy,
|
||||||
|
baseDir,
|
||||||
|
highlight,
|
||||||
|
regenerate,
|
||||||
|
}: {
|
||||||
|
m: Message;
|
||||||
|
results: Map<string, ToolResult>;
|
||||||
|
busy: boolean;
|
||||||
|
baseDir: string;
|
||||||
|
highlight?: string;
|
||||||
|
regenerate?: { sessionId: string; model: string };
|
||||||
|
}) {
|
||||||
|
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
||||||
|
if (m.role === "assistant") {
|
||||||
|
return (
|
||||||
|
<div className={cls} data-mid={m.id}>
|
||||||
|
<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />
|
||||||
|
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||||||
|
<MsgTime ms={m.created_at} />
|
||||||
|
{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} />
|
||||||
|
))}
|
||||||
|
<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />
|
||||||
|
<div className="msg-user">
|
||||||
|
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||||
|
<MsgTime ms={m.created_at} />
|
||||||
|
{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 }) {
|
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const session = useStore((s) => s.sessionById[sessionId]);
|
const session = useStore((s) => s.sessionById[sessionId]);
|
||||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
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 draft = useStore((s) => s.drafts[sessionId]);
|
||||||
const busy = useStore((s) => !!s.busy[sessionId]);
|
const busy = useStore((s) => !!s.busy[sessionId]);
|
||||||
const errors = useStore((s) => s.errors[sessionId]);
|
const errors = useStore((s) => s.errors[sessionId]);
|
||||||
@@ -155,6 +554,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
// The Telegram switch in the ⋯ menu only matters with a paired bot.
|
// The Telegram switch in the ⋯ menu only matters with a paired bot.
|
||||||
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
|
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
|
||||||
const [editingInstructions, setEditingInstructions] = useState(false);
|
const [editingInstructions, setEditingInstructions] = useState(false);
|
||||||
|
const [showApprovals, setShowApprovals] = useState(false);
|
||||||
|
const [planning, setPlanning] = useState(false);
|
||||||
const models = useModels();
|
const models = useModels();
|
||||||
const [loadError, setLoadError] = useState<string>();
|
const [loadError, setLoadError] = useState<string>();
|
||||||
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
||||||
@@ -176,11 +577,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scrolled away from the top: the jump buttons show up.
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
/** Scroll to the closest own message above the visible area (long chats on a phone). */
|
||||||
|
const jumpPrevOwn = () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const top = el.getBoundingClientRect().top;
|
||||||
|
const above = [...el.querySelectorAll<HTMLElement>(".msg-user")].filter((n) => n.getBoundingClientRect().top < top - 12);
|
||||||
|
const target = above[above.length - 1];
|
||||||
|
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
else el.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
// Every chat starts following.
|
// Every chat starts following.
|
||||||
useEffect(() => follow(true), [sessionId]);
|
useEffect(() => follow(true), [sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSession(sessionId).catch((e) => setLoadError(e.message));
|
loadSession(sessionId).catch((e) => setLoadError(e.message));
|
||||||
|
void loadChecks(sessionId);
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
|
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
|
||||||
@@ -197,11 +612,36 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
return m;
|
return m;
|
||||||
}, [messages]);
|
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
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.
|
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||||||
const hasSession = !!session;
|
const hasSession = !!session;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -247,13 +687,19 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
setText("");
|
setText("");
|
||||||
follow(true);
|
follow(true);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(e.message);
|
// No connection: keep the text on this device and send it when the server is reachable again.
|
||||||
|
if (isOffline(e) && txt && images.length === 0) {
|
||||||
|
addToOutbox(sessionId, txt, effort || undefined);
|
||||||
|
setText("");
|
||||||
|
toast(t("outbox.saved"));
|
||||||
|
} else toast(e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1);
|
|
||||||
const [rewinding, setRewinding] = useState<Message>();
|
const [rewinding, setRewinding] = useState<Message>();
|
||||||
const rewind = (m: Message) => setRewinding(m);
|
const rewind = (m: Message) => setRewinding(m);
|
||||||
|
rewindRef.current = rewind;
|
||||||
const rewindDone = async () => {
|
const rewindDone = async () => {
|
||||||
const m = rewinding!;
|
const m = rewinding!;
|
||||||
setRewinding(undefined);
|
setRewinding(undefined);
|
||||||
@@ -261,6 +707,46 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
|
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** New chats in this project start like this one (model, mode, colour, instructions, folder). */
|
||||||
|
const saveDefaults = async (remove: boolean) => {
|
||||||
|
try {
|
||||||
|
if (remove) await del(`/sessions/${sessionId}/project-defaults`);
|
||||||
|
else await put(`/sessions/${sessionId}/project-defaults`, {});
|
||||||
|
toast(t(remove ? "projectDefaults.removed" : "projectDefaults.saved"), "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The project's check command (tests, build …), run after every turn that changed files. */
|
||||||
|
const editCheck = async () => {
|
||||||
|
try {
|
||||||
|
const cur = await get<{ command: string; enabled: boolean }>(`/sessions/${sessionId}/check`);
|
||||||
|
const cmd = await promptDialog({ title: t("check.title"), message: t("check.hint", { project: session.cwd }), defaultValue: cur.command, placeholder: "cargo test", confirmLabel: t("common.save") });
|
||||||
|
if (cmd === null) return;
|
||||||
|
await put(`/sessions/${sessionId}/check`, { command: cmd.trim(), enabled: cmd.trim() !== "" });
|
||||||
|
toast(cmd.trim() ? t("check.saved") : t("check.removed"), "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Draft an automation from this chat (small model), create it switched off and open its editor. */
|
||||||
|
const planJob = async () => {
|
||||||
|
if (planning) return;
|
||||||
|
setPlanning(true);
|
||||||
|
toast(t("job.drafting"));
|
||||||
|
try {
|
||||||
|
const d = await post<{ name: string; prompt: string; cwd: string; agent: string }>(`/sessions/${sessionId}/job-draft`, {});
|
||||||
|
const job = await post<{ id: string }>("/jobs/create", { cwd: d.cwd, name: d.name, prompt: d.prompt, agent: d.agent === "plan" ? undefined : d.agent });
|
||||||
|
navigate(`/jobs/${job.id}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
} finally {
|
||||||
|
setPlanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
await del(`/sessions/${sessionId}`);
|
await del(`/sessions/${sessionId}`);
|
||||||
@@ -283,8 +769,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
|
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ChatCtx.Provider value={{ sessionId, canFork: !session.parent_id && !readOnly && !busy, canReply: !readOnly }}>
|
||||||
<div className="topbar">
|
<div className="topbar" data-color={session.color}>
|
||||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
<Icon name="menu" />
|
<Icon name="menu" />
|
||||||
</button>
|
</button>
|
||||||
@@ -331,7 +817,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
{!session.parent_id && (
|
{!session.parent_id && (
|
||||||
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
|
<button className="small ghost hide-mobile" onClick={() => setSharing(true)} title={t("share.title")}>
|
||||||
<Icon name="share" />
|
<Icon name="share" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -342,11 +828,14 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
>
|
>
|
||||||
<Icon name="terminal" />
|
<Icon name="terminal" />
|
||||||
</button>
|
</button>
|
||||||
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
|
||||||
|
<Icon name="search" />
|
||||||
|
</button>
|
||||||
|
<button className="small ghost hide-mobile" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||||
<Icon name="folder" />
|
<Icon name="folder" />
|
||||||
</button>
|
</button>
|
||||||
{!session.parent_id && (
|
{!session.parent_id && (
|
||||||
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
<button className="small ghost hide-mobile" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||||
<Icon name="fork" />
|
<Icon name="fork" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -355,6 +844,28 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<Icon name="more" />
|
<Icon name="more" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="menu-pop-body">
|
<div className="menu-pop-body">
|
||||||
|
{/* The buttons that do not fit into the narrow bar. */}
|
||||||
|
{!session.parent_id && (
|
||||||
|
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, setSharing(true))}>
|
||||||
|
<Icon name="share" /> {t("share.title")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, tabs.openFiles())}>
|
||||||
|
<Icon name="folder" /> {t("files.newTab")}
|
||||||
|
</a>
|
||||||
|
{!session.parent_id && !busy && (
|
||||||
|
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, forkSession())}>
|
||||||
|
<Icon name="fork" /> {t("chat.fork")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{!busy && (
|
||||||
|
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, post(`/sessions/${sessionId}/compact`).catch((err) => toast(err.message)))}>
|
||||||
|
<Icon name="compact" /> {t("command.compact")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<a href="#" className="mobile-only danger" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, remove())}>
|
||||||
|
<Icon name="trash" /> {t("chat.delete")}
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -365,6 +876,32 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
>
|
>
|
||||||
<Icon name="edit" /> {t("instructions.title")}
|
<Icon name="edit" /> {t("instructions.title")}
|
||||||
</a>
|
</a>
|
||||||
|
<ColorSwatches value={session.color} onChange={(color) => void update({ color })} />
|
||||||
|
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}>
|
||||||
|
<Icon name="clock" /> {t("job.plan")}
|
||||||
|
</a>
|
||||||
|
<a href="#" title={t("projectDefaults.hint")} onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(false))}>
|
||||||
|
<Icon name="template" /> {t("projectDefaults.save")}
|
||||||
|
</a>
|
||||||
|
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(true))}>
|
||||||
|
<Icon name="undo" /> {t("projectDefaults.remove")}
|
||||||
|
</a>
|
||||||
|
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void editCheck())}>
|
||||||
|
<Icon name="check" /> {t("check.menu")}
|
||||||
|
</a>
|
||||||
|
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), setShowApprovals(true))}>
|
||||||
|
<Icon name="lock" /> {t("permProject.menu")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||||
|
setGallery(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="image" /> {t("media.title")}
|
||||||
|
</a>
|
||||||
{telegramPaired && !session.parent_id && (
|
{telegramPaired && !session.parent_id && (
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@@ -404,10 +941,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
<button className="small ghost hide-mobile" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
||||||
<Icon name="compact" />
|
<Icon name="compact" />
|
||||||
</button>
|
</button>
|
||||||
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
<button className="small ghost danger hide-mobile" onClick={remove} title={t("chat.delete")}>
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -418,9 +955,20 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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} />
|
<ChatTabBar state={tabs} />
|
||||||
<ChatTabPanes state={tabs} />
|
<ChatTabPanes state={tabs} />
|
||||||
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
<div className="chat-pane" data-color={session.color} data-session-id={sessionId} hidden={tabs.tab !== "chat"}>
|
||||||
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
||||||
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
||||||
<div
|
<div
|
||||||
@@ -435,6 +983,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
// … scrolling back to the very end resumes it.
|
// … scrolling back to the very end resumes it.
|
||||||
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
|
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
|
||||||
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
|
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
|
||||||
|
setScrolled(el.scrollTop > 240);
|
||||||
}}
|
}}
|
||||||
onWheel={(e) => {
|
onWheel={(e) => {
|
||||||
if (e.deltaY < 0 && stick.current) follow(false);
|
if (e.deltaY < 0 && stick.current) follow(false);
|
||||||
@@ -443,71 +992,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<div className="chat">
|
<div className="chat">
|
||||||
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
||||||
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
||||||
{messages.map((m, idx) => {
|
{win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
|
||||||
if (m.role === "assistant") {
|
<MessageList
|
||||||
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
|
messages={messages}
|
||||||
return (
|
results={results}
|
||||||
<div key={m.id} className="msg">
|
busy={busy}
|
||||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
|
canEdit={!busy && !readOnly}
|
||||||
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
|
canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
|
||||||
</div>
|
sessionId={sessionId}
|
||||||
);
|
model={session.model}
|
||||||
}
|
baseDir={session.cwd}
|
||||||
const summary = m.parts.find((p) => p.type === "summary");
|
onRewind={onRewind}
|
||||||
if (summary && summary.type === "summary") {
|
highlight={highlight}
|
||||||
return (
|
currency={stats?.currency ?? "USD"}
|
||||||
<details key={m.id} className="summary-divider">
|
/>
|
||||||
<summary>
|
{win?.newer && (
|
||||||
{summary.auto
|
<button className="back-to-latest" onClick={() => void showLatest()}>
|
||||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
<Icon name="arrow-down" /> {t("chat.backToLatest")}
|
||||||
: summary.tokens
|
</button>
|
||||||
? 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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{draft && (
|
{draft && (
|
||||||
<div className="msg">
|
<div className="msg">
|
||||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||||
@@ -560,18 +1063,23 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!following && (
|
{(!following || scrolled) && (
|
||||||
<div className="jump-latest">
|
<div className="jump-latest">
|
||||||
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
|
<button onClick={jumpPrevOwn} title={t("chat.jumpPrevOwn")} aria-label={t("chat.jumpPrevOwn")}>
|
||||||
<Icon name="arrow-down" />
|
<Icon name="arrow-up" />
|
||||||
</button>
|
</button>
|
||||||
|
{!following && <button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
|
||||||
|
<Icon name="arrow-down" />
|
||||||
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showApprovals && <ProjectPermissions sessionId={sessionId} cwd={session.cwd} onClose={() => setShowApprovals(false)} />}
|
||||||
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
|
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
|
||||||
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||||
|
{!readOnly && <OutboxBar sessionId={sessionId} />}
|
||||||
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
|
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
|
||||||
@@ -587,7 +1095,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</ChatCtx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useT, type Key } from "../i18n";
|
||||||
|
|
||||||
|
/** Colour tags of chats: names, drawn in the colours of the active theme (see `[data-color]` in styles.css). */
|
||||||
|
export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue", "purple"] as const;
|
||||||
|
|
||||||
|
/** Row of colour dots; the selected one is ringed, a second click (or ✕) removes the tag. */
|
||||||
|
export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<div className="color-swatches" role="radiogroup" aria-label={t("color.label")}>
|
||||||
|
{SESSION_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={value === c}
|
||||||
|
className={`swatch ${value === c ? "on" : ""}`}
|
||||||
|
data-color={c}
|
||||||
|
title={t(`color.${c}` as Key)}
|
||||||
|
aria-label={t(`color.${c}` as Key)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(value === c ? "" : c);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ModelSelect } from "./ModelSelect";
|
|||||||
import { EffortSelect } from "./Regenerate";
|
import { EffortSelect } from "./Regenerate";
|
||||||
import { VoiceButton } from "./Voice";
|
import { VoiceButton } from "./Voice";
|
||||||
import { Markdown } from "./Markdown";
|
import { Markdown } from "./Markdown";
|
||||||
|
import { COMPOSE_EVENT, type ComposeDetail } from "../compose";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
interface Suggestion {
|
interface Suggestion {
|
||||||
@@ -207,6 +208,27 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Content sent from elsewhere (quote a message, image viewer): latest closures via refs.
|
||||||
|
const live = useRef({ text, addFiles, setText });
|
||||||
|
live.current = { text, addFiles, setText };
|
||||||
|
useEffect(() => {
|
||||||
|
const on = (e: Event) => {
|
||||||
|
const d = (e as CustomEvent<ComposeDetail>).detail;
|
||||||
|
if (d.sessionId !== session.id) return;
|
||||||
|
if (d.text) live.current.setText(live.current.text.trim() ? `${live.current.text.replace(/\s+$/, "")}\n\n${d.text}` : d.text);
|
||||||
|
if (d.files?.length) void live.current.addFiles(d.files);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
setPreview(false);
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(el.value.length, el.value.length);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener(COMPOSE_EVENT, on);
|
||||||
|
return () => window.removeEventListener(COMPOSE_EVENT, on);
|
||||||
|
}, [session.id]);
|
||||||
|
|
||||||
const removeFile = (path: string) => {
|
const removeFile = (path: string) => {
|
||||||
setFiles((cur) => cur.filter((f) => f.path !== path));
|
setFiles((cur) => cur.filter((f) => f.path !== path));
|
||||||
void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" });
|
void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" });
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const P: Record<string, ReactNode> = {
|
|||||||
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
|
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
|
||||||
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
|
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
|
||||||
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
|
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
|
||||||
|
reply: <path d="M9 7 4 12l5 5M4 12h9a7 7 0 0 1 7 7" />,
|
||||||
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
|
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
|
||||||
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
|
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
|
||||||
clip: <path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />,
|
clip: <path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />,
|
||||||
@@ -125,6 +126,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" />
|
<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: (
|
eye: (
|
||||||
<>
|
<>
|
||||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { toast } from "../dialogs";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
type Tool = "pen" | "arrow" | "rect";
|
||||||
|
interface Shape {
|
||||||
|
tool: Tool;
|
||||||
|
color: string;
|
||||||
|
/** pen: all points; arrow/rect: start and end. */
|
||||||
|
pts: { x: number; y: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["#ff3b30", "#ffcc00", "#34c759", "#0a84ff", "#ffffff", "#000000"];
|
||||||
|
const MAX_SIDE = 2000;
|
||||||
|
|
||||||
|
function paint(ctx: CanvasRenderingContext2D, shapes: Shape[], w: number) {
|
||||||
|
const lw = Math.max(3, Math.round(w / 280));
|
||||||
|
for (const s of shapes) {
|
||||||
|
ctx.strokeStyle = s.color;
|
||||||
|
ctx.fillStyle = s.color;
|
||||||
|
ctx.lineWidth = lw;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
const a = s.pts[0];
|
||||||
|
const b = s.pts[s.pts.length - 1];
|
||||||
|
ctx.beginPath();
|
||||||
|
if (s.tool === "pen") {
|
||||||
|
ctx.moveTo(a.x, a.y);
|
||||||
|
for (const p of s.pts.slice(1)) ctx.lineTo(p.x, p.y);
|
||||||
|
if (s.pts.length === 1) ctx.lineTo(a.x + 0.1, a.y);
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (s.tool === "rect") {
|
||||||
|
ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
|
||||||
|
} else {
|
||||||
|
// Arrow: line with a filled head at the end.
|
||||||
|
const ang = Math.atan2(b.y - a.y, b.x - a.x);
|
||||||
|
const head = lw * 5;
|
||||||
|
ctx.moveTo(a.x, a.y);
|
||||||
|
ctx.lineTo(b.x - Math.cos(ang) * head * 0.6, b.y - Math.sin(ang) * head * 0.6);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(b.x, b.y);
|
||||||
|
ctx.lineTo(b.x - head * Math.cos(ang - 0.45), b.y - head * Math.sin(ang - 0.45));
|
||||||
|
ctx.lineTo(b.x - head * Math.cos(ang + 0.45), b.y - head * Math.sin(ang + 0.45));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw on an image (pen, arrow, rectangle) and hand the result back as a PNG file. */
|
||||||
|
export function ImageAnnotator({ src, name, onDone, onCancel }: { src: string; name: string; onDone: (file: File) => void; onCancel: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const canvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const bitmap = useRef<HTMLImageElement | undefined>(undefined);
|
||||||
|
const [shapes, setShapes] = useState<Shape[]>([]);
|
||||||
|
const [tool, setTool] = useState<Tool>("pen");
|
||||||
|
const [color, setColor] = useState(COLORS[0]);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const drawing = useRef<Shape | null>(null);
|
||||||
|
|
||||||
|
const redraw = (extra?: Shape | null) => {
|
||||||
|
const c = canvas.current;
|
||||||
|
const img = bitmap.current;
|
||||||
|
if (!c || !img) return;
|
||||||
|
const ctx = c.getContext("2d")!;
|
||||||
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
ctx.drawImage(img, 0, 0, c.width, c.height);
|
||||||
|
paint(ctx, extra ? [...shapes, extra] : shapes, c.width);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
const c = canvas.current;
|
||||||
|
if (!c) return;
|
||||||
|
const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
|
||||||
|
c.width = Math.max(1, Math.round((img.naturalWidth || 300) * scale));
|
||||||
|
c.height = Math.max(1, Math.round((img.naturalHeight || 200) * scale));
|
||||||
|
bitmap.current = img;
|
||||||
|
setReady(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
toast(t("viewer.annotateFailed"));
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
img.src = src;
|
||||||
|
}, [src]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => redraw(), [shapes, ready]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const point = (e: React.PointerEvent) => {
|
||||||
|
const c = canvas.current!;
|
||||||
|
const r = c.getBoundingClientRect();
|
||||||
|
return { x: ((e.clientX - r.left) / r.width) * c.width, y: ((e.clientY - r.top) / r.height) * c.height };
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
const c = canvas.current;
|
||||||
|
if (!c) return;
|
||||||
|
c.toBlob((blob) => {
|
||||||
|
if (!blob) return toast(t("viewer.annotateFailed"));
|
||||||
|
const base = name.replace(/\.[a-z0-9]{1,5}$/i, "") || "image";
|
||||||
|
onDone(new File([blob], `${base}-marked.png`, { type: "image/png" }));
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="annotator" onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
|
||||||
|
<canvas
|
||||||
|
ref={canvas}
|
||||||
|
style={{ visibility: ready ? "visible" : "hidden" }}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
drawing.current = { tool, color, pts: [point(e)] };
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
const d = drawing.current;
|
||||||
|
if (!d) return;
|
||||||
|
const p = point(e);
|
||||||
|
d.pts = d.tool === "pen" ? [...d.pts, p] : [d.pts[0], p];
|
||||||
|
redraw(d);
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
const d = drawing.current;
|
||||||
|
drawing.current = null;
|
||||||
|
if (d) setShapes((s) => [...s, d]);
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
drawing.current = null;
|
||||||
|
redraw();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="annotator-tools" role="toolbar" aria-label={t("viewer.annotate")}>
|
||||||
|
{(["pen", "arrow", "rect"] as Tool[]).map((k) => (
|
||||||
|
<button key={k} className={`small ${tool === k ? "primary" : "ghost"}`} onClick={() => setTool(k)} aria-pressed={tool === k} title={t(`viewer.tool.${k}` as const)}>
|
||||||
|
<Icon name={k === "pen" ? "edit" : k === "arrow" ? "arrow-right" : "box-off"} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="annotator-colors">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button key={c} className={`swatch ${c === color ? "on" : ""}`} style={{ background: c }} onClick={() => setColor(c)} aria-label={c} aria-pressed={c === color} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<button className="small ghost" onClick={() => setShapes((s) => s.slice(0, -1))} disabled={!shapes.length} title={t("viewer.undo")} aria-label={t("viewer.undo")}>
|
||||||
|
<Icon name="undo" />
|
||||||
|
</button>
|
||||||
|
<span className="grow" />
|
||||||
|
<button className="small" onClick={onCancel}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button className="small primary" onClick={finish} disabled={!ready}>
|
||||||
|
<Icon name="clip" /> {t("viewer.attach")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { ImageAnnotator } from "./ImageAnnotator";
|
||||||
|
import { compose } from "../compose";
|
||||||
|
import { toast } from "../dialogs";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
|
/** Images that open in the viewer: everything in the app, except UI chrome. */
|
||||||
|
const SKIP = ".viewer-overlay, .sidebar, .topbar, button, .modal-backdrop .viewer, .model-viewer, .media-stage, .composer, .brand, .login";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popup image viewer: a click on any image of the app opens it fullscreen.
|
||||||
|
* Pinch / wheel / double tap to zoom, drag to pan, arrows or swipe for the other
|
||||||
|
* images of the same chat, Esc to close. Mounted once; listens for clicks globally.
|
||||||
|
*/
|
||||||
|
export function ImageViewer() {
|
||||||
|
const t = useT();
|
||||||
|
const [list, setList] = useState<Item[]>([]);
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
// Chat the image belongs to (for "use in message"); drawing mode.
|
||||||
|
const [sessionId, setSessionId] = useState<string>();
|
||||||
|
const [marking, setMarking] = useState(false);
|
||||||
|
const [view, setView] = useState({ x: 0, y: 0, s: 1 });
|
||||||
|
const pointers = useRef(new Map<number, { x: number; y: number }>());
|
||||||
|
const gesture = useRef<{ d: number; s: number } | null>(null);
|
||||||
|
const moved = useRef(false);
|
||||||
|
const lastTap = useRef(0);
|
||||||
|
const swipe = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const reset = useCallback(() => setView({ x: 0, y: 0, s: 1 }), []);
|
||||||
|
const close = useCallback(() => setOpen(false), []);
|
||||||
|
const go = useCallback(
|
||||||
|
(d: number) => {
|
||||||
|
setIdx((i) => clamp(i + d, 0, list.length - 1));
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
[list.length, reset],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
const img = (e.target as HTMLElement | null)?.closest?.("img");
|
||||||
|
if (!img || img.closest(SKIP) || e.defaultPrevented || e.button !== 0) return;
|
||||||
|
if (img.naturalWidth && img.naturalWidth < 24) return; // icons
|
||||||
|
e.preventDefault(); // the surrounding link would open a new tab
|
||||||
|
e.stopPropagation();
|
||||||
|
const scope = img.closest(".chat, .files-main, .gallery, .main") ?? document;
|
||||||
|
const all = [...scope.querySelectorAll("img")].filter((i) => !i.closest(SKIP) && i.src && (i.naturalWidth === 0 || i.naturalWidth >= 24));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: Item[] = [];
|
||||||
|
let start = 0;
|
||||||
|
for (const i of all) {
|
||||||
|
if (seen.has(i.currentSrc || i.src)) {
|
||||||
|
if (i === img) start = items.findIndex((x) => x.src === (i.currentSrc || i.src));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(i.currentSrc || i.src);
|
||||||
|
if (i === img) start = items.length;
|
||||||
|
items.push({ src: i.currentSrc || i.src, alt: i.alt });
|
||||||
|
}
|
||||||
|
setSessionId((img.closest("[data-session-id]") as HTMLElement | null)?.dataset.sessionId);
|
||||||
|
setMarking(false);
|
||||||
|
setList(items.length ? items : [{ src: img.currentSrc || img.src, alt: img.alt }]);
|
||||||
|
setIdx(Math.max(0, start));
|
||||||
|
reset();
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
// Capture phase: before the link around a thumbnail navigates.
|
||||||
|
document.addEventListener("click", onClick, true);
|
||||||
|
return () => document.removeEventListener("click", onClick, true);
|
||||||
|
}, [reset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (marking) return;
|
||||||
|
if (e.key === "Escape") close();
|
||||||
|
else if (e.key === "ArrowLeft") go(-1);
|
||||||
|
else if (e.key === "ArrowRight") go(1);
|
||||||
|
else if (e.key === "+" || e.key === "=") setView((v) => ({ ...v, s: clamp(v.s * 1.25, 1, 8) }));
|
||||||
|
else if (e.key === "-") setView((v) => ({ ...v, s: clamp(v.s / 1.25, 1, 8), ...(v.s / 1.25 <= 1 ? { x: 0, y: 0 } : {}) }));
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open, close, go, marking]);
|
||||||
|
|
||||||
|
if (!open || !list[idx]) return null;
|
||||||
|
const cur = list[idx];
|
||||||
|
|
||||||
|
/** The image as a file for the composer (variants, questions about it, …). */
|
||||||
|
async function useInChat() {
|
||||||
|
try {
|
||||||
|
const blob = await (await fetch(cur.src, { credentials: "same-origin" })).blob();
|
||||||
|
const ext = blob.type.split("/")[1]?.split("+")[0] || "png";
|
||||||
|
compose({ sessionId: sessionId!, files: [new File([blob], `image.${ext}`, { type: blob.type || "image/png" })] });
|
||||||
|
toast(t("viewer.attached"), "success");
|
||||||
|
close();
|
||||||
|
} catch {
|
||||||
|
toast(t("viewer.annotateFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist = () => {
|
||||||
|
const [a, b] = [...pointers.current.values()];
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="viewer-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("viewer.title")}
|
||||||
|
onClick={() => {
|
||||||
|
if (!moved.current) close();
|
||||||
|
}}
|
||||||
|
onWheel={(e) => setView((v) => {
|
||||||
|
const s = clamp(v.s * (e.deltaY < 0 ? 1.15 : 1 / 1.15), 1, 8);
|
||||||
|
return s === 1 ? { x: 0, y: 0, s } : { ...v, s };
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="viewer-bar" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<span className="viewer-count">{list.length > 1 ? `${idx + 1} / ${list.length}` : ""}</span>
|
||||||
|
<span className="viewer-name">{cur.alt && !/^(image|SVG)$/i.test(cur.alt) ? cur.alt : ""}</span>
|
||||||
|
{sessionId && (
|
||||||
|
<>
|
||||||
|
<button className="small ghost" onClick={() => setMarking(true)} title={t("viewer.annotate")} aria-label={t("viewer.annotate")}>
|
||||||
|
<Icon name="edit" />
|
||||||
|
</button>
|
||||||
|
<button className="small ghost" onClick={() => void useInChat()} title={t("viewer.useInChat")} aria-label={t("viewer.useInChat")}>
|
||||||
|
<Icon name="reply" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<a className="button small ghost" href={cur.src} target="_blank" rel="noreferrer" title={t("viewer.open")} aria-label={t("viewer.open")}>
|
||||||
|
<Icon name="share" />
|
||||||
|
</a>
|
||||||
|
<a className="button small ghost" href={cur.src} download title={t("viewer.download")} aria-label={t("viewer.download")}>
|
||||||
|
<Icon name="download" />
|
||||||
|
</a>
|
||||||
|
<button className="small ghost" onClick={close} aria-label={t("common.close")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{marking && (
|
||||||
|
<ImageAnnotator
|
||||||
|
src={cur.src}
|
||||||
|
name={cur.alt || "image"}
|
||||||
|
onCancel={() => setMarking(false)}
|
||||||
|
onDone={(file) => {
|
||||||
|
compose({ sessionId: sessionId!, files: [file] });
|
||||||
|
toast(t("viewer.attached"), "success");
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!marking && idx > 0 && (
|
||||||
|
<button className="viewer-nav prev" onClick={(e) => (e.stopPropagation(), go(-1))} aria-label={t("media.prev")}>
|
||||||
|
<Icon name="arrow-left" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!marking && idx < list.length - 1 && (
|
||||||
|
<button className="viewer-nav next" onClick={(e) => (e.stopPropagation(), go(1))} aria-label={t("media.next")}>
|
||||||
|
<Icon name="arrow-right" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!marking && <img
|
||||||
|
key={cur.src}
|
||||||
|
className="viewer-img"
|
||||||
|
src={cur.src}
|
||||||
|
alt={cur.alt}
|
||||||
|
draggable={false}
|
||||||
|
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`, cursor: view.s > 1 ? "grab" : "zoom-in" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
moved.current = false;
|
||||||
|
if (pointers.current.size === 2) gesture.current = { d: dist(), s: view.s };
|
||||||
|
else swipe.current = { x: e.clientX, y: e.clientY };
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
const p = pointers.current.get(e.pointerId);
|
||||||
|
if (!p) return;
|
||||||
|
const dx = e.clientX - p.x;
|
||||||
|
const dy = e.clientY - p.y;
|
||||||
|
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (pointers.current.size === 2 && gesture.current) {
|
||||||
|
moved.current = true;
|
||||||
|
const s = clamp((gesture.current.s * dist()) / gesture.current.d, 1, 8);
|
||||||
|
setView((v) => (s === 1 ? { x: 0, y: 0, s } : { ...v, s }));
|
||||||
|
} else if (view.s > 1) {
|
||||||
|
if (Math.abs(dx) + Math.abs(dy) > 1) moved.current = true;
|
||||||
|
setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
pointers.current.delete(e.pointerId);
|
||||||
|
gesture.current = null;
|
||||||
|
const s0 = swipe.current;
|
||||||
|
swipe.current = null;
|
||||||
|
if (s0 && view.s === 1) {
|
||||||
|
const dx = e.clientX - s0.x;
|
||||||
|
const dy = e.clientY - s0.y;
|
||||||
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||||
|
moved.current = true;
|
||||||
|
go(dx < 0 ? 1 : -1);
|
||||||
|
} else if (dy > 90 && Math.abs(dy) > Math.abs(dx) * 1.5) {
|
||||||
|
moved.current = true;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Double tap / double click: zoom in or back.
|
||||||
|
if (!moved.current) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTap.current < 300) setView((v) => (v.s > 1 ? { x: 0, y: 0, s: 1 } : { ...v, s: 2.5 }));
|
||||||
|
lastTap.current = now;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerCancel={(e) => {
|
||||||
|
pointers.current.delete(e.pointerId);
|
||||||
|
gesture.current = null;
|
||||||
|
}}
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { post } from "../api";
|
import { post } from "../api";
|
||||||
import { useT } from "../i18n";
|
import { getLang, useT } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import type { PermissionRequest, QuestionRequest } from "../types";
|
import type { PermissionRequest, QuestionRequest } from "../types";
|
||||||
@@ -31,7 +31,24 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
|||||||
const chosen = selected.filter(Boolean).length;
|
const chosen = selected.filter(Boolean).length;
|
||||||
const partial = hunkCount > 1 && chosen < hunkCount;
|
const partial = hunkCount > 1 && chosen < hunkCount;
|
||||||
|
|
||||||
const reply = async (r: "once" | "always" | "reject") => {
|
const [explaining, setExplaining] = useState(false);
|
||||||
|
const [explanation, setExplanation] = useState<string>();
|
||||||
|
const [explainError, setExplainError] = useState<string>();
|
||||||
|
/** The small model explains in plain words what the action does (approving on a phone without reading code). */
|
||||||
|
const explain = async () => {
|
||||||
|
setExplaining(true);
|
||||||
|
setExplainError(undefined);
|
||||||
|
try {
|
||||||
|
const r = await post<{ text: string }>(`/permissions/${req.id}/explain`, { lang: getLang() });
|
||||||
|
setExplanation(r.text);
|
||||||
|
} catch (e: any) {
|
||||||
|
setExplainError(e.message);
|
||||||
|
} finally {
|
||||||
|
setExplaining(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reply = async (r: "once" | "always" | "project" | "reject") => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined;
|
const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined;
|
||||||
@@ -47,6 +64,10 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
|||||||
? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
|
? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
|
||||||
: t("permission.alwaysTool", { tool: req.tool });
|
: t("permission.alwaysTool", { tool: req.tool });
|
||||||
|
|
||||||
|
// Dangerous commands are only ever allowed for the session (the server decides; this hides the button).
|
||||||
|
const dangerous = !!command && /\b(rm\s+-[a-zA-Z]*[rRf]|sudo|su|git\s+(push|reset|clean|rebase|checkout|restore)|docker|docker-compose|systemctl|chmod\s+-R|chown\s+-R|dd|mkfs|shutdown|reboot|kill|pkill|killall|crontab)\b|\|\s*(ba)?sh\b|publish/.test(command);
|
||||||
|
const projectLabel = req.tool === "bash" ? t("permission.projectCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") }) : t("permission.projectTool", { tool: req.tool });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
|
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
|
||||||
<h3>
|
<h3>
|
||||||
@@ -72,6 +93,16 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
|||||||
</details>
|
</details>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{explanation ? (
|
||||||
|
<div className="explain" role="note">
|
||||||
|
<Icon name="sparkle" className="tiny" /> {explanation}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="small ghost explain-btn" disabled={explaining} onClick={explain}>
|
||||||
|
<Icon name="sparkle" /> {explaining ? t("permission.explaining") : t("permission.explain")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{explainError && <div className="hint">{explainError}</div>}
|
||||||
<input
|
<input
|
||||||
style={{ width: "100%", marginTop: 8 }}
|
style={{ width: "100%", marginTop: 8 }}
|
||||||
placeholder={t("permission.feedback")}
|
placeholder={t("permission.feedback")}
|
||||||
@@ -86,6 +117,11 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
|||||||
<button disabled={busy || partial} onClick={() => reply("always")}>
|
<button disabled={busy || partial} onClick={() => reply("always")}>
|
||||||
{alwaysLabel}
|
{alwaysLabel}
|
||||||
</button>
|
</button>
|
||||||
|
{!dangerous && (
|
||||||
|
<button disabled={busy || partial} onClick={() => reply("project")} title={t("permission.projectHint")}>
|
||||||
|
{projectLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
|
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
|
||||||
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
|
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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,75 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { post } from "../api";
|
||||||
|
import { asQuote, compose } from "../compose";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { loadSessions } from "../store";
|
||||||
|
import { toast } from "../dialogs";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
/** What the message menus of one chat need to know. */
|
||||||
|
export const ChatCtx = createContext<{ sessionId: string; canFork: boolean; canReply: boolean }>({ sessionId: "", canFork: false, canReply: false });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions of one message: copy, quote into the reply, fork the chat from here and
|
||||||
|
* (for own messages) edit and resend. Hover on desktop, always faintly visible on touch.
|
||||||
|
*/
|
||||||
|
export function MessageMenu({ messageId, text, forkable, onRewind }: { messageId: string; text: string; forkable: boolean; onRewind?: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const { sessionId, canFork, canReply } = useContext(ChatCtx);
|
||||||
|
const close = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||||
|
};
|
||||||
|
if (!text.trim() && !onRewind) return null;
|
||||||
|
return (
|
||||||
|
<details className="menu-pop msg-menu">
|
||||||
|
<summary className="small ghost" title={t("msg.actions")} aria-label={t("msg.actions")}>
|
||||||
|
<Icon name="more" />
|
||||||
|
</summary>
|
||||||
|
<div className="menu-pop-body">
|
||||||
|
{text.trim() && (
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
close(e);
|
||||||
|
const done = () => toast(t("msg.copied"), "success");
|
||||||
|
if (navigator.clipboard?.writeText) void navigator.clipboard.writeText(text).then(done, () => toast(t("msg.copyFailed")));
|
||||||
|
else toast(t("msg.copyFailed"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="copy" /> {t("msg.copy")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{text.trim() && canReply && (
|
||||||
|
<a href="#" onClick={(e) => (close(e), compose({ sessionId, text: asQuote(text) }))}>
|
||||||
|
<Icon name="reply" /> {t("msg.quote")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{onRewind && (
|
||||||
|
<a href="#" onClick={(e) => (close(e), onRewind())}>
|
||||||
|
<Icon name="edit" /> {t("chat.rewind")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{forkable && canFork && (
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={async (e) => {
|
||||||
|
close(e);
|
||||||
|
try {
|
||||||
|
const fork = await post<{ id: string }>(`/sessions/${sessionId}/fork`, { message_id: messageId });
|
||||||
|
await loadSessions();
|
||||||
|
toast(t("chat.forked"));
|
||||||
|
navigate(`/s/${fork.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast(err.message);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="fork" /> {t("msg.forkHere")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -328,7 +328,8 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
const isCraft = task === "craft";
|
const isCraft = task === "craft";
|
||||||
const s = await post<{ id: string }>("/sessions", {
|
const s = await post<{ id: string }>("/sessions", {
|
||||||
cwd,
|
cwd,
|
||||||
model: model || undefined,
|
// The server default is not sent, so the project's own default model can apply.
|
||||||
|
model: model && model !== config?.default_model ? model : undefined,
|
||||||
agent: task === "cad" ? "build" : task,
|
agent: task === "cad" ? "build" : task,
|
||||||
clean_room: isCraft ? !deep : undefined,
|
clean_room: isCraft ? !deep : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { flushOutbox, removeFromOutbox, useOutbox } from "../outbox";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
/** Messages waiting for a connection: sent automatically once the server is reachable again. */
|
||||||
|
export function OutboxBar({ sessionId }: { sessionId: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const items = useOutbox(sessionId);
|
||||||
|
const connected = useStore((s) => s.connected);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!items.length) return;
|
||||||
|
const go = () => void flushOutbox(sessionId);
|
||||||
|
if (connected && navigator.onLine) go();
|
||||||
|
window.addEventListener("online", go);
|
||||||
|
return () => window.removeEventListener("online", go);
|
||||||
|
}, [sessionId, connected, items.length]);
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="outbox" role="status">
|
||||||
|
<div className="outbox-head">
|
||||||
|
<Icon name="clock" className="tiny" /> {t("outbox.waiting", { n: items.length })}
|
||||||
|
<span className="grow" />
|
||||||
|
<button className="small" onClick={() => void flushOutbox(sessionId)}>
|
||||||
|
{t("outbox.sendNow")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{items.map((i) => (
|
||||||
|
<div key={i.id} className="outbox-item">
|
||||||
|
<span>{i.text.length > 120 ? `${i.text.slice(0, 120)}…` : i.text}</span>
|
||||||
|
<button className="small ghost" onClick={() => removeFromOutbox(i.id)} aria-label={t("outbox.discard")} title={t("outbox.discard")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useFetch } from "../hooks";
|
||||||
|
import { fmtCost, fmtDate, fmtRelative, useT, type Key } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import type { Session } from "../types";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { basename } from "./FileFigure";
|
||||||
|
|
||||||
|
interface Overview {
|
||||||
|
sessions: Record<string, { files: number; check: string | null; checks_failed: number; cost: number }>;
|
||||||
|
check: { command: string; enabled: boolean } | null;
|
||||||
|
defaults: { model?: string; agent?: string; permission_mode?: string; color?: string; instructions?: string; folder?: string } | null;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = (ms: number) => new Date(ms).toDateString();
|
||||||
|
|
||||||
|
/** One project folder: its chats as a timeline (newest first) with what each one did. */
|
||||||
|
export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const all = useStore((s) => s.sessions);
|
||||||
|
const { data } = useFetch<Overview>(`/projects/overview?cwd=${encodeURIComponent(cwd)}`);
|
||||||
|
const sessions = useMemo(() => all.filter((s) => s.cwd === cwd && !s.archived).sort((a, b) => b.updated_at - a.updated_at), [all, cwd]);
|
||||||
|
const archived = all.filter((s) => s.cwd === cwd && s.archived).length;
|
||||||
|
|
||||||
|
// Day groups: "Today", "Yesterday", date.
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const out: { label: string; items: Session[] }[] = [];
|
||||||
|
const today = day(Date.now());
|
||||||
|
const yesterday = day(Date.now() - 86_400_000);
|
||||||
|
for (const s of sessions) {
|
||||||
|
const d = day(s.updated_at);
|
||||||
|
const label = d === today ? t("project.today") : d === yesterday ? t("project.yesterday") : fmtDate(s.updated_at, false);
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (last?.label === label) last.items.push(s);
|
||||||
|
else out.push({ label, items: [s] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [sessions, t]);
|
||||||
|
|
||||||
|
const totalCost = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.cost ?? 0), 0);
|
||||||
|
const totalFiles = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.files ?? 0), 0);
|
||||||
|
|
||||||
|
const newChat = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("codecraft.lastDir", cwd);
|
||||||
|
} catch {
|
||||||
|
// Without storage the wizard starts at its usual folder.
|
||||||
|
}
|
||||||
|
navigate("/new");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
|
<Icon name="menu" />
|
||||||
|
</button>
|
||||||
|
<div className="grow">
|
||||||
|
<h1>
|
||||||
|
<Icon name="folder" /> {basename(cwd) || "/"}
|
||||||
|
</h1>
|
||||||
|
<div className="path mono">{cwd}</div>
|
||||||
|
</div>
|
||||||
|
<button className="small primary" onClick={newChat}>
|
||||||
|
<Icon name="plus" /> {t("project.newChat")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="chat-scroll">
|
||||||
|
<div className="chat project-page">
|
||||||
|
<div className="project-summary">
|
||||||
|
<span className="chip">{t("project.chats", { n: sessions.length })}</span>
|
||||||
|
{totalFiles > 0 && <span className="chip">{t("project.files", { n: totalFiles })}</span>}
|
||||||
|
{data && totalCost > 0 && <span className="chip">{fmtCost(totalCost, data.currency)}</span>}
|
||||||
|
{data?.check?.enabled && (
|
||||||
|
<span className="chip mono" title={t("check.title")}>
|
||||||
|
<Icon name="check" className="tiny" /> {data.check.command}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data?.defaults?.model && (
|
||||||
|
<span className="chip" title={t("projectDefaults.hint")}>
|
||||||
|
<Icon name="template" className="tiny" /> {data.defaults.model.split("/").pop()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{archived > 0 && <span className="hint">{t("project.archived", { n: archived })}</span>}
|
||||||
|
</div>
|
||||||
|
{sessions.length === 0 && <div className="empty">{t("project.empty")}</div>}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<section key={g.label} className="timeline-day">
|
||||||
|
<h3>{g.label}</h3>
|
||||||
|
<ol className="timeline">
|
||||||
|
{g.items.map((s) => {
|
||||||
|
const info = data?.sessions[s.id];
|
||||||
|
return (
|
||||||
|
<li key={s.id} data-color={s.color} className={s.color ? "tagged" : ""}>
|
||||||
|
<a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}>
|
||||||
|
<div className="tl-title">
|
||||||
|
<span className={s.color ? "color-badge" : undefined}>{s.title || t("session.untitled")}</span>
|
||||||
|
<span className="tl-when" title={fmtDate(s.updated_at)}>
|
||||||
|
{fmtRelative(s.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.summary && <div className="tl-summary">{s.summary}</div>}
|
||||||
|
<div className="tl-meta">
|
||||||
|
<span>{s.agent}</span>
|
||||||
|
<span>{s.model.split("/").pop()}</span>
|
||||||
|
{!!info?.files && <span>{t("project.files", { n: info.files })}</span>}
|
||||||
|
{info?.check && (
|
||||||
|
<span className={`check-${info.check}`}>
|
||||||
|
<Icon name={info.check === "passed" ? "check" : info.check === "running" ? "clock" : "x-mark"} className="tiny" /> {t(`check.${info.check}` as Key)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!info?.checks_failed && <span className="check-failed">{t("project.checksFailed", { n: info.checks_failed })}</span>}
|
||||||
|
{!!info?.cost && data && <span>{fmtCost(info.cost, data.currency)}</span>}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, get } from "../api";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { toast } from "../dialogs";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
/** "bash:git status" → "git status"; tool names stay. */
|
||||||
|
const label = (key: string) => (key.startsWith("bash:") ? `$ ${key.slice(5)}` : key);
|
||||||
|
|
||||||
|
/** What the user allowed "for this project" (all chats of the folder) – with a way to revoke it. */
|
||||||
|
export function ProjectPermissions({ sessionId, cwd, onClose }: { sessionId: string; cwd: string; onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [keys, setKeys] = useState<string[]>();
|
||||||
|
useEffect(() => {
|
||||||
|
get<string[]>(`/sessions/${sessionId}/approvals`).then(setKeys, (e) => (toast(e.message), setKeys([])));
|
||||||
|
}, [sessionId]);
|
||||||
|
const revoke = async (key: string) => {
|
||||||
|
try {
|
||||||
|
await api(`/sessions/${sessionId}/approvals`, { method: "DELETE", json: { key } });
|
||||||
|
setKeys((k) => k?.filter((x) => x !== key));
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" role="dialog" aria-label={t("permProject.title")} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>
|
||||||
|
<Icon name="lock" /> {t("permProject.title")}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 4 }}>{t("permProject.intro")}</p>
|
||||||
|
<div className="path mono" style={{ marginBottom: 8 }}>{cwd}</div>
|
||||||
|
{keys === undefined ? (
|
||||||
|
<div className="empty">…</div>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13 }}>{t("permProject.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="rewind-files">
|
||||||
|
{keys.map((k) => (
|
||||||
|
<li key={k} className="row" style={{ justifyContent: "space-between" }}>
|
||||||
|
<code>{label(k)}</code>
|
||||||
|
<button className="small danger" onClick={() => void revoke(k)}>
|
||||||
|
{t("permProject.revoke")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="row" style={{ justifyContent: "flex-end", marginTop: 10 }}>
|
||||||
|
<button onClick={onClose}>{t("common.close")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -248,6 +248,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
small_model: s.small_model ?? "",
|
small_model: s.small_model ?? "",
|
||||||
currency: s.currency ?? "USD",
|
currency: s.currency ?? "USD",
|
||||||
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
||||||
|
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
|
||||||
max_parallel_subagents: s.max_parallel_subagents ?? 4,
|
max_parallel_subagents: s.max_parallel_subagents ?? 4,
|
||||||
monthly_budget: s.monthly_budget ?? 0,
|
monthly_budget: s.monthly_budget ?? 0,
|
||||||
budget_warning: s.budget_warning ?? 0.8,
|
budget_warning: s.budget_warning ?? 0.8,
|
||||||
@@ -271,6 +272,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
{ path: ["server", "small_model"], value: form.small_model },
|
{ path: ["server", "small_model"], value: form.small_model },
|
||||||
{ path: ["server", "currency"], value: form.currency },
|
{ path: ["server", "currency"], value: form.currency },
|
||||||
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
||||||
|
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
|
||||||
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
||||||
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
||||||
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
||||||
@@ -305,6 +307,9 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
<label>{t("settings.autoCompact")}</label>
|
<label>{t("settings.autoCompact")}</label>
|
||||||
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
||||||
<div className="hint">{t("settings.autoCompactHint")}</div>
|
<div className="hint">{t("settings.autoCompactHint")}</div>
|
||||||
|
<label>{t("settings.commitTrailer")}</label>
|
||||||
|
<input className="mono" value={form.commit_trailer} placeholder={t("settings.commitTrailerOff")} onChange={(e) => setForm({ ...form, commit_trailer: e.target.value })} />
|
||||||
|
<div className="hint">{t("settings.commitTrailerHint")}</div>
|
||||||
<label>{t("settings.monthlyBudgetTotal")}</label>
|
<label>{t("settings.monthlyBudgetTotal")}</label>
|
||||||
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||||
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
||||||
@@ -376,6 +381,7 @@ interface ModelRow {
|
|||||||
name?: string;
|
name?: string;
|
||||||
context?: number;
|
context?: number;
|
||||||
max_output?: number;
|
max_output?: number;
|
||||||
|
max_input?: number;
|
||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
vision?: boolean;
|
vision?: boolean;
|
||||||
effort?: string;
|
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,
|
price_multipliers: f.price_multipliers ?? m.price_multipliers,
|
||||||
context: m.context ?? f.context,
|
context: m.context ?? f.context,
|
||||||
max_output: m.max_output ?? f.max_output,
|
max_output: m.max_output ?? f.max_output,
|
||||||
|
max_input: m.max_input ?? f.max_input ?? undefined,
|
||||||
vision: m.vision || !!f.vision,
|
vision: m.vision || !!f.vision,
|
||||||
reasoning: m.reasoning || !!f.reasoning,
|
reasoning: m.reasoning || !!f.reasoning,
|
||||||
};
|
};
|
||||||
@@ -616,6 +623,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>{t("settings.context")}</th>
|
<th>{t("settings.context")}</th>
|
||||||
<th>{t("settings.maxOutput")}</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.reasoning")}><Icon name="brain" /></th>
|
||||||
<th title={t("settings.vision")}><Icon name="eye" /></th>
|
<th title={t("settings.vision")}><Icon name="eye" /></th>
|
||||||
<th>{t("settings.effort")}</th>
|
<th>{t("settings.effort")}</th>
|
||||||
@@ -640,6 +648,9 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
|||||||
<td>
|
<td>
|
||||||
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
|
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" value={m.max_input ?? ""} placeholder="–" onChange={(e) => setModel(i, { max_input: num(e.target.value) })} />
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
|
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ColorSwatches } from "./ColorSwatches";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { StatusLeds } from "./StatusLeds";
|
import { StatusLeds } from "./StatusLeds";
|
||||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||||
@@ -25,7 +26,7 @@ function groupKey(ms: number): Key {
|
|||||||
const GROUP_BY = "codecraft.sidebarGroupBy";
|
const GROUP_BY = "codecraft.sidebarGroupBy";
|
||||||
const COLLAPSED = "codecraft.sidebarCollapsed";
|
const COLLAPSED = "codecraft.sidebarCollapsed";
|
||||||
|
|
||||||
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
|
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string; color?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
|
||||||
|
|
||||||
/** One session in the list, with a menu to pin, file or archive it. */
|
/** One session in the list, with a menu to pin, file or archive it. */
|
||||||
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
|
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
|
||||||
@@ -53,11 +54,12 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className={`session-row ${menu ? "menu-open" : ""}`}>
|
<div className={`session-row ${menu ? "menu-open" : ""}`}>
|
||||||
<button className={`session-item ${active ? "active" : ""}`} onClick={() => navigate(`/s/${s.id}`)}>
|
<button className={`session-item ${active ? "active" : ""}`} data-color={s.color} onClick={() => navigate(`/s/${s.id}`)}>
|
||||||
<div className="title">
|
<div className="title">
|
||||||
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
|
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
|
||||||
<span>{s.title || t("session.untitled")}</span>
|
<span className={s.color ? "color-badge" : undefined}>{s.title || t("session.untitled")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
|
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
|
||||||
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||||||
@@ -93,6 +95,7 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
|
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
|
||||||
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
||||||
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
||||||
</button>
|
</button>
|
||||||
@@ -194,6 +197,11 @@ function GroupHeader({
|
|||||||
)}
|
)}
|
||||||
{menu && (
|
{menu && (
|
||||||
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
|
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{g.id.startsWith("project:") && (
|
||||||
|
<button role="menuitem" onClick={() => (setMenu(false), navigate(`/p/${encodeURIComponent(g.id.slice("project:".length))}`))}>
|
||||||
|
<Icon name="clock" /> {t("project.open")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
|
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
|
||||||
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
|
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||||
|
import { useState } from "react";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
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 }) {
|
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
|
||||||
const t = useT();
|
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 progress = useStore((s) => s.progress[call.id]);
|
||||||
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||||||
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
|
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 diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||||||
const openByDefault =
|
const openByDefault =
|
||||||
(status === "err" && !!result) ||
|
(status === "err" && !!result) ||
|
||||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d"));
|
||||||
|
const open = toggled ?? openByDefault;
|
||||||
|
|
||||||
return (
|
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>
|
<summary>
|
||||||
<span className="tool-name">{call.name}</span>
|
<span className="tool-name">{call.name}</span>
|
||||||
<span className="tool-title">{toolTitle(call.name, call.input)}</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>}
|
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
|
||||||
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
||||||
</summary>
|
</summary>
|
||||||
|
{open && (
|
||||||
<div className="tool-body">
|
<div className="tool-body">
|
||||||
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||||||
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
||||||
@@ -156,6 +164,18 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
<pre className="mono">{result.content}</pre>
|
<pre className="mono">{result.content}</pre>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{typeof meta?.model_file === "string" && (
|
||||||
|
<button
|
||||||
|
className="small"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
onClick={() => {
|
||||||
|
sessionStorage.setItem("codecraft.openFile", meta.model_file);
|
||||||
|
navigate("/files");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="cube" /> {t("tool.open3d")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{Array.isArray(meta?.image_ids) ? (
|
{Array.isArray(meta?.image_ids) ? (
|
||||||
<div className="gallery small">
|
<div className="gallery small">
|
||||||
{meta.image_ids.map((id: string, i: number) => {
|
{meta.image_ids.map((id: string, i: number) => {
|
||||||
@@ -186,6 +206,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { get } from "../api";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import type { Message } from "../types";
|
||||||
|
import { Diff } from "./Diff";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { basename } from "./FileFigure";
|
||||||
|
|
||||||
|
interface Change {
|
||||||
|
path: string;
|
||||||
|
created: boolean;
|
||||||
|
deleted?: boolean;
|
||||||
|
diff?: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = (diff: string | undefined, sign: "+" | "-") => (diff ?? "").split("\n").filter((l) => l.startsWith(sign) && !l.startsWith(sign.repeat(3))).length;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one turn changed: files with their diffs (from the checkpoints the agent leaves before
|
||||||
|
* every write/edit) and a button to roll the chat and the files back to before the turn.
|
||||||
|
*/
|
||||||
|
export function TurnChanges({
|
||||||
|
sessionId,
|
||||||
|
fromId,
|
||||||
|
untilId,
|
||||||
|
fileCount,
|
||||||
|
userMessage,
|
||||||
|
onRewind,
|
||||||
|
}: {
|
||||||
|
sessionId: string;
|
||||||
|
fromId: string;
|
||||||
|
untilId?: string;
|
||||||
|
fileCount: number;
|
||||||
|
userMessage?: Message;
|
||||||
|
onRewind?: (m: Message) => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [changes, setChanges] = useState<Change[]>();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const next = !open;
|
||||||
|
setOpen(next);
|
||||||
|
if (next && !changes) {
|
||||||
|
const q = `since_message=${encodeURIComponent(fromId)}&diff=true${untilId ? `&until_message=${encodeURIComponent(untilId)}` : ""}`;
|
||||||
|
get<Change[]>(`/sessions/${sessionId}/changes?${q}`).then(setChanges, (e) => setError(e.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="turn-changes">
|
||||||
|
<button className="small ghost" onClick={toggle} aria-expanded={open}>
|
||||||
|
<Icon name={open ? "chevron-down" : "chevron-right"} className="tiny" /> {t("turn.changes", { n: fileCount })}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="turn-changes-body">
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
{!changes && !error && <div className="hint">…</div>}
|
||||||
|
{changes?.length === 0 && <div className="hint">{t("turn.noChanges")}</div>}
|
||||||
|
{changes?.map((c) => (
|
||||||
|
<details key={c.path} className="turn-file">
|
||||||
|
<summary title={c.path}>
|
||||||
|
<span className="mono">{basename(c.path)}</span>
|
||||||
|
{c.created && <span className="chip">{t("turn.new")}</span>}
|
||||||
|
{c.deleted && <span className="chip warn">{t("turn.deleted")}</span>}
|
||||||
|
<span className="turn-stat">
|
||||||
|
<span className="add">+{count(c.diff, "+")}</span> <span className="del">−{count(c.diff, "-")}</span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
{c.diff ? <Diff diff={c.diff} /> : <div className="hint">{t("turn.noDiff")}</div>}
|
||||||
|
{c.truncated && <div className="hint">{t("turn.truncated")}</div>}
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
{userMessage && onRewind && changes && changes.length > 0 && (
|
||||||
|
<button className="small" onClick={() => onRewind(userMessage)}>
|
||||||
|
<Icon name="undo" /> {t("turn.rollback")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { post } from "../api";
|
||||||
|
import { toast } from "../dialogs";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import { Spinner } from "./Activity";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
/** Green/red badge of the project's check (tests, build …) for one turn; output and "let the agent fix it" on click. */
|
||||||
|
export function TurnCheck({ sessionId, messageId, canSend }: { sessionId: string; messageId: string; canSend: boolean }) {
|
||||||
|
const t = useT();
|
||||||
|
const check = useStore((s) => s.checks[sessionId]?.[messageId]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (!check) return null;
|
||||||
|
const seconds = check.duration_ms >= 1000 ? ` · ${(check.duration_ms / 1000).toFixed(check.duration_ms >= 10_000 ? 0 : 1)} s` : "";
|
||||||
|
const label =
|
||||||
|
check.status === "running" ? t("check.running") : check.status === "passed" ? t("check.passed") : check.status === "timeout" ? t("check.timeout") : t("check.failed");
|
||||||
|
const fix = async () => {
|
||||||
|
try {
|
||||||
|
await post(`/sessions/${sessionId}/prompt`, { text: `${t("check.fixPrompt")}\n\n\`\`\`\n${check.output}\n\`\`\`` });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="turn-check">
|
||||||
|
<button className={`chip check-${check.status}`} onClick={() => setOpen(!open)} aria-expanded={open} disabled={check.status === "running"}>
|
||||||
|
{check.status === "running" ? <Spinner small /> : <Icon name={check.status === "passed" ? "check" : "x-mark"} className="tiny" />} {label}
|
||||||
|
{seconds}
|
||||||
|
</button>
|
||||||
|
{open && check.status !== "running" && (
|
||||||
|
<div className="turn-check-body">
|
||||||
|
<pre className="mono">{check.output || t("check.noOutput")}</pre>
|
||||||
|
{check.status !== "passed" && canSend && (
|
||||||
|
<button className="small primary" onClick={fix}>
|
||||||
|
<Icon name="sparkle" /> {t("check.fix")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
|
|||||||
import { ApiTokens } from "./ApiTokens";
|
import { ApiTokens } from "./ApiTokens";
|
||||||
import { CliInstall } from "./CliInstall";
|
import { CliInstall } from "./CliInstall";
|
||||||
import { Memory } from "./Memory";
|
import { Memory } from "./Memory";
|
||||||
|
import { ClaudeCode } from "./ClaudeCode";
|
||||||
import { Telegram } from "./Telegram";
|
import { Telegram } from "./Telegram";
|
||||||
|
|
||||||
export function Account() {
|
export function Account() {
|
||||||
@@ -52,6 +53,7 @@ export function Account() {
|
|||||||
<Memory />
|
<Memory />
|
||||||
<GitAccount />
|
<GitAccount />
|
||||||
<CliInstall />
|
<CliInstall />
|
||||||
|
<ClaudeCode />
|
||||||
<Telegram />
|
<Telegram />
|
||||||
<ApiTokens />
|
<ApiTokens />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
||||||
refine_model: raw?.refine?.model ?? "",
|
refine_model: raw?.refine?.model ?? "",
|
||||||
refine_criteria: raw?.refine?.criteria ?? "",
|
refine_criteria: raw?.refine?.criteria ?? "",
|
||||||
|
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
||||||
|
reverse_tools: !!(raw?.reverse_tools ?? a.reverse),
|
||||||
});
|
});
|
||||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||||
|
|
||||||
@@ -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));
|
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
|
||||||
patches.push({
|
patches.push({
|
||||||
path: [...base, "refine"],
|
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 || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||||
if (!builtin) {
|
if (!builtin) {
|
||||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||||
@@ -158,6 +161,20 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
|||||||
<label>{t("agents.refineCriteria")}</label>
|
<label>{t("agents.refineCriteria")}</label>
|
||||||
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
|
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
|
||||||
<div className="hint">{t("agents.refineCriteriaHint")}</div>
|
<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>
|
<label>{t("agents.description")}</label>
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, get, post } from "../../api";
|
||||||
|
import { confirmDialog, toast } from "../../dialogs";
|
||||||
|
import { useT } from "../../i18n";
|
||||||
|
import { Icon } from "../Icon";
|
||||||
|
import { Spinner } from "../Activity";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
|
||||||
|
interface Status {
|
||||||
|
installed: boolean;
|
||||||
|
logged_in?: boolean;
|
||||||
|
method?: string;
|
||||||
|
email?: string;
|
||||||
|
org?: string;
|
||||||
|
expires?: string;
|
||||||
|
login_in_progress?: boolean;
|
||||||
|
error?: string;
|
||||||
|
version?: string | null;
|
||||||
|
latest?: string | null;
|
||||||
|
update_available?: boolean;
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude Code: sign in from the browser. The CLI prints a sign-in URL; the Anthropic page shows
|
||||||
|
* a code after login that is pasted here and handed back to the CLI on the server.
|
||||||
|
*/
|
||||||
|
export function ClaudeCode() {
|
||||||
|
const t = useT();
|
||||||
|
const [status, setStatus] = useState<Status>();
|
||||||
|
const [url, setUrl] = useState<string>();
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [console_, setConsole] = useState(false);
|
||||||
|
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [updateLog, setUpdateLog] = useState<string>();
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
setChecking(true);
|
||||||
|
try {
|
||||||
|
setStatus(await get<Status>("/claude/status?refresh=true"));
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const update = async () => {
|
||||||
|
setUpdating(true);
|
||||||
|
setUpdateLog(undefined);
|
||||||
|
try {
|
||||||
|
const r = await post<Status>("/claude/update", {});
|
||||||
|
setStatus(r);
|
||||||
|
setUpdateLog(r.output);
|
||||||
|
toast(t("claude.updated", { version: r.version ?? "" }), "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
setUpdateLog(e.message);
|
||||||
|
toast(t("claude.updateFailed"));
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = () => get<Status>("/claude/status").then(setStatus, (e) => setStatus({ installed: false, error: e.message }));
|
||||||
|
useEffect(() => void load(), []);
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await post<{ url: string }>("/claude/login", { console: console_ });
|
||||||
|
setUrl(r.url);
|
||||||
|
setCode("");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setStatus(await post<Status>("/claude/login/code", { code }));
|
||||||
|
setUrl(undefined);
|
||||||
|
setCode("");
|
||||||
|
toast(t("claude.signedIn"), "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cancel = async () => {
|
||||||
|
await api("/claude/login", { method: "DELETE" }).catch(() => undefined);
|
||||||
|
setUrl(undefined);
|
||||||
|
setCode("");
|
||||||
|
};
|
||||||
|
const logout = async () => {
|
||||||
|
if (!(await confirmDialog({ message: t("claude.logoutConfirm"), danger: true, confirmLabel: t("claude.logout") }))) return;
|
||||||
|
try {
|
||||||
|
setStatus(await post<Status>("/claude/logout", {}));
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
<Icon name="robot" /> {t("claude.title")}
|
||||||
|
<span className="spacer" />
|
||||||
|
{status?.logged_in && (
|
||||||
|
<button className="small ghost danger" onClick={logout}>
|
||||||
|
{t("claude.logout")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 10 }}>{t("claude.intro")}</div>
|
||||||
|
{!status && <Spinner />}
|
||||||
|
{status && !status.installed && <div className="notice err">{status.error ?? t("claude.notInstalled")}</div>}
|
||||||
|
{status?.installed && (
|
||||||
|
<div className="row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
|
||||||
|
<span className="chip mono">{t("claude.version", { version: status.version ?? "?" })}</span>
|
||||||
|
{status.update_available ? (
|
||||||
|
<>
|
||||||
|
<span className="chip warn">{t("claude.updateAvailable", { version: status.latest ?? "" })}</span>
|
||||||
|
{isAdmin ? (
|
||||||
|
<button className="small primary" disabled={updating} onClick={update}>
|
||||||
|
{updating ? <Spinner small /> : <Icon name="download" />} {updating ? t("claude.updating") : t("claude.update")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="hint">{t("claude.updateAdmin")}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
status.latest && <span className="chip">{t("claude.upToDate")}</span>
|
||||||
|
)}
|
||||||
|
<button className="small ghost" disabled={checking || updating} onClick={check} title={t("claude.checkNow")} aria-label={t("claude.checkNow")}>
|
||||||
|
{checking ? <Spinner small /> : <Icon name="refresh" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updateLog && (
|
||||||
|
<details style={{ marginBottom: 10 }}>
|
||||||
|
<summary className="hint" style={{ cursor: "pointer" }}>{t("claude.updateLog")}</summary>
|
||||||
|
<pre className="mono" style={{ whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>{updateLog}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{status?.installed && status.logged_in && (
|
||||||
|
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<span className="chip">
|
||||||
|
<Icon name="check" className="tiny" /> {t("claude.signedInAs", { who: status.email || status.org || status.method || "" })}
|
||||||
|
</span>
|
||||||
|
{status.method && <span className="chip">{status.method}</span>}
|
||||||
|
{status.expires && <span className="hint">{t("claude.expires", { date: new Date(status.expires).toLocaleDateString() })}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status?.installed && !status.logged_in && !url && (
|
||||||
|
<>
|
||||||
|
<label className="row" style={{ marginBottom: 8 }}>
|
||||||
|
<input type="checkbox" checked={console_} onChange={(e) => setConsole(e.target.checked)} /> {t("claude.console")}
|
||||||
|
</label>
|
||||||
|
<button className="primary" disabled={busy} onClick={start}>
|
||||||
|
{busy ? <Spinner small /> : <Icon name="lock" />} {t("claude.signIn")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{url && (
|
||||||
|
<ol className="cli-steps">
|
||||||
|
<li>
|
||||||
|
<a className="button" href={url} target="_blank" rel="noreferrer">
|
||||||
|
<Icon name="external" /> {t("claude.openLogin")}
|
||||||
|
</a>
|
||||||
|
<div className="hint">{t("claude.step1")}</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{t("claude.step2")}
|
||||||
|
<div className="row" style={{ marginTop: 6 }}>
|
||||||
|
<input style={{ flex: 1 }} value={code} placeholder={t("claude.codePlaceholder")} autoComplete="off" autoCapitalize="off" spellCheck={false} onChange={(e) => setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && code.trim() && void submit()} />
|
||||||
|
<button className="primary" disabled={busy || !code.trim()} onClick={submit}>
|
||||||
|
{busy ? <Spinner small /> : <Icon name="check" />} {t("claude.confirm")}
|
||||||
|
</button>
|
||||||
|
<button onClick={cancel}>{t("common.cancel")}</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** Put content into the composer of a chat from anywhere in the app (quote, image viewer, …). */
|
||||||
|
export interface ComposeDetail {
|
||||||
|
sessionId: string;
|
||||||
|
/** Appended to the text field (a blank line before it if there is text already). */
|
||||||
|
text?: string;
|
||||||
|
/** Attached like dropped files (images stay images). */
|
||||||
|
files?: File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMPOSE_EVENT = "codecraft:compose";
|
||||||
|
|
||||||
|
export function compose(detail: ComposeDetail) {
|
||||||
|
window.dispatchEvent(new CustomEvent<ComposeDetail>(COMPOSE_EVENT, { detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "> quoted\n> lines\n\n" for the composer. */
|
||||||
|
export function asQuote(text: string) {
|
||||||
|
const lines = text.trim().split("\n").map((l) => `> ${l}`.trimEnd());
|
||||||
|
return `${lines.join("\n")}\n\n`;
|
||||||
|
}
|
||||||
+144
-1
@@ -189,7 +189,7 @@ export const de: Messages = {
|
|||||||
"tools.install": "Ghidra installieren",
|
"tools.install": "Ghidra installieren",
|
||||||
"tools.reinstall": "Installation prüfen / reparieren",
|
"tools.reinstall": "Installation prüfen / reparieren",
|
||||||
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…",
|
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…",
|
||||||
"tools.ghidraHint": "Mit Ghidra können Agenten im Craft-Deep-Modus Programme dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren. Wird samt eigenem JDK ins Daten-Volume installiert.",
|
"tools.ghidraHint": "Mit Ghidra können Agenten Programme und Firmware dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren – im Craft-Deep-Modus und in Modi mit Reverse-Engineering-Werkzeugen (Einstellungen → Agenten & Befehle). Wird samt eigenem JDK ins Daten-Volume installiert.",
|
||||||
"rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.",
|
"rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.",
|
||||||
"rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
|
"rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
|
||||||
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
|
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
|
||||||
@@ -334,6 +334,133 @@ export const de: Messages = {
|
|||||||
"wizard.idea.cadBox": "Eine Box mit Schnappdeckel, Innenmaß 80 × 50 × 30 mm",
|
"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",
|
"wizard.idea.cadBracket": "Eine Wandhalterung für ein 25-mm-Rohr mit zwei M4-Schraublöchern",
|
||||||
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
||||||
|
"chat.reviewPassed": "Review bestanden:",
|
||||||
|
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
||||||
|
"agents.reverseTools": "Reverse-Engineering-Werkzeuge (binary_info, decompile mit Ghidra)",
|
||||||
|
"agents.reverseToolsHint": "Der Agent kann in diesem Modus Programme und Firmware untersuchen und dekompilieren (Ghidra muss unter Einstellungen → Werkzeuge installiert sein). Craft hat sie im Deep-Modus ohnehin.",
|
||||||
|
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
||||||
|
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
||||||
|
"chat.workSteps": "{n} Schritte",
|
||||||
|
"chat.workFiles": "{n} Dateien",
|
||||||
|
"chat.loadOlder": "Ältere Nachrichten laden",
|
||||||
|
"chat.showBeforeSummary": "Verlauf vor der Zusammenfassung anzeigen",
|
||||||
|
"chat.backToLatest": "Zu den neuesten Nachrichten",
|
||||||
|
"chat.search": "Im Chat suchen",
|
||||||
|
"chat.searchPlaceholder": "Nachrichten, Tool-Ausgaben … durchsuchen",
|
||||||
|
"chat.searchNone": "Keine Treffer",
|
||||||
|
"chat.searchHits": "{n} Treffer",
|
||||||
|
"chat.you": "Du",
|
||||||
|
"chat.agent": "Agent",
|
||||||
|
"media.title": "Medien",
|
||||||
|
"media.none": "Noch keine Bilder, Videos oder Audiodateien in diesem Chat.",
|
||||||
|
"media.filter.all": "Alle",
|
||||||
|
"media.filter.image": "Bilder",
|
||||||
|
"media.filter.video": "Videos",
|
||||||
|
"media.filter.model": "3D",
|
||||||
|
"media.filter.audio": "Audio",
|
||||||
|
"media.source.generated": "generiert",
|
||||||
|
"media.source.tool": "Tool-Ausgabe",
|
||||||
|
"media.source.written": "vom Agenten geschrieben",
|
||||||
|
"media.source.answer": "in einer Antwort",
|
||||||
|
"media.source.upload": "dein Anhang",
|
||||||
|
"media.showInChat": "Im Chat zeigen",
|
||||||
|
"media.open": "Öffnen",
|
||||||
|
"media.prev": "Vorheriges",
|
||||||
|
"msg.actions": "Nachrichtenaktionen",
|
||||||
|
"msg.copy": "Text kopieren",
|
||||||
|
"msg.copied": "Kopiert",
|
||||||
|
"msg.copyFailed": "Kopieren ist hier nicht möglich",
|
||||||
|
"msg.quote": "In Antwort zitieren",
|
||||||
|
"msg.forkHere": "Ab hier abzweigen",
|
||||||
|
"chat.jumpPrevOwn": "Vorherige eigene Nachricht",
|
||||||
|
"viewer.annotate": "Markieren",
|
||||||
|
"viewer.annotateFailed": "Dieses Bild lässt sich nicht bearbeiten",
|
||||||
|
"viewer.attach": "An Nachricht anhängen",
|
||||||
|
"viewer.undo": "Rückgängig",
|
||||||
|
"viewer.tool.pen": "Stift",
|
||||||
|
"viewer.tool.arrow": "Pfeil",
|
||||||
|
"viewer.tool.rect": "Rechteck",
|
||||||
|
"viewer.useInChat": "In Nachricht verwenden",
|
||||||
|
"viewer.attached": "Bild an die Nachricht angehängt",
|
||||||
|
"turn.changes": "{n} Datei(en) geändert",
|
||||||
|
"turn.noChanges": "Für diesen Schritt wurden keine Dateiänderungen gefunden.",
|
||||||
|
"turn.noDiff": "Kein Text-Diff verfügbar.",
|
||||||
|
"turn.truncated": "Der Diff ist gekürzt.",
|
||||||
|
"turn.new": "neu",
|
||||||
|
"turn.deleted": "gelöscht",
|
||||||
|
"turn.rollback": "Auf den Stand vor diesem Schritt zurücksetzen",
|
||||||
|
"job.plan": "Als Automatisierung planen",
|
||||||
|
"job.planHint": "Macht aus diesem Chat Anweisungen für eine wiederkehrende Automatisierung (aus, bis du den Zeitplan setzt)",
|
||||||
|
"job.drafting": "Automatisierung wird aus diesem Chat entworfen …",
|
||||||
|
"color.label": "Farbe dieses Chats",
|
||||||
|
"color.red": "Rot",
|
||||||
|
"color.orange": "Orange",
|
||||||
|
"color.yellow": "Gelb",
|
||||||
|
"color.green": "Grün",
|
||||||
|
"color.aqua": "Türkis",
|
||||||
|
"color.blue": "Blau",
|
||||||
|
"color.purple": "Violett",
|
||||||
|
"claude.title": "Claude Code",
|
||||||
|
"claude.intro": "Chats mit einem „Claude Code“-Modell führt die Claude-Code-CLI auf dem Server aus – mit deinem Claude-Abo oder Anthropic-Console-Konto. Einmal hier anmelden; Freigaben, Push und Sandbox funktionieren wie gewohnt.",
|
||||||
|
"claude.notInstalled": "Claude Code ist auf dem Server nicht installiert.",
|
||||||
|
"claude.console": "Anthropic Console (API-Abrechnung) statt Claude-Abo",
|
||||||
|
"claude.signIn": "Anmelden",
|
||||||
|
"claude.openLogin": "Anmeldeseite öffnen",
|
||||||
|
"claude.step1": "Bei Anthropic in einem beliebigen Browser (auch auf einem anderen Gerät) anmelden und den Zugriff erlauben.",
|
||||||
|
"claude.step2": "Den Code einfügen, den die Seite danach anzeigt:",
|
||||||
|
"claude.codePlaceholder": "Code von der Anmeldeseite",
|
||||||
|
"claude.confirm": "Bestätigen",
|
||||||
|
"claude.signedIn": "Bei Claude Code angemeldet",
|
||||||
|
"claude.signedInAs": "Angemeldet: {who}",
|
||||||
|
"claude.expires": "gültig bis {date}",
|
||||||
|
"claude.logout": "Abmelden",
|
||||||
|
"claude.logoutConfirm": "Auf dem Server von Claude Code abmelden?",
|
||||||
|
"claude.version": "CLI {version}",
|
||||||
|
"claude.updateAvailable": "Update verfügbar: {version}",
|
||||||
|
"claude.upToDate": "aktuell",
|
||||||
|
"claude.update": "Update installieren",
|
||||||
|
"claude.updating": "Installiere …",
|
||||||
|
"claude.updated": "Claude Code auf {version} aktualisiert",
|
||||||
|
"claude.updateFailed": "Das Update ist fehlgeschlagen (Details unten)",
|
||||||
|
"claude.updateAdmin": "Ein Administrator kann es installieren.",
|
||||||
|
"claude.checkNow": "Nach Updates suchen",
|
||||||
|
"claude.updateLog": "Ausgabe des Updates",
|
||||||
|
"check.menu": "Prüfung nach jedem Schritt …",
|
||||||
|
"check.title": "Prüfung nach jedem Schritt",
|
||||||
|
"check.hint": "Befehl, der in {project} nach jedem Schritt mit Dateiänderungen läuft (z. B. cargo test, npm test). Das Ergebnis erscheint als Badge am Schritt. Leer = aus.",
|
||||||
|
"check.saved": "Prüfung gespeichert",
|
||||||
|
"check.removed": "Prüfung entfernt",
|
||||||
|
"check.running": "Prüfung läuft …",
|
||||||
|
"check.passed": "Prüfung bestanden",
|
||||||
|
"check.failed": "Prüfung fehlgeschlagen",
|
||||||
|
"check.timeout": "Prüfung: Zeitüberschreitung",
|
||||||
|
"check.noOutput": "(keine Ausgabe)",
|
||||||
|
"check.fix": "Vom Agenten beheben lassen",
|
||||||
|
"check.fixPrompt": "Die Prüfung des Projekts ist nach deiner letzten Änderung fehlgeschlagen. Bitte beheben und erneut ausführen. Ausgabe:",
|
||||||
|
"projectDefaults.save": "Als Projekt-Standard verwenden",
|
||||||
|
"projectDefaults.remove": "Projekt-Standard entfernen",
|
||||||
|
"projectDefaults.hint": "Neue Chats in diesem Projektordner starten mit Modell, Modus, Farbe, Anweisungen und Ordner dieses Chats",
|
||||||
|
"projectDefaults.saved": "Neue Chats in diesem Projekt starten wie dieser",
|
||||||
|
"projectDefaults.removed": "Projekt-Standard entfernt",
|
||||||
|
"outbox.waiting": "{n} Nachricht(en) warten auf eine Verbindung",
|
||||||
|
"outbox.sendNow": "Jetzt senden",
|
||||||
|
"outbox.discard": "Verwerfen",
|
||||||
|
"outbox.saved": "Keine Verbindung – die Nachricht ist gespeichert und wird gesendet, sobald du wieder online bist",
|
||||||
|
"project.open": "Projektübersicht",
|
||||||
|
"project.newChat": "Neuer Chat",
|
||||||
|
"project.today": "Heute",
|
||||||
|
"project.yesterday": "Gestern",
|
||||||
|
"project.chats": "{n} Chat(s)",
|
||||||
|
"project.files": "{n} Datei(en) geändert",
|
||||||
|
"project.archived": "{n} archiviert",
|
||||||
|
"project.checksFailed": "{n} Prüfung(en) fehlgeschlagen",
|
||||||
|
"project.empty": "In diesem Ordner gibt es noch keine Chats.",
|
||||||
|
"viewer.title": "Bildbetrachter",
|
||||||
|
"viewer.open": "In neuem Tab öffnen",
|
||||||
|
"viewer.download": "Herunterladen",
|
||||||
|
"media.next": "Nächstes",
|
||||||
|
"media.back": "Zurück zur Übersicht",
|
||||||
|
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
|
||||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||||
"agents.refine": "Review-Schleife",
|
"agents.refine": "Review-Schleife",
|
||||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||||
@@ -679,6 +806,7 @@ export const de: Messages = {
|
|||||||
"plan.implementPrompt": "Setze den Plan um.",
|
"plan.implementPrompt": "Setze den Plan um.",
|
||||||
|
|
||||||
"tool.input": "Eingabe",
|
"tool.input": "Eingabe",
|
||||||
|
"tool.open3d": "In 3D-Ansicht öffnen",
|
||||||
"tool.output": "Ausgabe",
|
"tool.output": "Ausgabe",
|
||||||
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
|
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
|
||||||
"tool.todos": "Aufgaben",
|
"tool.todos": "Aufgaben",
|
||||||
@@ -688,6 +816,16 @@ export const de: Messages = {
|
|||||||
|
|
||||||
"permission.title": "Freigabe erforderlich",
|
"permission.title": "Freigabe erforderlich",
|
||||||
"permission.allowOnce": "Einmal erlauben",
|
"permission.allowOnce": "Einmal erlauben",
|
||||||
|
"permission.projectCommand": "„{prefix}“ in diesem Projekt immer erlauben",
|
||||||
|
"permission.projectTool": "{tool} in diesem Projekt immer erlauben",
|
||||||
|
"permission.projectHint": "Wird für diesen Projektordner gemerkt: in allen Chats, auch nach einem Neustart. Widerrufen im ⋯-Menü.",
|
||||||
|
"permission.explain": "Was macht das?",
|
||||||
|
"permission.explaining": "Erkläre …",
|
||||||
|
"permProject.title": "Projekt-Freigaben",
|
||||||
|
"permProject.intro": "In diesem Projektordner ohne Rückfrage erlaubt (alle Chats):",
|
||||||
|
"permProject.empty": "Für dieses Projekt ist noch nichts freigegeben. Der Knopf „… in diesem Projekt immer erlauben“ bei einer Freigabe-Anfrage legt hier Einträge an.",
|
||||||
|
"permProject.revoke": "Widerrufen",
|
||||||
|
"permProject.menu": "Projekt-Freigaben",
|
||||||
"permission.alwaysTool": "{tool} in dieser Session immer erlauben",
|
"permission.alwaysTool": "{tool} in dieser Session immer erlauben",
|
||||||
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
|
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
|
||||||
"permission.reject": "Ablehnen",
|
"permission.reject": "Ablehnen",
|
||||||
@@ -737,6 +875,9 @@ export const de: Messages = {
|
|||||||
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
||||||
"settings.currency": "Währung",
|
"settings.currency": "Währung",
|
||||||
"settings.autoCompact": "Auto-Komprimierung bei",
|
"settings.autoCompact": "Auto-Komprimierung bei",
|
||||||
|
"settings.commitTrailer": "Commit-Vermerk der Agenten",
|
||||||
|
"settings.commitTrailerOff": "aus",
|
||||||
|
"settings.commitTrailerHint": "Wird als Trailer an jeden Git-Commit eines Agenten angehängt. {model} = Modellname, {provider} = Anbieter, {model_id} = Modell-ID. Leer = aus. Deine eigenen Commits bleiben unverändert.",
|
||||||
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
||||||
"settings.craft": "Craft-Modus",
|
"settings.craft": "Craft-Modus",
|
||||||
"settings.cleanRoom": "Clean Room",
|
"settings.cleanRoom": "Clean Room",
|
||||||
@@ -757,6 +898,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.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.modelsEmptyHint": "Keine Modelle eingetragen: Sie werden automatisch beim Provider abgefragt.",
|
||||||
"settings.context": "Kontext",
|
"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.maxOutput": "Max. Ausgabe",
|
||||||
"settings.reasoning": "Reasoning",
|
"settings.reasoning": "Reasoning",
|
||||||
"settings.vision": "Bilder",
|
"settings.vision": "Bilder",
|
||||||
|
|||||||
+144
-1
@@ -188,7 +188,7 @@ export const en = {
|
|||||||
"tools.install": "Install Ghidra",
|
"tools.install": "Install Ghidra",
|
||||||
"tools.reinstall": "Check / repair installation",
|
"tools.reinstall": "Check / repair installation",
|
||||||
"tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…",
|
"tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…",
|
||||||
"tools.ghidraHint": "Ghidra lets agents in craft Deep mode decompile programs (Windows .exe/.dll, Linux, DOS, …) to study how they work. Installed into the data volume together with its own JDK.",
|
"tools.ghidraHint": "Ghidra lets agents decompile programs and firmware (Windows .exe/.dll, Linux, DOS, …) to study how they work – in craft Deep mode and in modes with reverse engineering tools (Settings → Agents & commands). Installed into the data volume together with its own JDK.",
|
||||||
"rewind.intro": "The conversation is reset to before this message; you can edit and resend it.",
|
"rewind.intro": "The conversation is reset to before this message; you can edit and resend it.",
|
||||||
"rewind.noFiles": "The agent did not change files since then.",
|
"rewind.noFiles": "The agent did not change files since then.",
|
||||||
"rewind.restore": "Also restore the {n} file(s) the agent changed",
|
"rewind.restore": "Also restore the {n} file(s) the agent changed",
|
||||||
@@ -333,6 +333,133 @@ export const en = {
|
|||||||
"wizard.idea.cadBox": "A box with a snap-on lid, inner size 80 × 50 × 30 mm",
|
"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",
|
"wizard.idea.cadBracket": "A wall bracket for a 25 mm pipe with two M4 screw holes",
|
||||||
"activity.reviewing": "Reviewing the result (round {round})…",
|
"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",
|
||||||
|
"msg.actions": "Message actions",
|
||||||
|
"msg.copy": "Copy text",
|
||||||
|
"msg.copied": "Copied",
|
||||||
|
"msg.copyFailed": "Copying is not possible here",
|
||||||
|
"msg.quote": "Quote in reply",
|
||||||
|
"msg.forkHere": "Fork from here",
|
||||||
|
"chat.jumpPrevOwn": "Previous own message",
|
||||||
|
"viewer.annotate": "Mark up",
|
||||||
|
"viewer.annotateFailed": "This image cannot be edited",
|
||||||
|
"viewer.attach": "Attach to message",
|
||||||
|
"viewer.undo": "Undo",
|
||||||
|
"viewer.tool.pen": "Pen",
|
||||||
|
"viewer.tool.arrow": "Arrow",
|
||||||
|
"viewer.tool.rect": "Rectangle",
|
||||||
|
"viewer.useInChat": "Use in message",
|
||||||
|
"viewer.attached": "Image attached to the message",
|
||||||
|
"turn.changes": "{n} file(s) changed",
|
||||||
|
"turn.noChanges": "No file changes found for this turn.",
|
||||||
|
"turn.noDiff": "No text diff available.",
|
||||||
|
"turn.truncated": "The diff is shortened.",
|
||||||
|
"turn.new": "new",
|
||||||
|
"turn.deleted": "deleted",
|
||||||
|
"turn.rollback": "Roll back to before this turn",
|
||||||
|
"job.plan": "Plan as automation",
|
||||||
|
"job.planHint": "Turns this chat into instructions for a recurring automation (switched off until you set the schedule)",
|
||||||
|
"job.drafting": "Drafting the automation from this chat …",
|
||||||
|
"color.label": "Colour of this chat",
|
||||||
|
"color.red": "Red",
|
||||||
|
"color.orange": "Orange",
|
||||||
|
"color.yellow": "Yellow",
|
||||||
|
"color.green": "Green",
|
||||||
|
"color.aqua": "Teal",
|
||||||
|
"color.blue": "Blue",
|
||||||
|
"color.purple": "Purple",
|
||||||
|
"claude.title": "Claude Code",
|
||||||
|
"claude.intro": "Chats with a \"Claude Code\" model are run by the Claude Code CLI on the server – with your Claude subscription or Anthropic Console account. Sign in here once; approvals, push and the sandbox work as usual.",
|
||||||
|
"claude.notInstalled": "Claude Code is not installed on the server.",
|
||||||
|
"claude.console": "Anthropic Console (API billing) instead of the Claude subscription",
|
||||||
|
"claude.signIn": "Sign in",
|
||||||
|
"claude.openLogin": "Open the sign-in page",
|
||||||
|
"claude.step1": "Sign in at Anthropic in any browser (also on another device) and allow access.",
|
||||||
|
"claude.step2": "Paste the code the page shows afterwards:",
|
||||||
|
"claude.codePlaceholder": "Code from the sign-in page",
|
||||||
|
"claude.confirm": "Confirm",
|
||||||
|
"claude.signedIn": "Signed in to Claude Code",
|
||||||
|
"claude.signedInAs": "Signed in: {who}",
|
||||||
|
"claude.expires": "valid until {date}",
|
||||||
|
"claude.logout": "Sign out",
|
||||||
|
"claude.logoutConfirm": "Sign out of Claude Code on the server?",
|
||||||
|
"claude.version": "CLI {version}",
|
||||||
|
"claude.updateAvailable": "Update available: {version}",
|
||||||
|
"claude.upToDate": "up to date",
|
||||||
|
"claude.update": "Install update",
|
||||||
|
"claude.updating": "Installing …",
|
||||||
|
"claude.updated": "Claude Code updated to {version}",
|
||||||
|
"claude.updateFailed": "The update failed (details below)",
|
||||||
|
"claude.updateAdmin": "An administrator can install it.",
|
||||||
|
"claude.checkNow": "Check for updates",
|
||||||
|
"claude.updateLog": "Output of the update",
|
||||||
|
"check.menu": "Check after each turn …",
|
||||||
|
"check.title": "Check after each turn",
|
||||||
|
"check.hint": "Command that runs in {project} after every turn that changed files (e.g. cargo test, npm test). The result appears as a badge on the turn. Empty = off.",
|
||||||
|
"check.saved": "Check saved",
|
||||||
|
"check.removed": "Check removed",
|
||||||
|
"check.running": "Check running …",
|
||||||
|
"check.passed": "Check passed",
|
||||||
|
"check.failed": "Check failed",
|
||||||
|
"check.timeout": "Check timed out",
|
||||||
|
"check.noOutput": "(no output)",
|
||||||
|
"check.fix": "Let the agent fix it",
|
||||||
|
"check.fixPrompt": "The check of the project failed after your last change. Please fix the cause and run it again. Output:",
|
||||||
|
"projectDefaults.save": "Use as project default",
|
||||||
|
"projectDefaults.remove": "Remove project default",
|
||||||
|
"projectDefaults.hint": "New chats in this project folder start with this chat's model, mode, colour, instructions and folder",
|
||||||
|
"projectDefaults.saved": "New chats in this project will start like this one",
|
||||||
|
"projectDefaults.removed": "Project default removed",
|
||||||
|
"outbox.waiting": "{n} message(s) waiting for a connection",
|
||||||
|
"outbox.sendNow": "Send now",
|
||||||
|
"outbox.discard": "Discard",
|
||||||
|
"outbox.saved": "No connection – the message is saved and sent when you are back online",
|
||||||
|
"project.open": "Project overview",
|
||||||
|
"project.newChat": "New chat",
|
||||||
|
"project.today": "Today",
|
||||||
|
"project.yesterday": "Yesterday",
|
||||||
|
"project.chats": "{n} chat(s)",
|
||||||
|
"project.files": "{n} file(s) changed",
|
||||||
|
"project.archived": "{n} archived",
|
||||||
|
"project.checksFailed": "{n} failed check(s)",
|
||||||
|
"project.empty": "No chats in this folder yet.",
|
||||||
|
"viewer.title": "Image viewer",
|
||||||
|
"viewer.open": "Open in new tab",
|
||||||
|
"viewer.download": "Download",
|
||||||
|
"media.back": "Back to the overview",
|
||||||
|
"chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)",
|
||||||
"chat.reviewRound": "Review {round}: changes requested",
|
"chat.reviewRound": "Review {round}: changes requested",
|
||||||
"agents.refine": "Review loop",
|
"agents.refine": "Review loop",
|
||||||
"agents.refineRounds": "Review rounds per task",
|
"agents.refineRounds": "Review rounds per task",
|
||||||
@@ -678,6 +805,7 @@ export const en = {
|
|||||||
"plan.implementPrompt": "Implement the plan.",
|
"plan.implementPrompt": "Implement the plan.",
|
||||||
|
|
||||||
"tool.input": "Input",
|
"tool.input": "Input",
|
||||||
|
"tool.open3d": "Open in the 3D view",
|
||||||
"tool.output": "Output",
|
"tool.output": "Output",
|
||||||
"tool.todosUpdated": "Tasks updated ({done}/{total} done)",
|
"tool.todosUpdated": "Tasks updated ({done}/{total} done)",
|
||||||
"tool.todos": "Tasks",
|
"tool.todos": "Tasks",
|
||||||
@@ -687,6 +815,16 @@ export const en = {
|
|||||||
|
|
||||||
"permission.title": "Permission required",
|
"permission.title": "Permission required",
|
||||||
"permission.allowOnce": "Allow once",
|
"permission.allowOnce": "Allow once",
|
||||||
|
"permission.projectCommand": "Always allow \"{prefix}\" in this project",
|
||||||
|
"permission.projectTool": "Always allow {tool} in this project",
|
||||||
|
"permission.projectHint": "Remembered for this project folder, in all chats, also after a restart. Revoke it in the ⋯ menu.",
|
||||||
|
"permission.explain": "What does this do?",
|
||||||
|
"permission.explaining": "Explaining …",
|
||||||
|
"permProject.title": "Project permissions",
|
||||||
|
"permProject.intro": "Allowed without asking in this project folder (all chats):",
|
||||||
|
"permProject.empty": "Nothing is allowed for this project yet. The button \"Always allow … in this project\" on an approval adds entries here.",
|
||||||
|
"permProject.revoke": "Revoke",
|
||||||
|
"permProject.menu": "Project permissions",
|
||||||
"permission.alwaysTool": "Always allow {tool} in this session",
|
"permission.alwaysTool": "Always allow {tool} in this session",
|
||||||
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
|
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
|
||||||
"permission.reject": "Reject",
|
"permission.reject": "Reject",
|
||||||
@@ -736,6 +874,9 @@ export const en = {
|
|||||||
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.autoCompact": "Auto-compact at",
|
"settings.autoCompact": "Auto-compact at",
|
||||||
|
"settings.commitTrailer": "Commit note of agents",
|
||||||
|
"settings.commitTrailerOff": "off",
|
||||||
|
"settings.commitTrailerHint": "Added as a trailer to every git commit an agent makes. {model} = model name, {provider} = provider, {model_id} = model id. Empty = off. Your own commits stay unchanged.",
|
||||||
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
||||||
"settings.craft": "Craft mode",
|
"settings.craft": "Craft mode",
|
||||||
"settings.cleanRoom": "Clean room",
|
"settings.cleanRoom": "Clean room",
|
||||||
@@ -756,6 +897,8 @@ export const en = {
|
|||||||
"settings.pricesHint": "Prices per 1M tokens in the configured currency. Cache prices default to the input price.",
|
"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.modelsEmptyHint": "No models listed: they are discovered automatically from the provider.",
|
||||||
"settings.context": "Context",
|
"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.maxOutput": "Max output",
|
||||||
"settings.reasoning": "Reasoning",
|
"settings.reasoning": "Reasoning",
|
||||||
"settings.vision": "Vision",
|
"settings.vision": "Vision",
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ export function fmtCompact(n: number) {
|
|||||||
return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Short date and time of a message: only the time for today. */
|
||||||
|
export function fmtMsgTime(ms: number) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const today = d.toDateString() === new Date().toDateString();
|
||||||
|
return new Intl.DateTimeFormat(current, today ? { timeStyle: "short" } : { dateStyle: "short", timeStyle: "short" }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
export function fmtCost(n: number, currency: string) {
|
export function fmtCost(n: number, currency: string) {
|
||||||
const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2;
|
const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { ApiError, post } from "./api";
|
||||||
|
|
||||||
|
/** Messages written without a connection: kept on this device and sent when it is back. */
|
||||||
|
export interface OutboxItem {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
text: string;
|
||||||
|
effort?: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "codecraft.outbox";
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let cache: OutboxItem[] | undefined;
|
||||||
|
|
||||||
|
function load(): OutboxItem[] {
|
||||||
|
if (cache) return cache;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(KEY) ?? "[]");
|
||||||
|
cache = Array.isArray(v) ? v : [];
|
||||||
|
} catch {
|
||||||
|
cache = [];
|
||||||
|
}
|
||||||
|
return cache!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(items: OutboxItem[]) {
|
||||||
|
cache = items;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(items));
|
||||||
|
} catch {
|
||||||
|
// Storage blocked: the list still works until the page is closed.
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToOutbox(sessionId: string, text: string, effort?: string) {
|
||||||
|
save([...load(), { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, sessionId, text, effort, createdAt: Date.now() }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFromOutbox(id: string) {
|
||||||
|
save(load().filter((i) => i.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOutbox(sessionId: string): OutboxItem[] {
|
||||||
|
const all = useSyncExternalStore(
|
||||||
|
(l) => {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => listeners.delete(l);
|
||||||
|
},
|
||||||
|
() => load(),
|
||||||
|
);
|
||||||
|
return all.filter((i) => i.sessionId === sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for failures of the connection itself (not answers of the server). */
|
||||||
|
export function isOffline(e: unknown) {
|
||||||
|
return !(e instanceof ApiError) && (!navigator.onLine || e instanceof TypeError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let flushing = false;
|
||||||
|
|
||||||
|
/** Send the waiting messages of a chat in order; stops at the first connection failure. Returns how many were sent. */
|
||||||
|
export async function flushOutbox(sessionId: string): Promise<number> {
|
||||||
|
if (flushing) return 0;
|
||||||
|
flushing = true;
|
||||||
|
let sent = 0;
|
||||||
|
try {
|
||||||
|
for (const item of load().filter((i) => i.sessionId === sessionId)) {
|
||||||
|
try {
|
||||||
|
await post(`/sessions/${item.sessionId}/prompt`, { text: item.text, effort: item.effort });
|
||||||
|
removeFromOutbox(item.id);
|
||||||
|
sent++;
|
||||||
|
} catch (e) {
|
||||||
|
if (isOffline(e)) break;
|
||||||
|
// The server refused it (deleted chat, read-only …): it would never go through.
|
||||||
|
removeFromOutbox(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
flushing = false;
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
+54
-2
@@ -9,6 +9,7 @@ import type {
|
|||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
ServerEvent,
|
ServerEvent,
|
||||||
|
TurnCheckInfo,
|
||||||
Session,
|
Session,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@ export interface State {
|
|||||||
/** Includes subagent sessions loaded on demand. */
|
/** Includes subagent sessions loaded on demand. */
|
||||||
sessionById: Record<string, Session>;
|
sessionById: Record<string, Session>;
|
||||||
messages: Record<string, Message[]>;
|
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). */
|
/** Sessions the current user may only read (shared with them). */
|
||||||
readOnly: Record<string, boolean>;
|
readOnly: Record<string, boolean>;
|
||||||
drafts: Record<string, Draft | undefined>;
|
drafts: Record<string, Draft | undefined>;
|
||||||
@@ -49,6 +52,8 @@ export interface State {
|
|||||||
waiting: Record<string, string | undefined>;
|
waiting: Record<string, string | undefined>;
|
||||||
/** Compaction running in a session (auto = context nearly full). */
|
/** Compaction running in a session (auto = context nearly full). */
|
||||||
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
|
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
|
||||||
|
/** Check results per session, by the id of the turn's last answer. */
|
||||||
|
checks: Record<string, Record<string, TurnCheckInfo> | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let state: State = {
|
let state: State = {
|
||||||
@@ -58,6 +63,7 @@ let state: State = {
|
|||||||
sessions: [],
|
sessions: [],
|
||||||
sessionById: {},
|
sessionById: {},
|
||||||
messages: {},
|
messages: {},
|
||||||
|
windows: {},
|
||||||
readOnly: {},
|
readOnly: {},
|
||||||
drafts: {},
|
drafts: {},
|
||||||
busy: {},
|
busy: {},
|
||||||
@@ -70,6 +76,7 @@ let state: State = {
|
|||||||
steps: {},
|
steps: {},
|
||||||
waiting: {},
|
waiting: {},
|
||||||
compacting: {},
|
compacting: {},
|
||||||
|
checks: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
@@ -130,7 +137,8 @@ export function applyEvent(ev: ServerEvent) {
|
|||||||
setState((s) => {
|
setState((s) => {
|
||||||
const list = s.messages[ev.session_id];
|
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;
|
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 };
|
if (list.some((m) => m.id === ev.message.id)) return { drafts };
|
||||||
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
||||||
});
|
});
|
||||||
@@ -199,6 +207,11 @@ export function applyEvent(ev: ServerEvent) {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
case "check_result": {
|
||||||
|
const info: TurnCheckInfo = { message_id: ev.message_id, status: ev.status, exit_code: ev.exit_code, duration_ms: ev.duration_ms, output: ev.output };
|
||||||
|
setState((s) => ({ checks: { ...s.checks, [ev.session_id]: { ...s.checks[ev.session_id], [ev.message_id]: info } } }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "mcp_status":
|
case "mcp_status":
|
||||||
setState(() => ({ mcp: ev.servers }));
|
setState(() => ({ mcp: ev.servers }));
|
||||||
break;
|
break;
|
||||||
@@ -231,9 +244,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) {
|
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) => ({
|
setState((s) => ({
|
||||||
|
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
|
||||||
...upsertSession(s, data.session),
|
...upsertSession(s, data.session),
|
||||||
sessionById: { ...s.sessionById, [id]: data.session },
|
sessionById: { ...s.sessionById, [id]: data.session },
|
||||||
messages: { ...s.messages, [id]: data.messages },
|
messages: { ...s.messages, [id]: data.messages },
|
||||||
@@ -247,6 +268,37 @@ export async function loadSession(id: string) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check results of a chat (badges on its turns). */
|
||||||
|
export async function loadChecks(id: string) {
|
||||||
|
const list = await get<TurnCheckInfo[]>(`/sessions/${id}/checks`).catch(() => [] as TurnCheckInfo[]);
|
||||||
|
setState((s) => ({ checks: { ...s.checks, [id]: { ...Object.fromEntries(list.map((c) => [c.message_id, c])), ...s.checks[id] } } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Older messages before the loaded ones (scrolling up). Returns how many came. */
|
||||||
|
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. */
|
/** After reconnects: reload everything that could have changed. */
|
||||||
export async function resync() {
|
export async function resync() {
|
||||||
const isAdmin = getState().me?.role === "admin";
|
const isAdmin = getState().me?.role === "admin";
|
||||||
|
|||||||
+722
-8
@@ -439,6 +439,9 @@ button.session-group:hover {
|
|||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
/* Nothing in the bars may widen the page (and push it sideways on phones). */
|
||||||
|
overflow-x: clip;
|
||||||
|
max-width: 100vw;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -456,7 +459,8 @@ button.session-group:hover {
|
|||||||
}
|
}
|
||||||
.topbar .grow {
|
.topbar .grow {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
/* The title stays readable: with many chips they wrap into a second row instead. */
|
||||||
|
min-width: min(160px, 40vw);
|
||||||
}
|
}
|
||||||
.topbar h1 {
|
.topbar h1 {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -517,6 +521,31 @@ button.session-group:hover {
|
|||||||
|
|
||||||
.msg {
|
.msg {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
/* Message actions: hover on desktop, faintly visible on touch. */
|
||||||
|
.msg-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
z-index: 3;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.msg:hover > .msg-menu,
|
||||||
|
.msg-menu:focus-within,
|
||||||
|
.msg-menu[open] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.msg-menu {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.msg-menu > summary {
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
line-height: 0;
|
||||||
}
|
}
|
||||||
.msg-user {
|
.msg-user {
|
||||||
background: var(--bg1);
|
background: var(--bg1);
|
||||||
@@ -541,14 +570,16 @@ button.session-group:hover {
|
|||||||
background: var(--bg1);
|
background: var(--bg1);
|
||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
.msg-user .msg-actions {
|
.msg-time {
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.msg-user:hover .msg-actions {
|
|
||||||
display: block;
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.msg-user .msg-time {
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.msg-user img {
|
.msg-user img {
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
@@ -1068,6 +1099,13 @@ button.session-group:hover {
|
|||||||
.form label {
|
.form label {
|
||||||
color: var(--fg2);
|
color: var(--fg2);
|
||||||
}
|
}
|
||||||
|
.form label.check {
|
||||||
|
grid-column: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--fg1);
|
||||||
|
}
|
||||||
.form .hint {
|
.form .hint {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1349,6 +1387,23 @@ table.grid input[type="number"] {
|
|||||||
.topbar .path {
|
.topbar .path {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.topbar .grow {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar .chip {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 30vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.menu-pop-body {
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
}
|
||||||
|
.menu-pop-body .mobile-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
.topbar button.small {
|
.topbar button.small {
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
}
|
}
|
||||||
@@ -2518,6 +2573,7 @@ button.danger-solid {
|
|||||||
height: 0;
|
height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
@@ -3164,6 +3220,28 @@ button.danger-solid {
|
|||||||
.refine-card summary .icon {
|
.refine-card summary .icon {
|
||||||
color: var(--purple);
|
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 ---------------------------------------------------------------- */
|
/* --- automations ---------------------------------------------------------------- */
|
||||||
.job.off {
|
.job.off {
|
||||||
@@ -4156,3 +4234,639 @@ span.chat-tab > button {
|
|||||||
font-size: 12px;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- image viewer ------------------------------------------------------------ */
|
||||||
|
.viewer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.viewer-img {
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100dvh;
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
touch-action: none;
|
||||||
|
transition: transform 0.08s ease-out;
|
||||||
|
background: repeating-conic-gradient(#222 0% 25%, #111 0% 50%) 50% / 16px 16px;
|
||||||
|
}
|
||||||
|
.viewer-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
padding-top: max(8px, env(safe-area-inset-top));
|
||||||
|
background: linear-gradient(rgba(0, 0, 0, 0.6), transparent);
|
||||||
|
color: #eee;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.viewer-bar .button,
|
||||||
|
.viewer-bar button {
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
.viewer-count {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 3.5em;
|
||||||
|
}
|
||||||
|
.viewer-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.viewer-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
width: 40px;
|
||||||
|
height: 56px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
.viewer-nav.prev {
|
||||||
|
left: 6px;
|
||||||
|
}
|
||||||
|
.viewer-nav.next {
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.viewer-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Thumbnails invite a tap. */
|
||||||
|
.chat img.inline-image,
|
||||||
|
.image-refs img,
|
||||||
|
.user-files img,
|
||||||
|
.image-result img,
|
||||||
|
.svg-block img {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- image markup ------------------------------------------------------------ */
|
||||||
|
.annotator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 52px 8px 64px;
|
||||||
|
}
|
||||||
|
.annotator canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
background: #111;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
.annotator-tools {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 10px;
|
||||||
|
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
.annotator-colors {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
.annotator-colors .swatch {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
.annotator-colors .swatch.on {
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- approvals: explanation, session summary ------------------------------------ */
|
||||||
|
.explain {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-left: 3px solid var(--purple);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg1);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.explain .icon {
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
|
.explain-btn {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.session-item .summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg3);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- changes of one turn ------------------------------------------------------- */
|
||||||
|
.turn-changes {
|
||||||
|
margin: -6px 0 12px;
|
||||||
|
}
|
||||||
|
.turn-changes-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg1);
|
||||||
|
}
|
||||||
|
.turn-changes-body > button {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.turn-file > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.turn-file .turn-stat {
|
||||||
|
margin-left: auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.turn-file .turn-stat .add {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.turn-file .turn-stat .del {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.turn-file .diff {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- colour tags of chats ------------------------------------------------------
|
||||||
|
* The tag is a name; the colour comes from the active theme (--red, --accent, …),
|
||||||
|
* the background is mixed into --bg0, so every theme (light or dark) stays readable. */
|
||||||
|
[data-color="red"] { --tag: var(--red); }
|
||||||
|
[data-color="orange"] { --tag: var(--accent2, var(--red)); }
|
||||||
|
[data-color="yellow"] { --tag: var(--accent, var(--green)); }
|
||||||
|
[data-color="green"] { --tag: var(--green); }
|
||||||
|
[data-color="aqua"] { --tag: var(--aqua); }
|
||||||
|
[data-color="blue"] { --tag: var(--blue); }
|
||||||
|
[data-color="purple"] { --tag: var(--purple); }
|
||||||
|
|
||||||
|
.chat-pane[data-color] {
|
||||||
|
background: color-mix(in srgb, var(--tag) 9%, var(--bg0));
|
||||||
|
}
|
||||||
|
.topbar[data-color] {
|
||||||
|
border-bottom: 2px solid var(--tag);
|
||||||
|
background: color-mix(in srgb, var(--tag) 14%, var(--bg0));
|
||||||
|
}
|
||||||
|
.session-item[data-color] {
|
||||||
|
box-shadow: inset 3px 0 0 var(--tag);
|
||||||
|
}
|
||||||
|
.color-badge {
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--fg0);
|
||||||
|
background: color-mix(in srgb, var(--tag) 30%, var(--bg1));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--tag) 60%, transparent);
|
||||||
|
}
|
||||||
|
.color-swatches {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.color-swatches .swatch {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tag);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.color-swatches .swatch.on {
|
||||||
|
border-color: var(--fg0);
|
||||||
|
box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag);
|
||||||
|
}
|
||||||
|
.chat-pane[data-color] .composer-wrap {
|
||||||
|
background: color-mix(in srgb, var(--tag) 9%, var(--bg0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- check badge of a turn ------------------------------------------------------ */
|
||||||
|
.turn-check {
|
||||||
|
margin: -6px 0 12px;
|
||||||
|
}
|
||||||
|
.turn-check .chip {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.turn-check .check-passed {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.turn-check .check-failed,
|
||||||
|
.turn-check .check-timeout {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.turn-check-body {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg1);
|
||||||
|
}
|
||||||
|
.turn-check-body pre {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- messages waiting for a connection ------------------------------------------ */
|
||||||
|
.outbox {
|
||||||
|
margin: 0 12px 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px dashed var(--accent2, var(--accent));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg1);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.outbox-head,
|
||||||
|
.outbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.outbox-item span {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--fg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- project page: timeline -------------------------------------------------------- */
|
||||||
|
.project-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.timeline-day h3 {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 14px;
|
||||||
|
border-left: 2px solid var(--bg3);
|
||||||
|
}
|
||||||
|
.timeline li {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.timeline li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -20px;
|
||||||
|
top: 12px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tag, var(--bg3));
|
||||||
|
border: 2px solid var(--bg0);
|
||||||
|
}
|
||||||
|
.timeline li > a {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg1);
|
||||||
|
color: var(--fg1);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.timeline li > a:hover {
|
||||||
|
border-color: var(--bg3);
|
||||||
|
}
|
||||||
|
.tl-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--fg0);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tl-when {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.tl-summary {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--fg2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tl-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.tl-meta .check-passed {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.tl-meta .check-failed,
|
||||||
|
.tl-meta .check-timeout {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export const THEMES = [
|
|||||||
{ id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" },
|
{ id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" },
|
||||||
{ id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" },
|
{ id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" },
|
||||||
{ id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" },
|
{ id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" },
|
||||||
|
{ id: "tintenblau", name: "Tintenblau", source: "zink/tintenblau", license: "private" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Theme = (typeof THEMES)[number]["id"];
|
export type Theme = (typeof THEMES)[number]["id"];
|
||||||
|
|||||||
@@ -30,3 +30,5 @@ import "./everforest-dark.css";
|
|||||||
import "./everforest-light.css";
|
import "./everforest-light.css";
|
||||||
import "./kanagawa-dark.css";
|
import "./kanagawa-dark.css";
|
||||||
import "./kanagawa-light.css";
|
import "./kanagawa-light.css";
|
||||||
|
import "./tintenblau-dark.css";
|
||||||
|
import "./tintenblau-light.css";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+21
-1
@@ -31,6 +31,8 @@ export interface Usage {
|
|||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
cache_read_tokens: number;
|
cache_read_tokens: number;
|
||||||
cache_write_tokens: number;
|
cache_write_tokens: number;
|
||||||
|
/** Cost of the request (charged, or valued at list prices for plan requests). */
|
||||||
|
cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -57,6 +59,10 @@ export interface Session {
|
|||||||
parent_id?: string;
|
parent_id?: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
|
/** One line "where things stand" (small model), for the session list. */
|
||||||
|
summary?: string;
|
||||||
|
/** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */
|
||||||
|
color?: string;
|
||||||
todos: Todo[] | null;
|
todos: Todo[] | null;
|
||||||
last_usage?: Usage | null;
|
last_usage?: Usage | null;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
@@ -110,6 +116,16 @@ export interface QuestionRequest {
|
|||||||
questions: Question[];
|
questions: Question[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of the project's check command after a turn. */
|
||||||
|
export type CheckStatus = "running" | "passed" | "failed" | "timeout";
|
||||||
|
export interface TurnCheckInfo {
|
||||||
|
message_id: string;
|
||||||
|
status: CheckStatus;
|
||||||
|
exit_code: number | null;
|
||||||
|
duration_ms: number;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerEvent =
|
export type ServerEvent =
|
||||||
| { type: "session_updated"; session: Session }
|
| { type: "session_updated"; session: Session }
|
||||||
| { type: "session_deleted"; session_id: string }
|
| { type: "session_deleted"; session_id: string }
|
||||||
@@ -127,6 +143,7 @@ export type ServerEvent =
|
|||||||
| { type: "waiting"; session_id: string; reason: string | null }
|
| { type: "waiting"; session_id: string; reason: string | null }
|
||||||
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
||||||
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
||||||
|
| { type: "check_result"; session_id: string; message_id: string; status: CheckStatus; exit_code: number | null; duration_ms: number; output: string }
|
||||||
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
|
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
|
||||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||||
| { type: "resync" };
|
| { type: "resync" };
|
||||||
@@ -141,6 +158,7 @@ export interface ModelInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
context?: number;
|
context?: number;
|
||||||
max_output?: number | null;
|
max_output?: number | null;
|
||||||
|
max_input?: number | null;
|
||||||
reasoning: boolean;
|
reasoning: boolean;
|
||||||
vision: boolean;
|
vision: boolean;
|
||||||
effort?: string | null;
|
effort?: string | null;
|
||||||
@@ -166,7 +184,9 @@ export interface AgentInfo {
|
|||||||
tools?: string[] | null;
|
tools?: string[] | null;
|
||||||
source: "builtin" | "config" | "file" | "personal";
|
source: "builtin" | "config" | "file" | "personal";
|
||||||
max_parallel?: number | null;
|
max_parallel?: number | null;
|
||||||
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
|
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
|
||||||
|
/** binary_info and decompile (Ghidra) available in this mode. */
|
||||||
|
reverse?: boolean;
|
||||||
/** Source file of Markdown agents. */
|
/** Source file of Markdown agents. */
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,50 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00026": {
|
||||||
|
en: [
|
||||||
|
"Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model",
|
||||||
|
"Image viewer, message menu, colour tags for chats, approvals from push notifications, check after each turn, project page",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
"Claude Code als Engine: unter Einstellungen → Konto anmelden, dann ein „Claude Code“-Modell wählen",
|
||||||
|
"Bildbetrachter, Nachrichtenmenü, Farben für Chats, Freigaben per Push, Prüfung nach jedem Schritt, Projektseite",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"202611-00024": {
|
||||||
|
en: ["3D models with fal.ai: from an image (also pasted into the chat), a text prompt or both – open them in the 3D view"],
|
||||||
|
de: ["3D-Modelle mit fal.ai: aus einem Bild (auch eingefügt), einem Text oder beidem – öffnen in der 3D-Ansicht"],
|
||||||
|
},
|
||||||
|
"202611-00023": {
|
||||||
|
en: ["Tokens and cost under every answer; the total cost of chats with opencode Go is shown correctly again"],
|
||||||
|
de: ["Tokens und Kosten unter jeder Antwort; der Gesamtpreis von Chats mit opencode Go stimmt wieder"],
|
||||||
|
},
|
||||||
|
"202611-00022": {
|
||||||
|
en: ["New theme “Tintenblau” (Settings → General → Theme)"],
|
||||||
|
de: ["Neues Theme „Tintenblau“ (Einstellungen → Allgemein → Theme)"],
|
||||||
|
},
|
||||||
|
"202611-00021": {
|
||||||
|
en: ["Git commits of agents name the model: “Co-Authored-By: <model> (<provider>) via CodeCraft” (Settings → General)"],
|
||||||
|
de: ["Git-Commits von Agenten nennen das Modell: „Co-Authored-By: <Modell> (<Anbieter>) via CodeCraft“ (Einstellungen → Allgemein)"],
|
||||||
|
},
|
||||||
|
"202611-00020": {
|
||||||
|
en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"],
|
||||||
|
de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"],
|
||||||
|
},
|
||||||
|
"202611-00019": {
|
||||||
|
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": {
|
"202611-00017": {
|
||||||
en: ["Your messages are shown as Markdown; the eye button next to the text field previews it before sending"],
|
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"],
|
de: ["Deine Nachrichten werden als Markdown dargestellt; das Augen-Symbol neben dem Textfeld zeigt vor dem Senden eine Vorschau"],
|
||||||
|
|||||||
Reference in new issue
Block a user