Compare commits

...
Author SHA1 Message Date
Richard Zink 084452abf6 Release v202611-00025 2026-11-12 12:12:19 +01:00
Richard ZinkandClaude Opus 5.5 8b4bd59572 mcp_search also points to built-in tools and their availability
Models looked for generate_3d with mcp_search, which only searched MCP
servers, and concluded it did not exist (the chat had been in plan mode, then
switched to build). The search now lists matching built-in tools first:
"available – call it directly" or "not in the read-only mode plan – ask the
user to switch to build mode".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:12:19 +01:00
Richard Zink c3df4a23cc Release v202611-00024 2026-11-12 11:38:25 +01:00
Richard ZinkandClaude Opus 5.5 48621f21d6 generate_3d: 3D models with fal.ai (image, text, or image steered by text)
- New tool: image-to-3D (project file or an image attached to the chat,
  optional back/left/right views) or text-to-3D; image + prompt refines the
  image with the prompt first (the 3D models take no text with an image).
  Styles textured / geometry / lowpoly, optional PBR, face count. Runs through
  fal.ai's queue API with progress, saves GLB + preview, books the cost
  (published prices, overridable per model). Request bodies for the Hunyuan3D,
  Tripo and Trellis families; defaults [images] model_3d / model_3d_text.
- Chat card: "Open in the 3D view"; media overview: kind "model" (also
  STL/3MF/OBJ written by agents) with previews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:38:24 +01:00
Richard Zink d7baa77922 Release v202611-00023 2026-11-12 07:58:23 +01:00
Richard ZinkandClaude Opus 5.5 1e8418336d Value opencode Go plan requests; per-duration cache prices; usage under answers
- opencode Go reports plan requests as cost 0 with the usage in allowance;
  ModelConfig::cost values them at the model's list prices (charged amounts
  and free requests stay as reported). Rows booked at 0 are valued once on
  start (idempotent).
- opencode's price list now has per-duration cache prices for some models
  ({"30m": ..., "12h": ...}): the shortest duration is used, also in tiers.
  Saving the provider's models failed with "untagged enum ModelEntry".
- Each answer shows tokens sent/received and the cost of the turn's requests
  (usage.cost filled when messages are saved and loaded).
- The chat title keeps a minimum width in a crowded top bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:58:23 +01:00
Richard Zink 33612bf6d4 Release v202611-00022 2026-11-11 11:04:20 +01:00
Richard ZinkandClaude Opus 5.5 ffecd62b83 Theme "Tintenblau" (dark and light)
Palette from the Tintenblau desktop theme (colors.toml, neovim.lua, zed.json):
ink blue with cream text, paper with dark ink. Also in the terminal client's
palettes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:04:19 +01:00
Richard Zink c77dcf5890 Release v202611-00021 2026-11-11 10:01:07 +01:00
Richard ZinkandClaude Opus 5.5 9c80f2c857 Commits of agents carry a co-author trailer naming the model
A git wrapper (docker/git-coauthor -> /usr/local/lib/codecraft/bin/git) adds
--trailer "$CODECRAFT_COMMIT_TRAILER" right after the commit subcommand; the
agent shell puts it first on PATH and sets the trailer in the script itself
(works inside the sandbox, which clears the environment). Default text:
"Co-Authored-By: {model} ({provider}) via CodeCraft" (server.commit_trailer,
Settings -> General; empty = off). Git skips duplicates (amend, model wrote it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:01:07 +01:00
32 changed files with 1108 additions and 35 deletions

No files matched your search

+56
View File
@@ -3,6 +3,62 @@
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
+2
View File
@@ -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.
+6 -1
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version v202611-00020" src="https://img.shields.io/badge/version-v202611--00020-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):
+54
View File
@@ -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
View File
@@ -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 {
+21
View File
@@ -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" "$@"
+15 -2
View File
@@ -843,6 +843,19 @@ impl WindowQ {
}
}
/// 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 busy = app.is_busy(&id);
@@ -853,7 +866,7 @@ async fn get_session(Extension(user): Extension<User>, State(app): State<AppStat
match q.window() {
Some(w) => {
let (messages, older, newer) = app.store.messages_window(&id, &w)?;
out["messages"] = json!(messages);
out["messages"] = json!(with_costs(&app, messages));
out["has_older"] = json!(older);
out["has_newer"] = json!(newer);
}
@@ -867,7 +880,7 @@ async fn session_messages(Extension(user): Extension<User>, State(app): State<Ap
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": messages, "has_older": older, "has_newer": newer})))
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).
+5
View File
@@ -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"),
+72 -10
View File
@@ -361,6 +361,49 @@ fn opencode_when(w: &Value) -> Option<When> {
Some((days, hours, from, until))
}
/// Plan requests (opencode Go) that were booked at 0: value them at the model's list
/// prices of the time of the request. Idempotent – valued rows no longer match.
pub fn value_plan_requests(app: &App) -> Result<usize> {
let mut n = 0;
for (row, ts, spec, usage) in app.store.plan_requests_without_cost()? {
let Ok((_, model)) = app.registry().resolve(&spec) else { continue };
let Some(at) = chrono::DateTime::from_timestamp_millis(ts) else { continue };
let cost = model.cost_at(&usage, at);
if cost > 0.0 {
app.store.set_usage_cost(row, cost)?;
n += 1;
}
}
Ok(n)
}
/// A price from opencode's list: a number, or one price per cache duration
/// (`{"30m": 0.125, "12h": 0.25}`) – then the shortest duration (the default caching).
fn price_of(v: &Value) -> Value {
match v {
Value::Number(_) => v.clone(),
Value::Object(o) => {
let secs = |k: &str| -> Option<f64> {
let (num, unit) = k.split_at(k.find(|c: char| c.is_ascii_alphabetic())?);
let n: f64 = num.parse().ok()?;
Some(n * match unit {
"s" => 1.0,
"m" => 60.0,
"h" => 3600.0,
"d" => 86400.0,
_ => return None,
})
};
o.iter()
.filter(|(_, x)| x.is_number())
.min_by(|a, b| secs(a.0).unwrap_or(f64::MAX).total_cmp(&secs(b.0).unwrap_or(f64::MAX)))
.map(|(_, x)| x.clone())
.unwrap_or(Value::Null)
}
_ => Value::Null,
}
}
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
fn opencode_model(m: &Value) -> Value {
let cost = &m["cost"]["text"];
@@ -371,13 +414,13 @@ fn opencode_model(m: &Value) -> Value {
for o in side["overrides"].as_array().into_iter().flatten() {
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
let t = tiers.entry(gt).or_default();
t.insert(field.into(), o["price"].clone());
t.insert(field.into(), price_of(&o["price"]));
if field == "input_price" {
if o["cache"]["read"].is_number() {
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
}
if o["cache"]["write"].is_number() {
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
for (key, k) in [("read", "cache_read_price"), ("write", "cache_write_price")] {
let p = price_of(&o["cache"][key]);
if !p.is_null() {
t.insert(k.into(), p);
}
}
}
}
@@ -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},
+21
View File
@@ -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(),
}
}
}
+4 -4
View File
@@ -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();
+1
View File
@@ -23,6 +23,7 @@ mod mentions;
mod metrics;
mod mcp;
mod media;
mod mesh3d;
mod permission;
mod prompts;
mod push;
+41 -2
View File
@@ -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));
+26 -6
View File
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, PartialEq)]
pub struct MediaItem {
/// "image", "video" or "audio".
/// "image", "video", "audio" or "model" (3D).
pub kind: &'static str,
pub url: String,
pub name: String,
@@ -21,11 +21,17 @@ pub struct MediaItem {
/// 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> {
@@ -36,6 +42,8 @@ pub fn kind_of(path: &str) -> Option<&'static str> {
Some("video")
} else if AUDIO.contains(&ext.as_str()) {
Some("audio")
} else if MODEL.contains(&ext.as_str()) {
Some("model")
} else {
None
}
@@ -118,7 +126,7 @@ pub fn collect(app: &App, session_id: &str) -> anyhow::Result<Vec<MediaItem>> {
// 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 },
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,
);
}
@@ -133,6 +141,10 @@ pub fn collect(app: &App, session_id: &str) -> anyhow::Result<Vec<MediaItem>> {
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()
}
@@ -144,25 +156,33 @@ fn collect_message(m: &Message, cwd: &Path, own: bool, call_msg: Option<&str>, a
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: format!("/api/files/raw?path={}", encode(&abs.display().to_string())), name: file_name(p), source, created_at: at, message_id: mid.clone() })
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() });
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() });
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() });
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 });
}
}
}
+430
View File
@@ -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 &notes {
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");
}
}
+3 -2
View File
@@ -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);
@@ -1113,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() });
+33
View File
@@ -653,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> {
+46 -1
View File
@@ -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'");
}
}
+1
View File
@@ -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),
]
+44 -2
View File
@@ -245,8 +245,10 @@ const MessageList = memo(function MessageList({
baseDir,
onRewind,
highlight,
currency,
}: {
messages: Message[];
currency: string;
results: Map<string, ToolResult>;
busy: boolean;
canEdit: boolean;
@@ -280,13 +282,52 @@ const MessageList = memo(function MessageList({
));
// 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[];
if (i === lastWork || steps.length < 2) return <div key={key}>{items}</div>;
return <WorkBlock key={key} messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>{items}</WorkBlock>;
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();
@@ -825,6 +866,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
baseDir={session.cwd}
onRewind={onRewind}
highlight={highlight}
currency={stats?.currency ?? "USD"}
/>
{win?.newer && (
<button className="back-to-latest" onClick={() => void showLatest()}>
+32 -2
View File
@@ -3,14 +3,24 @@ 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";
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"];
@@ -55,7 +65,7 @@ export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string
<Icon name="image" /> {t("media.title")}
</h3>
<div className="media-filters" role="radiogroup">
{(["all", "image", "video", "audio"] as Filter[]).map((k) => (
{(["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>
@@ -78,6 +88,16 @@ export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string
{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")}>
@@ -115,6 +135,16 @@ export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string
</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>
+5
View File
@@ -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>
+13 -1
View File
@@ -121,7 +121,7 @@ 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 (
@@ -164,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) => {
+6
View File
@@ -356,6 +356,7 @@ export const de: Messages = {
"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",
@@ -367,6 +368,7 @@ export const de: Messages = {
"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",
@@ -712,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",
@@ -770,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",
+6
View File
@@ -355,6 +355,7 @@ export const en = {
"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",
@@ -366,6 +367,7 @@ export const en = {
"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",
@@ -711,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",
@@ -769,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",
+42 -1
View File
@@ -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;
@@ -4398,3 +4399,43 @@ span.chat-tab > button {
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);
}
+1
View File
@@ -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"];
+2
View File
@@ -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";
+40
View File
@@ -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;
}
+40
View File
@@ -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;
}
+2
View File
@@ -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 {
+16
View File
@@ -3,6 +3,22 @@
* 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"],