Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08c6c7436 | ||
|
|
1b3e3af4b7 | ||
|
|
44dd6978d4 | ||
|
|
8a90f83db5 | ||
|
|
ce0d8afe69 | ||
|
|
f34a0c7a73 | ||
|
|
62fe5bf467 | ||
|
|
084452abf6 | ||
|
|
8b4bd59572 | ||
|
|
c3df4a23cc | ||
|
|
48621f21d6 | ||
|
|
d7baa77922 | ||
|
|
1e8418336d | ||
|
|
33612bf6d4 | ||
|
|
ffecd62b83 | ||
|
|
c77dcf5890 | ||
|
|
9c80f2c857 | ||
|
|
1253430ed4 | ||
|
|
e7d4b9ff76 | ||
|
|
b71dfc49c3 | ||
|
|
3b0764450a |
No files matched your search
+142
@@ -3,6 +3,148 @@
|
|||||||
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-00028] – 2026-11-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **18 more chat icons** (gamepad, computer, food, coffee, home, book, code, camera, cart, heart,
|
||||||
|
star, globe, calendar, car, plane, bulb, money, palette).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The ⋯ menu of a message sits inside its bubble** (in the foot next to the time, also in your own
|
||||||
|
bubbles); its popup opens upwards within the bubble and is no wider than the bubble.
|
||||||
|
|
||||||
|
## [v202611-00027] – 2026-11-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Chat bubbles:** your messages on the right in their own colour (the chat's colour tag if it
|
||||||
|
has one), the agent's answers on the left; tool-only steps and system notes stay without bubble.
|
||||||
|
- **Icons for chats:** pick one of 22 icons in the ⋯ menu (chat and session list); it shows in the
|
||||||
|
list, the top bar and the project page, in the chat's colour, and is part of the project default.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Colour tags are more vivid and more distinct:** eight fixed hues (new: pink), drawn with a
|
||||||
|
lightness that fits the theme (light/dark) and the highest chroma sRGB allows, so no two tags
|
||||||
|
look alike; backgrounds are tinted more strongly. Browsers without relative colours keep the
|
||||||
|
theme's own colours.
|
||||||
|
|
||||||
|
## [v202611-00026] – 2026-11-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Claude Code as an engine:** chats with a "Claude Code" model (Opus, Sonnet, Haiku, default) are
|
||||||
|
run by the `claude` CLI. Its events become normal chat messages; its tool approvals go through
|
||||||
|
CodeCraft's permission system (cards, push buttons, "allow for this project", diffs, sandbox).
|
||||||
|
Sign in from the web UI (Settings → Account → Claude Code: open the sign-in URL, paste the code),
|
||||||
|
see the CLI version and install updates (admins; installed into the persistent home). The Docker
|
||||||
|
image now contains the CLI.
|
||||||
|
- **Small models (8k context) for side tasks:** `small_model` jobs cut their input to the model's
|
||||||
|
context window. New with it: a one-line status per chat in the session list, "What does this
|
||||||
|
do?" on approvals, and "Plan as automation" (drafts job instructions from a chat).
|
||||||
|
- **Mobile:** the top bar no longer pushes the page sideways (extra actions moved into the ⋯ menu);
|
||||||
|
an image viewer for all images (zoom, swipe between images, mark up and attach to the message,
|
||||||
|
use in the message); jump to the previous own message; message menu (copy, quote, fork from
|
||||||
|
here, edit and resend); date and time under every message.
|
||||||
|
- **Approvals:** push notifications can allow or reject right away; "always allow in this project"
|
||||||
|
is remembered per folder (never for dangerous commands) and can be revoked in the ⋯ menu.
|
||||||
|
- **Changes per turn:** files changed by a turn with diffs and "roll back to before this turn".
|
||||||
|
- **Check after each turn:** a command per project (e.g. `cargo test`) runs after turns that
|
||||||
|
changed files; green/red badge with output and "let the agent fix it".
|
||||||
|
- **Colour tags for chats:** tinted chat background and a badge in the list, drawn in the colours
|
||||||
|
of the active theme.
|
||||||
|
- **Project defaults, project page, offline queue:** new chats in a folder inherit model, mode,
|
||||||
|
colour, instructions and folder; a timeline of a project's chats (sidebar → project → ⋯);
|
||||||
|
messages written without a connection are sent when it is back.
|
||||||
|
|
||||||
|
## [v202611-00025] – 2026-11-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Models did not find built-in tools via `mcp_search`** (e.g. `generate_3d` in a chat that
|
||||||
|
had just been switched from plan to build): the search only looked at MCP servers. It now also
|
||||||
|
lists matching built-in tools and says whether the current mode has them ("available – call it
|
||||||
|
directly", or "not in the read-only mode plan – ask the user to switch to build").
|
||||||
|
|
||||||
|
## [v202611-00024] – 2026-11-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **3D models with fal.ai (`generate_3d`):** from an image (a project file or one pasted into the
|
||||||
|
chat, optional back/left/right views), from a text prompt, or from an image steered by a prompt
|
||||||
|
(the image is refined with the prompt first – the 3D models take no text with an image).
|
||||||
|
Styles textured, geometry (untextured, for 3D printing) and low-poly, optional PBR. Uses fal.ai's
|
||||||
|
queue (takes minutes, with progress), saves a GLB plus a preview image, books the cost.
|
||||||
|
Defaults: Hunyuan3D v3 image-to-3D / text-to-3D (`[images] model_3d`, `model_3d_text`); Tripo
|
||||||
|
and Trellis endpoints work too.
|
||||||
|
- The chat card offers "Open in the 3D view"; the media overview lists 3D models (also STL/3MF
|
||||||
|
written by the agent) with their previews.
|
||||||
|
|
||||||
|
## [v202611-00023] – 2026-11-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Tokens and cost under every answer:** a small line under each answer of the model shows the
|
||||||
|
tokens sent/received and the cost of all requests of that turn.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Total cost of chats stopped growing (opencode Go):** opencode now reports plan requests as
|
||||||
|
"nothing charged" (cost 0, usage in `allowance`), and CodeCraft took the 0. Plan requests are
|
||||||
|
valued at the model's list prices again; earlier ones are corrected once on start.
|
||||||
|
- **Saving opencode Go models failed after fetching prices** ("data did not match any variant of
|
||||||
|
untagged enum ModelEntry"): opencode lists cache prices per duration for some models
|
||||||
|
(`{"30m": …, "12h": …}`); the shortest duration is used now.
|
||||||
|
- The chat title in the top bar no longer shrinks to a single letter when there are many chips.
|
||||||
|
|
||||||
|
## [v202611-00022] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Theme "Tintenblau"** (dark: ink blue with cream text; light: paper with dark ink), from the
|
||||||
|
Tintenblau desktop theme; also in the terminal client.
|
||||||
|
|
||||||
|
## [v202611-00021] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Commits of agents name the model:** every `git commit` an agent makes gets the trailer
|
||||||
|
`Co-Authored-By: <model> (<provider>) via CodeCraft` (a git wrapper in the agent shell, also in
|
||||||
|
the sandbox; your own commits stay unchanged). The text is set under *Settings → General*
|
||||||
|
(`server.commit_trailer`, `{model}`, `{provider}`, `{model_id}`; empty = off).
|
||||||
|
|
||||||
|
## [v202611-00020] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Reverse engineering outside craft:** *Settings → Agents & commands* can give any mode the
|
||||||
|
`binary_info` and `decompile` (Ghidra) tools; the clean-room rule stays a craft rule.
|
||||||
|
- **Raw firmware in `decompile`:** optional `processor` (Ghidra language id, e.g.
|
||||||
|
`ARM:LE:32:Cortex`) and `base_address` for images without a file format (.bin, converted .hex).
|
||||||
|
- **Skill "reverse-engineering":** how to identify, decompile and explore programs and firmware
|
||||||
|
(processor table for common chips, HEX conversion, notes in `analysis/`).
|
||||||
|
|
||||||
|
## [v202611-00019] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Search in a chat:** the magnifier in the chat's top bar searches all of it (messages, tool
|
||||||
|
output) on the server; a hit loads its part of the chat and marks the message.
|
||||||
|
- **Media of a chat:** *⋯ → Media* shows every image, video and audio file of the chat and its
|
||||||
|
subagents – generated images, screenshots, files the agent wrote, media in answers, your
|
||||||
|
attachments – filterable, large view, "show in chat" jumps to the message.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Long chats stay fast:** a chat opens with its messages since the last summary (at most 200);
|
||||||
|
older ones load when scrolling up, the history before a summary on request. Finished turns
|
||||||
|
fold into one line ("42 steps · 3 files", the answer stays visible). Typing, streaming and
|
||||||
|
status changes no longer re-render the whole history, and tool output is only built when a
|
||||||
|
card is opened. Measured with a chat of 5,900 messages: opening 9 s → 0.7 s (phone-like
|
||||||
|
CPU: 22 s → 1 s), typing 440 ms → 25 ms per key.
|
||||||
|
|
||||||
## [v202611-00018] – 2026-11-10
|
## [v202611-00018] – 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-00018" src="https://img.shields.io/badge/version-v202611--00018-d69e2e?style=flat-square">
|
<img alt="Version v202611-00028" src="https://img.shields.io/badge/version-v202611--00028-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",
|
||||||
|
|||||||
+22
-1
@@ -159,7 +159,13 @@ impl ModelConfig {
|
|||||||
/// Cost of one request now. Cache prices default to the input price.
|
/// Cost of one request now. Cache prices default to the input price.
|
||||||
/// The provider's own figure wins when it reports one (opencode).
|
/// The provider's own figure wins when it reports one (opencode).
|
||||||
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
||||||
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
|
match u.cost {
|
||||||
|
// A plan request (opencode Go): nothing charged, the usage counts toward the plan –
|
||||||
|
// its value at the model's list prices.
|
||||||
|
Some(c) if c == 0.0 && u.allowance.is_some_and(|a| a > 0.0) => self.cost_at(u, chrono::Utc::now()),
|
||||||
|
Some(c) => c,
|
||||||
|
None => self.cost_at(u, chrono::Utc::now()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cost of one request at a given time: context tiers and time factors applied.
|
/// Cost of one request at a given time: context tiers and time factors applied.
|
||||||
@@ -187,6 +193,21 @@ mod price_tests {
|
|||||||
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_requests_are_valued_at_list_prices() {
|
||||||
|
let m = ModelConfig { input_price: Some(1.0), output_price: Some(2.0), ..Default::default() };
|
||||||
|
let mut u = usage(1_000_000, 1_000_000, 0);
|
||||||
|
u.cost = Some(0.0);
|
||||||
|
u.allowance = Some(0.4);
|
||||||
|
assert_eq!(m.cost(&u), 3.0);
|
||||||
|
// Charged by the provider: that amount; free without a plan: free.
|
||||||
|
u.cost = Some(0.7);
|
||||||
|
assert_eq!(m.cost(&u), 0.7);
|
||||||
|
u.cost = Some(0.0);
|
||||||
|
u.allowance = None;
|
||||||
|
assert_eq!(m.cost(&u), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tiers_and_multipliers() {
|
fn tiers_and_multipliers() {
|
||||||
let m = ModelConfig {
|
let m = ModelConfig {
|
||||||
|
|||||||
+43
-1
@@ -187,8 +187,27 @@ pub struct Session {
|
|||||||
/// unless it is the chat currently open in Telegram.
|
/// unless it is the chat currently open in Telegram.
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub telegram_quiet: bool,
|
pub telegram_quiet: bool,
|
||||||
|
/// One-line "where things stand", written by the small model after a turn (session list).
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub summary: String,
|
||||||
|
/// Colour tag of the chat (a palette name, see `SESSION_COLORS`); the client maps it to the theme.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<String>,
|
||||||
|
/// Icon of the chat (a name from `SESSION_ICONS`), shown in the list and the top bar.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Names of the colour tags: the client draws them with the colours of the active theme.
|
||||||
|
pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"];
|
||||||
|
|
||||||
|
/// Icons a chat can get (names of the client's icon set).
|
||||||
|
pub const SESSION_ICONS: &[&str] = &[
|
||||||
|
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
|
||||||
|
"users", "toolbox", "plug", "scroll", "eye", "template",
|
||||||
|
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
|
||||||
|
];
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn clean_room(&self, default: bool) -> bool {
|
pub fn clean_room(&self, default: bool) -> bool {
|
||||||
self.clean_room.unwrap_or(default)
|
self.clean_room.unwrap_or(default)
|
||||||
@@ -243,6 +262,8 @@ pub enum Event {
|
|||||||
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
|
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
|
||||||
/// a nearly full context (tokens of limit).
|
/// a nearly full context (tokens of limit).
|
||||||
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
|
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
|
||||||
|
/// The project's check command ran after a turn: status "running" | "passed" | "failed" | "timeout".
|
||||||
|
CheckResult { session_id: String, message_id: String, status: String, exit_code: Option<i32>, duration_ms: u64, output: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Event {
|
impl Event {
|
||||||
@@ -265,7 +286,8 @@ impl Event {
|
|||||||
| Waiting { session_id, .. }
|
| Waiting { session_id, .. }
|
||||||
| QueueUpdated { session_id, .. }
|
| QueueUpdated { session_id, .. }
|
||||||
| StepStarted { session_id, .. }
|
| StepStarted { session_id, .. }
|
||||||
| Compaction { session_id, .. } => Some(session_id),
|
| Compaction { session_id, .. }
|
||||||
|
| CheckResult { session_id, .. } => Some(session_id),
|
||||||
McpStatus { .. } => None,
|
McpStatus { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,3 +300,23 @@ pub fn new_id() -> String {
|
|||||||
pub fn now_ms() -> i64 {
|
pub fn now_ms() -> i64 {
|
||||||
chrono::Utc::now().timestamp_millis()
|
chrono::Utc::now().timestamp_millis()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod icon_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The client's picker (ColorSwatches.tsx) and the server's whitelist must list the same names.
|
||||||
|
#[test]
|
||||||
|
fn session_icons_match_the_client() {
|
||||||
|
let ts = include_str!("../../web/src/components/ColorSwatches.tsx");
|
||||||
|
let list = ts.split("SESSION_ICONS = [").nth(1).and_then(|r| r.split("] as const").next()).expect("icon list in ColorSwatches.tsx");
|
||||||
|
let mut client: Vec<&str> = list.split('"').skip(1).step_by(2).collect();
|
||||||
|
let mut server: Vec<&str> = SESSION_ICONS.to_vec();
|
||||||
|
client.sort();
|
||||||
|
server.sort();
|
||||||
|
assert_eq!(client, server);
|
||||||
|
let mut dedup = server.clone();
|
||||||
|
dedup.dedup();
|
||||||
|
assert_eq!(dedup.len(), server.len(), "duplicate icon names");
|
||||||
|
}
|
||||||
|
}
|
||||||
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.
|
||||||
+358
-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,13 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
worktree: None,
|
worktree: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: dstr("folder"),
|
||||||
instructions: String::new(),
|
instructions: dstr("instructions").unwrap_or_default(),
|
||||||
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
||||||
telegram_quiet: true,
|
telegram_quiet: true,
|
||||||
|
summary: String::new(),
|
||||||
|
color: dstr("color").filter(|c| codecraft_core::types::SESSION_COLORS.contains(&c.as_str())),
|
||||||
|
icon: dstr("icon").filter(|c| codecraft_core::types::SESSION_ICONS.contains(&c.as_str())),
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
@@ -813,14 +836,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 +941,10 @@ struct UpdateSession {
|
|||||||
instructions: Option<String>,
|
instructions: Option<String>,
|
||||||
/// Approvals and questions of this chat are not sent to Telegram.
|
/// Approvals and questions of this chat are not sent to Telegram.
|
||||||
telegram_quiet: Option<bool>,
|
telegram_quiet: Option<bool>,
|
||||||
|
/// Colour tag ("" removes it).
|
||||||
|
color: Option<String>,
|
||||||
|
/// Icon ("" removes it).
|
||||||
|
icon: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||||
@@ -850,6 +957,12 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if let Some(m) = &body.model {
|
if let Some(m) = &body.model {
|
||||||
app.registry().resolve(m)?;
|
app.registry().resolve(m)?;
|
||||||
}
|
}
|
||||||
|
if body.color.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_COLORS.contains(&c)) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid colour".into()));
|
||||||
|
}
|
||||||
|
if body.icon.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_ICONS.contains(&c)) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid icon".into()));
|
||||||
|
}
|
||||||
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
|
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
|
||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||||
}
|
}
|
||||||
@@ -889,6 +1002,12 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if let Some(q) = body.telegram_quiet {
|
if let Some(q) = body.telegram_quiet {
|
||||||
s.telegram_quiet = q;
|
s.telegram_quiet = q;
|
||||||
}
|
}
|
||||||
|
if let Some(c) = body.color {
|
||||||
|
s.color = (!c.is_empty()).then_some(c);
|
||||||
|
}
|
||||||
|
if let Some(i) = body.icon {
|
||||||
|
s.icon = (!i.is_empty()).then_some(i);
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
}
|
}
|
||||||
@@ -1148,13 +1267,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 +1361,201 @@ async fn reply_permission(Extension(user): Extension<User>, State(app): State<Ap
|
|||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Instructions for an automation drafted from this chat (small model); the client creates the job.
|
||||||
|
async fn job_draft(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
let (name, prompt) = runner::draft_job(&app, &s).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
Ok(Json(json!({"name": name, "prompt": prompt, "cwd": s.cwd, "agent": s.agent})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Claude Code: sign-in from the web UI ---------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ClaudeStatusQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
refresh: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_status(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<ClaudeStatusQuery>) -> Json<Value> {
|
||||||
|
if q.refresh {
|
||||||
|
crate::claude_code::refresh_latest().await;
|
||||||
|
}
|
||||||
|
Json(crate::claude_code::status(&app, &user.id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ClaudeLoginBody {
|
||||||
|
/// Anthropic Console (API billing) instead of the Claude subscription.
|
||||||
|
#[serde(default)]
|
||||||
|
console: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_login_start(Extension(user): Extension<User>, State(app): State<AppState>, body: Option<Json<ClaudeLoginBody>>) -> ApiResult {
|
||||||
|
let console = body.map(|b| b.0.console).unwrap_or(false);
|
||||||
|
let url = crate::claude_code::login_start(&app, &user.id, console).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
app.audit(Some(&user), "claude.login_start", "", "");
|
||||||
|
Ok(Json(json!({"url": url})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ClaudeCodeBody {
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_login_code(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ClaudeCodeBody>) -> ApiResult {
|
||||||
|
crate::claude_code::login_code(&user.id, &b.code).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
|
app.audit(Some(&user), "claude.login", "", "");
|
||||||
|
// New models may have appeared (the CLI is signed in now): clients reload them.
|
||||||
|
Ok(Json(crate::claude_code::status(&app, &user.id).await))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_login_cancel(Extension(user): Extension<User>) -> Json<Value> {
|
||||||
|
crate::claude_code::login_cancel(&user.id);
|
||||||
|
Json(json!({"ok": true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the latest Claude Code CLI on the server (admins: it is shared by all accounts).
|
||||||
|
async fn claude_update(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
require_admin(&user)?;
|
||||||
|
let output = crate::claude_code::update().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
app.audit(Some(&user), "claude.update", "", "");
|
||||||
|
let mut v = crate::claude_code::status(&app, &user.id).await;
|
||||||
|
v["output"] = json!(output);
|
||||||
|
Ok(Json(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claude_logout(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
crate::claude_code::logout(&app, &user.id).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
app.audit(Some(&user), "claude.logout", "", "");
|
||||||
|
Ok(Json(crate::claude_code::status(&app, &user.id).await))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- project page ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OverviewQuery {
|
||||||
|
cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chats of a project folder with what they did (files, checks, cost) plus the folder's settings.
|
||||||
|
async fn project_overview(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<OverviewQuery>) -> ApiResult {
|
||||||
|
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
|
||||||
|
let sessions: Vec<Session> = app.store.list_sessions(filter)?.into_iter().filter(|s| s.cwd == q.cwd).collect();
|
||||||
|
let ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
|
||||||
|
let check = app.store.project_check(&q.cwd)?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"sessions": app.store.project_overview(&ids)?,
|
||||||
|
"check": check.map(|c| json!({"command": c.0, "enabled": c.1})),
|
||||||
|
"defaults": app.store.project_defaults(&q.cwd)?,
|
||||||
|
"currency": app.config().server.currency,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- defaults of new chats per project ---------------------------------------------------------
|
||||||
|
|
||||||
|
async fn get_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = readable(&app, &user, &id)?;
|
||||||
|
Ok(Json(app.store.project_defaults(&s.cwd)?.unwrap_or(Value::Null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember this chat's colour, model, mode, instructions and folder for new chats in the project folder.
|
||||||
|
async fn put_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
let data = json!({
|
||||||
|
"model": s.model, "agent": s.agent, "permission_mode": s.permission_mode,
|
||||||
|
"color": s.color, "icon": s.icon, "instructions": s.instructions, "folder": s.folder,
|
||||||
|
});
|
||||||
|
app.store.set_project_defaults(&s.cwd, Some(&data))?;
|
||||||
|
Ok(Json(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
app.store.set_project_defaults(&s.cwd, None)?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- check command per project ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn get_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = readable(&app, &user, &id)?;
|
||||||
|
let c = app.store.project_check(&s.cwd)?;
|
||||||
|
Ok(Json(json!({"command": c.as_ref().map(|c| c.0.clone()).unwrap_or_default(), "enabled": c.map(|c| c.1).unwrap_or(false)})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CheckBody {
|
||||||
|
command: String,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<CheckBody>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
if b.command.len() > 2000 {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "command too long".into()));
|
||||||
|
}
|
||||||
|
app.store.set_project_check(&s.cwd, &b.command, b.enabled)?;
|
||||||
|
app.audit(Some(&user), "project.check", &s.cwd, &b.command);
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_checks(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
Ok(Json(json!(app.store.turn_checks(&id)?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the check now for the latest answer.
|
||||||
|
async fn run_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
let last = app.store.messages(&id)?.into_iter().rev().find(|m| m.role == crate::types::Role::Assistant).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no answer yet".into()))?;
|
||||||
|
let app2 = app.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = crate::checks::run(&app2, &s, &last.id).await;
|
||||||
|
});
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ExplainBody {
|
||||||
|
lang: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain-words explanation of a pending approval (small model).
|
||||||
|
async fn explain_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<ExplainBody>>) -> ApiResult {
|
||||||
|
check_pending(&app, &user, &id)?;
|
||||||
|
let ev = app.pending_events().into_iter().find(|e| matches!(e, Event::PermissionRequest { id: i, .. } if *i == id)).ok_or_else(|| not_found("request"))?;
|
||||||
|
let Event::PermissionRequest { session_id, tool, title, input, diff, .. } = ev else { return Err(not_found("request")) };
|
||||||
|
let session = app.session(&session_id)?;
|
||||||
|
let lang = body.and_then(|b| b.0.lang).unwrap_or_default();
|
||||||
|
let text = crate::runner::explain_tool_call(&app, &session, &tool, &title, &input, diff.as_deref(), &lang)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||||
|
Ok(Json(json!({"text": text})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is allowed "for this project" (remembered across chats).
|
||||||
|
async fn project_approvals(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = readable(&app, &user, &id)?;
|
||||||
|
Ok(Json(json!(app.store.project_approvals(&s.cwd)?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ApprovalKey {
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_project_approval(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<ApprovalKey>) -> ApiResult {
|
||||||
|
let s = writable(&app, &user, &id)?;
|
||||||
|
app.store.delete_project_approval(&s.cwd, &body.key)?;
|
||||||
|
app.audit(Some(&user), "permission.project_revoked", body.key, "");
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct QuestionBody {
|
struct QuestionBody {
|
||||||
/// question text → answer (string or array of strings); null = dismissed
|
/// question text → answer (string or array of strings); null = dismissed
|
||||||
@@ -1804,6 +2152,9 @@ async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppSt
|
|||||||
folder: None,
|
folder: None,
|
||||||
instructions,
|
instructions,
|
||||||
telegram_quiet: true,
|
telegram_quiet: true,
|
||||||
|
summary: String::new(),
|
||||||
|
color: None,
|
||||||
|
icon: None,
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
st.assistant_session = Some(s.id.clone());
|
st.assistant_session = Some(s.id.clone());
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
+72
-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());
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,10 +455,10 @@ fn opencode_model(m: &Value) -> Value {
|
|||||||
"max_input": m["limit"]["input"],
|
"max_input": m["limit"]["input"],
|
||||||
"vision": images,
|
"vision": images,
|
||||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||||
"input_price": input["price"],
|
"input_price": price_of(&input["price"]),
|
||||||
"output_price": output["price"],
|
"output_price": price_of(&output["price"]),
|
||||||
"cache_read_price": input["cache"]["read"],
|
"cache_read_price": price_of(&input["cache"]["read"]),
|
||||||
"cache_write_price": input["cache"]["write"],
|
"cache_write_price": price_of(&input["cache"]["write"]),
|
||||||
"price_tiers": price_tiers,
|
"price_tiers": price_tiers,
|
||||||
"price_multipliers": price_multipliers,
|
"price_multipliers": price_multipliers,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
@@ -481,6 +524,25 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn prices_per_cache_duration() {
|
||||||
|
use super::{opencode_model, price_of};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
assert_eq!(price_of(&json!(0.5)), json!(0.5));
|
||||||
|
assert_eq!(price_of(&json!({"12h": 0.25, "30m": 0.125})), json!(0.125));
|
||||||
|
assert_eq!(price_of(&json!({"1h": 2.0, "5m": 1.25})), json!(1.25));
|
||||||
|
assert_eq!(price_of(&Value::Null), Value::Null);
|
||||||
|
// A model of opencode's list with per-duration cache write prices.
|
||||||
|
let m = json!({"id": "gpt-6-luna", "limit": {"context": 1050000, "input": 922000, "output": 128000},
|
||||||
|
"cost": {"text": {"input": {"price": 0.1, "cache": {"read": 0.01, "write": {"30m": 0.125, "12h": 0.25}},
|
||||||
|
"overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.2, "cache": {"read": 0.02, "write": {"30m": 0.25, "12h": 0.5}}}]},
|
||||||
|
"output": {"price": 0.5, "overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.75}]}}}});
|
||||||
|
let row = opencode_model(&m);
|
||||||
|
assert_eq!(row["cache_write_price"], json!(0.125));
|
||||||
|
assert_eq!(row["price_tiers"][0]["cache_write_price"], json!(0.25));
|
||||||
|
assert_eq!(row["max_input"], json!(922000));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn opencode_cost_conversion() {
|
fn opencode_cost_conversion() {
|
||||||
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,9 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
||||||
telegram_quiet: true,
|
telegram_quiet: true,
|
||||||
|
summary: String::new(),
|
||||||
|
color: None,
|
||||||
|
icon: None,
|
||||||
};
|
};
|
||||||
let session = app.save_session(session)?;
|
let session = app.save_session(session)?;
|
||||||
app.store.job_started(&job.id, &session.id, now)?;
|
app.store.job_started(&job.id, &session.id, now)?;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-6
@@ -32,6 +32,8 @@ pub struct Agent {
|
|||||||
pub max_parallel: Option<usize>,
|
pub max_parallel: Option<usize>,
|
||||||
/// Review loop after each turn (None/0 rounds = off).
|
/// Review loop after each turn (None/0 rounds = off).
|
||||||
pub refine: Option<crate::config::RefineConfig>,
|
pub refine: Option<crate::config::RefineConfig>,
|
||||||
|
/// binary_info and decompile available (besides craft).
|
||||||
|
pub reverse: bool,
|
||||||
/// Source file of Markdown agents.
|
/// Source file of Markdown agents.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
@@ -43,12 +45,12 @@ pub use codecraft_core::prompts::*;
|
|||||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||||
let craft = craft_prompt(clean_room);
|
let craft = craft_prompt(clean_room);
|
||||||
vec![
|
vec![
|
||||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
source: "file".into(),
|
source: "file".into(),
|
||||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
|
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
|
||||||
|
reverse: meta.get("reverse_tools").is_some_and(|v| v == "true"),
|
||||||
path: Some(p.display().to_string()),
|
path: Some(p.display().to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
source: "config".into(),
|
source: "config".into(),
|
||||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||||
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
||||||
|
reverse: a.reverse_tools.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.reverse)),
|
||||||
path: None,
|
path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -173,6 +177,7 @@ pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) ->
|
|||||||
source: "personal".into(),
|
source: "personal".into(),
|
||||||
max_parallel: None,
|
max_parallel: None,
|
||||||
refine: a.refine,
|
refine: a.refine,
|
||||||
|
reverse: a.reverse_tools.unwrap_or(false),
|
||||||
path: None,
|
path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
+183
-12
@@ -2,7 +2,7 @@
|
|||||||
//! checks), feed results back, repeat until the model stops.
|
//! checks), feed results back, repeat until the model stops.
|
||||||
|
|
||||||
use crate::app::{App, PermissionReply};
|
use crate::app::{App, PermissionReply};
|
||||||
use crate::permission::{Decision, Evaluator, bash_prefix, split_command};
|
use crate::permission::{Decision, Evaluator, bash_prefix, is_dangerous, split_command};
|
||||||
use crate::prompts::{self, Agent};
|
use crate::prompts::{self, Agent};
|
||||||
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
||||||
use crate::store::UsageRecord;
|
use crate::store::UsageRecord;
|
||||||
@@ -73,6 +73,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
|
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
|
||||||
let source = source.to_string();
|
let source = source.to_string();
|
||||||
|
let turn_started = now_ms();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let res = async {
|
let res = async {
|
||||||
@@ -114,6 +115,10 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
|||||||
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
|
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
|
||||||
});
|
});
|
||||||
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
|
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
|
||||||
|
if leftover.is_empty() && !cancel.is_cancelled() {
|
||||||
|
spawn_summary(app.clone(), session_id.clone());
|
||||||
|
crate::checks::spawn_after_turn(app.clone(), session_id.clone(), turn_started);
|
||||||
|
}
|
||||||
if !leftover.is_empty() {
|
if !leftover.is_empty() {
|
||||||
let _ = start_prompt(app, session_id, leftover);
|
let _ = start_prompt(app, session_id, leftover);
|
||||||
}
|
}
|
||||||
@@ -145,7 +150,7 @@ pub fn abort(app: &App, session_id: &str) {
|
|||||||
/// Messages since the last compaction summary. Tool calls left without a
|
/// Messages since the last compaction summary. Tool calls left without a
|
||||||
/// result (run interrupted, e.g. by a server restart) get a synthetic result:
|
/// result (run interrupted, e.g. by a server restart) get a synthetic result:
|
||||||
/// providers reject histories with unanswered tool calls.
|
/// providers reject histories with unanswered tool calls.
|
||||||
fn active_history(all: Vec<Message>) -> Vec<Message> {
|
pub(crate) fn active_history(all: Vec<Message>) -> Vec<Message> {
|
||||||
let start = all
|
let start = all
|
||||||
.iter()
|
.iter()
|
||||||
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
|
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
|
||||||
@@ -215,7 +220,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
|||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let mut all = tools::builtin_tools();
|
let mut all = tools::builtin_tools();
|
||||||
if !cfg.images.enabled {
|
if !cfg.images.enabled {
|
||||||
all.retain(|t| t.name() != "generate_image");
|
all.retain(|t| t.name() != "generate_image" && t.name() != "generate_3d");
|
||||||
}
|
}
|
||||||
let (direct, deferred) = app.mcp.tools(&cfg, owner);
|
let (direct, deferred) = app.mcp.tools(&cfg, owner);
|
||||||
all.extend(direct);
|
all.extend(direct);
|
||||||
@@ -226,7 +231,12 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
|||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|t| !agent.read_only || t.read_only())
|
.filter(|t| !agent.read_only || t.read_only())
|
||||||
// Craft tools only where they are useful.
|
// Craft tools only where they are useful.
|
||||||
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some())
|
.filter(|t| {
|
||||||
|
agent.name == "craft"
|
||||||
|
|| agent.tools.is_some()
|
||||||
|
|| (agent.reverse && matches!(t.name(), "binary_info" | "decompile"))
|
||||||
|
|| !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase")
|
||||||
|
})
|
||||||
.filter(|t| match &agent.tools {
|
.filter(|t| match &agent.tools {
|
||||||
None => true,
|
None => true,
|
||||||
Some(list) => {
|
Some(list) => {
|
||||||
@@ -258,6 +268,10 @@ fn run_session_boxed(
|
|||||||
|
|
||||||
/// Run the loop for a session until the model stops. Returns the final text.
|
/// Run the loop for a session until the model stops. Returns the final text.
|
||||||
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
|
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
|
||||||
|
// Chats on a "claude-code/…" model are run by the Claude Code CLI.
|
||||||
|
if depth == 0 && crate::claude_code::is_claude_code(&app.session(session_id)?.model) {
|
||||||
|
return crate::claude_code::run(app, session_id, cancel).await;
|
||||||
|
}
|
||||||
let mut final_text = String::new();
|
let mut final_text = String::new();
|
||||||
let mut stop_hooks = 0;
|
let mut stop_hooks = 0;
|
||||||
let mut refine_done = 0u32;
|
let mut refine_done = 0u32;
|
||||||
@@ -456,7 +470,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
agent: agent.name.clone(),
|
agent: agent.name.clone(),
|
||||||
roots: roots.clone(),
|
roots: roots.clone(),
|
||||||
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
||||||
clean_room,
|
// Clean room is a craft rule; other modes with reverse tools may analyse binaries.
|
||||||
|
clean_room: clean_room && agent.name == "craft",
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
};
|
};
|
||||||
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
||||||
@@ -633,9 +648,18 @@ async fn execute_call(
|
|||||||
// Permission check. Approvals are tracked on the top-level session.
|
// Permission check. Approvals are tracked on the top-level session.
|
||||||
let root = app.root_session_id(&session.id);
|
let root = app.root_session_id(&session.id);
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let decision = app.with_runtime(&root, |r| {
|
let (mut decision, session_approved) = app.with_runtime(&root, |r| {
|
||||||
Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input)
|
(Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input), r.approved.clone())
|
||||||
});
|
});
|
||||||
|
// What the user allowed "for this project" earlier counts as well.
|
||||||
|
if decision == Decision::Ask {
|
||||||
|
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
|
||||||
|
if !project.is_empty() {
|
||||||
|
let mut all = session_approved;
|
||||||
|
all.extend(project);
|
||||||
|
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(tool.as_ref(), &input);
|
||||||
|
}
|
||||||
|
}
|
||||||
match decision {
|
match decision {
|
||||||
Decision::Deny => {
|
Decision::Deny => {
|
||||||
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
|
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
|
||||||
@@ -653,16 +677,27 @@ async fn execute_call(
|
|||||||
}
|
}
|
||||||
return tool_result(call_id, call_name, text, true, None);
|
return tool_result(call_id, call_name, text, true, None);
|
||||||
}
|
}
|
||||||
PermissionReply::Always => app.with_runtime(&root, |r| {
|
PermissionReply::Always | PermissionReply::Project => {
|
||||||
|
// (key, may be remembered for the whole project)
|
||||||
|
let mut keys: Vec<(String, bool)> = Vec::new();
|
||||||
if name == "bash" {
|
if name == "bash" {
|
||||||
let cmd = input["command"].as_str().unwrap_or("");
|
let cmd = input["command"].as_str().unwrap_or("");
|
||||||
for part in split_command(cmd).unwrap_or_default() {
|
for part in split_command(cmd).unwrap_or_default() {
|
||||||
r.approved.insert(format!("bash:{}", bash_prefix(&part)));
|
// Dangerous commands (rm -r, git push, docker …) are never remembered beyond this chat.
|
||||||
|
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
r.approved.insert(name.to_string());
|
keys.push((name.to_string(), true));
|
||||||
}
|
}
|
||||||
}),
|
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
|
||||||
|
if reply == PermissionReply::Project {
|
||||||
|
for (k, ok) in &keys {
|
||||||
|
if *ok {
|
||||||
|
let _ = app.store.add_project_approval(&session.cwd, k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
PermissionReply::Once => {}
|
PermissionReply::Once => {}
|
||||||
}
|
}
|
||||||
if let Some(sel) = hunks {
|
if let Some(sel) = hunks {
|
||||||
@@ -862,6 +897,9 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
telegram_quiet: false,
|
telegram_quiet: false,
|
||||||
|
summary: String::new(),
|
||||||
|
color: None,
|
||||||
|
icon: None,
|
||||||
};
|
};
|
||||||
let child = app.save_session(child)?;
|
let child = app.save_session(child)?;
|
||||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||||
@@ -1107,7 +1145,8 @@ async fn stream_step(
|
|||||||
let mut message = Message::new(Role::Assistant, parts);
|
let mut message = Message::new(Role::Assistant, parts);
|
||||||
message.id = msg_id;
|
message.id = msg_id;
|
||||||
message.model = Some(model_spec.to_string());
|
message.model = Some(model_spec.to_string());
|
||||||
message.usage = Some(usage.clone());
|
// With its cost (charged, or valued at list prices) for the line under the answer.
|
||||||
|
message.usage = Some(Usage { cost: Some(req.model.cost(&usage)), ..usage.clone() });
|
||||||
if !message.parts.is_empty() {
|
if !message.parts.is_empty() {
|
||||||
app.store.add_message(&session.id, &message)?;
|
app.store.add_message(&session.id, &message)?;
|
||||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||||
@@ -1142,6 +1181,9 @@ fn small_model(app: &App, session: &Session) -> String {
|
|||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
|
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
|
||||||
cfg.server.small_model.clone()
|
cfg.server.small_model.clone()
|
||||||
|
} else if crate::claude_code::is_claude_code(&session.model) {
|
||||||
|
// Claude Code runs itself; side tasks need an ordinary model.
|
||||||
|
app.registry().first_model_excluding(crate::claude_code::PROVIDER).unwrap_or_else(|| session.model.clone())
|
||||||
} else {
|
} else {
|
||||||
session.model.clone()
|
session.model.clone()
|
||||||
}
|
}
|
||||||
@@ -1169,6 +1211,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() {
|
||||||
@@ -1517,6 +1675,19 @@ 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]
|
#[test]
|
||||||
fn todo_completion_and_reopening() {
|
fn todo_completion_and_reopening() {
|
||||||
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||||
|
|||||||
+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,9 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
|
|||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
// Started from Telegram: keeps reporting there after switching to another chat.
|
// Started from Telegram: keeps reporting there after switching to another chat.
|
||||||
telegram_quiet: false,
|
telegram_quiet: false,
|
||||||
|
summary: String::new(),
|
||||||
|
color: None,
|
||||||
|
icon: None,
|
||||||
})?;
|
})?;
|
||||||
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
||||||
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+574
-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, IconPicker } from "./ColorSwatches";
|
||||||
|
import { ProjectPermissions } from "./ProjectPermissions";
|
||||||
|
import { OutboxBar } from "./OutboxBar";
|
||||||
|
import { addToOutbox, isOffline } from "../outbox";
|
||||||
|
import { TurnCheck } from "./TurnCheck";
|
||||||
|
import { TurnChanges } from "./TurnChanges";
|
||||||
|
import { ChatCtx, MessageMenu } from "./MessageMenu";
|
||||||
|
import { MediaGallery } from "./MediaGallery";
|
||||||
|
|
||||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||||
|
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||||
|
|
||||||
const EMPTY: Message[] = [];
|
const EMPTY: Message[] = [];
|
||||||
|
|
||||||
@@ -166,10 +175,378 @@ function RefineFeedback({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Search the whole chat on the server; a hit loads its part of the chat. */
|
||||||
|
function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>();
|
||||||
|
useEffect(() => {
|
||||||
|
const query = q.trim();
|
||||||
|
if (query.length < 2) return setHits(undefined);
|
||||||
|
let alive = true;
|
||||||
|
const h = setTimeout(() => {
|
||||||
|
get<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
|
||||||
|
.then((r) => alive && setHits(r ?? []))
|
||||||
|
.catch(() => alive && setHits([]));
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearTimeout(h);
|
||||||
|
};
|
||||||
|
}, [q, sessionId]);
|
||||||
|
return (
|
||||||
|
<div className="chat-search" role="search">
|
||||||
|
<div className="row">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
|
||||||
|
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{hits && (
|
||||||
|
<div className="chat-search-hits">
|
||||||
|
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
|
||||||
|
{hits.map((h) => (
|
||||||
|
<button key={h.id} onClick={() => onPick(h.id)}>
|
||||||
|
<span className="who">
|
||||||
|
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||||
|
</span>
|
||||||
|
<span className="snippet">{h.snippet}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A part of the chat: a user message, a summary, or the work of the agent after it. */
|
||||||
|
type Block = { kind: "user" | "summary" | "work"; messages: Message[] };
|
||||||
|
|
||||||
|
const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image");
|
||||||
|
|
||||||
|
/** Group the messages into user messages and the agent's work between them. */
|
||||||
|
function blocksOf(messages: Message[]): Block[] {
|
||||||
|
const out: Block[] = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work";
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (kind === "work" && last?.kind === "work") last.messages.push(m);
|
||||||
|
else out.push({ kind, messages: [m] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The messages of a chat. Memoised: typing, streaming and status changes of the
|
||||||
|
* chat view do not touch it – only new messages, tool results or the busy state.
|
||||||
|
*/
|
||||||
|
const MessageList = memo(function MessageList({
|
||||||
|
messages,
|
||||||
|
results,
|
||||||
|
busy,
|
||||||
|
canEdit,
|
||||||
|
canRegenerate,
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
baseDir,
|
||||||
|
onRewind,
|
||||||
|
highlight,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
messages: Message[];
|
||||||
|
currency: string;
|
||||||
|
results: Map<string, ToolResult>;
|
||||||
|
busy: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canRegenerate: boolean;
|
||||||
|
sessionId: string;
|
||||||
|
model: string;
|
||||||
|
baseDir: string;
|
||||||
|
onRewind: (m: Message) => void;
|
||||||
|
highlight?: string;
|
||||||
|
}) {
|
||||||
|
const blocks = useMemo(() => blocksOf(messages), [messages]);
|
||||||
|
const lastWork = blocks.map((b) => b.kind).lastIndexOf("work");
|
||||||
|
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blocks.map((b, i) => {
|
||||||
|
// Stable while older messages are loaded above (they may join the first block):
|
||||||
|
// the last message; the block still growing at the end keeps its first one.
|
||||||
|
const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id;
|
||||||
|
if (b.kind !== "work") return <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
|
||||||
|
const items = b.messages.map((m) => (
|
||||||
|
<AssistantOrNote
|
||||||
|
key={m.id}
|
||||||
|
m={m}
|
||||||
|
results={results}
|
||||||
|
busy={busy}
|
||||||
|
baseDir={baseDir}
|
||||||
|
highlight={highlight}
|
||||||
|
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
// Finished turns with tool steps fold into one line (the answer stays visible).
|
||||||
|
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
||||||
|
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
|
||||||
|
// Files the agent wrote or edited in this turn: diffs and "roll back" (not while it is still working).
|
||||||
|
const changed = new Set(steps.filter((c) => (c.name === "write" || c.name === "edit") && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||||||
|
const lastAnswer = [...b.messages].reverse().find((m) => m.role === "assistant");
|
||||||
|
const checkBadge = lastAnswer && !(busy && i === lastWork) && <TurnCheck sessionId={sessionId} messageId={lastAnswer.id} canSend={canEdit} />;
|
||||||
|
const prev = blocks[i - 1];
|
||||||
|
const changes = changed.size > 0 && !(busy && i === lastWork) && (
|
||||||
|
<TurnChanges
|
||||||
|
sessionId={sessionId}
|
||||||
|
fromId={b.messages[0].id}
|
||||||
|
untilId={blocks[i + 1]?.messages[0].id}
|
||||||
|
fileCount={changed.size}
|
||||||
|
userMessage={canEdit && prev?.kind === "user" ? prev.messages[0] : undefined}
|
||||||
|
onRewind={onRewind}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (i === lastWork || steps.length < 2)
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
{items}
|
||||||
|
{usage}
|
||||||
|
{checkBadge}
|
||||||
|
{changes}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<WorkBlock messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>
|
||||||
|
{items}
|
||||||
|
</WorkBlock>
|
||||||
|
{usage}
|
||||||
|
{checkBadge}
|
||||||
|
{changes}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Tokens (in/out) and cost of one answer: all requests of the turn together. */
|
||||||
|
function TurnUsage({ messages, currency }: { messages: Message[]; currency: string }) {
|
||||||
|
const t = useT();
|
||||||
|
let inp = 0;
|
||||||
|
let out = 0;
|
||||||
|
let cost = 0;
|
||||||
|
let requests = 0;
|
||||||
|
for (const m of messages) {
|
||||||
|
const u = m.role === "assistant" ? m.usage : undefined;
|
||||||
|
if (!u) continue;
|
||||||
|
requests++;
|
||||||
|
inp += u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||||||
|
out += u.output_tokens;
|
||||||
|
cost += u.cost ?? 0;
|
||||||
|
}
|
||||||
|
if (!requests) return null;
|
||||||
|
return (
|
||||||
|
<div className="turn-usage" title={t("chat.turnUsageHint", { n: requests })}>
|
||||||
|
{fmtCompact(inp)}
|
||||||
|
<Icon name="arrow-up" className="tiny" /> {fmtCompact(out)}
|
||||||
|
<Icon name="arrow-down" className="tiny" /> · {fmtCost(cost, currency)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
|
||||||
|
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
||||||
|
const t = useT();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||||||
|
// The answer: text of the last assistant message of the turn.
|
||||||
|
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim()));
|
||||||
|
const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : "";
|
||||||
|
if (open || forced) {
|
||||||
|
return (
|
||||||
|
<div className="work-block open">
|
||||||
|
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
|
||||||
|
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||||
|
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`msg work-block ${answerText ? "bubble agent" : ""}`} data-mid={answer?.id}>
|
||||||
|
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
|
||||||
|
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||||
|
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||||
|
</button>
|
||||||
|
{answerText && (
|
||||||
|
<>
|
||||||
|
<Markdown text={answerText} baseDir={baseDir} />
|
||||||
|
<ImageRefs text={answerText} baseDir={baseDir} />
|
||||||
|
{answer && <MsgFoot ms={answer.created_at} menu={<MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Foot of a bubble: time on the left, the ⋯ menu inside the bubble on the right. */
|
||||||
|
function MsgFoot({ ms, menu }: { ms: number; menu: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="msg-foot">
|
||||||
|
<MsgTime ms={ms} />
|
||||||
|
{menu}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text parts of a message. */
|
||||||
|
const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n");
|
||||||
|
|
||||||
|
/** Small date/time line under a message. */
|
||||||
|
function MsgTime({ ms }: { ms: number }) {
|
||||||
|
if (!ms) return null;
|
||||||
|
return (
|
||||||
|
<time className="msg-time" dateTime={new Date(ms).toISOString()} title={new Date(ms).toLocaleString()}>
|
||||||
|
{fmtMsgTime(ms)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An assistant message, or a user message that only carries tool results / harness notes. */
|
||||||
|
const AssistantOrNote = memo(function AssistantOrNote({
|
||||||
|
m,
|
||||||
|
results,
|
||||||
|
busy,
|
||||||
|
baseDir,
|
||||||
|
highlight,
|
||||||
|
regenerate,
|
||||||
|
}: {
|
||||||
|
m: Message;
|
||||||
|
results: Map<string, ToolResult>;
|
||||||
|
busy: boolean;
|
||||||
|
baseDir: string;
|
||||||
|
highlight?: string;
|
||||||
|
regenerate?: { sessionId: string; model: string };
|
||||||
|
}) {
|
||||||
|
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
||||||
|
if (m.role === "assistant") {
|
||||||
|
return (
|
||||||
|
<div className={`${cls} ${textOfMessage(m).trim() ? "bubble agent" : ""}`} data-mid={m.id}>
|
||||||
|
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||||||
|
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />} />
|
||||||
|
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text));
|
||||||
|
if (!notes.length) return null;
|
||||||
|
return (
|
||||||
|
<div className={cls} data-mid={m.id}>
|
||||||
|
{notes.map((p, i) => (
|
||||||
|
<HarnessMessage key={i} text={p.text} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A user message or a summary of the history before it. */
|
||||||
|
const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight }: { m: Message; canEdit: boolean; onRewind: (m: Message) => void; highlight?: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||||
|
const summary = m.parts.find((p) => p.type === "summary");
|
||||||
|
if (summary && summary.type === "summary") {
|
||||||
|
return (
|
||||||
|
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
||||||
|
<summary>
|
||||||
|
{summary.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 user-msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
|
||||||
|
{background.map((p, i) => (
|
||||||
|
<HarnessMessage key={i} text={p.text} />
|
||||||
|
))}
|
||||||
|
<div className="msg-user">
|
||||||
|
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||||
|
{imgs.length > 0 && (
|
||||||
|
<div className="user-files">
|
||||||
|
{imgs.map((p, i) => (
|
||||||
|
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||||
|
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||||
|
</FileFigure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Older messages: loaded automatically when scrolled to the top; the history before a
|
||||||
|
* summary only on request. Keeps the scroll position while the list grows upwards.
|
||||||
|
*/
|
||||||
|
function OlderLoader({ sessionId, afterSummary, scrollRef }: { sessionId: string; afterSummary?: boolean; scrollRef: RefObject<HTMLDivElement | null> }) {
|
||||||
|
const t = useT();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const sentinel = useRef<HTMLDivElement>(null);
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (loading || !el) return;
|
||||||
|
setLoading(true);
|
||||||
|
const before = { height: el.scrollHeight, top: el.scrollTop };
|
||||||
|
try {
|
||||||
|
// Scrolling stops at a summary; its button loads the history before it.
|
||||||
|
await loadOlder(sessionId, !afterSummary);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollTop = before.top + (el.scrollHeight - before.height);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loading, sessionId, scrollRef, afterSummary]);
|
||||||
|
useEffect(() => {
|
||||||
|
const node = sentinel.current;
|
||||||
|
if (!node || afterSummary || typeof IntersectionObserver === "undefined") return;
|
||||||
|
const io = new IntersectionObserver((e) => e[0]?.isIntersecting && void load(), { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" });
|
||||||
|
io.observe(node);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [afterSummary, load, scrollRef]);
|
||||||
|
return (
|
||||||
|
<div ref={sentinel} className="older-loader">
|
||||||
|
{loading ? (
|
||||||
|
<Spinner small />
|
||||||
|
) : (
|
||||||
|
<button className="small ghost" onClick={() => void load()}>
|
||||||
|
<Icon name="arrow-up" /> {t(afterSummary ? "chat.showBeforeSummary" : "chat.loadOlder")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
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]);
|
||||||
@@ -184,6 +561,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) ?? "");
|
||||||
@@ -205,11 +584,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);
|
||||||
@@ -226,11 +619,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(() => {
|
||||||
@@ -276,13 +694,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);
|
||||||
@@ -290,6 +714,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}`);
|
||||||
@@ -312,8 +776,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>
|
||||||
@@ -324,6 +788,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<Icon name="undo" />
|
<Icon name="undo" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{session.icon && <Icon name={session.icon} className="chat-icon" />}
|
||||||
{session.title || t("session.untitled")}
|
{session.title || t("session.untitled")}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
|
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
|
||||||
@@ -360,7 +825,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>
|
||||||
)}
|
)}
|
||||||
@@ -371,11 +836,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>
|
||||||
)}
|
)}
|
||||||
@@ -384,6 +852,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) => {
|
||||||
@@ -394,6 +884,33 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
>
|
>
|
||||||
<Icon name="edit" /> {t("instructions.title")}
|
<Icon name="edit" /> {t("instructions.title")}
|
||||||
</a>
|
</a>
|
||||||
|
<ColorSwatches value={session.color} onChange={(color) => void update({ color })} />
|
||||||
|
<IconPicker value={session.icon} color={session.color} onChange={(icon) => void update({ icon })} />
|
||||||
|
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}>
|
||||||
|
<Icon name="clock" /> {t("job.plan")}
|
||||||
|
</a>
|
||||||
|
<a href="#" title={t("projectDefaults.hint")} onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(false))}>
|
||||||
|
<Icon name="template" /> {t("projectDefaults.save")}
|
||||||
|
</a>
|
||||||
|
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(true))}>
|
||||||
|
<Icon name="undo" /> {t("projectDefaults.remove")}
|
||||||
|
</a>
|
||||||
|
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void editCheck())}>
|
||||||
|
<Icon name="check" /> {t("check.menu")}
|
||||||
|
</a>
|
||||||
|
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), setShowApprovals(true))}>
|
||||||
|
<Icon name="lock" /> {t("permProject.menu")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
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="#"
|
||||||
@@ -433,10 +950,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>
|
||||||
</>
|
</>
|
||||||
@@ -447,9 +964,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
|
||||||
@@ -464,6 +992,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);
|
||||||
@@ -472,73 +1001,27 @@ 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) => isHarnessNote(p.text));
|
|
||||||
const texts = allTexts.filter((p) => !isHarnessNote(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) => (
|
|
||||||
<HarnessMessage key={i} text={p.text} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={m.id} className="msg">
|
|
||||||
{background.map((p, i) => (
|
|
||||||
<HarnessMessage 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 ${draft.parts.some((p) => p.type === "text" && p.text.trim()) ? "bubble agent" : ""}`}>
|
||||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -589,18 +1072,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>
|
||||||
@@ -616,7 +1104,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</ChatCtx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useT, type Key } from "../i18n";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
/** Colour tags of chats: names, drawn in the colours of the active theme (see `[data-color]` in styles.css). */
|
||||||
|
export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"] as const;
|
||||||
|
|
||||||
|
/** Icons a chat can get (must match SESSION_ICONS on the server). */
|
||||||
|
export const SESSION_ICONS = [
|
||||||
|
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
|
||||||
|
"users", "toolbox", "plug", "scroll", "eye", "template",
|
||||||
|
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Row of colour dots; the selected one is ringed, a second click removes the tag. */
|
||||||
|
export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<div className="color-swatches" role="radiogroup" aria-label={t("color.label")}>
|
||||||
|
{SESSION_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={value === c}
|
||||||
|
className={`swatch ${value === c ? "on" : ""}`}
|
||||||
|
data-color={c}
|
||||||
|
title={t(`color.${c}` as Key)}
|
||||||
|
aria-label={t(`color.${c}` as Key)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(value === c ? "" : c);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grid of icons for a chat (in the colour of the chat); a second click removes the icon. */
|
||||||
|
export function IconPicker({ value, color, onChange }: { value?: string; color?: string; onChange: (icon: string) => void }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<div className="icon-picker" role="radiogroup" aria-label={t("icon.label")} data-color={color}>
|
||||||
|
{SESSION_ICONS.map((i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={value === i}
|
||||||
|
className={`icon-choice ${value === i ? "on" : ""}`}
|
||||||
|
title={i}
|
||||||
|
aria-label={i}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(value === i ? "" : i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={i} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,98 @@ const P: Record<string, ReactNode> = {
|
|||||||
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
|
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
|
||||||
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
|
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
|
||||||
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
|
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
|
||||||
|
gamepad: (
|
||||||
|
<>
|
||||||
|
<path d="M7 8h10a4 4 0 0 1 4 4.2l-.6 3.8a2.4 2.4 0 0 1-4.1 1.3L14 15h-4l-2.3 2.3a2.4 2.4 0 0 1-4.1-1.3L3 12.2A4 4 0 0 1 7 8Z" />
|
||||||
|
<path d="M8 10.5v3M6.5 12h3M15.5 11h.01M17.5 13h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
computer: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||||
|
<path d="M8 20h8M12 16v4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
food: (
|
||||||
|
<>
|
||||||
|
<path d="M5 3v6a2 2 0 0 0 4 0V3M7 11v10" />
|
||||||
|
<path d="M17 21V3c-2.6 1.4-3.5 4.2-3.5 7.5H17" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
home: (
|
||||||
|
<>
|
||||||
|
<path d="M4 11.5 12 4l8 7.5V20H4Z" />
|
||||||
|
<path d="M10 20v-5h4v5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
book: (
|
||||||
|
<>
|
||||||
|
<path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3Z" />
|
||||||
|
<path d="M5 17a3 3 0 0 1 3-3h11" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
code: <path d="m8 7-5 5 5 5M16 7l5 5-5 5M14 5l-4 14" />,
|
||||||
|
camera: (
|
||||||
|
<>
|
||||||
|
<path d="M4 8h3l1.5-2h7L17 8h3v11H4Z" />
|
||||||
|
<circle cx="12" cy="13" r="3.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
cart: (
|
||||||
|
<>
|
||||||
|
<path d="M3 4h2.5l2 11h10l2-8H7" />
|
||||||
|
<circle cx="9" cy="19" r="1.4" />
|
||||||
|
<circle cx="17" cy="19" r="1.4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
heart: <path d="M12 20s-8-4.6-8-10.5A4.5 4.5 0 0 1 12 7a4.5 4.5 0 0 1 8 2.5C20 15.4 12 20 12 20Z" />,
|
||||||
|
star: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9L12 16.9l-5.2 2.8 1-5.9-4.3-4.1 5.9-.8Z" />,
|
||||||
|
globe: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M3.5 12h17M12 3.5c3 3 3 14 0 17M12 3.5c-3 3-3 14 0 17" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
calendar: (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="5" width="16" height="15" rx="2" />
|
||||||
|
<path d="M4 10h16M8 3v4M16 3v4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
coffee: (
|
||||||
|
<>
|
||||||
|
<path d="M5 9h11v5a5 5 0 0 1-5 5H10a5 5 0 0 1-5-5Z" />
|
||||||
|
<path d="M16 10h1.5a2.5 2.5 0 0 1 0 5H16M8 3v3M12 3v3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
car: (
|
||||||
|
<>
|
||||||
|
<path d="M3.5 16v-4l2-5h13l2 5v4Z" />
|
||||||
|
<path d="M3.5 12h17" />
|
||||||
|
<circle cx="7.5" cy="16" r="1.5" />
|
||||||
|
<circle cx="16.5" cy="16" r="1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
plane: (
|
||||||
|
<>
|
||||||
|
<path d="M21 3 3 10.5l6.5 2.5L12 20Z" />
|
||||||
|
<path d="M9.5 13 21 3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
bulb: <path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.3 1 2.1h5c0-.8.4-1.6 1-2.1A6 6 0 0 0 12 3Z" />,
|
||||||
|
money: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 1.9 0 2.7 5 1.2 5 4 0 1.1-1 2-2.5 2-1.1 0-2.1-.5-2.6-1.4M12 6.5V8M12 16v1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
palette: (
|
||||||
|
<>
|
||||||
|
<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.3 0 1.8-1 1.3-2-.6-1.2.2-2.5 1.6-2.5H17a3.5 3.5 0 0 0 3.5-3.5C20.5 7 16.8 3.5 12 3.5Z" />
|
||||||
|
<path d="M7.5 11h.01M9.5 7.5h.01M14 7.5h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
reply: <path d="M9 7 4 12l5 5M4 12h9a7 7 0 0 1 7 7" />,
|
||||||
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
|
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
|
||||||
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
|
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
|
||||||
clip: <path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />,
|
clip: <path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />,
|
||||||
@@ -125,6 +217,12 @@ const P: Record<string, ReactNode> = {
|
|||||||
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
|
<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,131 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useFetch } from "../hooks";
|
||||||
|
import { fmtCost, fmtDate, fmtRelative, useT, type Key } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import type { Session } from "../types";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { basename } from "./FileFigure";
|
||||||
|
|
||||||
|
interface Overview {
|
||||||
|
sessions: Record<string, { files: number; check: string | null; checks_failed: number; cost: number }>;
|
||||||
|
check: { command: string; enabled: boolean } | null;
|
||||||
|
defaults: { model?: string; agent?: string; permission_mode?: string; color?: string; instructions?: string; folder?: string } | null;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = (ms: number) => new Date(ms).toDateString();
|
||||||
|
|
||||||
|
/** One project folder: its chats as a timeline (newest first) with what each one did. */
|
||||||
|
export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const all = useStore((s) => s.sessions);
|
||||||
|
const { data } = useFetch<Overview>(`/projects/overview?cwd=${encodeURIComponent(cwd)}`);
|
||||||
|
const sessions = useMemo(() => all.filter((s) => s.cwd === cwd && !s.archived).sort((a, b) => b.updated_at - a.updated_at), [all, cwd]);
|
||||||
|
const archived = all.filter((s) => s.cwd === cwd && s.archived).length;
|
||||||
|
|
||||||
|
// Day groups: "Today", "Yesterday", date.
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const out: { label: string; items: Session[] }[] = [];
|
||||||
|
const today = day(Date.now());
|
||||||
|
const yesterday = day(Date.now() - 86_400_000);
|
||||||
|
for (const s of sessions) {
|
||||||
|
const d = day(s.updated_at);
|
||||||
|
const label = d === today ? t("project.today") : d === yesterday ? t("project.yesterday") : fmtDate(s.updated_at, false);
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (last?.label === label) last.items.push(s);
|
||||||
|
else out.push({ label, items: [s] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [sessions, t]);
|
||||||
|
|
||||||
|
const totalCost = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.cost ?? 0), 0);
|
||||||
|
const totalFiles = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.files ?? 0), 0);
|
||||||
|
|
||||||
|
const newChat = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("codecraft.lastDir", cwd);
|
||||||
|
} catch {
|
||||||
|
// Without storage the wizard starts at its usual folder.
|
||||||
|
}
|
||||||
|
navigate("/new");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
|
<Icon name="menu" />
|
||||||
|
</button>
|
||||||
|
<div className="grow">
|
||||||
|
<h1>
|
||||||
|
<Icon name="folder" /> {basename(cwd) || "/"}
|
||||||
|
</h1>
|
||||||
|
<div className="path mono">{cwd}</div>
|
||||||
|
</div>
|
||||||
|
<button className="small primary" onClick={newChat}>
|
||||||
|
<Icon name="plus" /> {t("project.newChat")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="chat-scroll">
|
||||||
|
<div className="chat project-page">
|
||||||
|
<div className="project-summary">
|
||||||
|
<span className="chip">{t("project.chats", { n: sessions.length })}</span>
|
||||||
|
{totalFiles > 0 && <span className="chip">{t("project.files", { n: totalFiles })}</span>}
|
||||||
|
{data && totalCost > 0 && <span className="chip">{fmtCost(totalCost, data.currency)}</span>}
|
||||||
|
{data?.check?.enabled && (
|
||||||
|
<span className="chip mono" title={t("check.title")}>
|
||||||
|
<Icon name="check" className="tiny" /> {data.check.command}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data?.defaults?.model && (
|
||||||
|
<span className="chip" title={t("projectDefaults.hint")}>
|
||||||
|
<Icon name="template" className="tiny" /> {data.defaults.model.split("/").pop()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{archived > 0 && <span className="hint">{t("project.archived", { n: archived })}</span>}
|
||||||
|
</div>
|
||||||
|
{sessions.length === 0 && <div className="empty">{t("project.empty")}</div>}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<section key={g.label} className="timeline-day">
|
||||||
|
<h3>{g.label}</h3>
|
||||||
|
<ol className="timeline">
|
||||||
|
{g.items.map((s) => {
|
||||||
|
const info = data?.sessions[s.id];
|
||||||
|
return (
|
||||||
|
<li key={s.id} data-color={s.color} className={s.color ? "tagged" : ""}>
|
||||||
|
<a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}>
|
||||||
|
<div className="tl-title">
|
||||||
|
<span className={s.color ? "color-badge" : undefined}>
|
||||||
|
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
|
||||||
|
{s.title || t("session.untitled")}
|
||||||
|
</span>
|
||||||
|
<span className="tl-when" title={fmtDate(s.updated_at)}>
|
||||||
|
{fmtRelative(s.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.summary && <div className="tl-summary">{s.summary}</div>}
|
||||||
|
<div className="tl-meta">
|
||||||
|
<span>{s.agent}</span>
|
||||||
|
<span>{s.model.split("/").pop()}</span>
|
||||||
|
{!!info?.files && <span>{t("project.files", { n: info.files })}</span>}
|
||||||
|
{info?.check && (
|
||||||
|
<span className={`check-${info.check}`}>
|
||||||
|
<Icon name={info.check === "passed" ? "check" : info.check === "running" ? "clock" : "x-mark"} className="tiny" /> {t(`check.${info.check}` as Key)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!info?.checks_failed && <span className="check-failed">{t("project.checksFailed", { n: info.checks_failed })}</span>}
|
||||||
|
{!!info?.cost && data && <span>{fmtCost(info.cost, data.currency)}</span>}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ColorSwatches, IconPicker } 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; icon?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
|
||||||
|
|
||||||
/** One session in the list, with a menu to pin, file or archive it. */
|
/** One session in the list, with a menu to pin, file or archive it. */
|
||||||
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
|
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
|
||||||
@@ -53,11 +54,15 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className={`session-row ${menu ? "menu-open" : ""}`}>
|
<div className={`session-row ${menu ? "menu-open" : ""}`}>
|
||||||
<button className={`session-item ${active ? "active" : ""}`} onClick={() => navigate(`/s/${s.id}`)}>
|
<button className={`session-item ${active ? "active" : ""}`} data-color={s.color} onClick={() => navigate(`/s/${s.id}`)}>
|
||||||
<div className="title">
|
<div className="title">
|
||||||
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
|
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
|
||||||
<span>{s.title || t("session.untitled")}</span>
|
<span className={s.color ? "color-badge" : undefined}>
|
||||||
|
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
|
||||||
|
{s.title || t("session.untitled")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
|
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
|
||||||
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||||||
@@ -93,6 +98,8 @@ 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 }))} />
|
||||||
|
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
|
||||||
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
<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 +201,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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
|||||||
refine_model: raw?.refine?.model ?? "",
|
refine_model: raw?.refine?.model ?? "",
|
||||||
refine_criteria: raw?.refine?.criteria ?? "",
|
refine_criteria: raw?.refine?.criteria ?? "",
|
||||||
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
||||||
|
reverse_tools: !!(raw?.reverse_tools ?? a.reverse),
|
||||||
});
|
});
|
||||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
|||||||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
|
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (a.name !== "craft") patches.push({ path: [...base, "reverse_tools"], value: form.reverse_tools || null });
|
||||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||||
if (!builtin) {
|
if (!builtin) {
|
||||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||||
@@ -166,6 +168,15 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
|||||||
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
|
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{a.name !== "craft" && (
|
||||||
|
<>
|
||||||
|
<label className="check">
|
||||||
|
<input type="checkbox" checked={form.reverse_tools} onChange={(e) => setForm({ ...form, reverse_tools: e.target.checked })} />
|
||||||
|
{t("agents.reverseTools")}
|
||||||
|
</label>
|
||||||
|
<div className="hint">{t("agents.reverseToolsHint")}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<label>{t("agents.description")}</label>
|
<label>{t("agents.description")}</label>
|
||||||
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
|
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
+140
-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",
|
||||||
@@ -336,8 +336,133 @@ export const de: Messages = {
|
|||||||
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
||||||
"chat.reviewPassed": "Review bestanden:",
|
"chat.reviewPassed": "Review bestanden:",
|
||||||
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
||||||
|
"agents.reverseTools": "Reverse-Engineering-Werkzeuge (binary_info, decompile mit Ghidra)",
|
||||||
|
"agents.reverseToolsHint": "Der Agent kann in diesem Modus Programme und Firmware untersuchen und dekompilieren (Ghidra muss unter Einstellungen → Werkzeuge installiert sein). Craft hat sie im Deep-Modus ohnehin.",
|
||||||
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
||||||
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
||||||
|
"chat.workSteps": "{n} Schritte",
|
||||||
|
"chat.workFiles": "{n} Dateien",
|
||||||
|
"chat.loadOlder": "Ältere Nachrichten laden",
|
||||||
|
"chat.showBeforeSummary": "Verlauf vor der Zusammenfassung anzeigen",
|
||||||
|
"chat.backToLatest": "Zu den neuesten Nachrichten",
|
||||||
|
"chat.search": "Im Chat suchen",
|
||||||
|
"chat.searchPlaceholder": "Nachrichten, Tool-Ausgaben … durchsuchen",
|
||||||
|
"chat.searchNone": "Keine Treffer",
|
||||||
|
"chat.searchHits": "{n} Treffer",
|
||||||
|
"chat.you": "Du",
|
||||||
|
"chat.agent": "Agent",
|
||||||
|
"media.title": "Medien",
|
||||||
|
"media.none": "Noch keine Bilder, Videos oder Audiodateien in diesem Chat.",
|
||||||
|
"media.filter.all": "Alle",
|
||||||
|
"media.filter.image": "Bilder",
|
||||||
|
"media.filter.video": "Videos",
|
||||||
|
"media.filter.model": "3D",
|
||||||
|
"media.filter.audio": "Audio",
|
||||||
|
"media.source.generated": "generiert",
|
||||||
|
"media.source.tool": "Tool-Ausgabe",
|
||||||
|
"media.source.written": "vom Agenten geschrieben",
|
||||||
|
"media.source.answer": "in einer Antwort",
|
||||||
|
"media.source.upload": "dein Anhang",
|
||||||
|
"media.showInChat": "Im Chat zeigen",
|
||||||
|
"media.open": "Öffnen",
|
||||||
|
"media.prev": "Vorheriges",
|
||||||
|
"msg.actions": "Nachrichtenaktionen",
|
||||||
|
"msg.copy": "Text kopieren",
|
||||||
|
"msg.copied": "Kopiert",
|
||||||
|
"msg.copyFailed": "Kopieren ist hier nicht möglich",
|
||||||
|
"msg.quote": "In Antwort zitieren",
|
||||||
|
"msg.forkHere": "Ab hier abzweigen",
|
||||||
|
"chat.jumpPrevOwn": "Vorherige eigene Nachricht",
|
||||||
|
"viewer.annotate": "Markieren",
|
||||||
|
"viewer.annotateFailed": "Dieses Bild lässt sich nicht bearbeiten",
|
||||||
|
"viewer.attach": "An Nachricht anhängen",
|
||||||
|
"viewer.undo": "Rückgängig",
|
||||||
|
"viewer.tool.pen": "Stift",
|
||||||
|
"viewer.tool.arrow": "Pfeil",
|
||||||
|
"viewer.tool.rect": "Rechteck",
|
||||||
|
"viewer.useInChat": "In Nachricht verwenden",
|
||||||
|
"viewer.attached": "Bild an die Nachricht angehängt",
|
||||||
|
"turn.changes": "{n} Datei(en) geändert",
|
||||||
|
"turn.noChanges": "Für diesen Schritt wurden keine Dateiänderungen gefunden.",
|
||||||
|
"turn.noDiff": "Kein Text-Diff verfügbar.",
|
||||||
|
"turn.truncated": "Der Diff ist gekürzt.",
|
||||||
|
"turn.new": "neu",
|
||||||
|
"turn.deleted": "gelöscht",
|
||||||
|
"turn.rollback": "Auf den Stand vor diesem Schritt zurücksetzen",
|
||||||
|
"job.plan": "Als Automatisierung planen",
|
||||||
|
"job.planHint": "Macht aus diesem Chat Anweisungen für eine wiederkehrende Automatisierung (aus, bis du den Zeitplan setzt)",
|
||||||
|
"job.drafting": "Automatisierung wird aus diesem Chat entworfen …",
|
||||||
|
"color.label": "Farbe dieses Chats",
|
||||||
|
"color.red": "Rot",
|
||||||
|
"color.orange": "Orange",
|
||||||
|
"color.yellow": "Gelb",
|
||||||
|
"color.green": "Grün",
|
||||||
|
"color.aqua": "Türkis",
|
||||||
|
"color.blue": "Blau",
|
||||||
|
"color.purple": "Violett",
|
||||||
|
"color.pink": "Pink",
|
||||||
|
"icon.label": "Symbol dieses Chats",
|
||||||
|
"claude.title": "Claude Code",
|
||||||
|
"claude.intro": "Chats mit einem „Claude Code“-Modell führt die Claude-Code-CLI auf dem Server aus – mit deinem Claude-Abo oder Anthropic-Console-Konto. Einmal hier anmelden; Freigaben, Push und Sandbox funktionieren wie gewohnt.",
|
||||||
|
"claude.notInstalled": "Claude Code ist auf dem Server nicht installiert.",
|
||||||
|
"claude.console": "Anthropic Console (API-Abrechnung) statt Claude-Abo",
|
||||||
|
"claude.signIn": "Anmelden",
|
||||||
|
"claude.openLogin": "Anmeldeseite öffnen",
|
||||||
|
"claude.step1": "Bei Anthropic in einem beliebigen Browser (auch auf einem anderen Gerät) anmelden und den Zugriff erlauben.",
|
||||||
|
"claude.step2": "Den Code einfügen, den die Seite danach anzeigt:",
|
||||||
|
"claude.codePlaceholder": "Code von der Anmeldeseite",
|
||||||
|
"claude.confirm": "Bestätigen",
|
||||||
|
"claude.signedIn": "Bei Claude Code angemeldet",
|
||||||
|
"claude.signedInAs": "Angemeldet: {who}",
|
||||||
|
"claude.expires": "gültig bis {date}",
|
||||||
|
"claude.logout": "Abmelden",
|
||||||
|
"claude.logoutConfirm": "Auf dem Server von Claude Code abmelden?",
|
||||||
|
"claude.version": "CLI {version}",
|
||||||
|
"claude.updateAvailable": "Update verfügbar: {version}",
|
||||||
|
"claude.upToDate": "aktuell",
|
||||||
|
"claude.update": "Update installieren",
|
||||||
|
"claude.updating": "Installiere …",
|
||||||
|
"claude.updated": "Claude Code auf {version} aktualisiert",
|
||||||
|
"claude.updateFailed": "Das Update ist fehlgeschlagen (Details unten)",
|
||||||
|
"claude.updateAdmin": "Ein Administrator kann es installieren.",
|
||||||
|
"claude.checkNow": "Nach Updates suchen",
|
||||||
|
"claude.updateLog": "Ausgabe des Updates",
|
||||||
|
"check.menu": "Prüfung nach jedem Schritt …",
|
||||||
|
"check.title": "Prüfung nach jedem Schritt",
|
||||||
|
"check.hint": "Befehl, der in {project} nach jedem Schritt mit Dateiänderungen läuft (z. B. cargo test, npm test). Das Ergebnis erscheint als Badge am Schritt. Leer = aus.",
|
||||||
|
"check.saved": "Prüfung gespeichert",
|
||||||
|
"check.removed": "Prüfung entfernt",
|
||||||
|
"check.running": "Prüfung läuft …",
|
||||||
|
"check.passed": "Prüfung bestanden",
|
||||||
|
"check.failed": "Prüfung fehlgeschlagen",
|
||||||
|
"check.timeout": "Prüfung: Zeitüberschreitung",
|
||||||
|
"check.noOutput": "(keine Ausgabe)",
|
||||||
|
"check.fix": "Vom Agenten beheben lassen",
|
||||||
|
"check.fixPrompt": "Die Prüfung des Projekts ist nach deiner letzten Änderung fehlgeschlagen. Bitte beheben und erneut ausführen. Ausgabe:",
|
||||||
|
"projectDefaults.save": "Als Projekt-Standard verwenden",
|
||||||
|
"projectDefaults.remove": "Projekt-Standard entfernen",
|
||||||
|
"projectDefaults.hint": "Neue Chats in diesem Projektordner starten mit Modell, Modus, Farbe, Anweisungen und Ordner dieses Chats",
|
||||||
|
"projectDefaults.saved": "Neue Chats in diesem Projekt starten wie dieser",
|
||||||
|
"projectDefaults.removed": "Projekt-Standard entfernt",
|
||||||
|
"outbox.waiting": "{n} Nachricht(en) warten auf eine Verbindung",
|
||||||
|
"outbox.sendNow": "Jetzt senden",
|
||||||
|
"outbox.discard": "Verwerfen",
|
||||||
|
"outbox.saved": "Keine Verbindung – die Nachricht ist gespeichert und wird gesendet, sobald du wieder online bist",
|
||||||
|
"project.open": "Projektübersicht",
|
||||||
|
"project.newChat": "Neuer Chat",
|
||||||
|
"project.today": "Heute",
|
||||||
|
"project.yesterday": "Gestern",
|
||||||
|
"project.chats": "{n} Chat(s)",
|
||||||
|
"project.files": "{n} Datei(en) geändert",
|
||||||
|
"project.archived": "{n} archiviert",
|
||||||
|
"project.checksFailed": "{n} Prüfung(en) fehlgeschlagen",
|
||||||
|
"project.empty": "In diesem Ordner gibt es noch keine Chats.",
|
||||||
|
"viewer.title": "Bildbetrachter",
|
||||||
|
"viewer.open": "In neuem Tab öffnen",
|
||||||
|
"viewer.download": "Herunterladen",
|
||||||
|
"media.next": "Nächstes",
|
||||||
|
"media.back": "Zurück zur Übersicht",
|
||||||
|
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
|
||||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
"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",
|
||||||
@@ -683,6 +808,7 @@ export const de: Messages = {
|
|||||||
"plan.implementPrompt": "Setze den Plan um.",
|
"plan.implementPrompt": "Setze den Plan um.",
|
||||||
|
|
||||||
"tool.input": "Eingabe",
|
"tool.input": "Eingabe",
|
||||||
|
"tool.open3d": "In 3D-Ansicht öffnen",
|
||||||
"tool.output": "Ausgabe",
|
"tool.output": "Ausgabe",
|
||||||
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
|
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
|
||||||
"tool.todos": "Aufgaben",
|
"tool.todos": "Aufgaben",
|
||||||
@@ -692,6 +818,16 @@ export const de: Messages = {
|
|||||||
|
|
||||||
"permission.title": "Freigabe erforderlich",
|
"permission.title": "Freigabe erforderlich",
|
||||||
"permission.allowOnce": "Einmal erlauben",
|
"permission.allowOnce": "Einmal erlauben",
|
||||||
|
"permission.projectCommand": "„{prefix}“ in diesem Projekt immer erlauben",
|
||||||
|
"permission.projectTool": "{tool} in diesem Projekt immer erlauben",
|
||||||
|
"permission.projectHint": "Wird für diesen Projektordner gemerkt: in allen Chats, auch nach einem Neustart. Widerrufen im ⋯-Menü.",
|
||||||
|
"permission.explain": "Was macht das?",
|
||||||
|
"permission.explaining": "Erkläre …",
|
||||||
|
"permProject.title": "Projekt-Freigaben",
|
||||||
|
"permProject.intro": "In diesem Projektordner ohne Rückfrage erlaubt (alle Chats):",
|
||||||
|
"permProject.empty": "Für dieses Projekt ist noch nichts freigegeben. Der Knopf „… in diesem Projekt immer erlauben“ bei einer Freigabe-Anfrage legt hier Einträge an.",
|
||||||
|
"permProject.revoke": "Widerrufen",
|
||||||
|
"permProject.menu": "Projekt-Freigaben",
|
||||||
"permission.alwaysTool": "{tool} in dieser Session immer erlauben",
|
"permission.alwaysTool": "{tool} in dieser Session immer erlauben",
|
||||||
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
|
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
|
||||||
"permission.reject": "Ablehnen",
|
"permission.reject": "Ablehnen",
|
||||||
@@ -741,6 +877,9 @@ export const de: Messages = {
|
|||||||
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
||||||
"settings.currency": "Währung",
|
"settings.currency": "Währung",
|
||||||
"settings.autoCompact": "Auto-Komprimierung bei",
|
"settings.autoCompact": "Auto-Komprimierung bei",
|
||||||
|
"settings.commitTrailer": "Commit-Vermerk der Agenten",
|
||||||
|
"settings.commitTrailerOff": "aus",
|
||||||
|
"settings.commitTrailerHint": "Wird als Trailer an jeden Git-Commit eines Agenten angehängt. {model} = Modellname, {provider} = Anbieter, {model_id} = Modell-ID. Leer = aus. Deine eigenen Commits bleiben unverändert.",
|
||||||
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
||||||
"settings.craft": "Craft-Modus",
|
"settings.craft": "Craft-Modus",
|
||||||
"settings.cleanRoom": "Clean Room",
|
"settings.cleanRoom": "Clean Room",
|
||||||
|
|||||||
+140
-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",
|
||||||
@@ -335,8 +335,133 @@ export const en = {
|
|||||||
"activity.reviewing": "Reviewing the result (round {round})…",
|
"activity.reviewing": "Reviewing the result (round {round})…",
|
||||||
"chat.reviewPassed": "Review passed:",
|
"chat.reviewPassed": "Review passed:",
|
||||||
"chat.todoReminder": "Back to work – {n} open todos:",
|
"chat.todoReminder": "Back to work – {n} open todos:",
|
||||||
|
"agents.reverseTools": "Reverse engineering tools (binary_info, decompile with Ghidra)",
|
||||||
|
"agents.reverseToolsHint": "The agent can inspect and decompile programs and firmware in this mode (Ghidra must be installed under Settings → Tools). Craft has them anyway in deep mode.",
|
||||||
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
||||||
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
||||||
|
"chat.workSteps": "{n} steps",
|
||||||
|
"chat.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",
|
||||||
|
"color.pink": "Pink",
|
||||||
|
"icon.label": "Icon of this chat",
|
||||||
|
"claude.title": "Claude Code",
|
||||||
|
"claude.intro": "Chats with a \"Claude Code\" model are run by the Claude Code CLI on the server – with your Claude subscription or Anthropic Console account. Sign in here once; approvals, push and the sandbox work as usual.",
|
||||||
|
"claude.notInstalled": "Claude Code is not installed on the server.",
|
||||||
|
"claude.console": "Anthropic Console (API billing) instead of the Claude subscription",
|
||||||
|
"claude.signIn": "Sign in",
|
||||||
|
"claude.openLogin": "Open the sign-in page",
|
||||||
|
"claude.step1": "Sign in at Anthropic in any browser (also on another device) and allow access.",
|
||||||
|
"claude.step2": "Paste the code the page shows afterwards:",
|
||||||
|
"claude.codePlaceholder": "Code from the sign-in page",
|
||||||
|
"claude.confirm": "Confirm",
|
||||||
|
"claude.signedIn": "Signed in to Claude Code",
|
||||||
|
"claude.signedInAs": "Signed in: {who}",
|
||||||
|
"claude.expires": "valid until {date}",
|
||||||
|
"claude.logout": "Sign out",
|
||||||
|
"claude.logoutConfirm": "Sign out of Claude Code on the server?",
|
||||||
|
"claude.version": "CLI {version}",
|
||||||
|
"claude.updateAvailable": "Update available: {version}",
|
||||||
|
"claude.upToDate": "up to date",
|
||||||
|
"claude.update": "Install update",
|
||||||
|
"claude.updating": "Installing …",
|
||||||
|
"claude.updated": "Claude Code updated to {version}",
|
||||||
|
"claude.updateFailed": "The update failed (details below)",
|
||||||
|
"claude.updateAdmin": "An administrator can install it.",
|
||||||
|
"claude.checkNow": "Check for updates",
|
||||||
|
"claude.updateLog": "Output of the update",
|
||||||
|
"check.menu": "Check after each turn …",
|
||||||
|
"check.title": "Check after each turn",
|
||||||
|
"check.hint": "Command that runs in {project} after every turn that changed files (e.g. cargo test, npm test). The result appears as a badge on the turn. Empty = off.",
|
||||||
|
"check.saved": "Check saved",
|
||||||
|
"check.removed": "Check removed",
|
||||||
|
"check.running": "Check running …",
|
||||||
|
"check.passed": "Check passed",
|
||||||
|
"check.failed": "Check failed",
|
||||||
|
"check.timeout": "Check timed out",
|
||||||
|
"check.noOutput": "(no output)",
|
||||||
|
"check.fix": "Let the agent fix it",
|
||||||
|
"check.fixPrompt": "The check of the project failed after your last change. Please fix the cause and run it again. Output:",
|
||||||
|
"projectDefaults.save": "Use as project default",
|
||||||
|
"projectDefaults.remove": "Remove project default",
|
||||||
|
"projectDefaults.hint": "New chats in this project folder start with this chat's model, mode, colour, instructions and folder",
|
||||||
|
"projectDefaults.saved": "New chats in this project will start like this one",
|
||||||
|
"projectDefaults.removed": "Project default removed",
|
||||||
|
"outbox.waiting": "{n} message(s) waiting for a connection",
|
||||||
|
"outbox.sendNow": "Send now",
|
||||||
|
"outbox.discard": "Discard",
|
||||||
|
"outbox.saved": "No connection – the message is saved and sent when you are back online",
|
||||||
|
"project.open": "Project overview",
|
||||||
|
"project.newChat": "New chat",
|
||||||
|
"project.today": "Today",
|
||||||
|
"project.yesterday": "Yesterday",
|
||||||
|
"project.chats": "{n} chat(s)",
|
||||||
|
"project.files": "{n} file(s) changed",
|
||||||
|
"project.archived": "{n} archived",
|
||||||
|
"project.checksFailed": "{n} failed check(s)",
|
||||||
|
"project.empty": "No chats in this folder yet.",
|
||||||
|
"viewer.title": "Image viewer",
|
||||||
|
"viewer.open": "Open in new tab",
|
||||||
|
"viewer.download": "Download",
|
||||||
|
"media.back": "Back to the overview",
|
||||||
|
"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",
|
||||||
@@ -682,6 +807,7 @@ export const en = {
|
|||||||
"plan.implementPrompt": "Implement the plan.",
|
"plan.implementPrompt": "Implement the plan.",
|
||||||
|
|
||||||
"tool.input": "Input",
|
"tool.input": "Input",
|
||||||
|
"tool.open3d": "Open in the 3D view",
|
||||||
"tool.output": "Output",
|
"tool.output": "Output",
|
||||||
"tool.todosUpdated": "Tasks updated ({done}/{total} done)",
|
"tool.todosUpdated": "Tasks updated ({done}/{total} done)",
|
||||||
"tool.todos": "Tasks",
|
"tool.todos": "Tasks",
|
||||||
@@ -691,6 +817,16 @@ export const en = {
|
|||||||
|
|
||||||
"permission.title": "Permission required",
|
"permission.title": "Permission required",
|
||||||
"permission.allowOnce": "Allow once",
|
"permission.allowOnce": "Allow once",
|
||||||
|
"permission.projectCommand": "Always allow \"{prefix}\" in this project",
|
||||||
|
"permission.projectTool": "Always allow {tool} in this project",
|
||||||
|
"permission.projectHint": "Remembered for this project folder, in all chats, also after a restart. Revoke it in the ⋯ menu.",
|
||||||
|
"permission.explain": "What does this do?",
|
||||||
|
"permission.explaining": "Explaining …",
|
||||||
|
"permProject.title": "Project permissions",
|
||||||
|
"permProject.intro": "Allowed without asking in this project folder (all chats):",
|
||||||
|
"permProject.empty": "Nothing is allowed for this project yet. The button \"Always allow … in this project\" on an approval adds entries here.",
|
||||||
|
"permProject.revoke": "Revoke",
|
||||||
|
"permProject.menu": "Project permissions",
|
||||||
"permission.alwaysTool": "Always allow {tool} in this session",
|
"permission.alwaysTool": "Always allow {tool} in this session",
|
||||||
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
|
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
|
||||||
"permission.reject": "Reject",
|
"permission.reject": "Reject",
|
||||||
@@ -740,6 +876,9 @@ export const en = {
|
|||||||
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.autoCompact": "Auto-compact at",
|
"settings.autoCompact": "Auto-compact at",
|
||||||
|
"settings.commitTrailer": "Commit note of agents",
|
||||||
|
"settings.commitTrailerOff": "off",
|
||||||
|
"settings.commitTrailerHint": "Added as a trailer to every git commit an agent makes. {model} = model name, {provider} = provider, {model_id} = model id. Empty = off. Your own commits stay unchanged.",
|
||||||
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
||||||
"settings.craft": "Craft mode",
|
"settings.craft": "Craft mode",
|
||||||
"settings.cleanRoom": "Clean room",
|
"settings.cleanRoom": "Clean room",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
+792
-12
@@ -439,6 +439,9 @@ button.session-group:hover {
|
|||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
/* Nothing in the bars may widen the page (and push it sideways on phones). */
|
||||||
|
overflow-x: clip;
|
||||||
|
max-width: 100vw;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -456,7 +459,8 @@ button.session-group:hover {
|
|||||||
}
|
}
|
||||||
.topbar .grow {
|
.topbar .grow {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
/* The title stays readable: with many chips they wrap into a second row instead. */
|
||||||
|
min-width: min(160px, 40vw);
|
||||||
}
|
}
|
||||||
.topbar h1 {
|
.topbar h1 {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -517,16 +521,93 @@ button.session-group:hover {
|
|||||||
|
|
||||||
.msg {
|
.msg {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
/* Message actions: hover on desktop, faintly visible on touch. */
|
||||||
|
/* The ⋯ menu sits in the foot of the bubble (next to the time); its popup opens upwards inside
|
||||||
|
* the bubble and never gets wider than the bubble. Faint on touch, appears on hover on desktop. */
|
||||||
|
.msg-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.msg-foot .msg-time {
|
||||||
|
margin-top: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.msg-menu.menu-pop {
|
||||||
|
position: static; /* the popup is placed against the bubble */
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.45;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.msg:hover .msg-menu,
|
||||||
|
.msg-menu:focus-within,
|
||||||
|
.msg-menu[open] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.msg-menu.menu-pop > summary {
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.msg-menu.menu-pop > summary:hover {
|
||||||
|
background: color-mix(in srgb, var(--fg1) 14%, transparent);
|
||||||
|
}
|
||||||
|
.msg-menu.menu-pop .menu-pop-body {
|
||||||
|
top: auto;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 0;
|
||||||
|
width: max-content;
|
||||||
|
max-width: max(calc(100% - 16px), 11rem);
|
||||||
|
}
|
||||||
|
.msg-menu.menu-pop .menu-pop-body a {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
/* Chat bubbles: the agent's answers on the left, your messages on the right in another colour. */
|
||||||
.msg-user {
|
.msg-user {
|
||||||
background: var(--bg1);
|
background: color-mix(in srgb, var(--accent) 26%, var(--bg1));
|
||||||
border: 1px solid var(--bg2);
|
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
border-radius: var(--radius);
|
border-radius: 18px 4px 18px 18px;
|
||||||
padding: 8px 12px;
|
color: var(--fg0);
|
||||||
|
padding: 9px 14px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.msg.user-msg {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: min(88%, 780px);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.msg.bubble.agent {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
border-radius: 4px 18px 18px 18px;
|
||||||
|
}
|
||||||
|
.msg.bubble.agent > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.chat-pane[data-color] .msg-user {
|
||||||
|
background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
|
||||||
|
border-color: color-mix(in srgb, var(--tag) 70%, transparent);
|
||||||
|
}
|
||||||
|
.chat-pane[data-color] .msg.bubble.agent {
|
||||||
|
background: color-mix(in srgb, var(--tag) 7%, var(--bg1));
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.msg.user-msg {
|
||||||
|
max-width: 94%;
|
||||||
|
}
|
||||||
|
}
|
||||||
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */
|
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */
|
||||||
.msg-user .markdown {
|
.msg-user .markdown {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
@@ -541,14 +622,16 @@ button.session-group:hover {
|
|||||||
background: var(--bg1);
|
background: var(--bg1);
|
||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
.msg-user .msg-actions {
|
.msg-time {
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.msg-user:hover .msg-actions {
|
|
||||||
display: block;
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.msg-user .msg-foot .msg-time {
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.msg-user img {
|
.msg-user img {
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
@@ -1356,6 +1439,23 @@ table.grid input[type="number"] {
|
|||||||
.topbar .path {
|
.topbar .path {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.topbar .grow {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar .chip {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 30vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.menu-pop-body {
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
}
|
||||||
|
.menu-pop-body .mobile-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
.topbar button.small {
|
.topbar button.small {
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
}
|
}
|
||||||
@@ -2525,6 +2625,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;
|
||||||
}
|
}
|
||||||
@@ -4185,3 +4286,682 @@ 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. Eight clearly separated hues, drawn with a lightness that fits the theme
|
||||||
|
* (light or dark). Browsers without relative colours use the theme's own colours.
|
||||||
|
* Backgrounds are mixed into --bg0, so text stays readable. */
|
||||||
|
[data-color="red"] { --tag: var(--red); --tag-h: 18; --tag-c: 0.14; }
|
||||||
|
[data-color="orange"] { --tag: var(--accent2, var(--red)); --tag-h: 57; --tag-c: 0.14; }
|
||||||
|
[data-color="yellow"] { --tag: var(--accent, var(--green)); --tag-h: 104; --tag-c: 0.12; }
|
||||||
|
[data-color="green"] { --tag: var(--green); --tag-h: 147; --tag-c: 0.17; }
|
||||||
|
[data-color="aqua"] { --tag: var(--aqua); --tag-h: 190; --tag-c: 0.1; }
|
||||||
|
[data-color="blue"] { --tag: var(--blue); --tag-h: 250; --tag-c: 0.12; }
|
||||||
|
[data-color="purple"] { --tag: var(--purple); --tag-h: 297; --tag-c: 0.135; }
|
||||||
|
[data-color="pink"] { --tag: var(--red); --tag-h: 338; --tag-c: 0.17; }
|
||||||
|
@supports (color: oklch(from red l c h)) {
|
||||||
|
/* Lightness 0.76 on dark and 0.60 on light themes (from the theme's background), chroma per hue
|
||||||
|
* as high as sRGB allows at both – vivid and still clearly different from each other. */
|
||||||
|
[data-color] {
|
||||||
|
--tag: oklch(from var(--bg0) calc(0.6 + 0.16 * clamp(0, (0.5 - l) * 20, 1)) var(--tag-c) var(--tag-h));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-pane[data-color] {
|
||||||
|
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
||||||
|
}
|
||||||
|
.chat-pane[data-color] .composer-wrap {
|
||||||
|
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
||||||
|
}
|
||||||
|
.topbar[data-color] {
|
||||||
|
border-bottom: 3px solid var(--tag);
|
||||||
|
background: color-mix(in srgb, var(--tag) 28%, var(--bg0));
|
||||||
|
}
|
||||||
|
.session-item[data-color] {
|
||||||
|
box-shadow: inset 4px 0 0 var(--tag);
|
||||||
|
}
|
||||||
|
.color-badge {
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--fg0);
|
||||||
|
background: color-mix(in srgb, var(--tag) 42%, var(--bg1));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--tag) 75%, transparent);
|
||||||
|
}
|
||||||
|
.chat-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
}
|
||||||
|
[data-color] .chat-icon {
|
||||||
|
color: var(--tag);
|
||||||
|
}
|
||||||
|
.color-badge .chat-icon {
|
||||||
|
color: var(--fg0);
|
||||||
|
}
|
||||||
|
.color-swatches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.color-swatches .swatch {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tag);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.color-swatches .swatch.on {
|
||||||
|
border-color: var(--fg0);
|
||||||
|
box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag);
|
||||||
|
}
|
||||||
|
.icon-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px 8px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.icon-choice {
|
||||||
|
padding: 5px;
|
||||||
|
line-height: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.icon-choice:hover {
|
||||||
|
background: var(--bg2);
|
||||||
|
}
|
||||||
|
.icon-choice.on {
|
||||||
|
color: var(--tag, var(--accent));
|
||||||
|
border-color: var(--tag, var(--accent));
|
||||||
|
background: var(--bg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- check badge of a turn ------------------------------------------------------ */
|
||||||
|
.turn-check {
|
||||||
|
margin: -6px 0 12px;
|
||||||
|
}
|
||||||
|
.turn-check .chip {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.turn-check .check-passed {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.turn-check .check-failed,
|
||||||
|
.turn-check .check-timeout {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.turn-check-body {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg1);
|
||||||
|
}
|
||||||
|
.turn-check-body pre {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- messages waiting for a connection ------------------------------------------ */
|
||||||
|
.outbox {
|
||||||
|
margin: 0 12px 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px dashed var(--accent2, var(--accent));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg1);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.outbox-head,
|
||||||
|
.outbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.outbox-item span {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--fg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- project page: timeline -------------------------------------------------------- */
|
||||||
|
.project-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.timeline-day h3 {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 14px;
|
||||||
|
border-left: 2px solid var(--bg3);
|
||||||
|
}
|
||||||
|
.timeline li {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.timeline li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -20px;
|
||||||
|
top: 12px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tag, var(--bg3));
|
||||||
|
border: 2px solid var(--bg0);
|
||||||
|
}
|
||||||
|
.timeline li > a {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg1);
|
||||||
|
color: var(--fg1);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.timeline li > a:hover {
|
||||||
|
border-color: var(--bg3);
|
||||||
|
}
|
||||||
|
.tl-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--fg0);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tl-when {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.tl-summary {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--fg2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tl-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.tl-meta .check-passed {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.tl-meta .check-failed,
|
||||||
|
.tl-meta .check-timeout {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ export interface Usage {
|
|||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
cache_read_tokens: number;
|
cache_read_tokens: number;
|
||||||
cache_write_tokens: number;
|
cache_write_tokens: number;
|
||||||
|
/** Cost of the request (charged, or valued at list prices for plan requests). */
|
||||||
|
cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -57,6 +59,12 @@ export interface Session {
|
|||||||
parent_id?: string;
|
parent_id?: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
|
/** One line "where things stand" (small model), for the session list. */
|
||||||
|
summary?: string;
|
||||||
|
/** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */
|
||||||
|
color?: string;
|
||||||
|
/** Icon name of the chat (from the picker). */
|
||||||
|
icon?: string;
|
||||||
todos: Todo[] | null;
|
todos: Todo[] | null;
|
||||||
last_usage?: Usage | null;
|
last_usage?: Usage | null;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
@@ -110,6 +118,16 @@ export interface QuestionRequest {
|
|||||||
questions: Question[];
|
questions: Question[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of the project's check command after a turn. */
|
||||||
|
export type CheckStatus = "running" | "passed" | "failed" | "timeout";
|
||||||
|
export interface TurnCheckInfo {
|
||||||
|
message_id: string;
|
||||||
|
status: CheckStatus;
|
||||||
|
exit_code: number | null;
|
||||||
|
duration_ms: number;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerEvent =
|
export type ServerEvent =
|
||||||
| { type: "session_updated"; session: Session }
|
| { type: "session_updated"; session: Session }
|
||||||
| { type: "session_deleted"; session_id: string }
|
| { type: "session_deleted"; session_id: string }
|
||||||
@@ -127,6 +145,7 @@ export type ServerEvent =
|
|||||||
| { type: "waiting"; session_id: string; reason: string | null }
|
| { type: "waiting"; session_id: string; reason: string | null }
|
||||||
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
||||||
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
||||||
|
| { type: "check_result"; session_id: string; message_id: string; status: CheckStatus; exit_code: number | null; duration_ms: number; output: string }
|
||||||
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
|
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
|
||||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||||
| { type: "resync" };
|
| { type: "resync" };
|
||||||
@@ -168,6 +187,8 @@ export interface AgentInfo {
|
|||||||
source: "builtin" | "config" | "file" | "personal";
|
source: "builtin" | "config" | "file" | "personal";
|
||||||
max_parallel?: number | null;
|
max_parallel?: number | null;
|
||||||
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
|
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
|
||||||
|
/** binary_info and decompile (Ghidra) available in this mode. */
|
||||||
|
reverse?: boolean;
|
||||||
/** Source file of Markdown agents. */
|
/** Source file of Markdown agents. */
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,48 @@
|
|||||||
* 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-00028": {
|
||||||
|
en: ["18 more icons for chats (gamepad, computer, food, …); the ⋯ menu of a message now sits inside its bubble"],
|
||||||
|
de: ["18 weitere Icons für Chats (Gamepad, Computer, Essen, …); das ⋯-Menü einer Nachricht sitzt jetzt in der Sprechblase"],
|
||||||
|
},
|
||||||
|
"202611-00027": {
|
||||||
|
en: ["Chat bubbles (yours on the right), icons for chats and more vivid, clearly different colour tags (⋯ menu of a chat)"],
|
||||||
|
de: ["Sprechblasen (deine rechts), Icons für Chats und kräftigere, besser unterscheidbare Farbmarkierungen (⋯-Menü eines Chats)"],
|
||||||
|
},
|
||||||
|
"202611-00026": {
|
||||||
|
en: [
|
||||||
|
"Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model",
|
||||||
|
"Image viewer, message menu, colour tags for chats, approvals from push notifications, check after each turn, project page",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
"Claude Code als Engine: unter Einstellungen → Konto anmelden, dann ein „Claude Code“-Modell wählen",
|
||||||
|
"Bildbetrachter, Nachrichtenmenü, Farben für Chats, Freigaben per Push, Prüfung nach jedem Schritt, Projektseite",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"202611-00024": {
|
||||||
|
en: ["3D models with fal.ai: from an image (also pasted into the chat), a text prompt or both – open them in the 3D view"],
|
||||||
|
de: ["3D-Modelle mit fal.ai: aus einem Bild (auch eingefügt), einem Text oder beidem – öffnen in der 3D-Ansicht"],
|
||||||
|
},
|
||||||
|
"202611-00023": {
|
||||||
|
en: ["Tokens and cost under every answer; the total cost of chats with opencode Go is shown correctly again"],
|
||||||
|
de: ["Tokens und Kosten unter jeder Antwort; der Gesamtpreis von Chats mit opencode Go stimmt wieder"],
|
||||||
|
},
|
||||||
|
"202611-00022": {
|
||||||
|
en: ["New theme “Tintenblau” (Settings → General → Theme)"],
|
||||||
|
de: ["Neues Theme „Tintenblau“ (Einstellungen → Allgemein → Theme)"],
|
||||||
|
},
|
||||||
|
"202611-00021": {
|
||||||
|
en: ["Git commits of agents name the model: “Co-Authored-By: <model> (<provider>) via CodeCraft” (Settings → General)"],
|
||||||
|
de: ["Git-Commits von Agenten nennen das Modell: „Co-Authored-By: <Modell> (<Anbieter>) via CodeCraft“ (Einstellungen → Allgemein)"],
|
||||||
|
},
|
||||||
|
"202611-00020": {
|
||||||
|
en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"],
|
||||||
|
de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"],
|
||||||
|
},
|
||||||
|
"202611-00019": {
|
||||||
|
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": {
|
"202611-00018": {
|
||||||
en: [
|
en: [
|
||||||
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
|
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
|
||||||
|
|||||||
Reference in new issue
Block a user