Compare commits
14
Commits
v202611-00018
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
084452abf6 | ||
|
|
8b4bd59572 | ||
|
|
c3df4a23cc | ||
|
|
48621f21d6 | ||
|
|
d7baa77922 | ||
|
|
1e8418336d | ||
|
|
33612bf6d4 | ||
|
|
ffecd62b83 | ||
|
|
c77dcf5890 | ||
|
|
9c80f2c857 | ||
|
|
1253430ed4 | ||
|
|
e7d4b9ff76 | ||
|
|
b71dfc49c3 | ||
|
|
3b0764450a |
No files matched your search
@@ -3,6 +3,92 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [v202611-00025] – 2026-11-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Models did not find built-in tools via `mcp_search`** (e.g. `generate_3d` in a chat that
|
||||
had just been switched from plan to build): the search only looked at MCP servers. It now also
|
||||
lists matching built-in tools and says whether the current mode has them ("available – call it
|
||||
directly", or "not in the read-only mode plan – ask the user to switch to build").
|
||||
|
||||
## [v202611-00024] – 2026-11-12
|
||||
|
||||
### Added
|
||||
|
||||
- **3D models with fal.ai (`generate_3d`):** from an image (a project file or one pasted into the
|
||||
chat, optional back/left/right views), from a text prompt, or from an image steered by a prompt
|
||||
(the image is refined with the prompt first – the 3D models take no text with an image).
|
||||
Styles textured, geometry (untextured, for 3D printing) and low-poly, optional PBR. Uses fal.ai's
|
||||
queue (takes minutes, with progress), saves a GLB plus a preview image, books the cost.
|
||||
Defaults: Hunyuan3D v3 image-to-3D / text-to-3D (`[images] model_3d`, `model_3d_text`); Tripo
|
||||
and Trellis endpoints work too.
|
||||
- The chat card offers "Open in the 3D view"; the media overview lists 3D models (also STL/3MF
|
||||
written by the agent) with their previews.
|
||||
|
||||
## [v202611-00023] – 2026-11-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Tokens and cost under every answer:** a small line under each answer of the model shows the
|
||||
tokens sent/received and the cost of all requests of that turn.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Total cost of chats stopped growing (opencode Go):** opencode now reports plan requests as
|
||||
"nothing charged" (cost 0, usage in `allowance`), and CodeCraft took the 0. Plan requests are
|
||||
valued at the model's list prices again; earlier ones are corrected once on start.
|
||||
- **Saving opencode Go models failed after fetching prices** ("data did not match any variant of
|
||||
untagged enum ModelEntry"): opencode lists cache prices per duration for some models
|
||||
(`{"30m": …, "12h": …}`); the shortest duration is used now.
|
||||
- The chat title in the top bar no longer shrinks to a single letter when there are many chips.
|
||||
|
||||
## [v202611-00022] – 2026-11-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Theme "Tintenblau"** (dark: ink blue with cream text; light: paper with dark ink), from the
|
||||
Tintenblau desktop theme; also in the terminal client.
|
||||
|
||||
## [v202611-00021] – 2026-11-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Commits of agents name the model:** every `git commit` an agent makes gets the trailer
|
||||
`Co-Authored-By: <model> (<provider>) via CodeCraft` (a git wrapper in the agent shell, also in
|
||||
the sandbox; your own commits stay unchanged). The text is set under *Settings → General*
|
||||
(`server.commit_trailer`, `{model}`, `{provider}`, `{model_id}`; empty = off).
|
||||
|
||||
## [v202611-00020] – 2026-11-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Reverse engineering outside craft:** *Settings → Agents & commands* can give any mode the
|
||||
`binary_info` and `decompile` (Ghidra) tools; the clean-room rule stays a craft rule.
|
||||
- **Raw firmware in `decompile`:** optional `processor` (Ghidra language id, e.g.
|
||||
`ARM:LE:32:Cortex`) and `base_address` for images without a file format (.bin, converted .hex).
|
||||
- **Skill "reverse-engineering":** how to identify, decompile and explore programs and firmware
|
||||
(processor table for common chips, HEX conversion, notes in `analysis/`).
|
||||
|
||||
## [v202611-00019] – 2026-11-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Search in a chat:** the magnifier in the chat's top bar searches all of it (messages, tool
|
||||
output) on the server; a hit loads its part of the chat and marks the message.
|
||||
- **Media of a chat:** *⋯ → Media* shows every image, video and audio file of the chat and its
|
||||
subagents – generated images, screenshots, files the agent wrote, media in answers, your
|
||||
attachments – filterable, large view, "show in chat" jumps to the message.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Long chats stay fast:** a chat opens with its messages since the last summary (at most 200);
|
||||
older ones load when scrolling up, the history before a summary on request. Finished turns
|
||||
fold into one line ("42 steps · 3 files", the answer stays visible). Typing, streaming and
|
||||
status changes no longer re-render the whole history, and tool output is only built when a
|
||||
card is opened. Measured with a chat of 5,900 messages: opening 9 s → 0.7 s (phone-like
|
||||
CPU: 22 s → 1 s), typing 440 ms → 25 ms per key.
|
||||
|
||||
## [v202611-00018] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -96,6 +96,8 @@ COPY --from=cli /out /app/cli
|
||||
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
||||
COPY --from=dockercli /docker /usr/local/bin/docker
|
||||
COPY server/ghidra /app/ghidra
|
||||
# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer).
|
||||
COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git
|
||||
COPY server/resources /app/resources
|
||||
# The agent's home lives on the data volume: tools it installs in user space
|
||||
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00018" src="https://img.shields.io/badge/version-v202611--00018-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00025" src="https://img.shields.io/badge/version-v202611--00025-d69e2e?style=flat-square">
|
||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
@@ -176,6 +176,10 @@ output limit, reasoning effort, extra request options and prices per million tok
|
||||
- **Images (fal.ai):** the agent drafts cheap previews (FLUX schnell), asks which one to
|
||||
refine, and creates the final image with the best model for the purpose – Nano Banana Pro
|
||||
refines the chosen draft, Ideogram handles lettering, Recraft design work.
|
||||
- **3D models (fal.ai):** `generate_3d` turns an image (also one pasted into the chat, plus
|
||||
optional back/left/right views), a text prompt, or an image steered by a prompt into a GLB
|
||||
model – textured, untextured for 3D printing, or low-poly (Hunyuan3D v3 by default,
|
||||
`[images] model_3d` / `model_3d_text`). Open it in the 3D view of the file manager.
|
||||
|
||||
## Terminal client
|
||||
|
||||
@@ -296,6 +300,7 @@ in turn are based on:
|
||||
| Coffee | Dark, Latte | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) | MIT |
|
||||
| Solarized | Dark, Light | [Solarized by Ethan Schoonover](https://ethanschoonover.com/solarized/) | MIT |
|
||||
| Tokyo Night | Night, Day | [folke/tokyonight.nvim](https://github.com/folke/tokyonight.nvim) | Apache-2.0 |
|
||||
| Tintenblau | Dark, Light | original (zink/tintenblau desktop theme) | private |
|
||||
|
||||
Further standard themes (palettes re-created from the published colour specifications):
|
||||
|
||||
|
||||
@@ -701,6 +701,60 @@
|
||||
"syntax-type": "#b58900",
|
||||
"syntax-variable": "#586e75"
|
||||
},
|
||||
"tintenblau-dark": {
|
||||
"accent": "#7aa0cc",
|
||||
"accent2": "#d4b46a",
|
||||
"aqua": "#96b4bc",
|
||||
"bg0": "#232a44",
|
||||
"bg1": "#2b3352",
|
||||
"bg2": "#353e60",
|
||||
"bg3": "#4a5478",
|
||||
"blue": "#7aa0cc",
|
||||
"fg0": "#f5efe3",
|
||||
"fg1": "#eae4d6",
|
||||
"fg2": "#d6d0c2",
|
||||
"fg3": "#bdcfd4",
|
||||
"green": "#96b4bc",
|
||||
"muted": "#8e96b4",
|
||||
"on-accent": "#232a44",
|
||||
"purple": "#c896b2",
|
||||
"red": "#d47a6a",
|
||||
"syntax-comment": "#8e96b4",
|
||||
"syntax-function": "#7aa0cc",
|
||||
"syntax-keyword": "#c896b2",
|
||||
"syntax-meta": "#baa0c4",
|
||||
"syntax-number": "#96b4bc",
|
||||
"syntax-string": "#d47a6a",
|
||||
"syntax-type": "#d4b46a",
|
||||
"syntax-variable": "#eae4d6"
|
||||
},
|
||||
"tintenblau-light": {
|
||||
"accent": "#2a3f6f",
|
||||
"accent2": "#6a4a2c",
|
||||
"aqua": "#3a5a6a",
|
||||
"bg0": "#f0ead9",
|
||||
"bg1": "#e6dfd0",
|
||||
"bg2": "#dbd3c1",
|
||||
"bg3": "#c9c0ac",
|
||||
"blue": "#3a5a8a",
|
||||
"fg0": "#0f1628",
|
||||
"fg1": "#1e2a4a",
|
||||
"fg2": "#3a3f5a",
|
||||
"fg3": "#5e5a6a",
|
||||
"green": "#4a5a3a",
|
||||
"muted": "#8a8694",
|
||||
"on-accent": "#f0ead9",
|
||||
"purple": "#5a4a72",
|
||||
"red": "#8a3028",
|
||||
"syntax-comment": "#8a8694",
|
||||
"syntax-function": "#3a5a6a",
|
||||
"syntax-keyword": "#2a3f6f",
|
||||
"syntax-meta": "#4a5a3a",
|
||||
"syntax-number": "#5a4a72",
|
||||
"syntax-string": "#8a3028",
|
||||
"syntax-type": "#6a4a2c",
|
||||
"syntax-variable": "#1e2a4a"
|
||||
},
|
||||
"tokyonight-dark": {
|
||||
"accent": "#7aa2f7",
|
||||
"accent2": "#ff9e64",
|
||||
|
||||
+22
-1
@@ -159,7 +159,13 @@ impl ModelConfig {
|
||||
/// Cost of one request now. Cache prices default to the input price.
|
||||
/// The provider's own figure wins when it reports one (opencode).
|
||||
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
||||
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
|
||||
match u.cost {
|
||||
// A plan request (opencode Go): nothing charged, the usage counts toward the plan –
|
||||
// its value at the model's list prices.
|
||||
Some(c) if c == 0.0 && u.allowance.is_some_and(|a| a > 0.0) => self.cost_at(u, chrono::Utc::now()),
|
||||
Some(c) => c,
|
||||
None => self.cost_at(u, chrono::Utc::now()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cost of one request at a given time: context tiers and time factors applied.
|
||||
@@ -187,6 +193,21 @@ mod price_tests {
|
||||
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_requests_are_valued_at_list_prices() {
|
||||
let m = ModelConfig { input_price: Some(1.0), output_price: Some(2.0), ..Default::default() };
|
||||
let mut u = usage(1_000_000, 1_000_000, 0);
|
||||
u.cost = Some(0.0);
|
||||
u.allowance = Some(0.4);
|
||||
assert_eq!(m.cost(&u), 3.0);
|
||||
// Charged by the provider: that amount; free without a plan: free.
|
||||
u.cost = Some(0.7);
|
||||
assert_eq!(m.cost(&u), 0.7);
|
||||
u.cost = Some(0.0);
|
||||
u.allowance = None;
|
||||
assert_eq!(m.cost(&u), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiers_and_multipliers() {
|
||||
let m = ModelConfig {
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# git for the shells of CodeCraft agents: `git commit` gets the co-author trailer from
|
||||
# $CODECRAFT_COMMIT_TRAILER (model, provider, CodeCraft). Everything else goes to the real
|
||||
# git unchanged. Git itself skips the trailer when the message already ends with it.
|
||||
real=${CODECRAFT_REAL_GIT:-/usr/bin/git}
|
||||
[[ -n $CODECRAFT_COMMIT_TRAILER ]] || exec "$real" "$@"
|
||||
# The subcommand comes after git's global options (-C dir, -c key=value, --git-dir=…).
|
||||
i=1
|
||||
while (( i <= $# )); do
|
||||
case ${!i} in
|
||||
-C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) i=$((i + 2)) ;;
|
||||
--) break ;;
|
||||
-*) i=$((i + 1)) ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
if (( i <= $# )) && [[ ${!i} == commit ]]; then
|
||||
# Right after "commit", so it stays an option even before "-- <paths>".
|
||||
exec "$real" "${@:1:i}" --trailer "$CODECRAFT_COMMIT_TRAILER" "${@:i+1}"
|
||||
fi
|
||||
exec "$real" "$@"
|
||||
@@ -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.
|
||||
+86
-3
@@ -81,6 +81,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
||||
.route("/sessions/{id}/export", get(export_session))
|
||||
.route("/blobs/{hash}", get(get_blob))
|
||||
.route("/sessions/{id}/messages", get(session_messages))
|
||||
.route("/sessions/{id}/search", get(search_in_session))
|
||||
.route("/sessions/{id}/media", get(session_media))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
.route("/sessions/{id}/abort", post(abort))
|
||||
@@ -813,14 +816,94 @@ async fn export_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
#[derive(Deserialize)]
|
||||
struct WindowQ {
|
||||
/// Only the end of the chat (from the last summary on, at most this many messages);
|
||||
/// without it, all messages (terminal client).
|
||||
limit: Option<i64>,
|
||||
before: Option<String>,
|
||||
after: Option<String>,
|
||||
around: Option<String>,
|
||||
/// Loading older messages stops at a summary (the history before it on request).
|
||||
#[serde(default)]
|
||||
stop_at_summary: bool,
|
||||
}
|
||||
|
||||
impl WindowQ {
|
||||
fn window(&self) -> Option<crate::store::MessageWindow> {
|
||||
use crate::store::MessageWindow as W;
|
||||
let limit = self.limit.unwrap_or(200).clamp(1, 1000);
|
||||
Some(match (&self.before, &self.after, &self.around) {
|
||||
(Some(id), _, _) => W::Before { id: id.clone(), limit, stop_at_summary: self.stop_at_summary },
|
||||
(_, Some(id), _) => W::After { id: id.clone(), limit },
|
||||
(_, _, Some(id)) => W::Around { id: id.clone(), limit },
|
||||
_ if self.limit.is_some() => W::Tail { limit },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The cost of each answer for the chat view (usage.cost: charged, or valued at list prices).
|
||||
fn with_costs(app: &App, mut messages: Vec<crate::types::Message>) -> Vec<crate::types::Message> {
|
||||
let registry = app.registry();
|
||||
for m in &mut messages {
|
||||
if let (Some(u), Some(spec)) = (m.usage.as_mut(), m.model.as_deref()) {
|
||||
if let Ok((_, model)) = registry.resolve(spec) {
|
||||
u.cost = Some(model.cost(u));
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||
let s = readable(&app, &user, &id)?;
|
||||
let messages = app.store.messages(&id)?;
|
||||
let busy = app.is_busy(&id);
|
||||
let can_write = app.access(&user, &id) == Access::Full;
|
||||
let queue = app.store.queue_list(&id)?;
|
||||
let step = app.with_runtime(&id, |r| r.step.clone());
|
||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue, "step": step})))
|
||||
let mut out = json!({"session": s, "busy": busy, "can_write": can_write, "queue": queue, "step": step});
|
||||
match q.window() {
|
||||
Some(w) => {
|
||||
let (messages, older, newer) = app.store.messages_window(&id, &w)?;
|
||||
out["messages"] = json!(with_costs(&app, messages));
|
||||
out["has_older"] = json!(older);
|
||||
out["has_newer"] = json!(newer);
|
||||
}
|
||||
None => out["messages"] = json!(app.store.messages(&id)?),
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// More messages of a chat: before / after / around a message.
|
||||
async fn session_messages(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
|
||||
let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?;
|
||||
Ok(Json(json!({"messages": with_costs(&app, messages), "has_older": older, "has_newer": newer})))
|
||||
}
|
||||
|
||||
/// Images, videos and audio files shown in a chat (all of it, with subagents).
|
||||
async fn session_media(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let a = app.clone();
|
||||
let items = tokio::task::spawn_blocking(move || crate::media::collect(&a, &id)).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
|
||||
Ok(Json(json!(items)))
|
||||
}
|
||||
|
||||
/// Search within one chat (all of it, not only the loaded part).
|
||||
async fn search_in_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<SearchQ>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
if q.q.trim().chars().count() < 2 {
|
||||
return Ok(Json(json!([])));
|
||||
}
|
||||
let hits: Vec<Value> = app
|
||||
.store
|
||||
.search_messages(&id, q.q.trim(), 100)?
|
||||
.into_iter()
|
||||
.map(|(mid, role, at, snippet)| json!({"id": mid, "role": role, "created_at": at, "snippet": snippet}))
|
||||
.collect();
|
||||
Ok(Json(json!(hits)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -144,6 +144,11 @@ impl App {
|
||||
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
||||
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
||||
}
|
||||
match crate::budget::value_plan_requests(&self) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("valued {n} plan requests (opencode Go) at list prices"),
|
||||
Err(e) => tracing::warn!("valuing plan requests: {e:#}"),
|
||||
}
|
||||
match crate::images::backfill_copies(&self) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
||||
|
||||
+71
-9
@@ -361,6 +361,49 @@ fn opencode_when(w: &Value) -> Option<When> {
|
||||
Some((days, hours, from, until))
|
||||
}
|
||||
|
||||
/// Plan requests (opencode Go) that were booked at 0: value them at the model's list
|
||||
/// prices of the time of the request. Idempotent – valued rows no longer match.
|
||||
pub fn value_plan_requests(app: &App) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for (row, ts, spec, usage) in app.store.plan_requests_without_cost()? {
|
||||
let Ok((_, model)) = app.registry().resolve(&spec) else { continue };
|
||||
let Some(at) = chrono::DateTime::from_timestamp_millis(ts) else { continue };
|
||||
let cost = model.cost_at(&usage, at);
|
||||
if cost > 0.0 {
|
||||
app.store.set_usage_cost(row, cost)?;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// A price from opencode's list: a number, or one price per cache duration
|
||||
/// (`{"30m": 0.125, "12h": 0.25}`) – then the shortest duration (the default caching).
|
||||
fn price_of(v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Number(_) => v.clone(),
|
||||
Value::Object(o) => {
|
||||
let secs = |k: &str| -> Option<f64> {
|
||||
let (num, unit) = k.split_at(k.find(|c: char| c.is_ascii_alphabetic())?);
|
||||
let n: f64 = num.parse().ok()?;
|
||||
Some(n * match unit {
|
||||
"s" => 1.0,
|
||||
"m" => 60.0,
|
||||
"h" => 3600.0,
|
||||
"d" => 86400.0,
|
||||
_ => return None,
|
||||
})
|
||||
};
|
||||
o.iter()
|
||||
.filter(|(_, x)| x.is_number())
|
||||
.min_by(|a, b| secs(a.0).unwrap_or(f64::MAX).total_cmp(&secs(b.0).unwrap_or(f64::MAX)))
|
||||
.map(|(_, x)| x.clone())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
||||
fn opencode_model(m: &Value) -> Value {
|
||||
let cost = &m["cost"]["text"];
|
||||
@@ -371,13 +414,13 @@ fn opencode_model(m: &Value) -> Value {
|
||||
for o in side["overrides"].as_array().into_iter().flatten() {
|
||||
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
||||
let t = tiers.entry(gt).or_default();
|
||||
t.insert(field.into(), o["price"].clone());
|
||||
t.insert(field.into(), price_of(&o["price"]));
|
||||
if field == "input_price" {
|
||||
if o["cache"]["read"].is_number() {
|
||||
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
|
||||
for (key, k) in [("read", "cache_read_price"), ("write", "cache_write_price")] {
|
||||
let p = price_of(&o["cache"][key]);
|
||||
if !p.is_null() {
|
||||
t.insert(k.into(), p);
|
||||
}
|
||||
if o["cache"]["write"].is_number() {
|
||||
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"],
|
||||
"vision": images,
|
||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||
"input_price": input["price"],
|
||||
"output_price": output["price"],
|
||||
"cache_read_price": input["cache"]["read"],
|
||||
"cache_write_price": input["cache"]["write"],
|
||||
"input_price": price_of(&input["price"]),
|
||||
"output_price": price_of(&output["price"]),
|
||||
"cache_read_price": price_of(&input["cache"]["read"]),
|
||||
"cache_write_price": price_of(&input["cache"]["write"]),
|
||||
"price_tiers": price_tiers,
|
||||
"price_multipliers": price_multipliers,
|
||||
"notes": notes,
|
||||
@@ -481,6 +524,25 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn prices_per_cache_duration() {
|
||||
use super::{opencode_model, price_of};
|
||||
use serde_json::{Value, json};
|
||||
assert_eq!(price_of(&json!(0.5)), json!(0.5));
|
||||
assert_eq!(price_of(&json!({"12h": 0.25, "30m": 0.125})), json!(0.125));
|
||||
assert_eq!(price_of(&json!({"1h": 2.0, "5m": 1.25})), json!(1.25));
|
||||
assert_eq!(price_of(&Value::Null), Value::Null);
|
||||
// A model of opencode's list with per-duration cache write prices.
|
||||
let m = json!({"id": "gpt-6-luna", "limit": {"context": 1050000, "input": 922000, "output": 128000},
|
||||
"cost": {"text": {"input": {"price": 0.1, "cache": {"read": 0.01, "write": {"30m": 0.125, "12h": 0.25}},
|
||||
"overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.2, "cache": {"read": 0.02, "write": {"30m": 0.25, "12h": 0.5}}}]},
|
||||
"output": {"price": 0.5, "overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.75}]}}}});
|
||||
let row = opencode_model(&m);
|
||||
assert_eq!(row["cache_write_price"], json!(0.125));
|
||||
assert_eq!(row["price_tiers"][0]["cache_write_price"], json!(0.25));
|
||||
assert_eq!(row["max_input"], json!(922000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_cost_conversion() {
|
||||
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
||||
|
||||
@@ -177,6 +177,19 @@ pub struct ImagesConfig {
|
||||
/// Optional fal.ai Admin key to query the account balance.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub admin_key: String,
|
||||
/// fal.ai model for 3D models from images (generate_3d).
|
||||
#[serde(default = "default_model_3d")]
|
||||
pub model_3d: String,
|
||||
/// fal.ai model for 3D models from a text prompt only.
|
||||
#[serde(default = "default_model_3d_text")]
|
||||
pub model_3d_text: String,
|
||||
}
|
||||
|
||||
fn default_model_3d() -> String {
|
||||
"fal-ai/hunyuan3d-v3/image-to-3d".into()
|
||||
}
|
||||
fn default_model_3d_text() -> String {
|
||||
"fal-ai/hunyuan3d-v3/text-to-3d".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
@@ -215,6 +228,8 @@ impl Default for ImagesConfig {
|
||||
models: vec![ImageModel { id: "fal-ai/flux/schnell".into(), name: Some("FLUX.1 schnell".into()), price: Some(0.003), ..Default::default() }],
|
||||
monthly_budget: None,
|
||||
admin_key: String::new(),
|
||||
model_3d: default_model_3d(),
|
||||
model_3d_text: default_model_3d_text(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,8 +322,13 @@ pub struct ServerConfig {
|
||||
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub metrics_token: String,
|
||||
/// Trailer added to every git commit an agent makes ({model}, {provider}, {model_id});
|
||||
/// empty = off.
|
||||
pub commit_trailer: String,
|
||||
}
|
||||
|
||||
pub const DEFAULT_COMMIT_TRAILER: &str = "Co-Authored-By: {model} ({provider}) via CodeCraft";
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -327,6 +347,7 @@ impl Default for ServerConfig {
|
||||
max_upload_mb: 500,
|
||||
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
|
||||
metrics_token: String::new(),
|
||||
commit_trailer: DEFAULT_COMMIT_TRAILER.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,6 +419,10 @@ pub struct AgentConfig {
|
||||
/// Review loop after each turn of this mode.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub refine: Option<RefineConfig>,
|
||||
/// Reverse engineering tools (binary_info, decompile with Ghidra) in this mode;
|
||||
/// craft has them anyway (deep mode).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reverse_tools: Option<bool>,
|
||||
}
|
||||
|
||||
/// Refinement loop: after the agent finishes, a reviewer checks the result
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct GenerateRequest {
|
||||
pub references: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> String {
|
||||
pub(crate) fn slug(s: &str) -> String {
|
||||
let mut out: String = s
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
|
||||
@@ -121,7 +121,7 @@ fn pick(all: &[ImageModel], stage: &str, purpose: Option<&str>, with_reference:
|
||||
}
|
||||
|
||||
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
|
||||
async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
||||
pub(crate) async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
|
||||
@@ -135,7 +135,7 @@ async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
|
||||
pub(crate) async fn download(url: &str) -> Result<(Vec<u8>, String)> {
|
||||
if let Some(rest) = url.strip_prefix("data:") {
|
||||
let (meta, data) = rest.split_once(',').context("malformed data URI")?;
|
||||
let ctype = meta.split(';').next().unwrap_or("image/png").to_string();
|
||||
@@ -348,7 +348,7 @@ pub fn backfill_copies(app: &App) -> Result<usize> {
|
||||
}
|
||||
|
||||
/// Downscaled copy for the model's context.
|
||||
fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
pub(crate) fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
let img = image::open(path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
@@ -22,6 +22,8 @@ mod lsp;
|
||||
mod mentions;
|
||||
mod metrics;
|
||||
mod mcp;
|
||||
mod media;
|
||||
mod mesh3d;
|
||||
mod permission;
|
||||
mod prompts;
|
||||
mod push;
|
||||
|
||||
+41
-2
@@ -558,6 +558,42 @@ impl McpManager {
|
||||
/// Search the on-demand MCP tools by keywords.
|
||||
pub struct McpSearch;
|
||||
|
||||
/// Built-in tools matching the search words, with their availability in the current mode.
|
||||
fn builtin_matches(ctx: &ToolCtx, words: &[&str]) -> String {
|
||||
if words.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let app = &ctx.app;
|
||||
let owner_id = app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
|
||||
let owner_user = owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
||||
let root = app.root_for(owner_id.as_deref(), &ctx.cwd);
|
||||
let agent = crate::prompts::find_agent_for(app, &root, &ctx.cwd, &ctx.agent, owner_id.as_deref());
|
||||
let available: std::collections::HashSet<String> = crate::runner::available_tools(app, &agent, owner_user.as_ref()).iter().map(|t| t.name().to_string()).collect();
|
||||
let mut hits: Vec<(usize, String)> = crate::tools::builtin_tools()
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let name = t.name().to_lowercase();
|
||||
let desc = t.description().to_lowercase();
|
||||
let score: usize = words.iter().map(|w| if name.contains(w) { 3 } else if desc.contains(w) { 1 } else { 0 }).sum();
|
||||
let state = if available.contains(t.name()) {
|
||||
"available – call it directly".to_string()
|
||||
} else if agent.read_only && !t.read_only() {
|
||||
format!("exists, but not in the read-only mode \"{}\" – ask the user to switch to build mode", agent.name)
|
||||
} else {
|
||||
format!("exists, but not available in mode \"{}\" (or disabled in the settings)", agent.name)
|
||||
};
|
||||
(score, format!("- {}: {state}", t.name()))
|
||||
})
|
||||
.filter(|(s, _)| *s >= 2 || words.len() == 1 && *s >= 1)
|
||||
.collect();
|
||||
hits.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
if hits.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let list: Vec<String> = hits.into_iter().take(4).map(|(_, l)| l).collect();
|
||||
format!("Built-in tools (not MCP – call them directly by name, not via mcp_call):\n{}\n\n", list.join("\n"))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for McpSearch {
|
||||
fn name(&self) -> &str {
|
||||
@@ -602,10 +638,13 @@ impl Tool for McpSearch {
|
||||
.filter(|(s, _)| *s > 0 || words.is_empty())
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
// Models also look here for built-in tools (e.g. generate_3d): say that they exist,
|
||||
// whether this mode has them, and that they are called directly.
|
||||
let builtin = if server.is_none() { builtin_matches(ctx, &words) } else { String::new() };
|
||||
if scored.is_empty() {
|
||||
return Ok(ToolOutput::text("No matching tools. Try other keywords or another server."));
|
||||
return Ok(ToolOutput::text(if builtin.is_empty() { "No matching tools. Try other keywords or another server.".to_string() } else { builtin }));
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut out = builtin;
|
||||
for (_, t) in scored.iter().take(8) {
|
||||
let desc: String = t.info.description.chars().take(300).collect();
|
||||
out.push_str(&format!("## {}\n{}\nInput schema: {}\n\n", t.full_name, desc, t.info.input_schema));
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Media overview of a chat: every image, video and audio file that was shown in it –
|
||||
//! generated images, images in tool results (screenshots), media files the agent wrote,
|
||||
//! media referenced in answers, and the user's own attachments. Includes subagents.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::types::{Message, Part, Role};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct MediaItem {
|
||||
/// "image", "video", "audio" or "model" (3D).
|
||||
pub kind: &'static str,
|
||||
pub url: String,
|
||||
pub name: String,
|
||||
/// "generated", "tool", "written", "answer" or "upload".
|
||||
pub source: &'static str,
|
||||
pub created_at: i64,
|
||||
/// Message to jump to (only for messages of the chat itself, not of subagents).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_id: Option<String>,
|
||||
/// 3D models: absolute path (opened in the 3D view) and a preview image.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"];
|
||||
const VIDEO: &[&str] = &["mp4", "webm", "mov", "m4v", "ogv"];
|
||||
const AUDIO: &[&str] = &["mp3", "wav", "ogg", "oga", "m4a", "flac", "opus", "aac"];
|
||||
const MODEL: &[&str] = &["glb", "gltf", "stl", "3mf", "obj", "step", "stp"];
|
||||
|
||||
/// Kind of a media file by its extension.
|
||||
pub fn kind_of(path: &str) -> Option<&'static str> {
|
||||
let ext = Path::new(path.split(['?', '#']).next().unwrap_or(path)).extension()?.to_str()?.to_ascii_lowercase();
|
||||
if IMAGE.contains(&ext.as_str()) {
|
||||
Some("image")
|
||||
} else if VIDEO.contains(&ext.as_str()) {
|
||||
Some("video")
|
||||
} else if AUDIO.contains(&ext.as_str()) {
|
||||
Some("audio")
|
||||
} else if MODEL.contains(&ext.as_str()) {
|
||||
Some("model")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_of_mime(mime: &str) -> &'static str {
|
||||
if mime.starts_with("video/") {
|
||||
"video"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"audio"
|
||||
} else {
|
||||
"image"
|
||||
}
|
||||
}
|
||||
|
||||
/// Like JavaScript's encodeURIComponent.
|
||||
fn encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Absolute, normalised path of `p` (relative to `cwd`).
|
||||
fn resolve(p: &str, cwd: &Path) -> PathBuf {
|
||||
let joined = if p.starts_with('/') { PathBuf::from(p) } else { cwd.join(p) };
|
||||
crate::tools::normalize(&joined)
|
||||
}
|
||||
|
||||
/// Media file paths in a text: Markdown images/links and plain paths with a media extension.
|
||||
fn paths_in(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for token in text.split(|c: char| c.is_whitespace() || "()[]<>\"'`,;".contains(c)) {
|
||||
let t = token.trim_end_matches(['.', ':', '!', '?']);
|
||||
if t.is_empty() || t.contains("://") || t.starts_with("data:") {
|
||||
continue;
|
||||
}
|
||||
if kind_of(t).is_some() && t.chars().any(|c| c == '/' || c == '.') {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// All media of a chat and its subagents, newest first.
|
||||
pub fn collect(app: &App, session_id: &str) -> anyhow::Result<Vec<MediaItem>> {
|
||||
let mut sessions = vec![session_id.to_string()];
|
||||
// Subagents (and theirs).
|
||||
let mut i = 0;
|
||||
while i < sessions.len() && sessions.len() < 500 {
|
||||
let children = app.store.child_sessions(&sessions[i])?;
|
||||
sessions.extend(children);
|
||||
i += 1;
|
||||
}
|
||||
let mut out: Vec<MediaItem> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut push = |item: MediaItem, out: &mut Vec<MediaItem>| {
|
||||
if seen.insert(item.url.clone()) {
|
||||
out.push(item);
|
||||
}
|
||||
};
|
||||
for sid in &sessions {
|
||||
let Some(session) = app.store.get_session(sid)? else { continue };
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let own = sid == session_id;
|
||||
// Tool results are shown in the card of the call: jump to that message.
|
||||
let mut call_msg: Option<String> = None;
|
||||
for m in app.store.messages(sid)? {
|
||||
collect_message(&m, &cwd, own, call_msg.as_deref(), &mut |item| push(item, &mut out));
|
||||
if m.role == Role::Assistant {
|
||||
call_msg = Some(m.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generated images whose tool result was not found (e.g. removed by a rewind).
|
||||
for img in app.store.images_of_sessions(&sessions)? {
|
||||
push(
|
||||
MediaItem { kind: "image", url: format!("/api/images/{}/file", img.id), name: file_name(&img.path), source: "generated", created_at: img.created_at, message_id: None, path: None, preview: None },
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
// Names of generated images (their files).
|
||||
let names: std::collections::HashMap<String, String> = app.store.images_of_sessions(&sessions)?.into_iter().map(|i| (format!("/api/images/{}/file", i.id), file_name(&i.path))).collect();
|
||||
for item in &mut out {
|
||||
if let Some(n) = names.get(&item.url) {
|
||||
item.name = n.clone();
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn raw_url(p: &Path) -> String {
|
||||
format!("/api/files/raw?path={}", encode(&p.display().to_string()))
|
||||
}
|
||||
|
||||
fn file_name(p: &str) -> String {
|
||||
Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p).to_string()
|
||||
}
|
||||
|
||||
fn collect_message(m: &Message, cwd: &Path, own: bool, call_msg: Option<&str>, add: &mut dyn FnMut(MediaItem)) {
|
||||
let mid = own.then(|| m.id.clone());
|
||||
let result_mid = own.then(|| call_msg.unwrap_or(&m.id).to_string());
|
||||
let at = m.created_at;
|
||||
let file = |p: &str, source: &'static str| -> Option<MediaItem> {
|
||||
let kind = kind_of(p)?;
|
||||
let abs = resolve(p, cwd);
|
||||
abs.is_file().then(|| MediaItem { kind, url: raw_url(&abs), name: file_name(p), source, created_at: at, message_id: mid.clone(), path: Some(abs.display().to_string()), preview: None })
|
||||
};
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Image { image } if m.role == Role::User => {
|
||||
if let Some(h) = &image.blob {
|
||||
add(MediaItem { kind: kind_of_mime(&image.media_type), url: format!("/api/blobs/{h}"), name: "image".into(), source: "upload", created_at: at, message_id: mid.clone(), path: None, preview: None });
|
||||
}
|
||||
}
|
||||
// generate_3d: the model with its preview (not the preview as a separate image).
|
||||
Part::ToolResult { meta: Some(meta), .. } if meta["model_file"].is_string() => {
|
||||
let file = PathBuf::from(meta["model_file"].as_str().unwrap_or_default());
|
||||
if file.is_file() {
|
||||
let preview = meta["preview"].as_str().map(PathBuf::from).filter(|p| p.is_file()).map(|p| raw_url(&p));
|
||||
add(MediaItem { kind: "model", url: raw_url(&file), name: file_name(&file.display().to_string()), source: "generated", created_at: at, message_id: result_mid.clone(), path: Some(file.display().to_string()), preview });
|
||||
}
|
||||
}
|
||||
Part::ToolResult { images, meta, .. } => {
|
||||
let ids: Vec<&str> = meta.as_ref().and_then(|v| v["image_ids"].as_array()).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
if !ids.is_empty() {
|
||||
for id in ids {
|
||||
add(MediaItem { kind: "image", url: format!("/api/images/{id}/file"), name: "image".into(), source: "generated", created_at: at, message_id: result_mid.clone(), path: None, preview: None });
|
||||
}
|
||||
} else {
|
||||
for img in images {
|
||||
if let Some(h) = &img.blob {
|
||||
add(MediaItem { kind: kind_of_mime(&img.media_type), url: format!("/api/blobs/{h}"), name: "screenshot".into(), source: "tool", created_at: at, message_id: result_mid.clone(), path: None, preview: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Part::ToolCall { name, input, .. } if matches!(name.as_str(), "write" | "edit") => {
|
||||
if let Some(item) = input["path"].as_str().and_then(|p| file(p, "written")) {
|
||||
add(item);
|
||||
}
|
||||
}
|
||||
Part::Text { text } if m.role == Role::Assistant => {
|
||||
for p in paths_in(text) {
|
||||
if let Some(item) = file(&p, "answer") {
|
||||
add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kinds_paths_and_encoding() {
|
||||
assert_eq!(kind_of("a/b.PNG"), Some("image"));
|
||||
assert_eq!(kind_of("clip.mp4?v=1"), Some("video"));
|
||||
assert_eq!(kind_of("song.flac"), Some("audio"));
|
||||
assert_eq!(kind_of("main.rs"), None);
|
||||
assert_eq!(paths_in("Saved to `art/final/hero.png` and . See https://x.org/y.png"), vec!["art/final/hero.png", "shots/a.webp"]);
|
||||
assert_eq!(encode("/w/ä b.png"), "%2Fw%2F%C3%A4%20b.png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! 3D models with fal.ai (generate_3d): from an image (plus optional back/left/right
|
||||
//! views), from a text prompt, or from an image steered by text – the 3D models take no
|
||||
//! text with an image, so the image is first refined with the prompt (image edit model)
|
||||
//! and then converted. The result is a GLB file (plus a preview image) in the project.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::expand_env;
|
||||
use crate::store::UsageRecord;
|
||||
use crate::tools::{Tool, ToolCtx, ToolOutput};
|
||||
use crate::types::now_ms;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const STYLES: &[&str] = &["textured", "geometry", "lowpoly"];
|
||||
|
||||
/// Request body for a fal.ai 3D endpoint (the families name their fields differently).
|
||||
fn request_body(model: &str, prompt: Option<&str>, images: &Views, style: &str, pbr: bool, faces: Option<u64>) -> Value {
|
||||
let mut b = json!({});
|
||||
if model.contains("tripo") {
|
||||
// Tripo: image_url / prompt, texture + pbr flags, face_limit.
|
||||
if let Some(front) = &images.front {
|
||||
b["image_url"] = json!(front);
|
||||
}
|
||||
if let Some(p) = prompt {
|
||||
b["prompt"] = json!(p);
|
||||
}
|
||||
b["texture"] = json!(style != "geometry");
|
||||
b["pbr"] = json!(style != "geometry" && pbr);
|
||||
if let Some(f) = faces {
|
||||
b["face_limit"] = json!(f);
|
||||
}
|
||||
if style == "lowpoly" {
|
||||
b["face_limit"] = json!(faces.unwrap_or(20_000));
|
||||
}
|
||||
} else if model.contains("trellis") {
|
||||
if let Some(front) = &images.front {
|
||||
b["image_url"] = json!(front);
|
||||
}
|
||||
} else {
|
||||
// Hunyuan3D: front + optional views, generate_type, enable_pbr, face_count.
|
||||
if let Some(front) = &images.front {
|
||||
b["input_image_url"] = json!(front);
|
||||
}
|
||||
for (k, v) in [("back_image_url", &images.back), ("left_image_url", &images.left), ("right_image_url", &images.right)] {
|
||||
if let Some(v) = v {
|
||||
b[k] = json!(v);
|
||||
}
|
||||
}
|
||||
if let Some(p) = prompt {
|
||||
b["prompt"] = json!(p);
|
||||
}
|
||||
b["generate_type"] = json!(match style {
|
||||
"geometry" => "Geometry",
|
||||
"lowpoly" => "LowPoly",
|
||||
_ => "Normal",
|
||||
});
|
||||
b["enable_pbr"] = json!(style != "geometry" && pbr);
|
||||
if let Some(f) = faces {
|
||||
b["face_count"] = json!(f.clamp(40_000, 1_500_000));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Data URIs of the views (front required for image-to-3D).
|
||||
#[derive(Default)]
|
||||
struct Views {
|
||||
front: Option<String>,
|
||||
back: Option<String>,
|
||||
left: Option<String>,
|
||||
right: Option<String>,
|
||||
}
|
||||
|
||||
impl Views {
|
||||
fn extra(&self) -> usize {
|
||||
[&self.back, &self.left, &self.right].iter().filter(|v| v.is_some()).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Price of one generation (USD) by the published fal.ai prices; a price configured for
|
||||
/// the model under [images] models wins.
|
||||
fn price(app: &App, model: &str, style: &str, pbr: bool, multiview: bool, faces: bool) -> f64 {
|
||||
if let Some(p) = app.config().images.models.iter().find(|m| m.id == model).and_then(|m| m.price) {
|
||||
return p;
|
||||
}
|
||||
if model.contains("tripo") {
|
||||
return match style {
|
||||
"geometry" => 0.20,
|
||||
_ => 0.30,
|
||||
};
|
||||
}
|
||||
if model.contains("hunyuan") {
|
||||
let base = match style {
|
||||
"geometry" => 0.225,
|
||||
"lowpoly" => 0.45,
|
||||
_ => 0.375,
|
||||
};
|
||||
let extras = [pbr && style != "geometry", multiview, faces].iter().filter(|x| **x).count() as f64;
|
||||
return base + 0.15 * extras;
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
/// URL of the queue API for fal.ai (requests that take minutes).
|
||||
fn queue_base(app: &App) -> String {
|
||||
let base = expand_env(&app.config().images.base_url);
|
||||
if base.contains("://fal.run") { "https://queue.fal.run".into() } else { base.trim_end_matches('/').to_string() }
|
||||
}
|
||||
|
||||
/// Submit to fal.ai's queue and wait for the result, reporting progress.
|
||||
async fn run_queued(app: &App, ctx: &ToolCtx, model: &str, body: &Value) -> Result<Value> {
|
||||
let key = expand_env(&app.config().images.api_key);
|
||||
let client = crate::provider::http_client();
|
||||
let submit = client
|
||||
.post(format!("{}/{}", queue_base(app), model.trim_matches('/')))
|
||||
.header("Authorization", format!("Key {key}"))
|
||||
.timeout(Duration::from_secs(120))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.context("fal.ai request failed")?;
|
||||
if !submit.status().is_success() {
|
||||
return Err(crate::provider::http_error(submit).await);
|
||||
}
|
||||
let sub: Value = submit.json().await?;
|
||||
let status_url = sub["status_url"].as_str().context("fal.ai: no status_url")?.to_string();
|
||||
let response_url = sub["response_url"].as_str().context("fal.ai: no response_url")?.to_string();
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
if ctx.cancel.is_cancelled() {
|
||||
bail!("cancelled");
|
||||
}
|
||||
if started.elapsed() > Duration::from_secs(20 * 60) {
|
||||
bail!("fal.ai did not finish within 20 minutes");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
||||
let st: Value = client.get(&status_url).header("Authorization", format!("Key {key}")).timeout(Duration::from_secs(30)).send().await?.json().await?;
|
||||
match st["status"].as_str().unwrap_or("") {
|
||||
"COMPLETED" => break,
|
||||
"IN_QUEUE" => ctx.progress(format!("waiting in fal.ai's queue (position {}) – {} s", st["queue_position"].as_u64().unwrap_or(0), started.elapsed().as_secs())),
|
||||
"IN_PROGRESS" => ctx.progress(format!("generating the 3D model – {} s", started.elapsed().as_secs())),
|
||||
other => bail!("fal.ai: unexpected status {other}: {st}"),
|
||||
}
|
||||
}
|
||||
let resp = client.get(&response_url).header("Authorization", format!("Key {key}")).timeout(Duration::from_secs(60)).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(crate::provider::http_error(resp).await);
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// URL of the mesh and of the preview image in a fal.ai 3D result.
|
||||
fn result_urls(out: &Value) -> (Option<String>, Option<String>) {
|
||||
let s = |v: &Value| v.as_str().map(String::from);
|
||||
let mesh = s(&out["model_glb"]["url"])
|
||||
.or_else(|| s(&out["model_mesh"]["url"]))
|
||||
.or_else(|| s(&out["model_urls"]["glb"]["url"]))
|
||||
.or_else(|| s(&out["model_file"]["url"]))
|
||||
.or_else(|| s(&out["mesh"]["url"]));
|
||||
let thumb = s(&out["thumbnail"]["url"]).or_else(|| s(&out["rendered_image"]["url"]));
|
||||
(mesh, thumb)
|
||||
}
|
||||
|
||||
/// File extension of a downloaded mesh: from the URL, else GLB (what the models return).
|
||||
fn ext_of(url: &str) -> &'static str {
|
||||
let path = url.split(['?', '#']).next().unwrap_or(url).to_ascii_lowercase();
|
||||
["glb", "gltf", "fbx", "obj", "usdz", "stl"].into_iter().find(|e| path.ends_with(&format!(".{e}"))).unwrap_or("glb")
|
||||
}
|
||||
|
||||
/// An image the user attached to the chat ("attached" = the first image of the latest
|
||||
/// message with images, "attached:2" = its second), written to `dir` so it can be used
|
||||
/// like a project file.
|
||||
async fn attached_image(app: &App, session_id: &str, spec: &str, dir: &Path) -> Result<PathBuf> {
|
||||
let n: usize = spec.strip_prefix("attached").map(|r| r.trim_start_matches(':').trim()).filter(|r| !r.is_empty()).map(|r| r.parse()).transpose().map_err(|_| anyhow!("use \"attached\" or \"attached:2\""))?.unwrap_or(1);
|
||||
let root = app.root_session_id(session_id);
|
||||
let messages = app.store.messages(&root)?;
|
||||
let images: Vec<crate::types::ImageData> = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|m| m.role == crate::types::Role::User)
|
||||
.map(|m| m.parts.iter().filter_map(|p| if let crate::types::Part::Image { image } = p { Some(image.clone()) } else { None }).collect::<Vec<_>>())
|
||||
.find(|imgs| !imgs.is_empty())
|
||||
.ok_or_else(|| anyhow!("no image attached to this chat"))?;
|
||||
let img = images.get(n.saturating_sub(1)).ok_or_else(|| anyhow!("the latest message has only {} image(s)", images.len()))?;
|
||||
let (mime, bytes) = match &img.blob {
|
||||
Some(h) => app.store.blob(h)?.ok_or_else(|| anyhow!("attached image not found"))?,
|
||||
None => (img.media_type.clone(), base64::Engine::decode(&base64::engine::general_purpose::STANDARD, img.data.as_bytes())?),
|
||||
};
|
||||
let ext = match mime.as_str() {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
_ => "png",
|
||||
};
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
let path = dir.join(format!("{}-attached-{n}.{ext}", chrono::Local::now().format("%Y%m%d-%H%M%S")));
|
||||
tokio::fs::write(&path, &bytes).await?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// An image argument: a project file, or an image attached to the chat.
|
||||
async fn image_arg(ctx: &ToolCtx, spec: &str, dir: &Path) -> Result<PathBuf> {
|
||||
if spec.starts_with("attached") {
|
||||
attached_image(&ctx.app, &ctx.session_id, spec, dir).await
|
||||
} else {
|
||||
ctx.resolve(spec)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Generate3d;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for Generate3d {
|
||||
fn name(&self) -> &str {
|
||||
"generate_3d"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Generate a 3D model (GLB) with fal.ai and save it in the project – from an image of the \
|
||||
object (a file, or image: \"attached\" for an image the user pasted into the chat; best: a \
|
||||
clean front view; optional back/left/right views improve the shape), from a \
|
||||
text prompt alone, or from an image steered by a prompt (the image is refined with the \
|
||||
prompt first, then converted). style: \"textured\" (default), \"geometry\" (untextured – for \
|
||||
3D printing), \"lowpoly\" (games). Takes one to several minutes and costs money (about \
|
||||
$0.25–0.75): generate only what the user asked for. The user can open the model in the 3D \
|
||||
view of the file manager."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "What the model should be (English). With an image: how to change/clean it up before the conversion."},
|
||||
"image": {"type": "string", "description": "Image of the object (front view): a file path, or \"attached\" for the image the user attached to the chat (\"attached:2\" = the second of that message)"},
|
||||
"back": {"type": "string", "description": "Optional back view image (path or \"attached:N\")"},
|
||||
"left": {"type": "string", "description": "Optional left view image (path or \"attached:N\")"},
|
||||
"right": {"type": "string", "description": "Optional right view image (path or \"attached:N\")"},
|
||||
"style": {"type": "string", "enum": STYLES},
|
||||
"pbr": {"type": "boolean", "description": "PBR materials (metal/roughness), textured models only; costs extra"},
|
||||
"face_count": {"type": "integer", "description": "Target number of faces (optional)"},
|
||||
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: <images.output_dir>/3d)"},
|
||||
"model": {"type": "string", "description": "fal.ai model id – only if the user asks for a specific one"}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
let what = input["prompt"].as_str().map(|p| p.chars().take(70).collect::<String>()).or_else(|| input["image"].as_str().map(String::from)).unwrap_or_default();
|
||||
format!("3D model: {what}")
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let app = ctx.app.clone();
|
||||
let cfg = app.config();
|
||||
if !cfg.images.enabled {
|
||||
bail!("fal.ai is disabled – enable image generation in the settings");
|
||||
}
|
||||
if expand_env(&cfg.images.api_key).is_empty() {
|
||||
bail!("no fal.ai API key configured");
|
||||
}
|
||||
let owner = app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
|
||||
crate::budget::check(&app, "fal", owner.as_deref())?;
|
||||
let prompt = input["prompt"].as_str().map(str::trim).filter(|p| !p.is_empty()).map(String::from);
|
||||
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| format!("{}/3d", cfg.images.output_dir.trim_end_matches('/')));
|
||||
let dir = ctx.resolve(&dir_arg)?;
|
||||
let image = match input["image"].as_str() {
|
||||
Some(spec) => Some(image_arg(ctx, spec, &dir).await?),
|
||||
None => None,
|
||||
};
|
||||
if prompt.is_none() && image.is_none() {
|
||||
bail!("give an image of the object, a prompt, or both");
|
||||
}
|
||||
let style = input["style"].as_str().filter(|s| STYLES.contains(s)).unwrap_or("textured");
|
||||
let pbr = input["pbr"].as_bool().unwrap_or(false);
|
||||
let faces = input["face_count"].as_u64();
|
||||
let mut notes = Vec::new();
|
||||
|
||||
// Image + prompt: refine the image with the prompt first (3D models take no text).
|
||||
let mut front = image.clone();
|
||||
if let (Some(img), Some(p)) = (&image, &prompt) {
|
||||
ctx.progress("refining the image with the prompt");
|
||||
let edit = crate::images::choose_model(&app, "final", Some("illustration"), true);
|
||||
if edit.reference_param.is_some() {
|
||||
let records = crate::images::generate(
|
||||
&app,
|
||||
crate::images::GenerateRequest {
|
||||
prompt: format!(
|
||||
"{p}. Show exactly one object, complete and centered, as a clean product shot: plain light neutral background, soft even lighting, no text, no other objects."
|
||||
),
|
||||
model: Some(edit.id.clone()),
|
||||
size: None,
|
||||
count: 1,
|
||||
dir: dir.clone(),
|
||||
user_id: owner.clone(),
|
||||
session_id: Some(ctx.session_id.clone()),
|
||||
references: vec![img.clone()],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("refining the image failed: {e:#}"))?;
|
||||
if let Some(r) = records.first() {
|
||||
front = Some(PathBuf::from(&r.path));
|
||||
notes.push(format!("Refined image: {}", rel(&r.path, &ctx.cwd)));
|
||||
}
|
||||
} else {
|
||||
notes.push("No image edit model configured – the prompt was not applied to the image.".into());
|
||||
}
|
||||
}
|
||||
|
||||
let mut views = Views::default();
|
||||
if let Some(f) = &front {
|
||||
views.front = crate::images::reference_uris(std::slice::from_ref(f)).await?.pop();
|
||||
for (field, slot) in [("back", &mut views.back), ("left", &mut views.left), ("right", &mut views.right)] {
|
||||
if let Some(p) = input[field].as_str() {
|
||||
*slot = crate::images::reference_uris(&[image_arg(ctx, p, &dir).await?]).await?.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
let model = input["model"].as_str().filter(|m| !m.is_empty()).map(String::from).unwrap_or_else(|| if front.is_some() { cfg.images.model_3d.clone() } else { cfg.images.model_3d_text.clone() });
|
||||
if views.extra() > 0 && !model.contains("hunyuan") {
|
||||
notes.push("This model takes only the front view; the other views were not used.".into());
|
||||
}
|
||||
let text = if front.is_none() { prompt.as_deref() } else { None };
|
||||
let body = request_body(&model, text, &views, style, pbr, faces);
|
||||
ctx.progress(format!("sending to {model}"));
|
||||
let out = run_queued(&app, ctx, &model, &body).await?;
|
||||
let (mesh_url, thumb_url) = result_urls(&out);
|
||||
let mesh_url = mesh_url.ok_or_else(|| anyhow!("fal.ai returned no 3D model: {}", out.to_string().chars().take(300).collect::<String>()))?;
|
||||
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||
// Named after the prompt, else the image (without a time stamp it already carries).
|
||||
let stem = image.as_ref().and_then(|p| p.file_stem()).and_then(|s| s.to_str()).unwrap_or("model");
|
||||
let stem = if stem.len() > 16 && stem.as_bytes()[8] == b'-' && stem[..8].bytes().all(|b| b.is_ascii_digit()) { &stem[16..] } else { stem };
|
||||
let base = crate::images::slug(prompt.as_deref().unwrap_or(stem).trim_start_matches('-'));
|
||||
let (bytes, _) = crate::images::download(&mesh_url).await?;
|
||||
let mesh_path = dir.join(format!("{stamp}-{base}.{}", ext_of(&mesh_url)));
|
||||
tokio::fs::write(&mesh_path, &bytes).await?;
|
||||
let mut thumb_path = None;
|
||||
if let Some(t) = thumb_url {
|
||||
if let Ok((tb, _)) = crate::images::download(&t).await {
|
||||
let p = dir.join(format!("{stamp}-{base}-preview.png"));
|
||||
if tokio::fs::write(&p, &tb).await.is_ok() {
|
||||
thumb_path = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cost into the statistics (provider "fal", like images).
|
||||
let cost = price(&app, &model, style, pbr, views.extra() > 0 && model.contains("hunyuan"), faces.is_some());
|
||||
let session = ctx.session_id.clone();
|
||||
let _ = app.store.add_usage(&UsageRecord {
|
||||
ts: now_ms(),
|
||||
session_id: session.clone(),
|
||||
root_session_id: app.root_session_id(&session),
|
||||
provider: "fal".into(),
|
||||
model: model.clone(),
|
||||
agent: "3d".into(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
ttft_ms: 0,
|
||||
duration_ms: 0,
|
||||
cost,
|
||||
error: false,
|
||||
user_id: owner,
|
||||
allowance: None,
|
||||
});
|
||||
|
||||
let mesh_rel = rel(&mesh_path.display().to_string(), &ctx.cwd);
|
||||
let mut text = format!("3D model ({model}, {style}{}): {mesh_rel} ({} KB)", if pbr && style != "geometry" { ", PBR" } else { "" }, bytes.len() / 1024);
|
||||
if let Some(t) = &thumb_path {
|
||||
text.push_str(&format!("\nPreview: {}", rel(&t.display().to_string(), &ctx.cwd)));
|
||||
}
|
||||
if cost > 0.0 {
|
||||
text.push_str(&format!("\nCost: about ${cost:.2}"));
|
||||
}
|
||||
for n in ¬es {
|
||||
text.push_str(&format!("\n{n}"));
|
||||
}
|
||||
text.push_str("\nThe user can open the model in the 3D view of the file manager.");
|
||||
let images = thumb_path.as_deref().and_then(crate::images::preview_for_model).into_iter().collect();
|
||||
Ok(ToolOutput {
|
||||
content: text,
|
||||
images,
|
||||
meta: Some(json!({
|
||||
"model_file": mesh_path.display().to_string(),
|
||||
"preview": thumb_path.map(|p| p.display().to_string()),
|
||||
})),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(path: &str, cwd: &Path) -> String {
|
||||
Path::new(path).strip_prefix(cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| path.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bodies_per_model_family() {
|
||||
let views = Views { front: Some("data:a".into()), back: Some("data:b".into()), ..Default::default() };
|
||||
let h = request_body("fal-ai/hunyuan3d-v3/image-to-3d", None, &views, "geometry", true, Some(10));
|
||||
assert_eq!(h["input_image_url"], "data:a");
|
||||
assert_eq!(h["back_image_url"], "data:b");
|
||||
assert_eq!(h["generate_type"], "Geometry");
|
||||
assert_eq!(h["enable_pbr"], false);
|
||||
assert_eq!(h["face_count"], 40_000);
|
||||
let t = request_body("tripo3d/h3.1/image-to-3d", None, &views, "textured", true, None);
|
||||
assert_eq!((t["image_url"].as_str(), t["texture"].as_bool(), t["pbr"].as_bool()), (Some("data:a"), Some(true), Some(true)));
|
||||
let txt = request_body("fal-ai/hunyuan3d-v3/text-to-3d", Some("a chair"), &Views::default(), "textured", false, None);
|
||||
assert_eq!((txt["prompt"].as_str(), txt["generate_type"].as_str()), (Some("a chair"), Some("Normal")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_urls_of_the_families() {
|
||||
let h = json!({"model_glb": {"url": "https://x/m.glb"}, "thumbnail": {"url": "https://x/t.png"}});
|
||||
assert_eq!(result_urls(&h), (Some("https://x/m.glb".into()), Some("https://x/t.png".into())));
|
||||
let t = json!({"model_mesh": {"url": "https://x/m.glb"}, "rendered_image": {"url": "https://x/r.webp"}});
|
||||
assert_eq!(result_urls(&t).1.as_deref(), Some("https://x/r.webp"));
|
||||
assert_eq!(ext_of("https://x/a.FBX?sig=1"), "fbx");
|
||||
assert_eq!(ext_of("https://x/blob"), "glb");
|
||||
}
|
||||
}
|
||||
+11
-6
@@ -32,6 +32,8 @@ pub struct Agent {
|
||||
pub max_parallel: Option<usize>,
|
||||
/// Review loop after each turn (None/0 rounds = off).
|
||||
pub refine: Option<crate::config::RefineConfig>,
|
||||
/// binary_info and decompile available (besides craft).
|
||||
pub reverse: bool,
|
||||
/// Source file of Markdown agents.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
@@ -43,12 +45,12 @@ pub use codecraft_core::prompts::*;
|
||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||
let craft = craft_prompt(clean_room);
|
||||
vec![
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -122,6 +124,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
source: "file".into(),
|
||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
|
||||
reverse: meta.get("reverse_tools").is_some_and(|v| v == "true"),
|
||||
path: Some(p.display().to_string()),
|
||||
});
|
||||
}
|
||||
@@ -141,6 +144,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
source: "config".into(),
|
||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
||||
reverse: a.reverse_tools.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.reverse)),
|
||||
path: None,
|
||||
});
|
||||
}
|
||||
@@ -173,6 +177,7 @@ pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) ->
|
||||
source: "personal".into(),
|
||||
max_parallel: None,
|
||||
refine: a.refine,
|
||||
reverse: a.reverse_tools.unwrap_or(false),
|
||||
path: None,
|
||||
});
|
||||
}
|
||||
|
||||
+30
-4
@@ -142,7 +142,10 @@ impl Tool for Decompile {
|
||||
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
|
||||
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
|
||||
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
|
||||
large programs; results are cached per file."
|
||||
large programs; results are cached per file. Raw firmware images (.bin without a file \
|
||||
format) need `processor` (Ghidra language id, e.g. \"ARM:LE:32:Cortex\" for Cortex-M, \
|
||||
\"ARM:LE:32:v8\", \"AVR8:LE:16:atmega256\", \"Xtensa:LE:32:default\" for ESP32) and usually \
|
||||
`base_address` (load address, e.g. \"0x08000000\" for STM32 flash)."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
@@ -151,7 +154,9 @@ impl Tool for Decompile {
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
|
||||
"force": {"type": "boolean", "description": "Re-run even if cached"}
|
||||
"force": {"type": "boolean", "description": "Re-run even if cached"},
|
||||
"processor": {"type": "string", "description": "Ghidra language id for raw images, e.g. ARM:LE:32:Cortex (default: detected from the file format)"},
|
||||
"base_address": {"type": "string", "description": "Load address of a raw image, e.g. 0x08000000"}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
@@ -171,7 +176,20 @@ impl Tool for Decompile {
|
||||
bail!("{} is not a file", path.display());
|
||||
}
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
|
||||
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}", file_hash(&path)?));
|
||||
let processor = input["processor"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||
let base = input["base_address"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||
if processor.as_deref().is_some_and(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || ":_-.".contains(c))) {
|
||||
bail!("invalid processor id (expected e.g. ARM:LE:32:Cortex)");
|
||||
}
|
||||
if base.as_deref().is_some_and(|b| !b.trim_start_matches("0x").chars().all(|c| c.is_ascii_hexdigit())) {
|
||||
bail!("invalid base_address (expected e.g. 0x08000000)");
|
||||
}
|
||||
// Another processor or load address is another analysis.
|
||||
let variant = match (&processor, &base) {
|
||||
(None, None) => String::new(),
|
||||
(p, b) => format!("-{}", crate::blobs::hash(format!("{p:?}{b:?}").as_bytes()).get(..8).unwrap_or("")),
|
||||
};
|
||||
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}{variant}", file_hash(&path)?));
|
||||
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
|
||||
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
|
||||
}
|
||||
@@ -183,7 +201,15 @@ impl Tool for Decompile {
|
||||
let scripts = scripts_dir().display().to_string();
|
||||
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
|
||||
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
|
||||
let args = vec![proj.as_str(), "cc", "-import", p.as_str(), "-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"];
|
||||
let mut args = vec![proj.as_str(), "cc", "-import", p.as_str()];
|
||||
// Raw firmware: the binary loader with the given processor and load address.
|
||||
if let Some(proc_id) = processor.as_deref() {
|
||||
args.extend(["-processor", proc_id]);
|
||||
}
|
||||
if let Some(addr) = base.as_deref() {
|
||||
args.extend(["-loader", "BinaryLoader", "-loader-baseAddr", addr]);
|
||||
}
|
||||
args.extend(["-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]);
|
||||
let java = tools.join("jdk").display().to_string();
|
||||
let mut cmd = match &ctx.sandbox {
|
||||
Some(spec) => {
|
||||
|
||||
+11
-4
@@ -215,7 +215,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
||||
let cfg = app.config();
|
||||
let mut all = tools::builtin_tools();
|
||||
if !cfg.images.enabled {
|
||||
all.retain(|t| t.name() != "generate_image");
|
||||
all.retain(|t| t.name() != "generate_image" && t.name() != "generate_3d");
|
||||
}
|
||||
let (direct, deferred) = app.mcp.tools(&cfg, owner);
|
||||
all.extend(direct);
|
||||
@@ -226,7 +226,12 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
||||
all.into_iter()
|
||||
.filter(|t| !agent.read_only || t.read_only())
|
||||
// Craft tools only where they are useful.
|
||||
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some())
|
||||
.filter(|t| {
|
||||
agent.name == "craft"
|
||||
|| agent.tools.is_some()
|
||||
|| (agent.reverse && matches!(t.name(), "binary_info" | "decompile"))
|
||||
|| !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase")
|
||||
})
|
||||
.filter(|t| match &agent.tools {
|
||||
None => true,
|
||||
Some(list) => {
|
||||
@@ -456,7 +461,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
agent: agent.name.clone(),
|
||||
roots: roots.clone(),
|
||||
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
||||
clean_room,
|
||||
// Clean room is a craft rule; other modes with reverse tools may analyse binaries.
|
||||
clean_room: clean_room && agent.name == "craft",
|
||||
owner_id: None,
|
||||
};
|
||||
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
||||
@@ -1107,7 +1113,8 @@ async fn stream_step(
|
||||
let mut message = Message::new(Role::Assistant, parts);
|
||||
message.id = msg_id;
|
||||
message.model = Some(model_spec.to_string());
|
||||
message.usage = Some(usage.clone());
|
||||
// With its cost (charged, or valued at list prices) for the line under the answer.
|
||||
message.usage = Some(Usage { cost: Some(req.model.cost(&usage)), ..usage.clone() });
|
||||
if !message.parts.is_empty() {
|
||||
app.store.add_message(&session.id, &message)?;
|
||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||
|
||||
+203
-23
@@ -1,6 +1,6 @@
|
||||
//! SQLite persistence for sessions and messages.
|
||||
|
||||
use crate::types::{ImageData, Message, Part, Session, now_ms};
|
||||
use crate::types::{ImageData, Message, Part, Role, Session, now_ms};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use anyhow::Result;
|
||||
@@ -244,28 +244,7 @@ impl Store {
|
||||
let (sdata, mdata) = row?;
|
||||
let session: Session = serde_json::from_str(&sdata)?;
|
||||
// Snippet from the visible text of the matching message (not JSON keys).
|
||||
let mut snippet = String::new();
|
||||
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
|
||||
for p in &m.parts {
|
||||
let text = match p {
|
||||
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::ToolResult { content, .. } => content.as_str(),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(i) = text.to_lowercase().find(&needle) {
|
||||
let mut a = i.saturating_sub(60);
|
||||
while !text.is_char_boundary(a) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut b = (i + needle.len() + 100).min(text.len());
|
||||
while !text.is_char_boundary(b) {
|
||||
b += 1;
|
||||
}
|
||||
snippet = format!("{}{}{}", if a > 0 { "…" } else { "" }, text[a..b].replace('\n', " "), if b < text.len() { "…" } else { "" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let snippet = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()).and_then(|m| snippet_in(&m, &needle)).unwrap_or_default();
|
||||
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
|
||||
continue; // matched only JSON structure (keys, ids)
|
||||
}
|
||||
@@ -274,6 +253,121 @@ impl Store {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A part of a session's messages for the chat view (long chats are not loaded at
|
||||
/// once). Returns the messages (oldest first) and whether older / newer ones exist.
|
||||
pub fn messages_window(&self, session_id: &str, w: &MessageWindow) -> Result<(Vec<Message>, bool, bool)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let seq_of = |id: &str| -> Result<i64> {
|
||||
conn.query_row("SELECT seq FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, id], |r| r.get(0))
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow::anyhow!("message {id} not found"))
|
||||
};
|
||||
let (sql, a, limit): (&str, i64, i64) = match w {
|
||||
// From the last summary (compaction) on, at most `limit`.
|
||||
MessageWindow::Tail { limit } => {
|
||||
let from: i64 = conn
|
||||
.query_row(
|
||||
"SELECT seq FROM messages WHERE session_id = ?1 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
|
||||
[session_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(i64::MIN);
|
||||
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq DESC LIMIT ?3", from, *limit)
|
||||
}
|
||||
// Scrolling up stops at a summary (compaction); the history before it on request.
|
||||
MessageWindow::Before { id, limit, stop_at_summary } => {
|
||||
let before = seq_of(id)?;
|
||||
let summary: Option<i64> = if *stop_at_summary {
|
||||
conn.query_row(
|
||||
"SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
|
||||
params![session_id, before],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match summary {
|
||||
Some(from) => {
|
||||
let mut stmt = conn.prepare("SELECT seq, data FROM messages WHERE session_id = ?1 AND seq >= ?2 AND seq < ?3 ORDER BY seq DESC LIMIT ?4")?;
|
||||
let mut rows: Vec<(i64, Message)> = Vec::new();
|
||||
for r in stmt.query_map(params![session_id, from, before, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
|
||||
let (seq, data) = r?;
|
||||
rows.push((seq, serde_json::from_str(&data)?));
|
||||
}
|
||||
drop(stmt);
|
||||
return Self::finish_window(&conn, session_id, rows, false);
|
||||
}
|
||||
None => ("SELECT data FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3", before, *limit),
|
||||
}
|
||||
}
|
||||
MessageWindow::After { id, limit } => ("SELECT data FROM messages WHERE session_id = ?1 AND seq > ?2 ORDER BY seq ASC LIMIT ?3", seq_of(id)?, *limit),
|
||||
// The message and about half the limit before it.
|
||||
MessageWindow::Around { id, limit } => {
|
||||
let seq = seq_of(id)?;
|
||||
let before: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MIN(seq), ?2) FROM (SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3)",
|
||||
params![session_id, seq, limit / 2],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq ASC LIMIT ?3", before, *limit)
|
||||
}
|
||||
};
|
||||
let mut rows: Vec<(i64, Message)> = Vec::new();
|
||||
let mut stmt = conn.prepare(&sql.replace("SELECT data", "SELECT seq, data"))?;
|
||||
for r in stmt.query_map(params![session_id, a, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
|
||||
let (seq, data) = r?;
|
||||
rows.push((seq, serde_json::from_str(&data)?));
|
||||
}
|
||||
drop(stmt);
|
||||
Self::finish_window(&conn, session_id, rows, matches!(w, MessageWindow::Tail { .. }))
|
||||
}
|
||||
|
||||
/// Sort a window and tell whether older / newer messages exist around it.
|
||||
fn finish_window(conn: &Connection, session_id: &str, mut rows: Vec<(i64, Message)>, tail: bool) -> Result<(Vec<Message>, bool, bool)> {
|
||||
rows.sort_by_key(|(seq, _)| *seq);
|
||||
let exists = |q: &str, seq: i64| -> Result<bool> { Ok(conn.prepare(q)?.exists(params![session_id, seq])?) };
|
||||
let older = match rows.first() {
|
||||
Some((first, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq < ?2", *first)?,
|
||||
None => tail && exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", i64::MIN)?,
|
||||
};
|
||||
let newer = match rows.last() {
|
||||
Some((last, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", *last)?,
|
||||
None => false,
|
||||
};
|
||||
Ok((rows.into_iter().map(|(_, m)| m).collect(), older, newer))
|
||||
}
|
||||
|
||||
/// Messages of one session containing `q` (case-insensitive), newest first:
|
||||
/// (message id, role, created_at, snippet).
|
||||
pub fn search_messages(&self, session_id: &str, q: &str, limit: usize) -> Result<Vec<(String, Role, i64, String)>> {
|
||||
let needle = q.to_lowercase();
|
||||
let like = format!("%{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT data FROM messages WHERE session_id = ?1 AND data LIKE ?2 ESCAPE '\\' ORDER BY seq DESC")?;
|
||||
let mut out = Vec::new();
|
||||
for r in stmt.query_map(params![session_id, like], |r| r.get::<_, String>(0))? {
|
||||
let Ok(m) = serde_json::from_str::<Message>(&r?) else { continue };
|
||||
// Only visible text counts (not JSON keys or ids).
|
||||
if let Some(snippet) = snippet_in(&m, &needle) {
|
||||
out.push((m.id.clone(), m.role, m.created_at, snippet));
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ids of the subagent sessions started by a session.
|
||||
pub fn child_sessions(&self, id: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM sessions WHERE parent_id = ?1")?;
|
||||
let rows = stmt.query_map([id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn delete_session(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Child sessions (subagents) go with their parent.
|
||||
@@ -559,6 +653,39 @@ impl Store {
|
||||
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
|
||||
}
|
||||
|
||||
/// Plan requests booked at 0 (opencode Go reports nothing charged, only `allowance`):
|
||||
/// (row id, ts, "provider/model", usage).
|
||||
pub fn plan_requests_without_cost(&self) -> Result<Vec<(i64, i64, String, crate::types::Usage)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, ts, provider || '/' || model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, allowance
|
||||
FROM usage WHERE cost = 0 AND allowance > 0",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
r.get(1)?,
|
||||
r.get(2)?,
|
||||
crate::types::Usage {
|
||||
input_tokens: r.get::<_, i64>(3)? as u64,
|
||||
output_tokens: r.get::<_, i64>(4)? as u64,
|
||||
cache_read_tokens: r.get::<_, i64>(5)? as u64,
|
||||
cache_write_tokens: r.get::<_, i64>(6)? as u64,
|
||||
allowance: r.get(7)?,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn set_usage_cost(&self, row: i64, cost: f64) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE usage SET cost = ?2 WHERE id = ?1", params![row, cost])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||
/// into the viewer's local time.
|
||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
|
||||
@@ -1045,6 +1172,17 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Latest images; `user = None` lists everybody's (admins).
|
||||
/// Generated images of these sessions.
|
||||
pub fn images_of_sessions(&self, sessions: &[String]) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!("SELECT {IMAGE_COLS} FROM images WHERE session_id = ?1"))?;
|
||||
let mut out = Vec::new();
|
||||
for s in sessions {
|
||||
out.extend(stmt.query_map([s], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
@@ -1392,3 +1530,45 @@ pub struct PushSub {
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
/// Which messages of a session the chat view loads (see Store::messages_window).
|
||||
pub enum MessageWindow {
|
||||
Tail { limit: i64 },
|
||||
Before { id: String, limit: i64, stop_at_summary: bool },
|
||||
After { id: String, limit: i64 },
|
||||
Around { id: String, limit: i64 },
|
||||
}
|
||||
|
||||
/// Text around the first occurrence of `needle` (lowercase) in the visible text of a
|
||||
/// message: its text, reasoning, summaries, tool calls and tool output.
|
||||
fn snippet_in(m: &Message, needle: &str) -> Option<String> {
|
||||
for p in &m.parts {
|
||||
let owned;
|
||||
let text = match p {
|
||||
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::ToolResult { content, .. } => content.as_str(),
|
||||
Part::ToolCall { input, .. } => {
|
||||
owned = input.to_string();
|
||||
owned.as_str()
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let lower = text.to_lowercase();
|
||||
// Lowercasing can change byte lengths (rare): then cut the lowercase text.
|
||||
let (src, i) = match lower.find(needle) {
|
||||
Some(i) if lower.len() == text.len() => (text, i),
|
||||
Some(i) => (lower.as_str(), i),
|
||||
None => continue,
|
||||
};
|
||||
let mut a = i.saturating_sub(60);
|
||||
while !src.is_char_boundary(a) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut b = (i + needle.len() + 100).min(src.len());
|
||||
while !src.is_char_boundary(b) {
|
||||
b += 1;
|
||||
}
|
||||
return Some(format!("{}{}{}", if a > 0 { "…" } else { "" }, src[a..b].replace('\n', " "), if b < src.len() { "…" } else { "" }));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -135,7 +135,7 @@ impl Tool for Bash {
|
||||
/// The bash command for a script, sandboxed for non-admin users.
|
||||
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
|
||||
// stderr of every command (not just the last one) goes into the output.
|
||||
let script = format!("exec 2>&1\n{command}");
|
||||
let script = format!("exec 2>&1\n{}{command}", commit_attribution(ctx));
|
||||
Ok(match &ctx.sandbox {
|
||||
Some(spec) => {
|
||||
if !crate::sandbox::available() {
|
||||
@@ -161,6 +161,36 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
||||
})
|
||||
}
|
||||
|
||||
/// Directory with the git wrapper that adds the co-author trailer to commits.
|
||||
fn git_wrapper_dir() -> String {
|
||||
std::env::var("CODECRAFT_GIT_WRAPPER").unwrap_or_else(|_| "/usr/local/lib/codecraft/bin".into())
|
||||
}
|
||||
|
||||
/// Shell lines that make `git commit` in this command carry the co-author trailer of
|
||||
/// the session's model (see docker/git-coauthor). Set in the script itself, so it
|
||||
/// works inside the sandbox (which clears the environment) too.
|
||||
fn commit_attribution(ctx: &ToolCtx) -> String {
|
||||
let template = ctx.app.config().server.commit_trailer.clone();
|
||||
let dir = git_wrapper_dir();
|
||||
if template.trim().is_empty() || !std::path::Path::new(&dir).join("git").exists() {
|
||||
return String::new();
|
||||
}
|
||||
let Ok(session) = ctx.app.session(&ctx.session_id) else { return String::new() };
|
||||
let (provider, model_id) = session.model.split_once('/').unwrap_or(("", session.model.as_str()));
|
||||
let name = ctx.app.registry().resolve(&session.model).ok().and_then(|(_, m)| m.name).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| model_id.to_string());
|
||||
let trailer = commit_trailer(&template, &name, provider, model_id);
|
||||
format!("export PATH={}:\"$PATH\" CODECRAFT_COMMIT_TRAILER={}\n", shell_quote(&dir), shell_quote(&trailer))
|
||||
}
|
||||
|
||||
/// The trailer text for a model ("Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft").
|
||||
pub fn commit_trailer(template: &str, model: &str, provider: &str, model_id: &str) -> String {
|
||||
template.replace("{model}", model).replace("{provider}", provider).replace("{model_id}", model_id).replace(['\n', '\r'], " ").trim().to_string()
|
||||
}
|
||||
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
fn tail(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
@@ -171,3 +201,18 @@ fn tail(s: &str, max: usize) -> String {
|
||||
}
|
||||
s[start..].to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn commit_trailer_text() {
|
||||
assert_eq!(
|
||||
commit_trailer(crate::config::DEFAULT_COMMIT_TRAILER, "DeepSeek V4.1 Flash", "opencode-go", "deepseek-v4.1-flash"),
|
||||
"Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft"
|
||||
);
|
||||
assert_eq!(commit_trailer("By {model_id}\nx", "M", "p", "m-1"), "By m-1 x");
|
||||
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(craft::ImageCompare),
|
||||
Arc::new(crate::prompts::SkillTool),
|
||||
Arc::new(crate::images::GenerateImage),
|
||||
Arc::new(crate::mesh3d::Generate3d),
|
||||
Arc::new(crate::reverse::BinaryInfo),
|
||||
Arc::new(crate::reverse::Decompile),
|
||||
]
|
||||
|
||||
+404
-68
@@ -1,12 +1,12 @@
|
||||
import { takePrefill } from "../share";
|
||||
import { Icon } from "./Icon";
|
||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { del, get, patch, post } from "../api";
|
||||
import { useFetch, useModels } from "../hooks";
|
||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||
import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store";
|
||||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||
@@ -26,8 +26,10 @@ import { ShellsChip } from "./ShellsPanel";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||
import { MediaGallery } from "./MediaGallery";
|
||||
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||
|
||||
const EMPTY: Message[] = [];
|
||||
|
||||
@@ -166,10 +168,340 @@ function RefineFeedback({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Search the whole chat on the server; a hit loads its part of the chat. */
|
||||
function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const [q, setQ] = useState("");
|
||||
const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>();
|
||||
useEffect(() => {
|
||||
const query = q.trim();
|
||||
if (query.length < 2) return setHits(undefined);
|
||||
let alive = true;
|
||||
const h = setTimeout(() => {
|
||||
get<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
|
||||
.then((r) => alive && setHits(r ?? []))
|
||||
.catch(() => alive && setHits([]));
|
||||
}, 250);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(h);
|
||||
};
|
||||
}, [q, sessionId]);
|
||||
return (
|
||||
<div className="chat-search" role="search">
|
||||
<div className="row">
|
||||
<Icon name="search" />
|
||||
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
|
||||
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
{hits && (
|
||||
<div className="chat-search-hits">
|
||||
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
|
||||
{hits.map((h) => (
|
||||
<button key={h.id} onClick={() => onPick(h.id)}>
|
||||
<span className="who">
|
||||
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||
</span>
|
||||
<span className="snippet">{h.snippet}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A part of the chat: a user message, a summary, or the work of the agent after it. */
|
||||
type Block = { kind: "user" | "summary" | "work"; messages: Message[] };
|
||||
|
||||
const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image");
|
||||
|
||||
/** Group the messages into user messages and the agent's work between them. */
|
||||
function blocksOf(messages: Message[]): Block[] {
|
||||
const out: Block[] = [];
|
||||
for (const m of messages) {
|
||||
const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work";
|
||||
const last = out[out.length - 1];
|
||||
if (kind === "work" && last?.kind === "work") last.messages.push(m);
|
||||
else out.push({ kind, messages: [m] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages of a chat. Memoised: typing, streaming and status changes of the
|
||||
* chat view do not touch it – only new messages, tool results or the busy state.
|
||||
*/
|
||||
const MessageList = memo(function MessageList({
|
||||
messages,
|
||||
results,
|
||||
busy,
|
||||
canEdit,
|
||||
canRegenerate,
|
||||
sessionId,
|
||||
model,
|
||||
baseDir,
|
||||
onRewind,
|
||||
highlight,
|
||||
currency,
|
||||
}: {
|
||||
messages: Message[];
|
||||
currency: string;
|
||||
results: Map<string, ToolResult>;
|
||||
busy: boolean;
|
||||
canEdit: boolean;
|
||||
canRegenerate: boolean;
|
||||
sessionId: string;
|
||||
model: string;
|
||||
baseDir: string;
|
||||
onRewind: (m: Message) => void;
|
||||
highlight?: string;
|
||||
}) {
|
||||
const blocks = useMemo(() => blocksOf(messages), [messages]);
|
||||
const lastWork = blocks.map((b) => b.kind).lastIndexOf("work");
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id;
|
||||
return (
|
||||
<>
|
||||
{blocks.map((b, i) => {
|
||||
// Stable while older messages are loaded above (they may join the first block):
|
||||
// the last message; the block still growing at the end keeps its first one.
|
||||
const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id;
|
||||
if (b.kind !== "work") return <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
|
||||
const items = b.messages.map((m) => (
|
||||
<AssistantOrNote
|
||||
key={m.id}
|
||||
m={m}
|
||||
results={results}
|
||||
busy={busy}
|
||||
baseDir={baseDir}
|
||||
highlight={highlight}
|
||||
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
|
||||
/>
|
||||
));
|
||||
// Finished turns with tool steps fold into one line (the answer stays visible).
|
||||
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
||||
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
|
||||
if (i === lastWork || steps.length < 2)
|
||||
return (
|
||||
<div key={key}>
|
||||
{items}
|
||||
{usage}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={key}>
|
||||
<WorkBlock messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>
|
||||
{items}
|
||||
</WorkBlock>
|
||||
{usage}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/** Tokens (in/out) and cost of one answer: all requests of the turn together. */
|
||||
function TurnUsage({ messages, currency }: { messages: Message[]; currency: string }) {
|
||||
const t = useT();
|
||||
let inp = 0;
|
||||
let out = 0;
|
||||
let cost = 0;
|
||||
let requests = 0;
|
||||
for (const m of messages) {
|
||||
const u = m.role === "assistant" ? m.usage : undefined;
|
||||
if (!u) continue;
|
||||
requests++;
|
||||
inp += u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||||
out += u.output_tokens;
|
||||
cost += u.cost ?? 0;
|
||||
}
|
||||
if (!requests) return null;
|
||||
return (
|
||||
<div className="turn-usage" title={t("chat.turnUsageHint", { n: requests })}>
|
||||
{fmtCompact(inp)}
|
||||
<Icon name="arrow-up" className="tiny" /> {fmtCompact(out)}
|
||||
<Icon name="arrow-down" className="tiny" /> · {fmtCost(cost, currency)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
|
||||
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||||
// The answer: text of the last assistant message of the turn.
|
||||
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim()));
|
||||
const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : "";
|
||||
if (open || forced) {
|
||||
return (
|
||||
<div className="work-block open">
|
||||
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
|
||||
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="msg work-block" data-mid={answer?.id}>
|
||||
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
|
||||
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||
</button>
|
||||
{answerText && (
|
||||
<>
|
||||
<Markdown text={answerText} baseDir={baseDir} />
|
||||
<ImageRefs text={answerText} baseDir={baseDir} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An assistant message, or a user message that only carries tool results / harness notes. */
|
||||
const AssistantOrNote = memo(function AssistantOrNote({
|
||||
m,
|
||||
results,
|
||||
busy,
|
||||
baseDir,
|
||||
highlight,
|
||||
regenerate,
|
||||
}: {
|
||||
m: Message;
|
||||
results: Map<string, ToolResult>;
|
||||
busy: boolean;
|
||||
baseDir: string;
|
||||
highlight?: string;
|
||||
regenerate?: { sessionId: string; model: string };
|
||||
}) {
|
||||
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
||||
if (m.role === "assistant") {
|
||||
return (
|
||||
<div className={cls} data-mid={m.id}>
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||||
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text));
|
||||
if (!notes.length) return null;
|
||||
return (
|
||||
<div className={cls} data-mid={m.id}>
|
||||
{notes.map((p, i) => (
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** A user message or a summary of the history before it. */
|
||||
const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight }: { m: Message; canEdit: boolean; onRewind: (m: Message) => void; highlight?: string }) {
|
||||
const t = useT();
|
||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
const summary = m.parts.find((p) => p.type === "summary");
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
||||
<summary>
|
||||
{summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
{summaryOpen && <Markdown text={summary.text} />}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => isHarnessNote(p.text));
|
||||
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
return (
|
||||
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
|
||||
{background.map((p, i) => (
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
<div className="msg-user">
|
||||
{canEdit && (
|
||||
<div className="msg-actions">
|
||||
<button className="small ghost" onClick={() => onRewind(m)} title={t("chat.rewind")}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Older messages: loaded automatically when scrolled to the top; the history before a
|
||||
* summary only on request. Keeps the scroll position while the list grows upwards.
|
||||
*/
|
||||
function OlderLoader({ sessionId, afterSummary, scrollRef }: { sessionId: string; afterSummary?: boolean; scrollRef: RefObject<HTMLDivElement | null> }) {
|
||||
const t = useT();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const sentinel = useRef<HTMLDivElement>(null);
|
||||
const load = useCallback(async () => {
|
||||
const el = scrollRef.current;
|
||||
if (loading || !el) return;
|
||||
setLoading(true);
|
||||
const before = { height: el.scrollHeight, top: el.scrollTop };
|
||||
try {
|
||||
// Scrolling stops at a summary; its button loads the history before it.
|
||||
await loadOlder(sessionId, !afterSummary);
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = before.top + (el.scrollHeight - before.height);
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading, sessionId, scrollRef, afterSummary]);
|
||||
useEffect(() => {
|
||||
const node = sentinel.current;
|
||||
if (!node || afterSummary || typeof IntersectionObserver === "undefined") return;
|
||||
const io = new IntersectionObserver((e) => e[0]?.isIntersecting && void load(), { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" });
|
||||
io.observe(node);
|
||||
return () => io.disconnect();
|
||||
}, [afterSummary, load, scrollRef]);
|
||||
return (
|
||||
<div ref={sentinel} className="older-loader">
|
||||
{loading ? (
|
||||
<Spinner small />
|
||||
) : (
|
||||
<button className="small ghost" onClick={() => void load()}>
|
||||
<Icon name="arrow-up" /> {t(afterSummary ? "chat.showBeforeSummary" : "chat.loadOlder")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
||||
const t = useT();
|
||||
const session = useStore((s) => s.sessionById[sessionId]);
|
||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||
const win = useStore((s) => s.windows[sessionId]);
|
||||
// Message to show (search hit, media): scrolled into view and marked.
|
||||
const [highlight, setHighlight] = useState<string>();
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [gallery, setGallery] = useState(false);
|
||||
const draft = useStore((s) => s.drafts[sessionId]);
|
||||
const busy = useStore((s) => !!s.busy[sessionId]);
|
||||
const errors = useStore((s) => s.errors[sessionId]);
|
||||
@@ -226,11 +558,36 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
return m;
|
||||
}, [messages]);
|
||||
|
||||
// Keep scrolled to the bottom while the user hasn't scrolled up.
|
||||
// Keep scrolled to the bottom while the user hasn't scrolled up (only when the
|
||||
// content changes – reading the height forces a layout).
|
||||
const permCount = permissions.length + questions.length;
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, [messages, draft, busy, waiting, compacting, permCount, errors]);
|
||||
|
||||
// Stable callbacks for the (memoised) message list.
|
||||
const rewindRef = useRef<(m: Message) => void>(() => undefined);
|
||||
const onRewind = useCallback((m: Message) => rewindRef.current(m), []);
|
||||
const showLatest = useCallback(async () => {
|
||||
setHighlight(undefined);
|
||||
await loadSession(sessionId);
|
||||
follow(true);
|
||||
}, [sessionId]);
|
||||
/** Load the part of the chat around a message and scroll to it. */
|
||||
const jumpTo = useCallback(
|
||||
async (messageId: string) => {
|
||||
follow(false);
|
||||
if (!getState().messages[sessionId]?.some((m) => m.id === messageId)) await loadAround(sessionId, messageId);
|
||||
setHighlight(messageId);
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!highlight) return;
|
||||
const h = setTimeout(() => document.querySelector(`[data-mid="${highlight}"]`)?.scrollIntoView({ block: "center" }), 50);
|
||||
return () => clearTimeout(h);
|
||||
}, [highlight, messages]);
|
||||
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||||
const hasSession = !!session;
|
||||
useEffect(() => {
|
||||
@@ -280,9 +637,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
}
|
||||
};
|
||||
|
||||
const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1);
|
||||
|
||||
const [rewinding, setRewinding] = useState<Message>();
|
||||
const rewind = (m: Message) => setRewinding(m);
|
||||
rewindRef.current = rewind;
|
||||
const rewindDone = async () => {
|
||||
const m = rewinding!;
|
||||
setRewinding(undefined);
|
||||
@@ -371,6 +729,9 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
>
|
||||
<Icon name="terminal" />
|
||||
</button>
|
||||
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
|
||||
<Icon name="search" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||
<Icon name="folder" />
|
||||
</button>
|
||||
@@ -394,6 +755,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
>
|
||||
<Icon name="edit" /> {t("instructions.title")}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||
setGallery(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="image" /> {t("media.title")}
|
||||
</a>
|
||||
{telegramPaired && !session.parent_id && (
|
||||
<a
|
||||
href="#"
|
||||
@@ -447,6 +818,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{gallery && (
|
||||
<MediaGallery
|
||||
sessionId={sessionId}
|
||||
onClose={() => setGallery(false)}
|
||||
onJump={(id) => {
|
||||
setGallery(false);
|
||||
void jumpTo(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
|
||||
<ChatTabBar state={tabs} />
|
||||
<ChatTabPanes state={tabs} />
|
||||
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
||||
@@ -472,71 +854,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
<div className="chat">
|
||||
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
||||
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
||||
{messages.map((m, idx) => {
|
||||
if (m.role === "assistant") {
|
||||
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
|
||||
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const summary = m.parts.find((p) => p.type === "summary");
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details key={m.id} className="summary-divider">
|
||||
<summary>
|
||||
{summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
<Markdown text={summary.text} />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => 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" />
|
||||
{win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
results={results}
|
||||
busy={busy}
|
||||
canEdit={!busy && !readOnly}
|
||||
canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
|
||||
sessionId={sessionId}
|
||||
model={session.model}
|
||||
baseDir={session.cwd}
|
||||
onRewind={onRewind}
|
||||
highlight={highlight}
|
||||
currency={stats?.currency ?? "USD"}
|
||||
/>
|
||||
{win?.newer && (
|
||||
<button className="back-to-latest" onClick={() => void showLatest()}>
|
||||
<Icon name="arrow-down" /> {t("chat.backToLatest")}
|
||||
</button>
|
||||
</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 && (
|
||||
<div className="msg">
|
||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||
|
||||
@@ -125,6 +125,12 @@ const P: Record<string, ReactNode> = {
|
||||
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4.5 4.5" />
|
||||
</>
|
||||
),
|
||||
eye: (
|
||||
<>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -248,6 +248,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
small_model: s.small_model ?? "",
|
||||
currency: s.currency ?? "USD",
|
||||
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
||||
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
|
||||
max_parallel_subagents: s.max_parallel_subagents ?? 4,
|
||||
monthly_budget: s.monthly_budget ?? 0,
|
||||
budget_warning: s.budget_warning ?? 0.8,
|
||||
@@ -271,6 +272,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
{ path: ["server", "small_model"], value: form.small_model },
|
||||
{ path: ["server", "currency"], value: form.currency },
|
||||
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
||||
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
|
||||
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
||||
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
||||
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
||||
@@ -305,6 +307,9 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<label>{t("settings.autoCompact")}</label>
|
||||
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
||||
<div className="hint">{t("settings.autoCompactHint")}</div>
|
||||
<label>{t("settings.commitTrailer")}</label>
|
||||
<input className="mono" value={form.commit_trailer} placeholder={t("settings.commitTrailerOff")} onChange={(e) => setForm({ ...form, commit_trailer: e.target.value })} />
|
||||
<div className="hint">{t("settings.commitTrailerHint")}</div>
|
||||
<label>{t("settings.monthlyBudgetTotal")}</label>
|
||||
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useState } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -68,6 +69,8 @@ export function TodoList({ todos }: { todos: Todo[] }) {
|
||||
|
||||
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
|
||||
const t = useT();
|
||||
// The body (diff, output) is only built while the card is open – long chats have thousands.
|
||||
const [toggled, setToggled] = useState<boolean | null>(null);
|
||||
const progress = useStore((s) => s.progress[call.id]);
|
||||
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||||
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
|
||||
@@ -118,10 +121,14 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||||
const openByDefault =
|
||||
(status === "err" && !!result) ||
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d"));
|
||||
const open = toggled ?? openByDefault;
|
||||
|
||||
return (
|
||||
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}>
|
||||
<details className={`tool ${status === "run" ? "running" : ""}`} open={open} onToggle={(e) => {
|
||||
const now = (e.currentTarget as HTMLDetailsElement).open;
|
||||
if (now !== open) setToggled(now);
|
||||
}}>
|
||||
<summary>
|
||||
<span className="tool-name">{call.name}</span>
|
||||
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
|
||||
@@ -130,6 +137,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
|
||||
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
||||
</summary>
|
||||
{open && (
|
||||
<div className="tool-body">
|
||||
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||||
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
||||
@@ -156,6 +164,18 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
<pre className="mono">{result.content}</pre>
|
||||
</>
|
||||
)}
|
||||
{typeof meta?.model_file === "string" && (
|
||||
<button
|
||||
className="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("codecraft.openFile", meta.model_file);
|
||||
navigate("/files");
|
||||
}}
|
||||
>
|
||||
<Icon name="cube" /> {t("tool.open3d")}
|
||||
</button>
|
||||
)}
|
||||
{Array.isArray(meta?.image_ids) ? (
|
||||
<div className="gallery small">
|
||||
{meta.image_ids.map((id: string, i: number) => {
|
||||
@@ -186,6 +206,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
||||
refine_model: raw?.refine?.model ?? "",
|
||||
refine_criteria: raw?.refine?.criteria ?? "",
|
||||
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
||||
reverse_tools: !!(raw?.reverse_tools ?? a.reverse),
|
||||
});
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
if (a.name !== "craft") patches.push({ path: [...base, "reverse_tools"], value: form.reverse_tools || null });
|
||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||
if (!builtin) {
|
||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||
@@ -166,6 +168,15 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
||||
<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>
|
||||
<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>}
|
||||
|
||||
+36
-1
@@ -189,7 +189,7 @@ export const de: Messages = {
|
||||
"tools.install": "Ghidra installieren",
|
||||
"tools.reinstall": "Installation prüfen / reparieren",
|
||||
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…",
|
||||
"tools.ghidraHint": "Mit Ghidra können Agenten im Craft-Deep-Modus Programme dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren. Wird samt eigenem JDK ins Daten-Volume installiert.",
|
||||
"tools.ghidraHint": "Mit Ghidra können Agenten Programme und Firmware dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren – im Craft-Deep-Modus und in Modi mit Reverse-Engineering-Werkzeugen (Einstellungen → Agenten & Befehle). Wird samt eigenem JDK ins Daten-Volume installiert.",
|
||||
"rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.",
|
||||
"rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
|
||||
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
|
||||
@@ -336,8 +336,39 @@ export const de: Messages = {
|
||||
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
||||
"chat.reviewPassed": "Review bestanden:",
|
||||
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
||||
"agents.reverseTools": "Reverse-Engineering-Werkzeuge (binary_info, decompile mit Ghidra)",
|
||||
"agents.reverseToolsHint": "Der Agent kann in diesem Modus Programme und Firmware untersuchen und dekompilieren (Ghidra muss unter Einstellungen → Werkzeuge installiert sein). Craft hat sie im Deep-Modus ohnehin.",
|
||||
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
||||
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
||||
"chat.workSteps": "{n} Schritte",
|
||||
"chat.workFiles": "{n} Dateien",
|
||||
"chat.loadOlder": "Ältere Nachrichten laden",
|
||||
"chat.showBeforeSummary": "Verlauf vor der Zusammenfassung anzeigen",
|
||||
"chat.backToLatest": "Zu den neuesten Nachrichten",
|
||||
"chat.search": "Im Chat suchen",
|
||||
"chat.searchPlaceholder": "Nachrichten, Tool-Ausgaben … durchsuchen",
|
||||
"chat.searchNone": "Keine Treffer",
|
||||
"chat.searchHits": "{n} Treffer",
|
||||
"chat.you": "Du",
|
||||
"chat.agent": "Agent",
|
||||
"media.title": "Medien",
|
||||
"media.none": "Noch keine Bilder, Videos oder Audiodateien in diesem Chat.",
|
||||
"media.filter.all": "Alle",
|
||||
"media.filter.image": "Bilder",
|
||||
"media.filter.video": "Videos",
|
||||
"media.filter.model": "3D",
|
||||
"media.filter.audio": "Audio",
|
||||
"media.source.generated": "generiert",
|
||||
"media.source.tool": "Tool-Ausgabe",
|
||||
"media.source.written": "vom Agenten geschrieben",
|
||||
"media.source.answer": "in einer Antwort",
|
||||
"media.source.upload": "dein Anhang",
|
||||
"media.showInChat": "Im Chat zeigen",
|
||||
"media.open": "Öffnen",
|
||||
"media.prev": "Vorheriges",
|
||||
"media.next": "Nächstes",
|
||||
"media.back": "Zurück zur Übersicht",
|
||||
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
|
||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||
"agents.refine": "Review-Schleife",
|
||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||
@@ -683,6 +714,7 @@ export const de: Messages = {
|
||||
"plan.implementPrompt": "Setze den Plan um.",
|
||||
|
||||
"tool.input": "Eingabe",
|
||||
"tool.open3d": "In 3D-Ansicht öffnen",
|
||||
"tool.output": "Ausgabe",
|
||||
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
|
||||
"tool.todos": "Aufgaben",
|
||||
@@ -741,6 +773,9 @@ export const de: Messages = {
|
||||
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
||||
"settings.currency": "Währung",
|
||||
"settings.autoCompact": "Auto-Komprimierung bei",
|
||||
"settings.commitTrailer": "Commit-Vermerk der Agenten",
|
||||
"settings.commitTrailerOff": "aus",
|
||||
"settings.commitTrailerHint": "Wird als Trailer an jeden Git-Commit eines Agenten angehängt. {model} = Modellname, {provider} = Anbieter, {model_id} = Modell-ID. Leer = aus. Deine eigenen Commits bleiben unverändert.",
|
||||
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
||||
"settings.craft": "Craft-Modus",
|
||||
"settings.cleanRoom": "Clean Room",
|
||||
|
||||
+36
-1
@@ -188,7 +188,7 @@ export const en = {
|
||||
"tools.install": "Install Ghidra",
|
||||
"tools.reinstall": "Check / repair installation",
|
||||
"tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…",
|
||||
"tools.ghidraHint": "Ghidra lets agents in craft Deep mode decompile programs (Windows .exe/.dll, Linux, DOS, …) to study how they work. Installed into the data volume together with its own JDK.",
|
||||
"tools.ghidraHint": "Ghidra lets agents decompile programs and firmware (Windows .exe/.dll, Linux, DOS, …) to study how they work – in craft Deep mode and in modes with reverse engineering tools (Settings → Agents & commands). Installed into the data volume together with its own JDK.",
|
||||
"rewind.intro": "The conversation is reset to before this message; you can edit and resend it.",
|
||||
"rewind.noFiles": "The agent did not change files since then.",
|
||||
"rewind.restore": "Also restore the {n} file(s) the agent changed",
|
||||
@@ -335,8 +335,39 @@ export const en = {
|
||||
"activity.reviewing": "Reviewing the result (round {round})…",
|
||||
"chat.reviewPassed": "Review passed:",
|
||||
"chat.todoReminder": "Back to work – {n} open todos:",
|
||||
"agents.reverseTools": "Reverse engineering tools (binary_info, decompile with Ghidra)",
|
||||
"agents.reverseToolsHint": "The agent can inspect and decompile programs and firmware in this mode (Ghidra must be installed under Settings → Tools). Craft has them anyway in deep mode.",
|
||||
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
||||
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
||||
"chat.workSteps": "{n} steps",
|
||||
"chat.workFiles": "{n} files",
|
||||
"chat.loadOlder": "Load older messages",
|
||||
"chat.showBeforeSummary": "Show the history before the summary",
|
||||
"chat.backToLatest": "Back to the latest messages",
|
||||
"chat.search": "Search in this chat",
|
||||
"chat.searchPlaceholder": "Search messages, tool output …",
|
||||
"chat.searchNone": "No matches",
|
||||
"chat.searchHits": "{n} matches",
|
||||
"chat.you": "You",
|
||||
"chat.agent": "Agent",
|
||||
"media.title": "Media",
|
||||
"media.none": "No images, videos or audio in this chat yet.",
|
||||
"media.filter.all": "All",
|
||||
"media.filter.image": "Images",
|
||||
"media.filter.video": "Videos",
|
||||
"media.filter.model": "3D",
|
||||
"media.filter.audio": "Audio",
|
||||
"media.source.generated": "generated",
|
||||
"media.source.tool": "tool output",
|
||||
"media.source.written": "written by the agent",
|
||||
"media.source.answer": "in an answer",
|
||||
"media.source.upload": "your attachment",
|
||||
"media.showInChat": "Show in chat",
|
||||
"media.open": "Open",
|
||||
"media.prev": "Previous",
|
||||
"media.next": "Next",
|
||||
"media.back": "Back to the overview",
|
||||
"chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)",
|
||||
"chat.reviewRound": "Review {round}: changes requested",
|
||||
"agents.refine": "Review loop",
|
||||
"agents.refineRounds": "Review rounds per task",
|
||||
@@ -682,6 +713,7 @@ export const en = {
|
||||
"plan.implementPrompt": "Implement the plan.",
|
||||
|
||||
"tool.input": "Input",
|
||||
"tool.open3d": "Open in the 3D view",
|
||||
"tool.output": "Output",
|
||||
"tool.todosUpdated": "Tasks updated ({done}/{total} done)",
|
||||
"tool.todos": "Tasks",
|
||||
@@ -740,6 +772,9 @@ export const en = {
|
||||
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
||||
"settings.currency": "Currency",
|
||||
"settings.autoCompact": "Auto-compact at",
|
||||
"settings.commitTrailer": "Commit note of agents",
|
||||
"settings.commitTrailerOff": "off",
|
||||
"settings.commitTrailerHint": "Added as a trailer to every git commit an agent makes. {model} = model name, {provider} = provider, {model_id} = model id. Empty = off. Your own commits stay unchanged.",
|
||||
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
||||
"settings.craft": "Craft mode",
|
||||
"settings.cleanRoom": "Clean room",
|
||||
|
||||
+39
-2
@@ -32,6 +32,8 @@ export interface State {
|
||||
/** Includes subagent sessions loaded on demand. */
|
||||
sessionById: Record<string, Session>;
|
||||
messages: Record<string, Message[]>;
|
||||
/** Long chats are loaded in parts: are there older / newer messages than the loaded ones? */
|
||||
windows: Record<string, { older: boolean; newer: boolean } | undefined>;
|
||||
/** Sessions the current user may only read (shared with them). */
|
||||
readOnly: Record<string, boolean>;
|
||||
drafts: Record<string, Draft | undefined>;
|
||||
@@ -58,6 +60,7 @@ let state: State = {
|
||||
sessions: [],
|
||||
sessionById: {},
|
||||
messages: {},
|
||||
windows: {},
|
||||
readOnly: {},
|
||||
drafts: {},
|
||||
busy: {},
|
||||
@@ -130,7 +133,8 @@ export function applyEvent(ev: ServerEvent) {
|
||||
setState((s) => {
|
||||
const list = s.messages[ev.session_id];
|
||||
const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts;
|
||||
if (!list) return { drafts };
|
||||
// Viewing an older part of the chat: the new message comes with "back to the end".
|
||||
if (!list || s.windows[ev.session_id]?.newer) return { drafts };
|
||||
if (list.some((m) => m.id === ev.message.id)) return { drafts };
|
||||
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
||||
});
|
||||
@@ -231,9 +235,17 @@ export async function loadSessions() {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Messages loaded when a chat opens (from the last summary on, at most this many). */
|
||||
export const PAGE = 200;
|
||||
|
||||
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
|
||||
|
||||
export async function loadSession(id: string) {
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any }>(`/sessions/${id}`);
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>(
|
||||
`/sessions/${id}?limit=${PAGE}`,
|
||||
);
|
||||
setState((s) => ({
|
||||
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
|
||||
...upsertSession(s, data.session),
|
||||
sessionById: { ...s.sessionById, [id]: data.session },
|
||||
messages: { ...s.messages, [id]: data.messages },
|
||||
@@ -247,6 +259,31 @@ export async function loadSession(id: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Older messages before the loaded ones (scrolling up). Returns how many came. */
|
||||
export async function loadOlder(id: string, stopAtSummary = true): Promise<number> {
|
||||
const first = getState().messages[id]?.[0];
|
||||
if (!first) return 0;
|
||||
const page = await get<Page>(`/sessions/${id}/messages?before=${encodeURIComponent(first.id)}&limit=${PAGE}${stopAtSummary ? "&stop_at_summary=true" : ""}`);
|
||||
setState((s) => {
|
||||
const cur = s.messages[id] ?? [];
|
||||
if (cur[0]?.id !== first.id) return {}; // changed meanwhile
|
||||
return {
|
||||
messages: { ...s.messages, [id]: [...page.messages, ...cur] },
|
||||
windows: { ...s.windows, [id]: { older: page.has_older, newer: s.windows[id]?.newer ?? false } },
|
||||
};
|
||||
});
|
||||
return page.messages.length;
|
||||
}
|
||||
|
||||
/** Show the part of the chat around a message (search hit, media). */
|
||||
export async function loadAround(id: string, messageId: string) {
|
||||
const page = await get<Page>(`/sessions/${id}/messages?around=${encodeURIComponent(messageId)}&limit=${PAGE}`);
|
||||
setState((s) => ({
|
||||
messages: { ...s.messages, [id]: page.messages },
|
||||
windows: { ...s.windows, [id]: { older: page.has_older, newer: page.has_newer } },
|
||||
}));
|
||||
}
|
||||
|
||||
/** After reconnects: reload everything that could have changed. */
|
||||
export async function resync() {
|
||||
const isAdmin = getState().me?.role === "admin";
|
||||
|
||||
+255
-1
@@ -456,7 +456,8 @@ button.session-group:hover {
|
||||
}
|
||||
.topbar .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* The title stays readable: with many chips they wrap into a second row instead. */
|
||||
min-width: min(160px, 40vw);
|
||||
}
|
||||
.topbar h1 {
|
||||
font-size: 15px;
|
||||
@@ -4185,3 +4186,256 @@ span.chat-tab > button {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- long chats ------------------------------------------------------------------
|
||||
* Finished turns fold into one line, older messages load on demand, search hits. */
|
||||
.work-block > .work-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px 0;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.work-block > .work-head:hover {
|
||||
color: var(--fg1);
|
||||
}
|
||||
.work-block.open {
|
||||
border-left: 2px solid var(--bg2);
|
||||
padding-left: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.older-loader {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0 12px;
|
||||
}
|
||||
.back-to-latest {
|
||||
position: sticky;
|
||||
bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 10px auto 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.msg.highlight,
|
||||
.summary-divider[data-mid].highlight {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.chat-search {
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
padding: 6px 12px;
|
||||
background: var(--bg0);
|
||||
}
|
||||
.chat-search > .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-search > .row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-search-hits {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.chat-search-hits button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.chat-search-hits button:hover {
|
||||
background: var(--bg1);
|
||||
}
|
||||
.chat-search-hits .who {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.chat-search-hits .snippet {
|
||||
font-size: 13px;
|
||||
color: var(--fg1);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* --- media overview of a chat --------------------------------------------------- */
|
||||
.media-gallery {
|
||||
width: min(1100px, calc(100vw - 24px));
|
||||
height: min(820px, calc(100dvh - 24px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.media-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.media-head h3 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.media-filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
.media-filters .chip-btn {
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.media-filters small {
|
||||
color: var(--muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.media-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-auto-rows: 150px;
|
||||
gap: 8px;
|
||||
}
|
||||
.media-tile {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--bg3);
|
||||
}
|
||||
.media-tile img,
|
||||
.media-tile video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.media-tile .media-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
.media-audio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--fg2);
|
||||
word-break: break-all;
|
||||
}
|
||||
.media-audio .icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.media-viewer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.media-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.media-stage img,
|
||||
.media-stage video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.media-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.media-bar .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Tokens and cost under each answer. */
|
||||
.turn-usage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin: -6px 0 12px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg0);
|
||||
color: var(--accent);
|
||||
}
|
||||
.media-model {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-height: 100%;
|
||||
}
|
||||
.media-model img {
|
||||
max-height: calc(100% - 50px);
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.media-model > .icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const THEMES = [
|
||||
{ id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" },
|
||||
{ id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" },
|
||||
{ id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" },
|
||||
{ id: "tintenblau", name: "Tintenblau", source: "zink/tintenblau", license: "private" },
|
||||
] as const;
|
||||
|
||||
export type Theme = (typeof THEMES)[number]["id"];
|
||||
|
||||
@@ -30,3 +30,5 @@ import "./everforest-dark.css";
|
||||
import "./everforest-light.css";
|
||||
import "./kanagawa-dark.css";
|
||||
import "./kanagawa-light.css";
|
||||
import "./tintenblau-dark.css";
|
||||
import "./tintenblau-light.css";
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Tintenblau Dark
|
||||
* Palette: zink/tintenblau (colors.toml, neovim.lua, zed.json of the desktop theme).
|
||||
*/
|
||||
:root[data-theme="tintenblau-dark"] {
|
||||
color-scheme: dark;
|
||||
--theme-color: #232a44;
|
||||
|
||||
--bg0: #232a44;
|
||||
--bg1: #2b3352;
|
||||
--bg2: #353e60;
|
||||
--bg3: #4a5478;
|
||||
--fg0: #f5efe3;
|
||||
--fg1: #eae4d6;
|
||||
--fg2: #d6d0c2;
|
||||
--fg3: #bdcfd4;
|
||||
--muted: #8e96b4;
|
||||
|
||||
--accent: #7aa0cc;
|
||||
--accent2: #d4b46a;
|
||||
--on-accent: #232a44;
|
||||
--red: #d47a6a;
|
||||
--green: #96b4bc;
|
||||
--aqua: #96b4bc;
|
||||
--blue: #7aa0cc;
|
||||
--purple: #c896b2;
|
||||
|
||||
--diff-add: rgba(150, 180, 188, 0.16);
|
||||
--diff-del: rgba(212, 122, 106, 0.16);
|
||||
--shadow: 0 6px 24px rgba(10, 14, 30, 0.45);
|
||||
|
||||
--syntax-keyword: #c896b2;
|
||||
--syntax-string: #d47a6a;
|
||||
--syntax-number: #96b4bc;
|
||||
--syntax-comment: #8e96b4;
|
||||
--syntax-function: #7aa0cc;
|
||||
--syntax-type: #d4b46a;
|
||||
--syntax-variable: #eae4d6;
|
||||
--syntax-meta: #baa0c4;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Tintenblau (light)
|
||||
* Palette: zink/tintenblau (colors.toml, neovim.lua, zed.json of the desktop theme).
|
||||
*/
|
||||
:root[data-theme="tintenblau-light"] {
|
||||
color-scheme: light;
|
||||
--theme-color: #f0ead9;
|
||||
|
||||
--bg0: #f0ead9;
|
||||
--bg1: #e6dfd0;
|
||||
--bg2: #dbd3c1;
|
||||
--bg3: #c9c0ac;
|
||||
--fg0: #0f1628;
|
||||
--fg1: #1e2a4a;
|
||||
--fg2: #3a3f5a;
|
||||
--fg3: #5e5a6a;
|
||||
--muted: #8a8694;
|
||||
|
||||
--accent: #2a3f6f;
|
||||
--accent2: #6a4a2c;
|
||||
--on-accent: #f0ead9;
|
||||
--red: #8a3028;
|
||||
--green: #4a5a3a;
|
||||
--aqua: #3a5a6a;
|
||||
--blue: #3a5a8a;
|
||||
--purple: #5a4a72;
|
||||
|
||||
--diff-add: rgba(74, 90, 58, 0.14);
|
||||
--diff-del: rgba(138, 48, 40, 0.12);
|
||||
--shadow: 0 6px 24px rgba(30, 42, 74, 0.16);
|
||||
|
||||
--syntax-keyword: #2a3f6f;
|
||||
--syntax-string: #8a3028;
|
||||
--syntax-number: #5a4a72;
|
||||
--syntax-comment: #8a8694;
|
||||
--syntax-function: #3a5a6a;
|
||||
--syntax-type: #6a4a2c;
|
||||
--syntax-variable: #1e2a4a;
|
||||
--syntax-meta: #4a5a3a;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export interface Usage {
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
/** Cost of the request (charged, or valued at list prices for plan requests). */
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -168,6 +170,8 @@ export interface AgentInfo {
|
||||
source: "builtin" | "config" | "file" | "personal";
|
||||
max_parallel?: number | null;
|
||||
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
|
||||
/** binary_info and decompile (Ghidra) available in this mode. */
|
||||
reverse?: boolean;
|
||||
/** Source file of Markdown agents. */
|
||||
path?: string | null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00024": {
|
||||
en: ["3D models with fal.ai: from an image (also pasted into the chat), a text prompt or both – open them in the 3D view"],
|
||||
de: ["3D-Modelle mit fal.ai: aus einem Bild (auch eingefügt), einem Text oder beidem – öffnen in der 3D-Ansicht"],
|
||||
},
|
||||
"202611-00023": {
|
||||
en: ["Tokens and cost under every answer; the total cost of chats with opencode Go is shown correctly again"],
|
||||
de: ["Tokens und Kosten unter jeder Antwort; der Gesamtpreis von Chats mit opencode Go stimmt wieder"],
|
||||
},
|
||||
"202611-00022": {
|
||||
en: ["New theme “Tintenblau” (Settings → General → Theme)"],
|
||||
de: ["Neues Theme „Tintenblau“ (Einstellungen → Allgemein → Theme)"],
|
||||
},
|
||||
"202611-00021": {
|
||||
en: ["Git commits of agents name the model: “Co-Authored-By: <model> (<provider>) via CodeCraft” (Settings → General)"],
|
||||
de: ["Git-Commits von Agenten nennen das Modell: „Co-Authored-By: <Modell> (<Anbieter>) via CodeCraft“ (Einstellungen → Allgemein)"],
|
||||
},
|
||||
"202611-00020": {
|
||||
en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"],
|
||||
de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"],
|
||||
},
|
||||
"202611-00019": {
|
||||
en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"],
|
||||
de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"],
|
||||
},
|
||||
"202611-00018": {
|
||||
en: [
|
||||
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
|
||||
|
||||
Reference in new issue
Block a user