Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes
- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas, login rate limiting, audit log and backups - Ghidra headless decompilation (ARM64 natives built from source), binary_info tool, clean room / deep workflow per session - Recreate skill and commands for analysing and re-building old software - File checkpoints with rewind/restore, background shells (bash_output, kill_shell) - web_search tool (DuckDuckGo, SearXNG, Brave, Tavily) - In-app dialogs and toasts instead of browser dialogs - 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One, Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fab9add5d5
commit
5b569b6835
108 files changed
+6785
-279
No files matched your search
+14
-3
@@ -23,15 +23,26 @@ RUN cd server && touch src/main.rs && cargo build --release
|
|||||||
# Node is included so agents can work on JS projects and run npx-based MCP servers.
|
# Node is included so agents can work on JS projects and run npx-based MCP servers.
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates bash git curl ripgrep jq less procps \
|
ca-certificates bash git curl wget ripgrep jq less procps file bubblewrap \
|
||||||
python3 python3-venv build-essential \
|
unzip zip xz-utils bzip2 p7zip-full unar innoextract unshield cabextract msitools arj lhasa \
|
||||||
chromium fonts-dejavu-core fonts-noto-color-emoji \
|
python3 python3-pip python3-venv pipx \
|
||||||
|
build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \
|
||||||
|
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=server /src/server/target/release/codecraft /usr/local/bin/codecraft
|
COPY --from=server /src/server/target/release/codecraft /usr/local/bin/codecraft
|
||||||
COPY --from=web /web/dist /app/web
|
COPY --from=web /web/dist /app/web
|
||||||
|
COPY server/ghidra /app/ghidra
|
||||||
|
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.
|
||||||
ENV CODECRAFT_WEB=/app/web \
|
ENV CODECRAFT_WEB=/app/web \
|
||||||
CODECRAFT_CONFIG=/data/config.toml \
|
CODECRAFT_CONFIG=/data/config.toml \
|
||||||
HOME=/data/home \
|
HOME=/data/home \
|
||||||
|
PATH=/data/home/.local/bin:/data/home/.cargo/bin:/data/home/.npm-global/bin:/usr/local/bin:/usr/bin:/bin \
|
||||||
|
NPM_CONFIG_PREFIX=/data/home/.npm-global \
|
||||||
|
PIP_BREAK_SYSTEM_PACKAGES=1 \
|
||||||
|
PIPX_HOME=/data/home/.local/pipx \
|
||||||
|
PIPX_BIN_DIR=/data/home/.local/bin \
|
||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
RUN mkdir -p /data /workspace && chmod 777 /data /workspace
|
RUN mkdir -p /data /workspace && chmod 777 /data /workspace
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|||||||
@@ -173,16 +173,46 @@ Compound shell commands (`&&`, `;`, `|`) are only auto-allowed when every part i
|
|||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
Colours, syntax highlighting and chart palettes come from CSS variables. Each theme is one
|
Pick a **base theme** in *Settings → Interface* and switch **light / dark / auto** there or with
|
||||||
file in [`web/src/themes`](web/src/themes) (Gruvbox Dark and Light ship by default):
|
the ◐ button in the sidebar (auto follows the system preference). Every base theme has a dark and
|
||||||
|
a light variant. The palettes come from [zink/trilium-themes](https://git.familie-zink.org/zink/trilium-themes)
|
||||||
|
(MIT, see [`web/src/themes/LICENSE-trilium-themes`](web/src/themes/LICENSE-trilium-themes)), which
|
||||||
|
in turn are based on:
|
||||||
|
|
||||||
1. Copy `gruvbox-dark.css` to `<id>.css`, change the values on `:root[data-theme="<id>"]`.
|
| Theme | Variants | Palette source | License |
|
||||||
2. Import it in `themes/index.ts` and add it to `THEMES` in `web/src/theme.ts`.
|
|---|---|---|---|
|
||||||
|
| Gruvbox | Dark, Light | [morhetz/gruvbox](https://github.com/morhetz/gruvbox) | MIT |
|
||||||
|
| Ash | Dark, Light | original, Gruvbox-derived ([zink/trilium-themes](https://git.familie-zink.org/zink/trilium-themes)) | MIT |
|
||||||
|
| Harbor | Dark, Light | [HANCORE-linux/omarchy-harbordark-theme](https://github.com/HANCORE-linux/omarchy-harbordark-theme) | MIT |
|
||||||
|
| Coffee | Dark, Latte | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) | MIT |
|
||||||
|
| Solarized | Dark, Light | [Solarized by Ethan Schoonover](https://ethanschoonover.com/solarized/) | MIT |
|
||||||
|
| Tokyo Night | Night, Day | [folke/tokyonight.nvim](https://github.com/folke/tokyonight.nvim) | Apache-2.0 |
|
||||||
|
|
||||||
|
Further standard themes (palettes re-created from the published colour specifications):
|
||||||
|
|
||||||
|
| Theme | Variants | Palette source | License |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Nord | Dark, Light (Snow Storm background) | [nordtheme/nord](https://github.com/nordtheme/nord) | MIT |
|
||||||
|
| VS Code | Dark Modern, Light Modern | [microsoft/vscode](https://github.com/microsoft/vscode) | MIT |
|
||||||
|
| GitHub | Dark, Light | [primer/github-vscode-theme](https://github.com/primer/github-vscode-theme) | MIT |
|
||||||
|
| Catppuccin | Mocha, Latte | [catppuccin/catppuccin](https://github.com/catppuccin/catppuccin) | MIT |
|
||||||
|
| One | One Dark, One Light | [atom/atom](https://github.com/atom/atom) | MIT |
|
||||||
|
| Dracula | Dracula, Alucard | [dracula/dracula-theme](https://github.com/dracula/dracula-theme) | MIT |
|
||||||
|
| Rosé Pine | Main, Dawn | [rose-pine/rose-pine-theme](https://github.com/rose-pine/rose-pine-theme) | MIT |
|
||||||
|
| Everforest | Dark, Light | [sainnhe/everforest](https://github.com/sainnhe/everforest) | MIT |
|
||||||
|
| Kanagawa | Wave, Lotus | [rebelot/kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) | MIT |
|
||||||
|
|
||||||
|
Colours, syntax highlighting and chart palettes come from CSS variables, one file per variant in
|
||||||
|
[`web/src/themes`](web/src/themes). To add a base theme `<id>`:
|
||||||
|
|
||||||
|
1. Copy `gruvbox-dark.css` / `gruvbox-light.css` to `<id>-dark.css` / `<id>-light.css` and change
|
||||||
|
the values on `:root[data-theme="<id>-dark"]` / `"<id>-light"`; note source and license in the header.
|
||||||
|
2. Import both in `themes/index.ts` and add the base theme to `THEMES` in `web/src/theme.ts`.
|
||||||
|
|
||||||
The variables cover surfaces (`--bg0`–`--bg3`), text (`--fg0`–`--fg3`, `--muted`), accents,
|
The variables cover surfaces (`--bg0`–`--bg3`), text (`--fg0`–`--fg3`, `--muted`), accents,
|
||||||
diffs, syntax (`--syntax-*`), charts (`--series-1`–`8` categorical, `--seq-0`–`6` sequential)
|
diffs, syntax (`--syntax-*`) and the browser bar colour (`--theme-color`). The chart palettes
|
||||||
and the browser bar colour (`--theme-color`). The chart colours are validated for colour-vision
|
(`--series-1`–`8` categorical, `--seq-1`–`6` sequential) live in `themes/base.css` per light/dark
|
||||||
deficiencies against the background – re-check them when the background changes a lot.
|
mode and are validated for colour-vision deficiencies.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ enabled = false
|
|||||||
# type = "stdio" → command + args (+ env), started as a child process
|
# type = "stdio" → command + args (+ env), started as a child process
|
||||||
# type = "http" → Streamable HTTP endpoint (url + optional headers)
|
# type = "http" → Streamable HTTP endpoint (url + optional headers)
|
||||||
# Tools appear to the model as mcp__<server>__<tool>.
|
# Tools appear to the model as mcp__<server>__<tool>.
|
||||||
|
# users = [...]: who may use the server. Default: admins only.
|
||||||
|
# Use ["*"] for all users or list usernames, e.g. ["anna", "ben"].
|
||||||
# load = "auto" (default) | "always" | "on_demand": servers with many tools
|
# load = "auto" (default) | "always" | "on_demand": servers with many tools
|
||||||
# (more than server.mcp_direct_limit) are not sent with every request; the
|
# (more than server.mcp_direct_limit) are not sent with every request; the
|
||||||
# model finds them with mcp_search and calls them with mcp_call. This keeps
|
# model finds them with mcp_search and calls them with mcp_call. This keeps
|
||||||
@@ -176,7 +178,10 @@ task = "allow"
|
|||||||
write = "ask"
|
write = "ask"
|
||||||
edit = "ask"
|
edit = "ask"
|
||||||
bash = "ask"
|
bash = "ask"
|
||||||
|
bash_output = "allow"
|
||||||
|
kill_shell = "allow"
|
||||||
web_fetch = "ask"
|
web_fetch = "ask"
|
||||||
|
web_search = "allow"
|
||||||
browser_snapshot = "allow"
|
browser_snapshot = "allow"
|
||||||
image_compare = "allow"
|
image_compare = "allow"
|
||||||
generate_image = "ask" # costs money per image
|
generate_image = "ask" # costs money per image
|
||||||
@@ -283,6 +288,30 @@ link_by_username = false
|
|||||||
button_label = "Single sign-on"
|
button_label = "Single sign-on"
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Web search (web_search tool)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
[search]
|
||||||
|
# "duckduckgo" (no key, may be rate-limited), "searxng" (self-hosted, set url),
|
||||||
|
# "brave" or "tavily" (api_key, e.g. "${BRAVE_API_KEY}")
|
||||||
|
provider = "duckduckgo"
|
||||||
|
url = ""
|
||||||
|
api_key = ""
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Sandbox
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Shell commands and git of a user run in a bubblewrap sandbox: system
|
||||||
|
# read-only, only the user's folders writable, own home, own processes, no
|
||||||
|
# server environment. The network is shared (package managers keep working).
|
||||||
|
# Needs the security options in docker-compose.yml. Without a working
|
||||||
|
# sandbox, non-admin users cannot run shell commands.
|
||||||
|
[sandbox]
|
||||||
|
# "auto" = non-admin users, "always" = everybody (admins too), "off" = nobody
|
||||||
|
mode = "auto"
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Craft mode
|
# Craft mode
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
shm_size: "1gb" # Chromium (craft mode)
|
shm_size: "1gb" # Chromium (craft mode)
|
||||||
|
# Shell commands of non-admin users run in a bubblewrap sandbox. It needs
|
||||||
|
# user namespaces: a seccomp profile that adds exactly those syscalls to
|
||||||
|
# Docker's default, AppArmor relaxed for the namespace mounts, and an
|
||||||
|
# unmasked /proc so the sandbox can mount its own (hiding server processes).
|
||||||
|
# Remove these three lines and set [sandbox] mode = "off" if you are the
|
||||||
|
# only user.
|
||||||
|
security_opt:
|
||||||
|
- seccomp=./docker/seccomp-sandbox.json
|
||||||
|
- apparmor=unconfined
|
||||||
|
- systempaths=unconfined
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
codecraft-data:
|
codecraft-data:
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Generated
+165
@@ -55,6 +55,16 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atoi_simd"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44"
|
||||||
|
dependencies = [
|
||||||
|
"debug_unsafe",
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -230,6 +240,12 @@ version = "1.25.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder-lite"
|
name = "byteorder-lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -242,6 +258,23 @@ version = "1.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "calamine"
|
||||||
|
version = "0.38.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f431b67cba6122a1f7d26b7adafc71bbdf07026095fe811f5efc025d8748e1d8"
|
||||||
|
dependencies = [
|
||||||
|
"atoi_simd",
|
||||||
|
"byteorder",
|
||||||
|
"codepage",
|
||||||
|
"encoding_rs",
|
||||||
|
"fast-float2",
|
||||||
|
"log",
|
||||||
|
"quick-xml",
|
||||||
|
"serde",
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -316,6 +349,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"calamine",
|
||||||
"chrono",
|
"chrono",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -341,6 +375,16 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codepage"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bdff162541cd8b79de82e2edcc7eff3a8c2a6dc3d75152636028f96d93de3b26"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -359,6 +403,15 @@ version = "0.10.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -375,6 +428,12 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_detect"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -461,6 +520,12 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "debug_unsafe"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -500,6 +565,20 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"core_detect",
|
||||||
|
"multiversion_no_op",
|
||||||
|
"rustversion",
|
||||||
|
"scopeguard",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -539,6 +618,12 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fast-float2"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -1330,12 +1415,30 @@ dependencies = [
|
|||||||
"pxfm",
|
"pxfm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion_no_op"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "new_debug_unreachable"
|
name = "new_debug_unreachable"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
@@ -1534,6 +1637,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.42.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.12"
|
version = "0.11.12"
|
||||||
@@ -2554,6 +2667,12 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
@@ -3029,6 +3148,20 @@ name = "zeroize"
|
|||||||
version = "1.9.1"
|
version = "1.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
@@ -3063,6 +3196,26 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "9.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3845f734baabdd02de353a20580e30ceccd03897def44a4eb8e9654992e294"
|
||||||
|
dependencies = [
|
||||||
|
"constant_time_eq",
|
||||||
|
"crc32fast",
|
||||||
|
"flate2",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"indexmap",
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"nix",
|
||||||
|
"typed-path",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"zeroize",
|
||||||
|
"zopfli",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
@@ -3075,6 +3228,18 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ globset = "0.4"
|
|||||||
html2text = "0.15"
|
html2text = "0.15"
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
calamine = "0.38"
|
||||||
|
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||||
shell-words = "1"
|
shell-words = "1"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Exports a Ghidra analysis for CodeCraft: program info, function index,
|
||||||
|
// pseudo-C per function, strings, imports and exports.
|
||||||
|
// Usage: analyzeHeadless ... -postScript CodeCraftExport.java <output dir> [max functions]
|
||||||
|
//@category CodeCraft
|
||||||
|
|
||||||
|
import ghidra.app.decompiler.DecompInterface;
|
||||||
|
import ghidra.app.decompiler.DecompileResults;
|
||||||
|
import ghidra.app.script.GhidraScript;
|
||||||
|
import ghidra.program.model.address.Address;
|
||||||
|
import ghidra.program.model.address.AddressIterator;
|
||||||
|
import ghidra.program.model.listing.Data;
|
||||||
|
import ghidra.program.model.listing.Function;
|
||||||
|
import ghidra.program.model.listing.FunctionIterator;
|
||||||
|
import ghidra.program.model.listing.Program;
|
||||||
|
import ghidra.program.model.symbol.Symbol;
|
||||||
|
import ghidra.program.model.symbol.SymbolIterator;
|
||||||
|
import ghidra.program.util.DefinedStringIterator;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
|
||||||
|
public class CodeCraftExport extends GhidraScript {
|
||||||
|
|
||||||
|
private static String clean(String s) {
|
||||||
|
return s.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() throws Exception {
|
||||||
|
String[] args = getScriptArgs();
|
||||||
|
File out = new File(args[0]);
|
||||||
|
int maxFunctions = args.length > 1 ? Integer.parseInt(args[1]) : Integer.MAX_VALUE;
|
||||||
|
File funcDir = new File(out, "functions");
|
||||||
|
funcDir.mkdirs();
|
||||||
|
Program p = currentProgram;
|
||||||
|
|
||||||
|
try (PrintWriter info = new PrintWriter(new File(out, "info.txt"), StandardCharsets.UTF_8)) {
|
||||||
|
info.println("name: " + p.getName());
|
||||||
|
info.println("format: " + p.getExecutableFormat());
|
||||||
|
info.println("language: " + p.getLanguageID());
|
||||||
|
info.println("compiler: " + p.getCompilerSpec().getCompilerSpecID());
|
||||||
|
info.println("image_base: " + p.getImageBase());
|
||||||
|
info.println("functions: " + p.getFunctionManager().getFunctionCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
DecompInterface di = new DecompInterface();
|
||||||
|
di.openProgram(p);
|
||||||
|
int n = 0;
|
||||||
|
try (PrintWriter idx = new PrintWriter(new File(out, "functions.tsv"), StandardCharsets.UTF_8)) {
|
||||||
|
idx.println("address\tname\tsize\tfile\tsignature");
|
||||||
|
FunctionIterator it = p.getFunctionManager().getFunctions(true);
|
||||||
|
while (it.hasNext() && !monitor.isCancelled() && n < maxFunctions) {
|
||||||
|
Function f = it.next();
|
||||||
|
if (f.isThunk() || f.isExternal()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String base = f.getEntryPoint().toString() + "_" + f.getName().replaceAll("[^A-Za-z0-9_]", "_");
|
||||||
|
if (base.length() > 120) {
|
||||||
|
base = base.substring(0, 120);
|
||||||
|
}
|
||||||
|
DecompileResults r = di.decompileFunction(f, 60, monitor);
|
||||||
|
String code = (r != null && r.decompileCompleted())
|
||||||
|
? r.getDecompiledFunction().getC()
|
||||||
|
: "/* decompilation failed: " + (r == null ? "no result" : r.getErrorMessage()) + " */\n";
|
||||||
|
Files.writeString(new File(funcDir, base + ".c").toPath(),
|
||||||
|
"// " + f.getName() + " @ " + f.getEntryPoint() + "\n" + code, StandardCharsets.UTF_8);
|
||||||
|
idx.println(f.getEntryPoint() + "\t" + f.getName() + "\t" + f.getBody().getNumAddresses()
|
||||||
|
+ "\tfunctions/" + base + ".c\t" + clean(f.getSignature().getPrototypeString()));
|
||||||
|
n++;
|
||||||
|
if (n % 200 == 0) {
|
||||||
|
println("CodeCraft: decompiled " + n + " functions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
di.dispose();
|
||||||
|
|
||||||
|
try (PrintWriter sw = new PrintWriter(new File(out, "strings.tsv"), StandardCharsets.UTF_8)) {
|
||||||
|
sw.println("address\tstring");
|
||||||
|
for (Data d : DefinedStringIterator.forProgram(p)) {
|
||||||
|
Object v = d.getValue();
|
||||||
|
if (v != null) {
|
||||||
|
sw.println(d.getAddress() + "\t" + clean(v.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PrintWriter iw = new PrintWriter(new File(out, "imports.tsv"), StandardCharsets.UTF_8)) {
|
||||||
|
iw.println("library\tname");
|
||||||
|
SymbolIterator si = p.getSymbolTable().getExternalSymbols();
|
||||||
|
while (si.hasNext()) {
|
||||||
|
Symbol s = si.next();
|
||||||
|
iw.println(s.getParentNamespace().getName() + "\t" + s.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PrintWriter ew = new PrintWriter(new File(out, "exports.tsv"), StandardCharsets.UTF_8)) {
|
||||||
|
ew.println("name\taddress");
|
||||||
|
AddressIterator ai = p.getSymbolTable().getExternalEntryPointIterator();
|
||||||
|
while (ai.hasNext()) {
|
||||||
|
Address a = ai.next();
|
||||||
|
Symbol s = p.getSymbolTable().getPrimarySymbol(a);
|
||||||
|
ew.println((s == null ? "?" : s.getName()) + "\t" + a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.writeString(new File(out, ".done").toPath(), String.valueOf(n), StandardCharsets.UTF_8);
|
||||||
|
println("CodeCraft: export complete, " + n + " functions");
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Installs Temurin JDK 25 and Ghidra into $1 (default /data/tools). Idempotent.
|
||||||
|
# On architectures without prebuilt decompiler binaries (e.g. ARM64) the
|
||||||
|
# native decompiler is built from the sources shipped with Ghidra.
|
||||||
|
set -eu
|
||||||
|
DEST="${1:-/data/tools}"
|
||||||
|
GHIDRA_URL="${GHIDRA_URL:-https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.2_build/ghidra_12.2_PUBLIC_20261016.zip}"
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) ARCH=x64 ;;
|
||||||
|
aarch64|arm64) ARCH=aarch64 ;;
|
||||||
|
*) echo "unsupported architecture $(uname -m)"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
cd "$DEST"
|
||||||
|
if ! jdk/bin/java -version 2>&1 | grep -q '"25'; then
|
||||||
|
echo "Downloading JDK 25 ($ARCH)..."
|
||||||
|
curl -fsSL "https://api.adoptium.net/v3/binary/latest/25/ga/linux/$ARCH/jdk/hotspot/normal/eclipse" -o jdk.tar.gz
|
||||||
|
rm -rf jdk jdk.tmp && mkdir jdk.tmp && tar -xzf jdk.tar.gz -C jdk.tmp --strip-components=1 && mv jdk.tmp jdk && rm jdk.tar.gz
|
||||||
|
fi
|
||||||
|
if [ ! -x ghidra/support/analyzeHeadless ]; then
|
||||||
|
echo "Downloading Ghidra..."
|
||||||
|
curl -fsSL "$GHIDRA_URL" -o ghidra.zip
|
||||||
|
rm -rf ghidra ghidra.tmp && mkdir ghidra.tmp && unzip -q ghidra.zip -d ghidra.tmp && mv ghidra.tmp/ghidra_* ghidra && rmdir ghidra.tmp && rm ghidra.zip
|
||||||
|
fi
|
||||||
|
# Use our JDK without asking.
|
||||||
|
sed -i "s#^JAVA_HOME_OVERRIDE=.*#JAVA_HOME_OVERRIDE=$DEST/jdk#" ghidra/support/launch.properties
|
||||||
|
|
||||||
|
# Native decompiler for this platform.
|
||||||
|
OSDIR=linux_x86_64
|
||||||
|
[ "$ARCH" = aarch64 ] && OSDIR=linux_arm_64
|
||||||
|
NATIVE="ghidra/Ghidra/Features/Decompiler/os/$OSDIR"
|
||||||
|
if [ ! -x "$NATIVE/decompile" ]; then
|
||||||
|
echo "Building the native decompiler for $OSDIR (a few minutes)..."
|
||||||
|
SRC="ghidra/Ghidra/Features/Decompiler/src/decompile/cpp"
|
||||||
|
( cd "$SRC" && mkdir -p ghi_opt sla_opt com_opt && make -j"$(nproc)" ARCH_TYPE= ghidra_opt sleigh_opt >/tmp/ghidra-native.log 2>&1 ) || { tail -20 /tmp/ghidra-native.log; exit 1; }
|
||||||
|
mkdir -p "$NATIVE"
|
||||||
|
cp "$SRC/ghidra_opt" "$NATIVE/decompile"
|
||||||
|
cp "$SRC/sleigh_opt" "$NATIVE/sleigh"
|
||||||
|
( cd "$SRC" && make clean >/dev/null 2>&1 || true )
|
||||||
|
fi
|
||||||
|
"$DEST/jdk/bin/java" -version 2>&1 | head -1
|
||||||
|
echo "Ghidra installed in $DEST/ghidra ($OSDIR)"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
description: Set up a project for re-creating existing software (folders, templates, .gitignore)
|
||||||
|
---
|
||||||
|
Load the skill "recreate" with the skill tool and set up the project layout it describes in the
|
||||||
|
current working directory:
|
||||||
|
|
||||||
|
1. Create the folders original/, original/extracted/, analysis/, docs/, spec/, proposal/, src/, tests/
|
||||||
|
(keep empty ones with a .gitkeep).
|
||||||
|
2. Copy the templates from the skill's templates/ folder: 01–06 into docs/, recreation-plan.md into proposal/.
|
||||||
|
3. Create or extend .gitignore with `original/`, `analysis/` and `.codecraft/`.
|
||||||
|
4. Create README.md (in English) describing the project's purpose and the layout, if it does not exist.
|
||||||
|
5. Do not commit. Finish with a short message telling the user to put the program, its
|
||||||
|
libraries, archives or installers into original/ and then run /recreate-start.
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: Analyse the software in original/, document it and propose how to re-create it
|
||||||
|
agent: craft
|
||||||
|
clean_room: false
|
||||||
|
---
|
||||||
|
Load the skill "recreate" with the skill tool and carry out phases 1–4 for the software in
|
||||||
|
original/ (inventory and unpacking, analysis, documentation and specification, proposal).
|
||||||
|
Track the phases with todo_write. Stop after the proposal and ask the user how to proceed.
|
||||||
|
|
||||||
|
Additional notes from the user: $ARGUMENTS
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: recreate
|
||||||
|
description: Re-create existing software (old DOS/Windows/Linux programs and games) as a new, modern implementation – project layout, unpacking, analysis, documentation, specification and a recreation proposal. Use for /recreate-init and /recreate-start.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Re-creating software
|
||||||
|
|
||||||
|
Goal: understand an existing program well enough to build a **new, independent implementation**
|
||||||
|
that behaves like the original – on modern systems. We re-create, we do not copy: decompiled
|
||||||
|
code is a source of understanding, never of code. Everything we build comes from the
|
||||||
|
specification written in our own words.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
original/ the user's files: program, libraries, archives, installers (never modified, not committed)
|
||||||
|
original/extracted/ unpacked archives and installers (one folder per source file)
|
||||||
|
analysis/ tool output: binary_info, decompile results, notes (not committed)
|
||||||
|
docs/ documentation of the original (from the templates)
|
||||||
|
spec/ specification of the re-creation – the only input for building
|
||||||
|
proposal/ the recreation proposal
|
||||||
|
src/ tests/ the new implementation and comparison tests (later)
|
||||||
|
```
|
||||||
|
|
||||||
|
`.gitignore` must exclude `original/` and `analysis/` (third-party binaries and decompiled code
|
||||||
|
stay out of the repository).
|
||||||
|
|
||||||
|
## Phase 1 – Inventory and unpacking
|
||||||
|
|
||||||
|
1. List everything in `original/` recursively with sizes (`find original -type f -printf '%s %p\n'`)
|
||||||
|
and identify each file with `file`.
|
||||||
|
2. Unpack every archive and installer **without running it**, each into
|
||||||
|
`original/extracted/<file name>/`, then repeat on the results (archives inside archives,
|
||||||
|
disk images, installers inside archives) until nothing is left to unpack:
|
||||||
|
|
||||||
|
| Found | Command |
|
||||||
|
|---|---|
|
||||||
|
| zip, 7z, tar(.gz/.bz2/.xz), iso, img, NSIS installer, MSI, CAB, many self-extracting .exe | `7z x -y -o<dir> <file>` |
|
||||||
|
| rar, lzh/lha, arj, sit, zoo and other old formats | `unar -o <dir> -f <file>` (or `arj x`, `lha x`) |
|
||||||
|
| Inno Setup installer (`file` or `innoextract -i` says so) | `innoextract -e -d <dir> <file>` |
|
||||||
|
| InstallShield (`data1.cab` + `data1.hdr`) | `unshield -d <dir> x data1.cab` |
|
||||||
|
| Microsoft CAB | `cabextract -d <dir> <file>` |
|
||||||
|
| MSI (if 7z gives no useful names) | `msiextract -C <dir> <file>` |
|
||||||
|
| DOS executables packed with PKLITE/LZEXE/EXEPACK (see `strings`, `binary_info`) | note it – decompile the unpacked image if an unpacker is available, otherwise analyse behaviour and data files |
|
||||||
|
|
||||||
|
If a format cannot be unpacked, record it and continue – never execute installers.
|
||||||
|
3. Write `docs/01-inventory.md` (template): every file with origin (which archive), type,
|
||||||
|
size, role guess (main program, overlay, library/driver, data, graphics, sound, music,
|
||||||
|
configuration, save game, documentation).
|
||||||
|
|
||||||
|
## Phase 2 – Analysis
|
||||||
|
|
||||||
|
1. `binary_info` for every executable and library: format (MZ/NE/PE/LE/ELF), CPU,
|
||||||
|
compiler/linker hints, imports, strings.
|
||||||
|
2. `decompile` the main executable(s); for big programs start with `max_functions` and widen.
|
||||||
|
3. Explore the output with grep/read: entry point and main loop, initialisation, input,
|
||||||
|
graphics (video modes, palettes, sprites), sound/music drivers, file I/O (which files are
|
||||||
|
opened, how they are parsed), game/application logic, timing, configuration, save games.
|
||||||
|
4. Data files: identify formats (headers, magic numbers, `xxd | head`), match them with the
|
||||||
|
loading code, decode them where possible (write small scripts in `analysis/tools/`).
|
||||||
|
5. Identify engines, libraries and middleware (strings, imports, copyright notices).
|
||||||
|
6. Keep working notes in `analysis/notes.md`. Ask the user (ask_user) when the purpose of
|
||||||
|
something is unclear and they may know it.
|
||||||
|
|
||||||
|
## Phase 3 – Documentation and specification
|
||||||
|
|
||||||
|
Fill the templates in `docs/` (02–06) from the analysis – in your own words, with function
|
||||||
|
addresses only as references. Then write `spec/spec.md`: the behaviour to re-create, screens
|
||||||
|
and controls, rules/logic, data and file formats (byte layouts are facts and may be
|
||||||
|
specified exactly for compatibility), timing, edge cases, and what counts as "done".
|
||||||
|
The spec must be complete enough that someone who never saw the decompiled code could
|
||||||
|
build the program from it.
|
||||||
|
|
||||||
|
## Phase 4 – Proposal
|
||||||
|
|
||||||
|
Write `proposal/recreation-plan.md` (template): 2–3 technology options (e.g. Rust + SDL2,
|
||||||
|
C++ + SDL, Godot, web/TypeScript) with fit, pros/cons and effort; the recommended option;
|
||||||
|
architecture sketch; milestones (first playable/usable version first); how original assets
|
||||||
|
are handled (loaded from the user's original files at runtime vs. new assets); comparison
|
||||||
|
tests against the original; risks and open questions.
|
||||||
|
|
||||||
|
Then **stop**: summarise the findings and the recommendation for the user and ask with
|
||||||
|
ask_user how to proceed. Do not start building before the user agrees.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never paste or mechanically translate decompiled code into `src/`. Names, structure and
|
||||||
|
code are new. Facts needed for compatibility (file formats, constants, rules) are fine.
|
||||||
|
- Original assets (graphics, sounds, levels) stay the user's files: the re-creation may load
|
||||||
|
them from `original/` at runtime but does not copy them into the repository.
|
||||||
|
- Use todo_write for the phases; delegate broad searches through decompiled output to
|
||||||
|
explore subagents to keep your own context small.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Inventory
|
||||||
|
|
||||||
|
| File | From (archive/installer) | Type (`file`) | Size | Role |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
|
||||||
|
## Unpacking log
|
||||||
|
|
||||||
|
| Source | Tool | Result |
|
||||||
|
|---|---|---|
|
||||||
|
|
||||||
|
## Not unpacked / open questions
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Overview
|
||||||
|
|
||||||
|
- Name, publisher, year, platform, original system requirements:
|
||||||
|
- What the program does (from the user's perspective):
|
||||||
|
- Executables and how they relate (main program, setup, overlays, drivers):
|
||||||
|
- Engines, libraries, compilers detected:
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Architecture of the original
|
||||||
|
|
||||||
|
## Start-up and main loop
|
||||||
|
## Modules / subsystems (input, graphics, sound, logic, file I/O, configuration)
|
||||||
|
## Important functions (address → purpose)
|
||||||
|
## Memory and data structures
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# File and data formats
|
||||||
|
|
||||||
|
For each format: files using it, header/magic, layout (offset, size, meaning), compression,
|
||||||
|
how the program reads/writes it (function references), decoding status and tools in analysis/tools/.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# User interface and behaviour
|
||||||
|
|
||||||
|
## Screens / windows / menus
|
||||||
|
## Controls and input
|
||||||
|
## Rules and logic
|
||||||
|
## Timing (frame rate, tick rate, delays)
|
||||||
|
## Sound and music
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Assets
|
||||||
|
|
||||||
|
Graphics, palettes, fonts, sounds, music, levels/maps, texts: where they are, format, how many,
|
||||||
|
how the re-creation will use them.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Recreation proposal
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
## Technology options
|
||||||
|
| Option | Fit | Pros | Cons | Effort |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
## Recommendation
|
||||||
|
## Architecture sketch
|
||||||
|
## Milestones
|
||||||
|
1. First runnable version:
|
||||||
|
## Handling of original assets
|
||||||
|
## Comparison tests against the original
|
||||||
|
## Risks and open questions
|
||||||
+153
-4
@@ -75,6 +75,9 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions/{id}/abort", post(abort))
|
.route("/sessions/{id}/abort", post(abort))
|
||||||
.route("/sessions/{id}/compact", post(compact))
|
.route("/sessions/{id}/compact", post(compact))
|
||||||
.route("/sessions/{id}/rewind", post(rewind))
|
.route("/sessions/{id}/rewind", post(rewind))
|
||||||
|
.route("/sessions/{id}/changes", get(changed_files))
|
||||||
|
.route("/sessions/{id}/shells", get(list_shells))
|
||||||
|
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
|
||||||
.route("/sessions/{id}/stats", get(session_stats))
|
.route("/sessions/{id}/stats", get(session_stats))
|
||||||
.route("/sessions/{id}/shares", get(get_shares).put(put_shares))
|
.route("/sessions/{id}/shares", get(get_shares).put(put_shares))
|
||||||
.route("/sessions/{id}/queue", get(get_queue))
|
.route("/sessions/{id}/queue", get(get_queue))
|
||||||
@@ -95,10 +98,19 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/files/delete", delete(crate::files::remove))
|
.route("/files/delete", delete(crate::files::remove))
|
||||||
.route("/files/upload", put(crate::files::upload))
|
.route("/files/upload", put(crate::files::upload))
|
||||||
.route("/files/search", get(crate::files::search))
|
.route("/files/search", get(crate::files::search))
|
||||||
|
.route("/files/preview/pdf-info", get(crate::files::pdf_info))
|
||||||
|
.route("/files/preview/pdf-page", get(crate::files::pdf_page))
|
||||||
|
.route("/files/preview/doc", get(crate::files::doc_preview))
|
||||||
|
.route("/files/preview/module", get(crate::files::module_audio))
|
||||||
.route("/git", post(crate::git::handle))
|
.route("/git", post(crate::git::handle))
|
||||||
.route("/fs/dirs", get(list_dirs))
|
.route("/fs/dirs", get(list_dirs))
|
||||||
.route("/stats", get(stats))
|
.route("/stats", get(stats))
|
||||||
.route("/budget", get(budget))
|
.route("/budget", get(budget))
|
||||||
|
.route("/audit", get(audit_log))
|
||||||
|
.route("/backup", get(backup))
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/tools", get(crate::reverse::status))
|
||||||
|
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
|
||||||
.route("/providers/{name}/prices", get(provider_prices))
|
.route("/providers/{name}/prices", get(provider_prices))
|
||||||
.route("/images", get(list_images).post(create_images))
|
.route("/images", get(list_images).post(create_images))
|
||||||
.route("/images/config", get(images_config))
|
.route("/images/config", get(images_config))
|
||||||
@@ -166,7 +178,9 @@ async fn patch_config(Extension(user): Extension<User>, State(app): State<AppSta
|
|||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
// Masked secrets coming back from the UI must not overwrite real values.
|
// Masked secrets coming back from the UI must not overwrite real values.
|
||||||
let patches: Vec<ConfigPatch> = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect();
|
let patches: Vec<ConfigPatch> = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect();
|
||||||
|
let paths: Vec<String> = patches.iter().map(|p| p.path.join(".")).collect();
|
||||||
app.patch_config(&patches).await?;
|
app.patch_config(&patches).await?;
|
||||||
|
app.audit(Some(&user), "config.patch", paths.join(", "), "");
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +197,7 @@ struct RawBody {
|
|||||||
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
app.save_config(&body.text).await?;
|
app.save_config(&body.text).await?;
|
||||||
|
app.audit(Some(&user), "config.raw", format!("{} bytes", body.text.len()), "");
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +266,7 @@ async fn create_folder(Extension(user): Extension<User>, State(app): State<AppSt
|
|||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "folder must be inside the workspace".into()));
|
return Err(ApiError(StatusCode::BAD_REQUEST, "folder must be inside the workspace".into()));
|
||||||
}
|
}
|
||||||
let id = app.store.add_folder(&path.display().to_string())?;
|
let id = app.store.add_folder(&path.display().to_string())?;
|
||||||
|
app.audit(Some(&user), "folder.create", path.display().to_string(), "");
|
||||||
app.store.set_folder_access(&id, &body.users)?;
|
app.store.set_folder_access(&id, &body.users)?;
|
||||||
Ok(Json(json!({"id": id, "path": path})))
|
Ok(Json(json!({"id": id, "path": path})))
|
||||||
}
|
}
|
||||||
@@ -270,6 +286,7 @@ struct AccessBody {
|
|||||||
async fn set_folder_access(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<AccessBody>) -> ApiResult {
|
async fn set_folder_access(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<AccessBody>) -> ApiResult {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
app.store.set_folder_access(&id, &body.users)?;
|
app.store.set_folder_access(&id, &body.users)?;
|
||||||
|
app.audit(Some(&user), "folder.access", format!("{id}: {} user(s)", body.users.len()), "");
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +360,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
todos: Value::Null,
|
todos: Value::Null,
|
||||||
last_usage: None,
|
last_usage: None,
|
||||||
|
clean_room: None,
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
@@ -354,7 +372,8 @@ async fn get_session(Extension(user): Extension<User>, State(app): State<AppStat
|
|||||||
let busy = app.is_busy(&id);
|
let busy = app.is_busy(&id);
|
||||||
let can_write = app.access(&user, &id) == Access::Full;
|
let can_write = app.access(&user, &id) == Access::Full;
|
||||||
let queue = app.store.queue_list(&id)?;
|
let queue = app.store.queue_list(&id)?;
|
||||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue})))
|
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})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -363,6 +382,7 @@ struct UpdateSession {
|
|||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
agent: Option<String>,
|
agent: Option<String>,
|
||||||
permission_mode: Option<String>,
|
permission_mode: Option<String>,
|
||||||
|
clean_room: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||||
@@ -377,6 +397,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if let Some(a) = body.agent {
|
if let Some(a) = body.agent {
|
||||||
s.agent = a;
|
s.agent = a;
|
||||||
}
|
}
|
||||||
|
if let Some(c) = body.clean_room {
|
||||||
|
s.clean_room = Some(c);
|
||||||
|
}
|
||||||
if let Some(p) = body.permission_mode {
|
if let Some(p) = body.permission_mode {
|
||||||
if !crate::permission::MODES.contains(&p.as_str()) {
|
if !crate::permission::MODES.contains(&p.as_str()) {
|
||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||||
@@ -391,6 +414,7 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
// Notify before deleting: visibility is checked against the stored session.
|
// Notify before deleting: visibility is checked against the stored session.
|
||||||
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
||||||
runner::abort(&app, &id);
|
runner::abort(&app, &id);
|
||||||
|
crate::shells::kill_session(&app, &id);
|
||||||
app.store.delete_session(&id)?;
|
app.store.delete_session(&id)?;
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
@@ -417,11 +441,16 @@ async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, P
|
|||||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||||
let mut prompt_text = text.clone();
|
let mut prompt_text = text.clone();
|
||||||
if text.starts_with('/') {
|
if text.starts_with('/') {
|
||||||
if let Some((expanded, agent)) = prompts::expand_command(&app, &root, &cwd, &text) {
|
if let Some((expanded, agent, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) {
|
||||||
prompt_text = expanded;
|
prompt_text = expanded;
|
||||||
if let Some(a) = agent {
|
if agent.is_some() || clean_room.is_some() {
|
||||||
let mut s = session.clone();
|
let mut s = session.clone();
|
||||||
s.agent = a;
|
if let Some(a) = agent {
|
||||||
|
s.agent = a;
|
||||||
|
}
|
||||||
|
if clean_room.is_some() {
|
||||||
|
s.clean_room = clean_room;
|
||||||
|
}
|
||||||
app.save_session(s)?;
|
app.save_session(s)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -485,6 +514,9 @@ async fn compact(Extension(user): Extension<User>, State(app): State<AppState>,
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RewindBody {
|
struct RewindBody {
|
||||||
message_id: String,
|
message_id: String,
|
||||||
|
/// Also restore files the agent changed since that message.
|
||||||
|
#[serde(default)]
|
||||||
|
restore_files: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a message and everything after it (edit & resend). Files are not restored.
|
/// Remove a message and everything after it (edit & resend). Files are not restored.
|
||||||
@@ -493,12 +525,73 @@ async fn rewind(Extension(user): Extension<User>, State(app): State<AppState>, P
|
|||||||
if app.is_busy(&id) {
|
if app.is_busy(&id) {
|
||||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||||
}
|
}
|
||||||
|
let since = app.store.message_created_at(&id, &body.message_id)?.ok_or_else(|| not_found("message"))?;
|
||||||
|
let mut restored = Vec::new();
|
||||||
|
if body.restore_files {
|
||||||
|
let roots = app.user_roots(s.owner_id.as_deref());
|
||||||
|
for (path, content) in app.store.checkpoints_since(&id, since)? {
|
||||||
|
let p = PathBuf::from(&path);
|
||||||
|
// Only inside the owner's folders (they may have changed since).
|
||||||
|
if !roots.iter().any(|r| p.starts_with(r)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match content {
|
||||||
|
Some(bytes) => {
|
||||||
|
if let Some(dir) = p.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
}
|
||||||
|
std::fs::write(&p, bytes)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restored.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.store.delete_checkpoints_since(&id, since)?;
|
||||||
app.store.truncate_from(&id, &body.message_id)?;
|
app.store.truncate_from(&id, &body.message_id)?;
|
||||||
s.last_usage = None;
|
s.last_usage = None;
|
||||||
app.save_session(s)?;
|
app.save_session(s)?;
|
||||||
|
Ok(Json(json!({"ok": true, "restored": restored})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background shells of a session (and its subagents).
|
||||||
|
async fn list_shells(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
let root = app.root_session_id(&id);
|
||||||
|
let list: Vec<Value> = app
|
||||||
|
.shells
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.filter(|s| app.root_session_id(&s.session_id) == root)
|
||||||
|
.map(|s| s.json(20_000))
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!(list)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kill_shell(Extension(user): Extension<User>, State(app): State<AppState>, Path((id, shell)): Path<(String, String)>) -> ApiResult {
|
||||||
|
writable(&app, &user, &id)?;
|
||||||
|
let root = app.root_session_id(&id);
|
||||||
|
let s = app.shells.lock().unwrap().get(&shell).cloned().filter(|s| app.root_session_id(&s.session_id) == root).ok_or_else(|| not_found("shell"))?;
|
||||||
|
s.kill();
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChangesQuery {
|
||||||
|
since_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files the agent changed since a message (for the rewind dialog).
|
||||||
|
async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX);
|
||||||
|
let files: Vec<Value> = app.store.checkpoints_since(&id, since)?.into_iter().map(|(p, c)| json!({"path": p, "created": c.is_none()})).collect();
|
||||||
|
Ok(Json(json!(files)))
|
||||||
|
}
|
||||||
|
|
||||||
async fn session_stats(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
async fn session_stats(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
readable(&app, &user, &id)?;
|
readable(&app, &user, &id)?;
|
||||||
let mut v = app.store.session_stats(&id)?;
|
let mut v = app.store.session_stats(&id)?;
|
||||||
@@ -518,6 +611,7 @@ async fn put_shares(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
}
|
}
|
||||||
let users: Vec<String> = body.users.into_iter().filter(|u| Some(u) != s.owner_id.as_ref()).collect();
|
let users: Vec<String> = body.users.into_iter().filter(|u| Some(u) != s.owner_id.as_ref()).collect();
|
||||||
app.set_shares(&id, &users)?;
|
app.set_shares(&id, &users)?;
|
||||||
|
app.audit(Some(&user), "session.share", format!("{id}: {} user(s)", users.len()), "");
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +634,9 @@ struct PermissionBody {
|
|||||||
|
|
||||||
async fn reply_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
|
async fn reply_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
|
||||||
check_pending(&app, &user, &id)?;
|
check_pending(&app, &user, &id)?;
|
||||||
|
let what = app.pending_title(&id).unwrap_or_default();
|
||||||
app.reply_permission(&id, body.reply, body.message)?;
|
app.reply_permission(&id, body.reply, body.message)?;
|
||||||
|
app.audit(Some(&user), &format!("permission.{}", serde_json::to_value(body.reply).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default()), what, "");
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +714,59 @@ async fn list_dirs(Extension(user): Extension<User>, State(app): State<AppState>
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- audit & backup -------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AuditQuery {
|
||||||
|
limit: Option<i64>,
|
||||||
|
action: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn audit_log(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<AuditQuery>) -> ApiResult {
|
||||||
|
require_admin(&user)?;
|
||||||
|
Ok(Json(json!(app.store.audit_list(q.limit.unwrap_or(300).min(5000), q.action.as_deref().filter(|a| !a.is_empty()))?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ZIP with config.toml and a consistent database snapshot.
|
||||||
|
async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -> Result<Response, ApiError> {
|
||||||
|
require_admin(&user)?;
|
||||||
|
let tmp = std::env::temp_dir().join(format!("cc-backup-{}.db", new_id()));
|
||||||
|
app.store.snapshot(&tmp)?;
|
||||||
|
let config = app.config_text()?;
|
||||||
|
let db = tokio::fs::read(&tmp).await?;
|
||||||
|
let _ = tokio::fs::remove_file(&tmp).await;
|
||||||
|
let zip_bytes = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
{
|
||||||
|
let mut z = zip::ZipWriter::new(&mut buf);
|
||||||
|
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||||
|
z.start_file("config.toml", opts)?;
|
||||||
|
z.write_all(config.as_bytes())?;
|
||||||
|
z.start_file("codecraft.db", opts)?;
|
||||||
|
z.write_all(&db)?;
|
||||||
|
z.finish()?;
|
||||||
|
}
|
||||||
|
Ok(buf.into_inner())
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
app.audit(Some(&user), "backup.download", format!("{} bytes", zip_bytes.len()), "");
|
||||||
|
let name = format!("codecraft-backup-{}.zip", chrono::Local::now().format("%Y%m%d-%H%M"));
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(axum::http::header::CONTENT_TYPE, "application/zip".to_string()),
|
||||||
|
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\"")),
|
||||||
|
],
|
||||||
|
zip_bytes,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liveness for monitoring (no auth needed – see auth::is_public).
|
||||||
|
async fn health(State(app): State<AppState>) -> Json<Value> {
|
||||||
|
Json(json!({"ok": true, "version": env!("CARGO_PKG_VERSION"), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
|
||||||
|
}
|
||||||
|
|
||||||
// --- budget -------------------------------------------------------------------
|
// --- budget -------------------------------------------------------------------
|
||||||
|
|
||||||
/// Budgets concern the whole installation: admins only.
|
/// Budgets concern the whole installation: admins only.
|
||||||
|
|||||||
+33
-1
@@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex, RwLock};
|
|||||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, oneshot};
|
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, oneshot};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum PermissionReply {
|
pub enum PermissionReply {
|
||||||
/// Allow this one call.
|
/// Allow this one call.
|
||||||
@@ -39,6 +39,8 @@ pub struct Runtime {
|
|||||||
pub cancel: Option<CancellationToken>,
|
pub cancel: Option<CancellationToken>,
|
||||||
pub read_files: HashSet<PathBuf>,
|
pub read_files: HashSet<PathBuf>,
|
||||||
pub approved: HashSet<String>,
|
pub approved: HashSet<String>,
|
||||||
|
/// The model request currently running (for clients that connect mid-step).
|
||||||
|
pub step: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -61,6 +63,12 @@ pub struct App {
|
|||||||
owners: Mutex<HashMap<String, (String, Option<String>)>>,
|
owners: Mutex<HashMap<String, (String, Option<String>)>>,
|
||||||
/// root session id → users it is shared with.
|
/// root session id → users it is shared with.
|
||||||
shares: Mutex<HashMap<String, Vec<String>>>,
|
shares: Mutex<HashMap<String, Vec<String>>>,
|
||||||
|
/// Failed sign-ins per key (ip or username): timestamps in ms.
|
||||||
|
pub login_failures: Mutex<HashMap<String, Vec<i64>>>,
|
||||||
|
/// State of an admin-triggered tool installation (Ghidra).
|
||||||
|
pub tool_install: crate::reverse::ToolInstall,
|
||||||
|
/// Background shells of all sessions.
|
||||||
|
pub shells: crate::shells::Shells,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a user may do with a session.
|
/// What a user may do with a session.
|
||||||
@@ -94,6 +102,9 @@ impl App {
|
|||||||
oidc_discovery: Mutex::default(),
|
oidc_discovery: Mutex::default(),
|
||||||
owners: Mutex::default(),
|
owners: Mutex::default(),
|
||||||
shares: Mutex::default(),
|
shares: Mutex::default(),
|
||||||
|
login_failures: Mutex::default(),
|
||||||
|
tool_install: Mutex::default(),
|
||||||
|
shells: Mutex::default(),
|
||||||
});
|
});
|
||||||
crate::auth::bootstrap(&app)?;
|
crate::auth::bootstrap(&app)?;
|
||||||
app.clone().start_mcp();
|
app.clone().start_mcp();
|
||||||
@@ -195,6 +206,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record an action in the audit log.
|
||||||
|
pub fn audit(&self, user: Option<&crate::store::User>, action: &str, detail: impl AsRef<str>, ip: &str) {
|
||||||
|
self.store.audit(user.map(|u| u.id.as_str()), user.map(|u| u.username.as_str()).unwrap_or(""), action, detail.as_ref(), ip);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn data_dir(&self) -> PathBuf {
|
pub fn data_dir(&self) -> PathBuf {
|
||||||
self.config().data_dir()
|
self.config().data_dir()
|
||||||
}
|
}
|
||||||
@@ -232,6 +248,14 @@ impl App {
|
|||||||
// --- events ---------------------------------------------------------
|
// --- events ---------------------------------------------------------
|
||||||
|
|
||||||
pub fn emit(&self, ev: Event) {
|
pub fn emit(&self, ev: Event) {
|
||||||
|
match &ev {
|
||||||
|
Event::StepStarted { session_id, .. } => {
|
||||||
|
let v = serde_json::to_value(&ev).ok();
|
||||||
|
self.with_runtime(session_id, |r| r.step = v);
|
||||||
|
}
|
||||||
|
Event::SessionStatus { session_id, busy: false } => self.with_runtime(session_id, |r| r.step = None),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
let _ = self.events.send(ev);
|
let _ = self.events.send(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +397,14 @@ impl App {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "tool: title" of a pending permission request (for the audit log).
|
||||||
|
pub fn pending_title(&self, id: &str) -> Option<String> {
|
||||||
|
self.permissions.lock().unwrap().get(id).and_then(|p| match &p.event {
|
||||||
|
Event::PermissionRequest { tool, title, .. } => Some(format!("{tool}: {title}")),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Session of a pending permission request or question.
|
/// Session of a pending permission request or question.
|
||||||
pub fn pending_session(&self, id: &str) -> Option<String> {
|
pub fn pending_session(&self, id: &str) -> Option<String> {
|
||||||
let from_perm = self.permissions.lock().unwrap().get(id).and_then(|p| p.event.session_id().map(String::from));
|
let from_perm = self.permissions.lock().unwrap().get(id).and_then(|p| p.event.session_id().map(String::from));
|
||||||
|
|||||||
+65
-2
@@ -97,6 +97,40 @@ pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
|
|||||||
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from))
|
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Client address: the reverse proxy's X-Forwarded-For (the port is bound to localhost).
|
||||||
|
pub fn client_ip(headers: &HeaderMap) -> String {
|
||||||
|
headers
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.split(',').next())
|
||||||
|
.or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()))
|
||||||
|
.unwrap_or("local")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FAILURES: usize = 5;
|
||||||
|
const LOCK_MS: i64 = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
/// True if this ip or username has too many recent failures.
|
||||||
|
fn locked(app: &App, keys: &[String]) -> bool {
|
||||||
|
let now = crate::types::now_ms();
|
||||||
|
let mut map = app.login_failures.lock().unwrap();
|
||||||
|
keys.iter().any(|k| {
|
||||||
|
let v = map.entry(k.clone()).or_default();
|
||||||
|
v.retain(|t| now - t < LOCK_MS);
|
||||||
|
v.len() >= MAX_FAILURES
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_failure(app: &App, keys: &[String]) {
|
||||||
|
let now = crate::types::now_ms();
|
||||||
|
let mut map = app.login_failures.lock().unwrap();
|
||||||
|
for k in keys {
|
||||||
|
map.entry(k.clone()).or_default().push(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
||||||
let token = token_from_headers(headers)?;
|
let token = token_from_headers(headers)?;
|
||||||
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
|
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
|
||||||
@@ -104,7 +138,7 @@ pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
|||||||
|
|
||||||
/// Paths reachable without a login.
|
/// Paths reachable without a login.
|
||||||
fn is_public(path: &str) -> bool {
|
fn is_public(path: &str) -> bool {
|
||||||
["/info", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
["/info", "/health", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|p| path.ends_with(p))
|
.any(|p| path.ends_with(p))
|
||||||
}
|
}
|
||||||
@@ -177,14 +211,23 @@ pub struct LoginBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<LoginBody>) -> ApiResult<Response> {
|
pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<LoginBody>) -> ApiResult<Response> {
|
||||||
|
let ip = client_ip(&headers);
|
||||||
|
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
|
||||||
|
if locked(&app, &keys) {
|
||||||
|
app.store.audit(None, body.username.trim(), "login.blocked", "", &ip);
|
||||||
|
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
|
||||||
|
}
|
||||||
let found = app.store.user_by_name(body.username.trim())?;
|
let found = app.store.user_by_name(body.username.trim())?;
|
||||||
// Verify even for unknown users so timing does not reveal which names exist.
|
// Verify even for unknown users so timing does not reveal which names exist.
|
||||||
let hash = found.as_ref().and_then(|(_, h)| h.clone());
|
let hash = found.as_ref().and_then(|(_, h)| h.clone());
|
||||||
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
|
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
|
||||||
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
|
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
|
||||||
|
record_failure(&app, &keys);
|
||||||
|
app.store.audit(None, body.username.trim(), "login.failed", "", &ip);
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
|
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
|
||||||
};
|
};
|
||||||
|
app.audit(Some(&user), "login", "password", &ip);
|
||||||
let cookie = issue_login(&app, &headers, &user)?;
|
let cookie = issue_login(&app, &headers, &user)?;
|
||||||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||||||
}
|
}
|
||||||
@@ -208,7 +251,13 @@ pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
|
|||||||
let Some(expected) = expected else {
|
let Some(expected) = expected else {
|
||||||
return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into()));
|
return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into()));
|
||||||
};
|
};
|
||||||
|
let ip = client_ip(&headers);
|
||||||
|
let keys = [format!("ip:{ip}")];
|
||||||
|
if locked(&app, &keys) {
|
||||||
|
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many attempts – try again in 15 minutes".into()));
|
||||||
|
}
|
||||||
if body.code.trim() != expected {
|
if body.code.trim() != expected {
|
||||||
|
record_failure(&app, &keys);
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into()));
|
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into()));
|
||||||
}
|
}
|
||||||
@@ -222,6 +271,7 @@ pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
|
|||||||
let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?;
|
let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?;
|
||||||
app.store.adopt_orphans(&user.id)?;
|
app.store.adopt_orphans(&user.id)?;
|
||||||
*app.setup_code.lock().unwrap() = None;
|
*app.setup_code.lock().unwrap() = None;
|
||||||
|
app.audit(Some(&user), "setup", "first admin created", &ip);
|
||||||
let cookie = issue_login(&app, &headers, &user)?;
|
let cookie = issue_login(&app, &headers, &user)?;
|
||||||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||||||
}
|
}
|
||||||
@@ -305,6 +355,7 @@ pub async fn create_user(Extension(me): Extension<User>, State(app): State<Arc<A
|
|||||||
}
|
}
|
||||||
let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() };
|
let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() };
|
||||||
let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?;
|
let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?;
|
||||||
|
app.audit(Some(&me), "user.create", format!("{} ({})", user.username, user.role), "");
|
||||||
Ok(Json(json!(user)))
|
Ok(Json(json!(user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +365,8 @@ pub struct UpdateUserBody {
|
|||||||
role: Option<String>,
|
role: Option<String>,
|
||||||
disabled: Option<bool>,
|
disabled: Option<bool>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
/// Monthly cost limit; a negative value removes it.
|
||||||
|
monthly_quota: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>, Json(body): Json<UpdateUserBody>) -> ApiResult {
|
pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>, Json(body): Json<UpdateUserBody>) -> ApiResult {
|
||||||
@@ -334,6 +387,15 @@ pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<A
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?;
|
app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?;
|
||||||
|
if let Some(q) = body.monthly_quota {
|
||||||
|
app.store.set_user_quota(&id, (q >= 0.0).then_some(q))?;
|
||||||
|
}
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
if let Some(r) = &body.role { changes.push(format!("role={r}")); }
|
||||||
|
if let Some(d) = body.disabled { changes.push(format!("disabled={d}")); }
|
||||||
|
if hash.is_some() { changes.push("password".into()); }
|
||||||
|
if let Some(q) = body.monthly_quota { changes.push(format!("quota={q}")); }
|
||||||
|
app.audit(Some(&me), "user.update", format!("{}: {}", target.username, changes.join(", ")), "");
|
||||||
if hash.is_some() {
|
if hash.is_some() {
|
||||||
app.store.delete_user_tokens(&id)?;
|
app.store.delete_user_tokens(&id)?;
|
||||||
}
|
}
|
||||||
@@ -351,6 +413,7 @@ pub async fn delete_user(Extension(me): Extension<User>, State(app): State<Arc<A
|
|||||||
}
|
}
|
||||||
// Sessions stay (statistics); they become visible to admins only.
|
// Sessions stay (statistics); they become visible to admins only.
|
||||||
app.store.delete_user(&id)?;
|
app.store.delete_user(&id)?;
|
||||||
|
app.audit(Some(&me), "user.delete", &target.username, "");
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +506,7 @@ pub async fn oidc_callback(State(app): State<Arc<App>>, headers: HeaderMap, Quer
|
|||||||
return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default()));
|
return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default()));
|
||||||
}
|
}
|
||||||
match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await {
|
match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await {
|
||||||
Ok(user) => match issue_login(&app, &headers, &user) {
|
Ok(user) => match { app.audit(Some(&user), "login", "oidc", &client_ip(&headers)); issue_login(&app, &headers, &user) } {
|
||||||
Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(),
|
Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(),
|
||||||
Err(e) => error_page(&format!("{e:#}")),
|
Err(e) => error_page(&format!("{e:#}")),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -221,3 +221,38 @@ mod tests {
|
|||||||
assert_eq!(none["warning"], false);
|
assert_eq!(none["warning"], false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refuse a request when a budget or the owner's quota is used up.
|
||||||
|
/// `provider` is the provider name ("fal" for images).
|
||||||
|
pub fn check(app: &App, provider: &str, owner_id: Option<&str>) -> Result<()> {
|
||||||
|
let cfg = app.config();
|
||||||
|
let from = month_start();
|
||||||
|
if let Some(user) = owner_id.and_then(|id| app.store.user(id).ok().flatten()) {
|
||||||
|
if let Some(q) = user.monthly_quota.filter(|q| *q > 0.0) {
|
||||||
|
let spent = app.store.user_cost_since(&user.id, from)?;
|
||||||
|
if spent >= q {
|
||||||
|
bail!(
|
||||||
|
"monthly quota of {q:.2} {cur} for user {} is used up ({spent:.2} {cur} spent) – ask an admin to raise it",
|
||||||
|
user.username,
|
||||||
|
cur = cfg.server.currency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.server.budget_action != "block" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let spent = app.store.cost_by_provider(from)?;
|
||||||
|
let total: f64 = spent.values().sum();
|
||||||
|
if cfg.server.monthly_budget > 0.0 && total >= cfg.server.monthly_budget {
|
||||||
|
bail!("the monthly budget of {:.2} {} is used up", cfg.server.monthly_budget, cfg.server.currency);
|
||||||
|
}
|
||||||
|
let limit = if provider == "fal" { cfg.images.monthly_budget } else { cfg.providers.get(provider).and_then(|p| p.monthly_budget) };
|
||||||
|
if let Some(b) = limit.filter(|b| *b > 0.0) {
|
||||||
|
let s = spent.get(provider).copied().unwrap_or(0.0);
|
||||||
|
if s >= b {
|
||||||
|
bail!("the monthly budget of {b:.2} {} for {provider} is used up", cfg.server.currency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -21,6 +21,38 @@ pub struct Config {
|
|||||||
pub craft: CraftConfig,
|
pub craft: CraftConfig,
|
||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
pub images: ImagesConfig,
|
pub images: ImagesConfig,
|
||||||
|
pub sandbox: SandboxConfig,
|
||||||
|
pub search: SearchConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web search for the web_search tool.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SearchConfig {
|
||||||
|
/// "duckduckgo" (no key), "searxng", "brave" or "tavily".
|
||||||
|
pub provider: String,
|
||||||
|
/// SearXNG base URL.
|
||||||
|
pub url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SearchConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { provider: "duckduckgo".into(), url: String::new(), api_key: String::new() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SandboxConfig {
|
||||||
|
/// "auto": sandbox shell/git of non-admin users, "always": everybody, "off": nobody.
|
||||||
|
pub mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SandboxConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { mode: "auto".into() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Image generation via fal.ai.
|
/// Image generation via fal.ai.
|
||||||
@@ -152,6 +184,8 @@ pub struct ServerConfig {
|
|||||||
pub monthly_budget: f64,
|
pub monthly_budget: f64,
|
||||||
/// Warn when this share of a budget is used (0.0–1.0).
|
/// Warn when this share of a budget is used (0.0–1.0).
|
||||||
pub budget_warning: f64,
|
pub budget_warning: f64,
|
||||||
|
/// "warn" (default) or "block": refuse new model requests once a budget is used up.
|
||||||
|
pub budget_action: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
@@ -168,6 +202,7 @@ impl Default for ServerConfig {
|
|||||||
mcp_direct_limit: 15,
|
mcp_direct_limit: 15,
|
||||||
monthly_budget: 0.0,
|
monthly_budget: 0.0,
|
||||||
budget_warning: 0.8,
|
budget_warning: 0.8,
|
||||||
|
budget_action: "warn".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,6 +327,20 @@ pub struct McpConfig {
|
|||||||
/// "auto" (default), "always" or "on_demand": how the server's tools reach the model.
|
/// "auto" (default), "always" or "on_demand": how the server's tools reach the model.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub load: Option<String>,
|
pub load: Option<String>,
|
||||||
|
/// Who may use this server besides admins: usernames or "*" for everybody.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub users: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Whether a session owner may use an MCP server (no owner = legacy/admin).
|
||||||
|
pub fn mcp_allowed(&self, server: &str, user: Option<&crate::store::User>) -> bool {
|
||||||
|
match user {
|
||||||
|
None => true,
|
||||||
|
Some(u) if u.is_admin() => true,
|
||||||
|
Some(u) => self.mcp.get(server).is_some_and(|m| m.users.iter().any(|x| x == "*" || x.eq_ignore_ascii_case(&u.username))),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
@@ -533,6 +582,9 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
|||||||
if let Some(t) = v.pointer_mut("/images/admin_key") {
|
if let Some(t) = v.pointer_mut("/images/admin_key") {
|
||||||
mask(t);
|
mask(t);
|
||||||
}
|
}
|
||||||
|
if let Some(t) = v.pointer_mut("/search/api_key") {
|
||||||
|
mask(t);
|
||||||
|
}
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
//! Text extraction for documents the read tool cannot show as text:
|
||||||
|
//! PDF (poppler-utils), spreadsheets (calamine), DOCX and PPTX (zip + XML).
|
||||||
|
|
||||||
|
use crate::types::ImageData;
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use base64::Engine;
|
||||||
|
use std::io::Read as _;
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
pub const DOC_EXTENSIONS: &[&str] = &["pdf", "docx", "pptx", "xlsx", "xlsm", "xls", "ods"];
|
||||||
|
|
||||||
|
const MAX_PDF_PAGES: u32 = 30;
|
||||||
|
const MAX_RENDERED_PAGES: usize = 4;
|
||||||
|
const MAX_ROWS: usize = 2000;
|
||||||
|
|
||||||
|
pub fn is_document(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.is_some_and(|e| DOC_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract readable content. `pages` ("3" or "2-5") applies to PDFs, `sheet` to spreadsheets.
|
||||||
|
pub async fn extract(path: &Path, pages: Option<&str>, sheet: Option<&str>) -> Result<(String, Vec<ImageData>)> {
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||||
|
match ext.as_str() {
|
||||||
|
"pdf" => pdf(path, pages).await,
|
||||||
|
"docx" => {
|
||||||
|
let p = path.to_path_buf();
|
||||||
|
Ok((tokio::task::spawn_blocking(move || docx(&p)).await??, vec![]))
|
||||||
|
}
|
||||||
|
"pptx" => {
|
||||||
|
let p = path.to_path_buf();
|
||||||
|
Ok((tokio::task::spawn_blocking(move || pptx(&p)).await??, vec![]))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let p = path.to_path_buf();
|
||||||
|
let s = sheet.map(String::from);
|
||||||
|
Ok((tokio::task::spawn_blocking(move || spreadsheet(&p, s.as_deref())).await??, vec![]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_range(spec: Option<&str>, total: u32) -> (u32, u32) {
|
||||||
|
let Some(spec) = spec.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||||
|
return (1, total.min(MAX_PDF_PAGES));
|
||||||
|
};
|
||||||
|
let (a, b) = spec.split_once('-').unwrap_or((spec, spec));
|
||||||
|
let first = a.trim().parse::<u32>().unwrap_or(1).clamp(1, total.max(1));
|
||||||
|
let last = b.trim().parse::<u32>().unwrap_or(first).clamp(first, total.max(first));
|
||||||
|
(first, last.min(first + MAX_PDF_PAGES - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tool_output(cmd: &str, args: &[&str]) -> Result<std::process::Output> {
|
||||||
|
let out = Command::new(cmd)
|
||||||
|
.args(args)
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("'{cmd}' is not installed (poppler-utils)"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("{cmd} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pdf(path: &Path, pages: Option<&str>) -> Result<(String, Vec<ImageData>)> {
|
||||||
|
let p = path.to_string_lossy();
|
||||||
|
let info = tool_output("pdfinfo", &[&p]).await?;
|
||||||
|
let info = String::from_utf8_lossy(&info.stdout);
|
||||||
|
let total: u32 = info
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("Pages:").map(|v| v.trim().parse().unwrap_or(1)))
|
||||||
|
.unwrap_or(1);
|
||||||
|
let (first, last) = parse_range(pages, total);
|
||||||
|
let (f, l) = (first.to_string(), last.to_string());
|
||||||
|
let out = tool_output("pdftotext", &["-layout", "-f", &f, "-l", &l, &p, "-"]).await?;
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
|
||||||
|
let mut result = format!("PDF with {total} pages – showing pages {first}-{last}.\n");
|
||||||
|
let mut images = Vec::new();
|
||||||
|
// pdftotext separates pages with form feeds.
|
||||||
|
for (i, page) in text.split('\u{c}').enumerate() {
|
||||||
|
let n = first + i as u32;
|
||||||
|
if n > last {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result.push_str(&format!("\n--- Page {n} ---\n"));
|
||||||
|
if page.trim().is_empty() {
|
||||||
|
// Scanned page: render it so a vision model can read it.
|
||||||
|
if images.len() < MAX_RENDERED_PAGES {
|
||||||
|
match render_page(&p, n).await {
|
||||||
|
Ok(img) => {
|
||||||
|
images.push(img);
|
||||||
|
result.push_str("(no text layer – rendered as image)\n");
|
||||||
|
}
|
||||||
|
Err(e) => result.push_str(&format!("(no text layer; rendering failed: {e})\n")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push_str("(no text layer – read this page separately with pages)\n");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push_str(page.trim_end());
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if last < total {
|
||||||
|
result.push_str(&format!("\n[{} more pages – use pages=\"{}-{}\"]\n", total - last, last + 1, (last + MAX_PDF_PAGES).min(total)));
|
||||||
|
}
|
||||||
|
Ok((result, images))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_page(pdf: &str, page: u32) -> Result<ImageData> {
|
||||||
|
let dir = std::env::temp_dir().join(format!("cc-pdf-{}", crate::types::new_id()));
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
let prefix = dir.join("p");
|
||||||
|
let n = page.to_string();
|
||||||
|
let res = tool_output("pdftoppm", &["-png", "-r", "110", "-f", &n, "-l", &n, "-singlefile", pdf, &prefix.to_string_lossy()]).await;
|
||||||
|
let file = dir.join("p.png");
|
||||||
|
let bytes = match res {
|
||||||
|
Ok(_) => tokio::fs::read(&file).await,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||||
|
let img = image::load_from_memory(&bytes?)?;
|
||||||
|
let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
|
||||||
|
Ok(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
|
||||||
|
use calamine::Reader;
|
||||||
|
let mut wb = calamine::open_workbook_auto(path).context("cannot open spreadsheet")?;
|
||||||
|
let names = wb.sheet_names();
|
||||||
|
let selected: Vec<String> = match sheet {
|
||||||
|
Some(s) => {
|
||||||
|
if !names.iter().any(|n| n == s) {
|
||||||
|
bail!("no sheet '{s}' – sheets: {}", names.join(", "));
|
||||||
|
}
|
||||||
|
vec![s.to_string()]
|
||||||
|
}
|
||||||
|
None => names.clone(),
|
||||||
|
};
|
||||||
|
let mut out = format!("Spreadsheet with {} sheet(s): {}\n", names.len(), names.join(", "));
|
||||||
|
for name in selected {
|
||||||
|
let range = wb.worksheet_range(&name).with_context(|| format!("cannot read sheet {name}"))?;
|
||||||
|
let (rows, cols) = range.get_size();
|
||||||
|
out.push_str(&format!("\n--- Sheet \"{name}\" ({rows} rows × {cols} columns, tab-separated) ---\n"));
|
||||||
|
for (i, row) in range.rows().enumerate() {
|
||||||
|
if i >= MAX_ROWS {
|
||||||
|
out.push_str(&format!("[... {} more rows ...]\n", rows - MAX_ROWS));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let line: Vec<String> = row.iter().map(|c| c.to_string().replace(['\t', '\n'], " ")).collect();
|
||||||
|
out.push_str(line.join("\t").trim_end());
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zip_text(path: &Path, entry: &str) -> Result<String> {
|
||||||
|
let file = std::fs::File::open(path)?;
|
||||||
|
let mut zip = zip::ZipArchive::new(file).context("not a valid Office file")?;
|
||||||
|
let mut f = zip.by_name(entry).with_context(|| format!("missing {entry}"))?;
|
||||||
|
let mut s = String::new();
|
||||||
|
f.read_to_string(&mut s)?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut rest = s;
|
||||||
|
while let Some(i) = rest.find('&') {
|
||||||
|
out.push_str(&rest[..i]);
|
||||||
|
let tail = &rest[i..];
|
||||||
|
let Some(end) = tail.find(';').filter(|e| *e < 10) else {
|
||||||
|
out.push('&');
|
||||||
|
rest = &tail[1..];
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ent = &tail[1..end];
|
||||||
|
let ch = match ent {
|
||||||
|
"amp" => Some('&'),
|
||||||
|
"lt" => Some('<'),
|
||||||
|
"gt" => Some('>'),
|
||||||
|
"quot" => Some('"'),
|
||||||
|
"apos" => Some('\''),
|
||||||
|
_ if ent.starts_with("#x") => u32::from_str_radix(&ent[2..], 16).ok().and_then(char::from_u32),
|
||||||
|
_ if ent.starts_with('#') => ent[1..].parse().ok().and_then(char::from_u32),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
match ch {
|
||||||
|
Some(c) => out.push(c),
|
||||||
|
None => out.push_str(&tail[..=end]),
|
||||||
|
}
|
||||||
|
rest = &tail[end + 1..];
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect text from WordprocessingML / DrawingML: `w:t`/`a:t` runs,
|
||||||
|
/// paragraphs, tabs, breaks and table cells.
|
||||||
|
pub fn xml_text(xml: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut rest = xml;
|
||||||
|
let mut in_text = false;
|
||||||
|
while let Some(lt) = rest.find('<') {
|
||||||
|
if in_text {
|
||||||
|
out.push_str(&unescape(&rest[..lt]));
|
||||||
|
}
|
||||||
|
let Some(gt) = rest[lt..].find('>') else { break };
|
||||||
|
let tag = &rest[lt + 1..lt + gt];
|
||||||
|
rest = &rest[lt + gt + 1..];
|
||||||
|
let closing = tag.starts_with('/');
|
||||||
|
let self_closing = tag.ends_with('/');
|
||||||
|
let name = tag.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("");
|
||||||
|
match name {
|
||||||
|
"w:t" | "a:t" => in_text = !closing && !self_closing,
|
||||||
|
"w:tab" if !closing => out.push('\t'),
|
||||||
|
"w:br" | "a:br" | "w:cr" if !closing => out.push('\n'),
|
||||||
|
"w:p" | "a:p" if closing => out.push('\n'),
|
||||||
|
"w:tc" if closing => out.push_str(" | "),
|
||||||
|
"w:tr" if closing => out.push('\n'),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collapse runs of empty lines.
|
||||||
|
let mut cleaned = String::new();
|
||||||
|
let mut blank = 0;
|
||||||
|
for line in out.lines() {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
blank += 1;
|
||||||
|
if blank > 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blank = 0;
|
||||||
|
}
|
||||||
|
cleaned.push_str(line.trim_end());
|
||||||
|
cleaned.push('\n');
|
||||||
|
}
|
||||||
|
cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
fn docx(path: &Path) -> Result<String> {
|
||||||
|
let xml = zip_text(path, "word/document.xml")?;
|
||||||
|
Ok(format!("Word document:\n\n{}", xml_text(&xml)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pptx(path: &Path) -> Result<String> {
|
||||||
|
let file = std::fs::File::open(path)?;
|
||||||
|
let zip = zip::ZipArchive::new(file).context("not a valid PowerPoint file")?;
|
||||||
|
let mut slides: Vec<(u32, String)> = zip
|
||||||
|
.file_names()
|
||||||
|
.filter_map(|n| n.ok().map(|n| n.to_string()))
|
||||||
|
.filter_map(|n| {
|
||||||
|
let num = n.strip_prefix("ppt/slides/slide")?.strip_suffix(".xml")?.parse().ok()?;
|
||||||
|
Some((num, n))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
slides.sort();
|
||||||
|
let mut out = format!("Presentation with {} slides:\n", slides.len());
|
||||||
|
for (num, name) in slides {
|
||||||
|
let xml = zip_text(path, &name)?;
|
||||||
|
out.push_str(&format!("\n--- Slide {num} ---\n{}", xml_text(&xml)));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_word_xml() {
|
||||||
|
let xml = r#"<w:document><w:body><w:p><w:r><w:t>Hello & welcome</w:t></w:r><w:r><w:tab/><w:t xml:space="preserve">to CodeCraft</w:t></w:r></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>"#;
|
||||||
|
let t = xml_text(xml);
|
||||||
|
assert!(t.contains("Hello & welcome\tto CodeCraft"));
|
||||||
|
assert!(t.contains("A1"));
|
||||||
|
assert!(t.contains("B1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_ranges() {
|
||||||
|
assert_eq!(parse_range(None, 100), (1, 30));
|
||||||
|
assert_eq!(parse_range(Some("5"), 100), (5, 5));
|
||||||
|
assert_eq!(parse_range(Some("3-7"), 100), (3, 7));
|
||||||
|
assert_eq!(parse_range(Some("90-200"), 100), (90, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unescapes_entities() {
|
||||||
|
assert_eq!(unescape("a <b> ä ☺ &bogus; &"), "a <b> ä ☺ &bogus; &");
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
-1
@@ -112,13 +112,117 @@ fn mime_for(p: &Path) -> &'static str {
|
|||||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
Some("gif") => "image/gif",
|
Some("gif") => "image/gif",
|
||||||
Some("webp") => "image/webp",
|
Some("webp") => "image/webp",
|
||||||
|
Some("bmp") => "image/bmp",
|
||||||
Some("svg") => "image/svg+xml",
|
Some("svg") => "image/svg+xml",
|
||||||
Some("pdf") => "application/pdf",
|
Some("pdf") => "application/pdf",
|
||||||
|
Some("wav") => "audio/wav",
|
||||||
|
Some("mp3") => "audio/mpeg",
|
||||||
|
Some("ogg") | Some("oga") => "audio/ogg",
|
||||||
|
Some("flac") => "audio/flac",
|
||||||
|
Some("m4a") => "audio/mp4",
|
||||||
|
Some("glb") => "model/gltf-binary",
|
||||||
|
Some("gltf") => "model/gltf+json",
|
||||||
|
Some("stl") => "model/stl",
|
||||||
|
Some("obj") => "model/obj",
|
||||||
Some("txt") | Some("md") | Some("log") => "text/plain; charset=utf-8",
|
Some("txt") | Some("md") | Some("log") => "text/plain; charset=utf-8",
|
||||||
_ => "application/octet-stream",
|
_ => "application/octet-stream",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mtime_ms(p: &Path) -> u64 {
|
||||||
|
std::fs::metadata(p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache file for a derived preview (keyed by path, mtime and variant).
|
||||||
|
fn cache_path(p: &Path, variant: &str, ext: &str) -> PathBuf {
|
||||||
|
let key = crate::auth::sha256_hex(&format!("{}|{}|{variant}", p.display(), mtime_ms(p)));
|
||||||
|
let dir = std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| std::env::temp_dir()).join(".cache/codecraft/previews");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
dir.join(format!("{}.{ext}", &key[..32]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_tool(cmd: &str, args: &[&str]) -> Result<(), ApiError> {
|
||||||
|
let out = tokio::time::timeout(std::time::Duration::from_secs(120), tokio::process::Command::new(cmd).args(args).kill_on_drop(true).output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, format!("{cmd} timed out")))?
|
||||||
|
.map_err(|_| ApiError(StatusCode::NOT_IMPLEMENTED, format!("'{cmd}' is not installed")))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(bad(format!("{cmd}: {}", String::from_utf8_lossy(&out.stderr).trim())));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PreviewQuery {
|
||||||
|
path: String,
|
||||||
|
page: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page count of a PDF.
|
||||||
|
pub async fn pdf_info(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> ApiResult {
|
||||||
|
let p = checked_path(&app, &user, &q.path, true)?;
|
||||||
|
let out = tokio::process::Command::new("pdfinfo").arg(&p).output().await.map_err(|_| ApiError(StatusCode::NOT_IMPLEMENTED, "pdfinfo is not installed".into()))?;
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let pages: u32 = text.lines().find_map(|l| l.strip_prefix("Pages:").map(|v| v.trim().parse().unwrap_or(1))).unwrap_or(1);
|
||||||
|
Ok(Json(json!({"pages": pages})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One PDF page rendered as PNG (cached).
|
||||||
|
pub async fn pdf_page(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> Result<Response, ApiError> {
|
||||||
|
let p = checked_path(&app, &user, &q.path, true)?;
|
||||||
|
let page = q.page.unwrap_or(1).max(1);
|
||||||
|
let out = cache_path(&p, &format!("page{page}"), "png");
|
||||||
|
if !out.exists() {
|
||||||
|
let base = out.with_extension("");
|
||||||
|
let n = page.to_string();
|
||||||
|
run_tool("pdftoppm", &["-png", "-r", "110", "-f", &n, "-l", &n, "-singlefile", &p.to_string_lossy(), &base.to_string_lossy()]).await?;
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(&out).await?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "private, max-age=3600")], bytes).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Office documents as text (docx, pptx) or tables (xlsx, xls, ods).
|
||||||
|
pub async fn doc_preview(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> ApiResult {
|
||||||
|
let p = checked_path(&app, &user, &q.path, true)?;
|
||||||
|
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||||
|
if matches!(ext.as_str(), "xlsx" | "xlsm" | "xls" | "ods") {
|
||||||
|
let path = p.clone();
|
||||||
|
let sheets = tokio::task::spawn_blocking(move || -> anyhow::Result<Value> {
|
||||||
|
use calamine::Reader;
|
||||||
|
let mut wb = calamine::open_workbook_auto(&path)?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for name in wb.sheet_names() {
|
||||||
|
let range = wb.worksheet_range(&name)?;
|
||||||
|
let rows: Vec<Vec<String>> = range.rows().take(500).map(|r| r.iter().take(50).map(|c| c.to_string()).collect()).collect();
|
||||||
|
out.push(json!({"name": name, "rows": rows, "total_rows": range.get_size().0}));
|
||||||
|
}
|
||||||
|
Ok(json!(out))
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
return Ok(Json(json!({"kind": "table", "sheets": sheets})));
|
||||||
|
}
|
||||||
|
let (text, _) = crate::docs::extract(&p, None, None).await?;
|
||||||
|
Ok(Json(json!({"kind": "text", "text": text})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracker modules (xm, mod, s3m, it) rendered to WAV with openmpt123 (cached).
|
||||||
|
pub async fn module_audio(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> Result<Response, ApiError> {
|
||||||
|
let p = checked_path(&app, &user, &q.path, true)?;
|
||||||
|
let out = cache_path(&p, "audio", "wav");
|
||||||
|
if !out.exists() {
|
||||||
|
// openmpt123 writes <input>.wav next to the input; render into the cache via a copy.
|
||||||
|
let tmp_in = out.with_extension(p.extension().and_then(|e| e.to_str()).unwrap_or("xm"));
|
||||||
|
tokio::fs::copy(&p, &tmp_in).await?;
|
||||||
|
let res = run_tool("openmpt123", &["--quiet", "--batch", "--force", "--render", &tmp_in.to_string_lossy()]).await;
|
||||||
|
let rendered = PathBuf::from(format!("{}.wav", tmp_in.display()));
|
||||||
|
let _ = tokio::fs::remove_file(&tmp_in).await;
|
||||||
|
res?;
|
||||||
|
tokio::fs::rename(&rendered, &out).await?;
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(&out).await?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, "audio/wav"), (header::CACHE_CONTROL, "private, max-age=3600")], bytes).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct RawQuery {
|
pub struct RawQuery {
|
||||||
path: String,
|
path: String,
|
||||||
@@ -141,12 +245,14 @@ pub async fn raw(Extension(user): Extension<User>, State(app): State<AppState>,
|
|||||||
} else {
|
} else {
|
||||||
format!("inline; filename=\"{name}\"")
|
format!("inline; filename=\"{name}\"")
|
||||||
};
|
};
|
||||||
|
// Raw files never run scripts in the app's origin; PDFs keep the browser viewer.
|
||||||
|
let csp = if mime == "application/pdf" { "sandbox" } else { "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'" };
|
||||||
Ok((
|
Ok((
|
||||||
[
|
[
|
||||||
(header::CONTENT_TYPE, mime.to_string()),
|
(header::CONTENT_TYPE, mime.to_string()),
|
||||||
(header::CONTENT_DISPOSITION, disposition),
|
(header::CONTENT_DISPOSITION, disposition),
|
||||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
|
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
|
||||||
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'".to_string()),
|
(header::CONTENT_SECURITY_POLICY, csp.to_string()),
|
||||||
],
|
],
|
||||||
bytes,
|
bytes,
|
||||||
)
|
)
|
||||||
|
|||||||
+33
-8
@@ -36,15 +36,32 @@ struct Out {
|
|||||||
stderr: String,
|
stderr: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Options that keep repository content from running code (hooks, fsmonitor).
|
||||||
|
const SAFE_OPTS: &[&str] = &["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.ext.allow=never"];
|
||||||
|
|
||||||
|
tokio::task_local! {
|
||||||
|
/// Sandbox of the requesting user for all git calls of this request.
|
||||||
|
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
||||||
|
}
|
||||||
|
|
||||||
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||||
let fut = Command::new("git")
|
let mut all: Vec<&str> = SAFE_OPTS.to_vec();
|
||||||
.args(args)
|
all.extend_from_slice(args);
|
||||||
.current_dir(dir)
|
let spec = SANDBOX.try_with(|s| s.clone()).ok().flatten();
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
let mut cmd = match &spec {
|
||||||
.env("GIT_PAGER", "cat")
|
Some(spec) => {
|
||||||
.env("LC_ALL", "C")
|
if !crate::sandbox::available() {
|
||||||
.kill_on_drop(true)
|
return Err(ApiError(StatusCode::SERVICE_UNAVAILABLE, "git is disabled: the sandbox is not available".into()));
|
||||||
.output();
|
}
|
||||||
|
crate::sandbox::command(spec, dir, "git", &all)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut c = Command::new("git");
|
||||||
|
c.args(&all).current_dir(dir).env("GIT_TERMINAL_PROMPT", "0").env("GIT_PAGER", "cat").env("LC_ALL", "C");
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let fut = cmd.kill_on_drop(true).output();
|
||||||
let out = tokio::time::timeout(Duration::from_secs(timeout), fut)
|
let out = tokio::time::timeout(Duration::from_secs(timeout), fut)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, "git timed out".into()))?
|
.map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, "git timed out".into()))?
|
||||||
@@ -147,6 +164,11 @@ fn parse_status(raw: &str) -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
||||||
|
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
||||||
|
SANDBOX.scope(spec, handle_inner(user, app, b)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
||||||
let target = checked_path(&app, &user, &b.path, b.action != "clone")?;
|
let target = checked_path(&app, &user, &b.path, b.action != "clone")?;
|
||||||
|
|
||||||
// Actions that create a repository.
|
// Actions that create a repository.
|
||||||
@@ -309,6 +331,9 @@ pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let o = git(&repo, &args, 300).await?;
|
let o = git(&repo, &args, 300).await?;
|
||||||
|
if b.action == "push" {
|
||||||
|
app.audit(Some(&user), "git.push", format!("{} ({})", repo.display(), if o.ok { "ok" } else { "failed" }), "");
|
||||||
|
}
|
||||||
if !o.ok {
|
if !o.ok {
|
||||||
return Err(fail(o));
|
return Err(fail(o));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
|||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
bail!("no fal.ai API key configured (images.api_key, e.g. ${{FAL_KEY}})");
|
bail!("no fal.ai API key configured (images.api_key, e.g. ${{FAL_KEY}})");
|
||||||
}
|
}
|
||||||
|
crate::budget::check(app, "fal", req.user_id.as_deref())?;
|
||||||
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
|
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
|
||||||
let model = model_config(app, &model_id);
|
let model = model_config(app, &model_id);
|
||||||
let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into());
|
let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into());
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod app;
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod budget;
|
mod budget;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod docs;
|
||||||
mod files;
|
mod files;
|
||||||
mod git;
|
mod git;
|
||||||
mod images;
|
mod images;
|
||||||
@@ -12,7 +13,10 @@ mod mcp;
|
|||||||
mod permission;
|
mod permission;
|
||||||
mod prompts;
|
mod prompts;
|
||||||
mod provider;
|
mod provider;
|
||||||
|
mod reverse;
|
||||||
mod runner;
|
mod runner;
|
||||||
|
mod sandbox;
|
||||||
|
mod shells;
|
||||||
mod store;
|
mod store;
|
||||||
mod tools;
|
mod tools;
|
||||||
mod types;
|
mod types;
|
||||||
@@ -35,6 +39,7 @@ async fn main() -> Result<()> {
|
|||||||
let (cfg, _) = config::load_or_init(&path)?;
|
let (cfg, _) = config::load_or_init(&path)?;
|
||||||
tracing::info!("config: {}", path.display());
|
tracing::info!("config: {}", path.display());
|
||||||
tracing::info!("workspace: {}", cfg.workspace().display());
|
tracing::info!("workspace: {}", cfg.workspace().display());
|
||||||
|
let _ = sandbox::probe().await;
|
||||||
let bind = cfg.server.bind.clone();
|
let bind = cfg.server.bind.clone();
|
||||||
let app = app::App::new(path, cfg).await?;
|
let app = app::App::new(path, cfg).await?;
|
||||||
let router = api::router(app);
|
let router = api::router(app);
|
||||||
|
|||||||
+14
-5
@@ -504,11 +504,14 @@ impl McpManager {
|
|||||||
|
|
||||||
/// All MCP tools, split into tools given to the model directly and tools
|
/// All MCP tools, split into tools given to the model directly and tools
|
||||||
/// that are only reachable through mcp_search / mcp_call.
|
/// that are only reachable through mcp_search / mcp_call.
|
||||||
pub fn tools(&self, cfg: &crate::config::Config) -> (Vec<Arc<dyn Tool>>, Vec<Arc<McpTool>>) {
|
pub fn tools(&self, cfg: &crate::config::Config, user: Option<&crate::store::User>) -> (Vec<Arc<dyn Tool>>, Vec<Arc<McpTool>>) {
|
||||||
let servers = self.servers.lock().unwrap();
|
let servers = self.servers.lock().unwrap();
|
||||||
let mut direct: Vec<Arc<dyn Tool>> = Vec::new();
|
let mut direct: Vec<Arc<dyn Tool>> = Vec::new();
|
||||||
let mut deferred = Vec::new();
|
let mut deferred = Vec::new();
|
||||||
for (sname, server) in servers.iter() {
|
for (sname, server) in servers.iter() {
|
||||||
|
if !cfg.mcp_allowed(sname, user) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let tools = server.tools.lock().unwrap();
|
let tools = server.tools.lock().unwrap();
|
||||||
let load = cfg.mcp.get(sname).and_then(|m| m.load.clone()).unwrap_or_else(|| "auto".into());
|
let load = cfg.mcp.get(sname).and_then(|m| m.load.clone()).unwrap_or_else(|| "auto".into());
|
||||||
let on_demand = match load.as_str() {
|
let on_demand = match load.as_str() {
|
||||||
@@ -529,8 +532,8 @@ impl McpManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Short overview of on-demand servers for the system prompt.
|
/// Short overview of on-demand servers for the system prompt.
|
||||||
pub fn deferred_summary(&self, cfg: &crate::config::Config) -> Vec<(String, usize, Vec<String>)> {
|
pub fn deferred_summary(&self, cfg: &crate::config::Config, user: Option<&crate::store::User>) -> Vec<(String, usize, Vec<String>)> {
|
||||||
let (_, deferred) = self.tools(cfg);
|
let (_, deferred) = self.tools(cfg, user);
|
||||||
let mut by_server: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
let mut by_server: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||||
for t in deferred {
|
for t in deferred {
|
||||||
by_server.entry(t.server.name.clone()).or_default().push(t.info.name.clone());
|
by_server.entry(t.server.name.clone()).or_default().push(t.info.name.clone());
|
||||||
@@ -579,7 +582,7 @@ impl Tool for McpSearch {
|
|||||||
let query = input["query"].as_str().unwrap_or("").to_lowercase();
|
let query = input["query"].as_str().unwrap_or("").to_lowercase();
|
||||||
let server = input["server"].as_str();
|
let server = input["server"].as_str();
|
||||||
let words: Vec<&str> = query.split_whitespace().collect();
|
let words: Vec<&str> = query.split_whitespace().collect();
|
||||||
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config());
|
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config(), owner(ctx).as_ref());
|
||||||
let mut scored: Vec<(usize, &Arc<McpTool>)> = deferred
|
let mut scored: Vec<(usize, &Arc<McpTool>)> = deferred
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|t| server.is_none_or(|s| t.server.name == s))
|
.filter(|t| server.is_none_or(|s| t.server.name == s))
|
||||||
@@ -635,10 +638,16 @@ impl Tool for McpCall {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The session owner (MCP access is decided per user).
|
||||||
|
fn owner(ctx: &ToolCtx) -> Option<crate::store::User> {
|
||||||
|
let s = ctx.app.session(&ctx.session_id).ok()?;
|
||||||
|
ctx.app.store.user(s.owner_id.as_deref()?).ok().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the MCP tool an mcp_call refers to.
|
/// Find the MCP tool an mcp_call refers to.
|
||||||
pub fn resolve_call_target(ctx: &ToolCtx, input: &Value) -> Result<Arc<McpTool>> {
|
pub fn resolve_call_target(ctx: &ToolCtx, input: &Value) -> Result<Arc<McpTool>> {
|
||||||
let name = input["tool"].as_str().unwrap_or("");
|
let name = input["tool"].as_str().unwrap_or("");
|
||||||
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config());
|
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config(), owner(ctx).as_ref());
|
||||||
deferred
|
deferred
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|t| t.full_name == name)
|
.find(|t| t.full_name == name)
|
||||||
|
|||||||
+29
-8
@@ -60,13 +60,18 @@ const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour o
|
|||||||
Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#;
|
Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#;
|
||||||
|
|
||||||
const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
|
const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
|
||||||
const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts for the user's private use.";
|
const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine.";
|
||||||
|
|
||||||
const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
|
const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
|
||||||
const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
|
const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
|
||||||
|
|
||||||
|
/// The craft prompt for clean room or deep mode.
|
||||||
|
pub fn craft_prompt(clean_room: bool) -> String {
|
||||||
|
CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF })
|
||||||
|
}
|
||||||
|
|
||||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||||
let craft = CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF });
|
let craft = craft_prompt(clean_room);
|
||||||
vec![
|
vec![
|
||||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: 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, model: None, source: "builtin".into(), max_parallel: 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, model: None, source: "builtin".into(), max_parallel: None },
|
||||||
@@ -95,8 +100,17 @@ pub fn frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resource directories, lowest priority first: data dir, workspace, project.
|
/// Resource directories, lowest priority first: data dir, workspace, project.
|
||||||
|
/// Skills and commands shipped with CodeCraft (read-only for agents).
|
||||||
|
pub fn builtin_resources() -> PathBuf {
|
||||||
|
if let Ok(p) = std::env::var("CODECRAFT_RESOURCES") {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
["/app/resources", "./resources", "./server/resources"].iter().map(PathBuf::from).find(|p| p.is_dir()).unwrap_or_else(|| PathBuf::from("/app/resources"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resource directories, lowest priority first: built-in, data dir, workspace, project.
|
||||||
fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
||||||
let mut dirs = vec![app.data_dir().join(kind)];
|
let mut dirs = vec![builtin_resources().join(kind), app.data_dir().join(kind)];
|
||||||
let ws = root.to_path_buf();
|
let ws = root.to_path_buf();
|
||||||
let mut chain = vec![ws.clone()];
|
let mut chain = vec![ws.clone()];
|
||||||
if cwd != ws {
|
if cwd != ws {
|
||||||
@@ -191,10 +205,13 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str) -> String {
|
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
|
||||||
let mut s = String::from(BASE_PROMPT);
|
let mut s = String::from(BASE_PROMPT);
|
||||||
s.push_str("\n\n");
|
s.push_str("\n\n");
|
||||||
s.push_str(&agent.prompt);
|
s.push_str(&agent.prompt);
|
||||||
|
if std::env::var("HOME").is_ok_and(|h| h.starts_with("/data")) {
|
||||||
|
s.push_str("\n\n# Installing tools\nYou run without root (no apt/sudo). Install what you need in user space – it persists: Python packages with `pip install <pkg>` (user site) or a venv, CLI tools with `pipx install`, Rust with `curl https://sh.rustup.rs -sSf | sh -s -- -y` (then `~/.cargo/bin` is on PATH), Node packages with `npm i -g`, and other binaries (e.g. Godot) by downloading them to `~/.local/bin`. Check with `which`/`--version` first whether a tool is already installed.");
|
||||||
|
}
|
||||||
if permission_mode == "auto" && !agent.read_only {
|
if permission_mode == "auto" && !agent.read_only {
|
||||||
s.push_str("\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.");
|
s.push_str("\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.");
|
||||||
}
|
}
|
||||||
@@ -219,7 +236,7 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let deferred = app.mcp.deferred_summary(&app.config());
|
let deferred = app.mcp.deferred_summary(&app.config(), owner);
|
||||||
if !deferred.is_empty() {
|
if !deferred.is_empty() {
|
||||||
s.push_str("\n# On-demand MCP servers\nThese servers have many tools that are not listed directly. Find tools with mcp_search, then call them with mcp_call:\n");
|
s.push_str("\n# On-demand MCP servers\nThese servers have many tools that are not listed directly. Find tools with mcp_search, then call them with mcp_call:\n");
|
||||||
for (server, n, names) in &deferred {
|
for (server, n, names) in &deferred {
|
||||||
@@ -253,6 +270,9 @@ pub struct Command {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub agent: Option<String>,
|
pub agent: Option<String>,
|
||||||
|
/// Craft: switch the session to clean room (true) or deep (false).
|
||||||
|
#[serde(skip)]
|
||||||
|
pub clean_room: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec<Command> {
|
pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec<Command> {
|
||||||
@@ -271,14 +291,15 @@ pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec<Command> {
|
|||||||
.get("description")
|
.get("description")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(80).collect());
|
.unwrap_or_else(|| body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(80).collect());
|
||||||
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned() });
|
let clean_room = meta.get("clean_room").and_then(|v| v.parse::<bool>().ok());
|
||||||
|
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned(), clean_room });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
map.into_values().collect()
|
map.into_values().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand "/name args" into the command's prompt. Returns None if not a known command.
|
/// Expand "/name args" into the command's prompt. Returns None if not a known command.
|
||||||
pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<(String, Option<String>)> {
|
pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<(String, Option<String>, Option<bool>)> {
|
||||||
let rest = text.strip_prefix('/')?;
|
let rest = text.strip_prefix('/')?;
|
||||||
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||||
let cmd = commands(app, root, cwd).into_iter().find(|c| c.name == name)?;
|
let cmd = commands(app, root, cwd).into_iter().find(|c| c.name == name)?;
|
||||||
@@ -290,7 +311,7 @@ pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<
|
|||||||
if !cmd.body.contains("$ARGUMENTS") && !args.is_empty() {
|
if !cmd.body.contains("$ARGUMENTS") && !args.is_empty() {
|
||||||
body.push_str(&format!("\n\n{args}"));
|
body.push_str(&format!("\n\n{args}"));
|
||||||
}
|
}
|
||||||
Some((body, cmd.agent))
|
Some((body, cmd.agent, cmd.clean_room))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -219,8 +219,15 @@ impl Registry {
|
|||||||
"id": m.id,
|
"id": m.id,
|
||||||
"name": m.name.clone().unwrap_or_else(|| m.id.clone()),
|
"name": m.name.clone().unwrap_or_else(|| m.id.clone()),
|
||||||
"context": m.context,
|
"context": m.context,
|
||||||
|
"max_output": m.max_output,
|
||||||
"reasoning": m.reasoning,
|
"reasoning": m.reasoning,
|
||||||
"vision": m.vision,
|
"vision": m.vision,
|
||||||
|
"effort": m.effort,
|
||||||
|
"input_price": m.input_price,
|
||||||
|
"output_price": m.output_price,
|
||||||
|
"cache_read_price": m.cache_read_price,
|
||||||
|
"cache_write_price": m.cache_write_price,
|
||||||
|
"max_concurrency": m.max_concurrency,
|
||||||
})).collect::<Vec<_>>()
|
})).collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
//! Reverse engineering for craft "deep" mode: binary inspection (binutils)
|
||||||
|
//! and decompilation with a Ghidra installation in <data>/tools.
|
||||||
|
|
||||||
|
use crate::api::{ApiError, ApiResult};
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::auth::require_admin;
|
||||||
|
use crate::store::User;
|
||||||
|
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::{Extension, Json};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
pub fn tools_dir(app: &App) -> PathBuf {
|
||||||
|
app.data_dir().join("tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory with CodeCraftExport.java and install-ghidra.sh.
|
||||||
|
pub fn scripts_dir() -> PathBuf {
|
||||||
|
if let Ok(p) = std::env::var("CODECRAFT_GHIDRA_SCRIPTS") {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
["/app/ghidra", "./ghidra", "./server/ghidra"].iter().map(PathBuf::from).find(|p| p.join("CodeCraftExport.java").exists()).unwrap_or_else(|| PathBuf::from("/app/ghidra"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ghidra_installed(app: &App) -> bool {
|
||||||
|
let t = tools_dir(app);
|
||||||
|
t.join("ghidra/support/analyzeHeadless").exists() && t.join("jdk/bin/java").exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deep_mode_required(ctx: &ToolCtx) -> Option<ToolOutput> {
|
||||||
|
ctx.clean_room.then(|| {
|
||||||
|
ToolOutput::error("Clean room mode: binaries may not be inspected. The user can switch this session to Deep mode to analyse the original's code.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a command (sandboxed for non-admin users) and collect stdout+stderr.
|
||||||
|
async fn run(ctx: &ToolCtx, program: &str, args: &[&str], timeout: Duration) -> Result<String> {
|
||||||
|
let mut cmd = match &ctx.sandbox {
|
||||||
|
Some(spec) => {
|
||||||
|
if !crate::sandbox::available() {
|
||||||
|
bail!("the sandbox is not available on this server");
|
||||||
|
}
|
||||||
|
crate::sandbox::command(spec, &ctx.cwd, program, args)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut c = Command::new(program);
|
||||||
|
c.args(args).current_dir(&ctx.cwd);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let out = tokio::time::timeout(timeout, cmd.kill_on_drop(true).output()).await.context("timed out")??;
|
||||||
|
Ok(format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BinaryInfo;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for BinaryInfo {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"binary_info"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Inspect an executable or library (Windows PE .exe/.dll, Linux ELF, Mach-O, DOS MZ): file type, \
|
||||||
|
architecture, sections, imported libraries and functions, exported symbols and printable \
|
||||||
|
strings. Fast; use it before decompile."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({"type": "object", "properties": {"path": {"type": "string"}, "strings": {"type": "integer", "description": "Max. strings to list (default 200)"}}, "required": ["path"]})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
if let Some(e) = deep_mode_required(ctx) {
|
||||||
|
return Ok(e);
|
||||||
|
}
|
||||||
|
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||||
|
let p = path.to_string_lossy().to_string();
|
||||||
|
let max_strings = input["strings"].as_u64().unwrap_or(200).min(2000) as usize;
|
||||||
|
let t = Duration::from_secs(60);
|
||||||
|
let mut out = format!("## file\n{}\n", run(ctx, "file", &["-b", &p], t).await?.trim());
|
||||||
|
out.push_str(&format!("\n## headers and imports (objdump -x)\n{}\n", truncate(&run(ctx, "objdump", &["-x", "-w", &p], t).await?, 25_000)));
|
||||||
|
if run(ctx, "file", &["-b", &p], t).await?.contains("ELF") {
|
||||||
|
out.push_str(&format!("\n## dynamic symbols\n{}\n", truncate(&run(ctx, "objdump", &["-T", "-w", &p], t).await?, 10_000)));
|
||||||
|
}
|
||||||
|
let strings = run(ctx, "strings", &["-n", "6", &p], t).await?;
|
||||||
|
let lines: Vec<&str> = strings.lines().collect();
|
||||||
|
out.push_str(&format!("\n## strings ({} total, first {})\n{}\n", lines.len(), max_strings.min(lines.len()), lines.iter().take(max_strings).copied().collect::<Vec<_>>().join("\n")));
|
||||||
|
Ok(ToolOutput::text(truncate(&out, 60_000)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Decompile;
|
||||||
|
|
||||||
|
fn file_hash(path: &Path) -> Result<String> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let bytes = std::fs::read(path)?;
|
||||||
|
Ok(Sha256::digest(&bytes).iter().take(6).map(|b| format!("{b:02x}")).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize(out_dir: &Path, cwd: &Path) -> Result<String> {
|
||||||
|
let rel = out_dir.strip_prefix(cwd).unwrap_or(out_dir).display().to_string();
|
||||||
|
let info = std::fs::read_to_string(out_dir.join("info.txt")).unwrap_or_default();
|
||||||
|
let funcs = std::fs::read_to_string(out_dir.join("functions.tsv")).unwrap_or_default();
|
||||||
|
let count_lines = |f: &str| std::fs::read_to_string(out_dir.join(f)).map(|s| s.lines().count().saturating_sub(1)).unwrap_or(0);
|
||||||
|
let mut rows: Vec<(u64, String)> = funcs
|
||||||
|
.lines()
|
||||||
|
.skip(1)
|
||||||
|
.filter_map(|l| {
|
||||||
|
let c: Vec<&str> = l.split('\t').collect();
|
||||||
|
Some((c.get(2)?.parse().ok()?, format!("{} {} ({} bytes) → {}", c[0], c[1], c[2], c.get(3)?)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
rows.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
let top: Vec<String> = rows.iter().take(40).map(|r| r.1.clone()).collect();
|
||||||
|
Ok(format!(
|
||||||
|
"Decompiled output in {rel}/\n\n{info}\nFiles:\n- functions.tsv: {} decompiled functions (address, name, size, file, signature)\n- functions/*.c: pseudo-C per function\n- strings.tsv: {} strings\n- imports.tsv: {} imported functions\n- exports.tsv: {} exports\n\nLargest functions:\n{}\n\nUse grep/read on these files (e.g. grep -l \"CreateWindow\" {rel}/functions) to explore. \
|
||||||
|
Recreate behaviour in new code; do not copy decompiled code verbatim.",
|
||||||
|
rows.len(),
|
||||||
|
count_lines("strings.tsv"),
|
||||||
|
count_lines("imports.tsv"),
|
||||||
|
count_lines("exports.tsv"),
|
||||||
|
top.join("\n")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Decompile {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"decompile"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"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."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"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"}
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
format!("Decompile {}", input["path"].as_str().unwrap_or("?"))
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
if let Some(e) = deep_mode_required(ctx) {
|
||||||
|
return Ok(e);
|
||||||
|
}
|
||||||
|
if !ghidra_installed(&ctx.app) {
|
||||||
|
return Ok(ToolOutput::error("Ghidra is not installed. An admin can install it under Settings → Tools."));
|
||||||
|
}
|
||||||
|
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||||
|
if !path.is_file() {
|
||||||
|
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)?));
|
||||||
|
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)?)));
|
||||||
|
}
|
||||||
|
tokio::fs::create_dir_all(&out_dir).await?;
|
||||||
|
let project = std::env::temp_dir().join(format!("cc-ghidra-{}", crate::types::new_id()));
|
||||||
|
tokio::fs::create_dir_all(&project).await?;
|
||||||
|
let tools = tools_dir(&ctx.app);
|
||||||
|
let headless = tools.join("ghidra/support/analyzeHeadless").display().to_string();
|
||||||
|
let scripts = scripts_dir().display().to_string();
|
||||||
|
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
|
||||||
|
let (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 java = tools.join("jdk").display().to_string();
|
||||||
|
let mut cmd = match &ctx.sandbox {
|
||||||
|
Some(spec) => {
|
||||||
|
// Ghidra itself (tools dir, scripts) is mounted read-only into the sandbox.
|
||||||
|
let mut c = crate::sandbox::command_with(spec, &ctx.cwd, &[tools.as_path(), Path::new(&scripts)], "env", &[]);
|
||||||
|
c.arg(format!("JAVA_HOME={java}")).arg(format!("PATH={java}/bin:/usr/bin:/bin")).arg(&headless).args(&args);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut c = Command::new(&headless);
|
||||||
|
c.args(&args).env("JAVA_HOME", &java).env("PATH", format!("{java}/bin:/usr/local/bin:/usr/bin:/bin")).current_dir(&ctx.cwd);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
|
||||||
|
let mut child = cmd.spawn().context("cannot start Ghidra")?;
|
||||||
|
let stdout = child.stdout.take().unwrap();
|
||||||
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
let mut log = String::new();
|
||||||
|
let deadline = tokio::time::sleep(Duration::from_secs(45 * 60));
|
||||||
|
tokio::pin!(deadline);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
l = lines.next_line() => match l {
|
||||||
|
Ok(Some(l)) => {
|
||||||
|
if l.contains("CodeCraft:") || l.contains("ERROR") || l.contains("Analyzing") {
|
||||||
|
ctx.progress(l.clone());
|
||||||
|
}
|
||||||
|
if log.len() < 20_000 { log.push_str(&l); log.push('\n'); }
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
},
|
||||||
|
_ = &mut deadline => { let _ = child.kill().await; bail!("Ghidra timed out after 45 minutes"); }
|
||||||
|
_ = ctx.cancel.cancelled() => { let _ = child.kill().await; bail!("aborted"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let status = child.wait().await?;
|
||||||
|
let _ = tokio::fs::remove_dir_all(&project).await;
|
||||||
|
if !out_dir.join(".done").exists() {
|
||||||
|
let tail: String = log.lines().rev().take(25).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n");
|
||||||
|
return Ok(ToolOutput::error(format!("Ghidra did not finish (exit {status}). Last output:\n{tail}")));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput::text(summarize(&out_dir, &ctx.cwd)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- admin: tool installation ------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct InstallState {
|
||||||
|
pub running: bool,
|
||||||
|
pub log: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
|
||||||
|
require_admin(&user)?;
|
||||||
|
let st = app.tool_install.lock().unwrap();
|
||||||
|
let version = std::fs::read_to_string(tools_dir(&app).join("ghidra/Ghidra/application.properties"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.lines().find_map(|l| l.strip_prefix("application.version=").map(String::from)));
|
||||||
|
Ok(Json(json!({"ghidra": {"installed": ghidra_installed(&app), "version": version, "running": st.running, "log": st.log}})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn install_ghidra(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
|
||||||
|
require_admin(&user)?;
|
||||||
|
{
|
||||||
|
let mut st = app.tool_install.lock().unwrap();
|
||||||
|
if st.running {
|
||||||
|
return Err(ApiError(axum::http::StatusCode::CONFLICT, "installation already running".into()));
|
||||||
|
}
|
||||||
|
st.running = true;
|
||||||
|
st.log.clear();
|
||||||
|
}
|
||||||
|
app.audit(Some(&user), "tools.install", "ghidra", "");
|
||||||
|
let app2 = app.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let script = scripts_dir().join("install-ghidra.sh");
|
||||||
|
let res = Command::new("sh").arg(&script).arg(tools_dir(&app2)).stdout(Stdio::piped()).stderr(Stdio::piped()).output().await;
|
||||||
|
let mut st = app2.tool_install.lock().unwrap();
|
||||||
|
st.running = false;
|
||||||
|
st.log = match res {
|
||||||
|
Ok(o) => format!("{}{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr), if o.status.success() { "\n✓ done" } else { "\n✗ failed" }),
|
||||||
|
Err(e) => format!("cannot run installer: {e}"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
Ok(Json(json!({"started": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ToolInstall = Mutex<InstallState>;
|
||||||
+58
-6
@@ -188,13 +188,13 @@ fn normalize_tool_name(s: &str) -> String {
|
|||||||
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_lowercase()
|
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
|
pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::User>) -> Vec<Arc<dyn Tool>> {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let mut all = tools::builtin_tools();
|
let mut all = tools::builtin_tools();
|
||||||
if !cfg.images.enabled {
|
if !cfg.images.enabled {
|
||||||
all.retain(|t| t.name() != "generate_image");
|
all.retain(|t| t.name() != "generate_image");
|
||||||
}
|
}
|
||||||
let (direct, deferred) = app.mcp.tools(&cfg);
|
let (direct, deferred) = app.mcp.tools(&cfg, owner);
|
||||||
all.extend(direct);
|
all.extend(direct);
|
||||||
if !deferred.is_empty() {
|
if !deferred.is_empty() {
|
||||||
all.push(Arc::new(crate::mcp::McpSearch));
|
all.push(Arc::new(crate::mcp::McpSearch));
|
||||||
@@ -203,7 +203,7 @@ pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
|
|||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|t| !agent.read_only || t.read_only())
|
.filter(|t| !agent.read_only || t.read_only())
|
||||||
// Craft tools only where they are useful.
|
// Craft tools only where they are useful.
|
||||||
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare") || agent.tools.is_some())
|
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile") || agent.tools.is_some())
|
||||||
.filter(|t| match &agent.tools {
|
.filter(|t| match &agent.tools {
|
||||||
None => true,
|
None => true,
|
||||||
Some(list) => {
|
Some(list) => {
|
||||||
@@ -244,7 +244,12 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
let cwd = PathBuf::from(&session.cwd);
|
let cwd = PathBuf::from(&session.cwd);
|
||||||
let roots = app.user_roots(session.owner_id.as_deref());
|
let roots = app.user_roots(session.owner_id.as_deref());
|
||||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||||
let agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
|
let mut agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
|
||||||
|
let clean_room = session.clean_room(app.config().craft.clean_room);
|
||||||
|
// The craft prompt follows the session's clean room / deep switch.
|
||||||
|
if agent.name == "craft" && agent.source == "builtin" {
|
||||||
|
agent.prompt = prompts::craft_prompt(clean_room);
|
||||||
|
}
|
||||||
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
|
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
|
||||||
let (provider, model) = app.registry().resolve(&model_spec)?;
|
let (provider, model) = app.registry().resolve(&model_spec)?;
|
||||||
|
|
||||||
@@ -257,10 +262,11 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let tools = available_tools(&app, &agent);
|
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
||||||
|
let tools = available_tools(&app, &agent, owner.as_ref());
|
||||||
let history = active_history(app.store.messages(session_id)?);
|
let history = active_history(app.store.messages(session_id)?);
|
||||||
let req = ChatRequest {
|
let req = ChatRequest {
|
||||||
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode),
|
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()),
|
||||||
messages: history,
|
messages: history,
|
||||||
tools: tools.iter().map(|t| t.def()).collect(),
|
tools: tools.iter().map(|t| t.def()).collect(),
|
||||||
max_tokens: model.max_output.unwrap_or(16_000),
|
max_tokens: model.max_output.unwrap_or(16_000),
|
||||||
@@ -320,6 +326,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
cancel: cancel.clone(),
|
cancel: cancel.clone(),
|
||||||
agent: agent.name.clone(),
|
agent: agent.name.clone(),
|
||||||
roots: roots.clone(),
|
roots: roots.clone(),
|
||||||
|
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
||||||
|
clean_room,
|
||||||
};
|
};
|
||||||
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
||||||
let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only()));
|
let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only()));
|
||||||
@@ -387,6 +395,8 @@ async fn execute_call(
|
|||||||
cancel: base.cancel.clone(),
|
cancel: base.cancel.clone(),
|
||||||
agent: base.agent.clone(),
|
agent: base.agent.clone(),
|
||||||
roots: base.roots.clone(),
|
roots: base.roots.clone(),
|
||||||
|
sandbox: base.sandbox.clone(),
|
||||||
|
clean_room: base.clean_room,
|
||||||
};
|
};
|
||||||
// mcp_call is judged (and approved) as the MCP tool it calls.
|
// mcp_call is judged (and approved) as the MCP tool it calls.
|
||||||
let call_name = name;
|
let call_name = name;
|
||||||
@@ -443,6 +453,15 @@ async fn execute_call(
|
|||||||
Decision::Allow => {}
|
Decision::Allow => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checkpoint: remember files before the agent changes them (rewind can restore them).
|
||||||
|
if tool.is_edit() {
|
||||||
|
if let Some(path) = input["path"].as_str().and_then(|p| ctx.resolve(p).ok()) {
|
||||||
|
let content = std::fs::read(&path).ok().filter(|c| c.len() <= 5 * 1024 * 1024);
|
||||||
|
if path.exists() == content.is_some() {
|
||||||
|
let _ = app.store.add_checkpoint(&session.id, &path.display().to_string(), content.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let res = if name == "task" {
|
let res = if name == "task" {
|
||||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||||
run_task(app, &ctx, input, depth).await
|
run_task(app, &ctx, input, depth).await
|
||||||
@@ -551,6 +570,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
todos: Value::Null,
|
todos: Value::Null,
|
||||||
last_usage: None,
|
last_usage: None,
|
||||||
|
clean_room: parent.clean_room,
|
||||||
};
|
};
|
||||||
let child = app.save_session(child)?;
|
let child = app.save_session(child)?;
|
||||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||||
@@ -586,6 +606,29 @@ async fn run_child(app: Arc<App>, child: &Session, agent: &Agent, cancel: Cancel
|
|||||||
Ok(if text.trim().is_empty() { "(subagent returned no text)".into() } else { text })
|
Ok(if text.trim().is_empty() { "(subagent returned no text)".into() } else { text })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rough prompt size (≈ 3.8 characters per token, ~1,000 tokens per image).
|
||||||
|
fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
|
||||||
|
let mut chars = req.system.len();
|
||||||
|
let mut images = 0u64;
|
||||||
|
for m in &req.messages {
|
||||||
|
for p in &m.parts {
|
||||||
|
match p {
|
||||||
|
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(),
|
||||||
|
Part::ToolCall { input, .. } => chars += input.to_string().len(),
|
||||||
|
Part::ToolResult { content, images: imgs, .. } => {
|
||||||
|
chars += content.len();
|
||||||
|
images += imgs.len() as u64;
|
||||||
|
}
|
||||||
|
Part::Image { .. } => images += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for t in &req.tools {
|
||||||
|
chars += t.description.len() + t.input_schema.to_string().len() + t.name.len();
|
||||||
|
}
|
||||||
|
(chars as f64 / 3.8) as u64 + images * 1000
|
||||||
|
}
|
||||||
|
|
||||||
fn is_retryable(e: &anyhow::Error) -> bool {
|
fn is_retryable(e: &anyhow::Error) -> bool {
|
||||||
let s = format!("{e:#}");
|
let s = format!("{e:#}");
|
||||||
s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out")
|
s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out")
|
||||||
@@ -601,6 +644,8 @@ async fn stream_step(
|
|||||||
req: ChatRequest,
|
req: ChatRequest,
|
||||||
cancel: &CancellationToken,
|
cancel: &CancellationToken,
|
||||||
) -> Result<Step> {
|
) -> Result<Step> {
|
||||||
|
let provider_name = model_spec.split_once('/').map(|(p, _)| p).unwrap_or(model_spec);
|
||||||
|
crate::budget::check(app, provider_name, session.owner_id.as_deref())?;
|
||||||
let mut attempt = 0;
|
let mut attempt = 0;
|
||||||
loop {
|
loop {
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
@@ -619,6 +664,13 @@ async fn stream_step(
|
|||||||
_ = cancel.cancelled() => bail!("aborted"),
|
_ = cancel.cancelled() => bail!("aborted"),
|
||||||
};
|
};
|
||||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
|
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
|
||||||
|
app.emit(Event::StepStarted {
|
||||||
|
session_id: session.id.clone(),
|
||||||
|
message_id: msg_id.clone(),
|
||||||
|
started_at: now_ms(),
|
||||||
|
prompt_tokens: estimate_prompt_tokens(&req),
|
||||||
|
estimated: true,
|
||||||
|
});
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let _slot = slot;
|
let _slot = slot;
|
||||||
p.stream(r, tx, c).await
|
p.stream(r, tx, c).await
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! Per-user sandbox for shell commands (and git) using bubblewrap.
|
||||||
|
//!
|
||||||
|
//! A sandboxed command sees the system read-only, only the user's folders
|
||||||
|
//! writable, its own home directory, a private /tmp, its own process
|
||||||
|
//! namespace and an empty environment (no server secrets). The network is
|
||||||
|
//! shared so package managers keep working.
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
/// What a sandboxed process may access.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SandboxSpec {
|
||||||
|
pub username: String,
|
||||||
|
/// Writable directories (the user's roots).
|
||||||
|
pub roots: Vec<PathBuf>,
|
||||||
|
/// Host directory mounted as the user's home.
|
||||||
|
pub home: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxSpec {
|
||||||
|
pub fn home_inside(&self) -> String {
|
||||||
|
format!("/home/{}", self.username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static AVAILABLE: OnceLock<Result<(), String>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Check once whether bubblewrap works in this environment.
|
||||||
|
pub async fn probe() -> Result<(), String> {
|
||||||
|
if let Some(r) = AVAILABLE.get() {
|
||||||
|
return r.clone();
|
||||||
|
}
|
||||||
|
let res = match Command::new("bwrap")
|
||||||
|
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
|
||||||
|
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
|
||||||
|
.env_clear()
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(o) if o.status.success() => Ok(()),
|
||||||
|
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
|
||||||
|
Err(e) => Err(format!("bubblewrap is not installed: {e}")),
|
||||||
|
};
|
||||||
|
match &res {
|
||||||
|
Ok(()) => tracing::info!("sandbox: bubblewrap available"),
|
||||||
|
Err(e) => tracing::warn!("sandbox: bubblewrap unavailable – shell commands of non-admin users are disabled ({e})"),
|
||||||
|
}
|
||||||
|
let _ = AVAILABLE.set(res.clone());
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn available() -> bool {
|
||||||
|
matches!(AVAILABLE.get(), Some(Ok(())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sandbox for a session owner, or None if commands run unsandboxed
|
||||||
|
/// (admins in "auto" mode, or sandbox mode "off").
|
||||||
|
pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
|
||||||
|
let mode = app.config().sandbox.mode.clone();
|
||||||
|
if mode == "off" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user = owner_id.and_then(|id| app.store.user(id).ok().flatten())?;
|
||||||
|
if user.is_admin() && mode != "always" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let home = app.data_dir().join("users").join(&user.username).join("home");
|
||||||
|
let _ = std::fs::create_dir_all(&home);
|
||||||
|
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a command that runs `program args` inside the sandbox in `cwd`.
|
||||||
|
pub fn command(spec: &SandboxSpec, cwd: &Path, program: &str, args: &[&str]) -> Command {
|
||||||
|
command_with(spec, cwd, &[], program, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `command`, with extra host directories mounted read-only (e.g. tools).
|
||||||
|
pub fn command_with(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], program: &str, args: &[&str]) -> Command {
|
||||||
|
let mut c = Command::new("bwrap");
|
||||||
|
// Empty environment: bubblewrap's own helper process must not carry secrets.
|
||||||
|
c.env_clear();
|
||||||
|
for dir in ["/usr", "/bin", "/lib", "/etc", "/sbin", "/opt"] {
|
||||||
|
if Path::new(dir).is_dir() && !Path::new(dir).is_symlink() {
|
||||||
|
c.args(["--ro-bind", dir, dir]);
|
||||||
|
} else if let Ok(target) = std::fs::read_link(dir) {
|
||||||
|
c.arg("--symlink").arg(target).arg(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dir in ["/lib64", "/lib32"] {
|
||||||
|
let p = Path::new(dir);
|
||||||
|
if let Ok(target) = std::fs::read_link(p) {
|
||||||
|
c.arg("--symlink").arg(target).arg(dir);
|
||||||
|
} else if p.is_dir() {
|
||||||
|
c.args(["--ro-bind", dir, dir]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for root in &spec.roots {
|
||||||
|
c.arg("--bind").arg(root).arg(root);
|
||||||
|
}
|
||||||
|
// Built-in skills and templates are readable (not writable) in the sandbox.
|
||||||
|
let builtin = crate::prompts::builtin_resources();
|
||||||
|
for dir in extra_ro.iter().copied().chain(std::iter::once(builtin.as_path())) {
|
||||||
|
if dir.exists() {
|
||||||
|
c.arg("--ro-bind").arg(dir).arg(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let home = spec.home_inside();
|
||||||
|
c.arg("--bind").arg(&spec.home).arg(&home);
|
||||||
|
c.args(["--tmpfs", "/tmp", "--dev", "/dev", "--proc", "/proc"]);
|
||||||
|
c.args(["--unshare-all", "--share-net", "--die-with-parent", "--new-session", "--clearenv"]);
|
||||||
|
let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin");
|
||||||
|
for (k, v) in [
|
||||||
|
("HOME", home.as_str()),
|
||||||
|
("USER", spec.username.as_str()),
|
||||||
|
("PATH", path.as_str()),
|
||||||
|
("LANG", "C.UTF-8"),
|
||||||
|
("TERM", "xterm-256color"),
|
||||||
|
("CODECRAFT", "1"),
|
||||||
|
("GIT_TERMINAL_PROMPT", "0"),
|
||||||
|
("PAGER", "cat"),
|
||||||
|
("PIP_BREAK_SYSTEM_PACKAGES", "1"),
|
||||||
|
] {
|
||||||
|
c.args(["--setenv", k, v]);
|
||||||
|
}
|
||||||
|
c.arg("--setenv").arg("NPM_CONFIG_PREFIX").arg(format!("{home}/.npm-global"));
|
||||||
|
c.arg("--setenv").arg("PIPX_HOME").arg(format!("{home}/.local/pipx"));
|
||||||
|
c.arg("--setenv").arg("PIPX_BIN_DIR").arg(format!("{home}/.local/bin"));
|
||||||
|
c.arg("--chdir").arg(cwd);
|
||||||
|
c.arg(program).args(args);
|
||||||
|
c
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! Background shells: long-running commands started by agents (dev servers,
|
||||||
|
//! watchers). Output is buffered; agents read it incrementally.
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
const MAX_BUFFER: usize = 1024 * 1024;
|
||||||
|
const MAX_PER_SESSION: usize = 8;
|
||||||
|
|
||||||
|
pub struct Shell {
|
||||||
|
pub id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub command: String,
|
||||||
|
pub started_at: i64,
|
||||||
|
/// Everything printed so far (the oldest part is dropped beyond 1 MB).
|
||||||
|
output: Mutex<String>,
|
||||||
|
/// Bytes dropped from the front of `output`.
|
||||||
|
dropped: Mutex<usize>,
|
||||||
|
/// Absolute position up to which the agent has read.
|
||||||
|
read_pos: Mutex<usize>,
|
||||||
|
exit: Mutex<Option<i32>>,
|
||||||
|
stop: CancellationToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shell {
|
||||||
|
pub fn running(&self) -> bool {
|
||||||
|
self.exit.lock().unwrap().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn json(&self, tail: usize) -> Value {
|
||||||
|
let out = self.output.lock().unwrap();
|
||||||
|
let start = out.len().saturating_sub(tail);
|
||||||
|
let start = (start..=out.len()).find(|i| out.is_char_boundary(*i)).unwrap_or(out.len());
|
||||||
|
json!({
|
||||||
|
"id": self.id, "command": self.command, "started_at": self.started_at,
|
||||||
|
"running": self.running(), "exit_code": *self.exit.lock().unwrap(), "output": &out[start..],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output not yet seen by the agent.
|
||||||
|
fn take_new(&self) -> String {
|
||||||
|
let out = self.output.lock().unwrap();
|
||||||
|
let dropped = *self.dropped.lock().unwrap();
|
||||||
|
let mut pos = self.read_pos.lock().unwrap();
|
||||||
|
let from = pos.saturating_sub(dropped).min(out.len());
|
||||||
|
let from = (from..=out.len()).find(|i| out.is_char_boundary(*i)).unwrap_or(out.len());
|
||||||
|
let text = out[from..].to_string();
|
||||||
|
*pos = dropped + out.len();
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kill(&self) {
|
||||||
|
self.stop.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Shells = Mutex<HashMap<String, Arc<Shell>>>;
|
||||||
|
|
||||||
|
pub fn spawn(app: &Arc<App>, session_id: &str, command: &str, mut cmd: Command) -> Result<String> {
|
||||||
|
let running = app.shells.lock().unwrap().values().filter(|s| s.session_id == session_id && s.running()).count();
|
||||||
|
if running >= MAX_PER_SESSION {
|
||||||
|
bail!("too many background shells in this session ({running}) – stop some with kill_shell");
|
||||||
|
}
|
||||||
|
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
|
||||||
|
cmd.process_group(0);
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
let pid = child.id();
|
||||||
|
let shell = Arc::new(Shell {
|
||||||
|
id: format!("sh{}", &crate::types::new_id()[20..]),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
command: command.to_string(),
|
||||||
|
started_at: crate::types::now_ms(),
|
||||||
|
output: Mutex::default(),
|
||||||
|
dropped: Mutex::default(),
|
||||||
|
read_pos: Mutex::default(),
|
||||||
|
exit: Mutex::default(),
|
||||||
|
stop: CancellationToken::new(),
|
||||||
|
});
|
||||||
|
app.shells.lock().unwrap().insert(shell.id.clone(), shell.clone());
|
||||||
|
let stdout = child.stdout.take().unwrap();
|
||||||
|
let s = shell.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stdout).split(b'\n');
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
l = lines.next_segment() => match l {
|
||||||
|
Ok(Some(l)) => {
|
||||||
|
let mut out = s.output.lock().unwrap();
|
||||||
|
out.push_str(&String::from_utf8_lossy(&l));
|
||||||
|
out.push('\n');
|
||||||
|
if out.len() > MAX_BUFFER {
|
||||||
|
let mut cut = out.len() - MAX_BUFFER / 2;
|
||||||
|
while !out.is_char_boundary(cut) { cut += 1; }
|
||||||
|
out.drain(..cut);
|
||||||
|
*s.dropped.lock().unwrap() += cut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
},
|
||||||
|
_ = s.stop.cancelled() => {
|
||||||
|
if let Some(pid) = pid {
|
||||||
|
// SAFETY: plain syscall; negative pid targets the process group.
|
||||||
|
unsafe { libc::kill(-(pid as i32), libc::SIGTERM); }
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
unsafe { libc::kill(-(pid as i32), libc::SIGKILL); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let code = child.wait().await.ok().and_then(|st| st.code()).unwrap_or(-1);
|
||||||
|
*s.exit.lock().unwrap() = Some(code);
|
||||||
|
});
|
||||||
|
Ok(shell.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop all shells of a session (on delete).
|
||||||
|
pub fn kill_session(app: &App, session_id: &str) {
|
||||||
|
for s in app.shells.lock().unwrap().values().filter(|s| s.session_id == session_id) {
|
||||||
|
s.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(ctx: &ToolCtx, id: &str) -> Result<Arc<Shell>> {
|
||||||
|
let shells = ctx.app.shells.lock().unwrap();
|
||||||
|
match shells.get(id) {
|
||||||
|
// Shells belong to the session tree that started them.
|
||||||
|
Some(s) if ctx.app.root_session_id(&s.session_id) == ctx.app.root_session_id(&ctx.session_id) => Ok(s.clone()),
|
||||||
|
_ => bail!("unknown shell id '{id}'"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BashOutput;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for BashOutput {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"bash_output"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Read new output of a background shell (started with bash run_in_background) since the last read, and whether it is still running.".into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({"type": "object", "properties": {"shell_id": {"type": "string"}, "filter": {"type": "string", "description": "Only lines matching this regex"}}, "required": ["shell_id"]})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let shell = find(ctx, arg_str(&input, "shell_id")?)?;
|
||||||
|
let mut text = shell.take_new();
|
||||||
|
if let Some(f) = input["filter"].as_str() {
|
||||||
|
let re = regex::Regex::new(f)?;
|
||||||
|
text = text.lines().filter(|l| re.is_match(l)).collect::<Vec<_>>().join("\n");
|
||||||
|
}
|
||||||
|
let status = match *shell.exit.lock().unwrap() {
|
||||||
|
None => "running".to_string(),
|
||||||
|
Some(c) => format!("exited with code {c}"),
|
||||||
|
};
|
||||||
|
Ok(ToolOutput::text(format!("[{status}]\n{}", if text.is_empty() { "(no new output)".into() } else { truncate(&text, 30_000) })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct KillShell;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for KillShell {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"kill_shell"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Stop a background shell.".into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({"type": "object", "properties": {"shell_id": {"type": "string"}}, "required": ["shell_id"]})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let shell = find(ctx, arg_str(&input, "shell_id")?)?;
|
||||||
|
shell.kill();
|
||||||
|
Ok(ToolOutput::text(format!("Stopping shell {}.", shell.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
-3
@@ -97,6 +97,25 @@ impl Store {
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
|
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
ip TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_ts ON audit(ts);
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
existed INTEGER NOT NULL,
|
||||||
|
content BLOB
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts);
|
||||||
CREATE TABLE IF NOT EXISTS session_shares (
|
CREATE TABLE IF NOT EXISTS session_shares (
|
||||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -107,6 +126,7 @@ impl Store {
|
|||||||
for (table, column, ddl) in [
|
for (table, column, ddl) in [
|
||||||
("sessions", "owner_id", "ALTER TABLE sessions ADD COLUMN owner_id TEXT"),
|
("sessions", "owner_id", "ALTER TABLE sessions ADD COLUMN owner_id TEXT"),
|
||||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||||
|
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||||
] {
|
] {
|
||||||
let exists: bool = conn
|
let exists: bool = conn
|
||||||
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
||||||
@@ -299,7 +319,19 @@ impl Store {
|
|||||||
Ok(v)
|
Ok(v)
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
Ok(json!({"total": total, "by_model": by_model}))
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT agent || ' · ' || provider || '/' || model, {AGG} FROM usage WHERE root_session_id = ?1 GROUP BY 1 ORDER BY 1"
|
||||||
|
))?;
|
||||||
|
let by_agent = stmt
|
||||||
|
.query_map([root_session_id], |r| {
|
||||||
|
let mut v = agg_row(r, 1)?;
|
||||||
|
v["key"] = json!(r.get::<_, String>(0)?);
|
||||||
|
Ok(v)
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
let (first, last): (Option<i64>, Option<i64>) =
|
||||||
|
conn.query_row("SELECT MIN(ts), MAX(ts) FROM usage WHERE root_session_id = ?1", [root_session_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||||
@@ -407,6 +439,8 @@ pub struct User {
|
|||||||
pub oidc: bool,
|
pub oidc: bool,
|
||||||
pub disabled: bool,
|
pub disabled: bool,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
/// Monthly cost limit for this user (None = unlimited).
|
||||||
|
pub monthly_quota: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
@@ -416,7 +450,7 @@ impl User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const USER_COLS: &str =
|
const USER_COLS: &str =
|
||||||
"id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at";
|
"id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at, monthly_quota";
|
||||||
|
|
||||||
fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
|
fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
|
||||||
Ok(User {
|
Ok(User {
|
||||||
@@ -428,6 +462,7 @@ fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
|
|||||||
oidc: r.get(5)?,
|
oidc: r.get(5)?,
|
||||||
disabled: r.get::<_, i64>(6)? != 0,
|
disabled: r.get::<_, i64>(6)? != 0,
|
||||||
created_at: r.get(7)?,
|
created_at: r.get(7)?,
|
||||||
|
monthly_quota: r.get(8)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +496,7 @@ impl Store {
|
|||||||
.query_row(
|
.query_row(
|
||||||
&format!("SELECT {USER_COLS}, password_hash FROM users WHERE username = ?1"),
|
&format!("SELECT {USER_COLS}, password_hash FROM users WHERE username = ?1"),
|
||||||
[username],
|
[username],
|
||||||
|r| Ok((user_row(r)?, r.get::<_, Option<String>>(8)?)),
|
|r| Ok((user_row(r)?, r.get::<_, Option<String>>(9)?)),
|
||||||
)
|
)
|
||||||
.optional()?)
|
.optional()?)
|
||||||
}
|
}
|
||||||
@@ -770,3 +805,93 @@ impl Store {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn set_user_quota(&self, id: &str, quota: Option<f64>) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("UPDATE users SET monthly_quota = ?2 WHERE id = ?1", params![id, quota])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost of a user's requests since `from` (ms).
|
||||||
|
pub fn user_cost_since(&self, user_id: &str, from: i64) -> Result<f64> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
Ok(conn.query_row("SELECT COALESCE(SUM(cost), 0) FROM usage WHERE user_id = ?1 AND ts >= ?2", params![user_id, from], |r| r.get(0))?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- audit log & backup ----------------------------------------------------------
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn audit(&self, user_id: Option<&str>, username: &str, action: &str, detail: &str, ip: &str) {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"INSERT INTO audit (ts, user_id, username, action, detail, ip) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![now_ms(), user_id, username, action, detail, ip],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn audit_list(&self, limit: i64, action: Option<&str>) -> Result<Vec<Value>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT ts, username, action, detail, ip FROM audit WHERE ?2 IS NULL OR action LIKE ?2 || '%' ORDER BY id DESC LIMIT ?1",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![limit, action], |r| {
|
||||||
|
Ok(json!({"ts": r.get::<_, i64>(0)?, "user": r.get::<_, String>(1)?, "action": r.get::<_, String>(2)?, "detail": r.get::<_, String>(3)?, "ip": r.get::<_, String>(4)?}))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consistent copy of the database (safe while the server runs).
|
||||||
|
pub fn snapshot(&self, target: &std::path::Path) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("VACUUM INTO ?1", [target.to_string_lossy()])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- file checkpoints ------------------------------------------------------------
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// Remember a file's content before the agent changes it.
|
||||||
|
pub fn add_checkpoint(&self, session_id: &str, path: &str, content: Option<&[u8]>) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO checkpoints (session_id, ts, path, existed, content) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
params![session_id, now_ms(), path, content.is_some() as i64, content],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore.
|
||||||
|
pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2
|
||||||
|
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND path = c.path)",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![session_id, since], |r| {
|
||||||
|
let existed: i64 = r.get(1)?;
|
||||||
|
Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None }))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_checkpoints_since(&self, session_id: &str, since: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("DELETE FROM checkpoints WHERE session_id = ?1 AND ts >= ?2", params![session_id, since])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message_created_at(&self, session_id: &str, message_id: &str) -> Result<Option<i64>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let data: Option<String> = conn
|
||||||
|
.query_row("SELECT data FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, message_id], |r| r.get(0))
|
||||||
|
.optional()?;
|
||||||
|
Ok(data.and_then(|d| serde_json::from_str::<Value>(&d).ok()).and_then(|v| v["created_at"].as_i64()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-13
@@ -24,7 +24,9 @@ impl Tool for Bash {
|
|||||||
"Run a shell command (bash) in the session working directory. stdout and stderr are \
|
"Run a shell command (bash) in the session working directory. stdout and stderr are \
|
||||||
combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \
|
combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \
|
||||||
does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \
|
does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \
|
||||||
dedicated read/edit/glob/grep tools over cat/sed/find/grep."
|
dedicated read/edit/glob/grep tools over cat/sed/find/grep. For long-running processes \
|
||||||
|
(dev servers, watchers, long builds) set run_in_background=true: you get a shell id, \
|
||||||
|
read new output with bash_output and stop it with kill_shell."
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
@@ -33,7 +35,8 @@ impl Tool for Bash {
|
|||||||
"properties": {
|
"properties": {
|
||||||
"command": {"type": "string"},
|
"command": {"type": "string"},
|
||||||
"description": {"type": "string", "description": "5-10 word description of what the command does"},
|
"description": {"type": "string", "description": "5-10 word description of what the command does"},
|
||||||
"timeout_ms": {"type": "integer"}
|
"timeout_ms": {"type": "integer"},
|
||||||
|
"run_in_background": {"type": "boolean", "description": "Keep running; read output with bash_output"}
|
||||||
},
|
},
|
||||||
"required": ["command"]
|
"required": ["command"]
|
||||||
})
|
})
|
||||||
@@ -49,17 +52,18 @@ impl Tool for Bash {
|
|||||||
let timeout = Duration::from_millis(
|
let timeout = Duration::from_millis(
|
||||||
input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS),
|
input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS),
|
||||||
);
|
);
|
||||||
let mut cmd = Command::new("bash");
|
let mut cmd = match shell_command(ctx, command) {
|
||||||
cmd.arg("-c")
|
Ok(c) => c,
|
||||||
.arg(format!("{command} 2>&1"))
|
Err(e) => return Ok(e),
|
||||||
.current_dir(&ctx.cwd)
|
};
|
||||||
.stdin(Stdio::null())
|
if input["run_in_background"].as_bool().unwrap_or(false) {
|
||||||
.stdout(Stdio::piped())
|
let id = crate::shells::spawn(&ctx.app, &ctx.session_id, command, cmd)?;
|
||||||
.stderr(Stdio::null())
|
return Ok(ToolOutput::text(format!(
|
||||||
.env("CODECRAFT", "1")
|
"Started in the background with shell id {id}. Read its output with bash_output (shell_id \"{id}\") and stop it with kill_shell."
|
||||||
.env("GIT_TERMINAL_PROMPT", "0")
|
))
|
||||||
.env("PAGER", "cat")
|
.with_meta(json!({"shell_id": id})));
|
||||||
.kill_on_drop(true);
|
}
|
||||||
|
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
|
||||||
// Own process group so the whole tree can be killed on abort/timeout.
|
// Own process group so the whole tree can be killed on abort/timeout.
|
||||||
cmd.process_group(0);
|
cmd.process_group(0);
|
||||||
let mut child = cmd.spawn()?;
|
let mut child = cmd.spawn()?;
|
||||||
@@ -128,6 +132,27 @@ 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}");
|
||||||
|
Ok(match &ctx.sandbox {
|
||||||
|
Some(spec) => {
|
||||||
|
if !crate::sandbox::available() {
|
||||||
|
return Err(ToolOutput::error(
|
||||||
|
"Shell commands are disabled for this account: the server's sandbox (bubblewrap) is not available. Ask an admin.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
crate::sandbox::command(spec, &ctx.cwd, "bash", &["-c", &script])
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut c = Command::new("bash");
|
||||||
|
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||||
|
c
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn tail(s: &str, max: usize) -> String {
|
fn tail(s: &str, max: usize) -> String {
|
||||||
if s.len() <= max {
|
if s.len() <= max {
|
||||||
return s.to_string();
|
return s.to_string();
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ impl Tool for BrowserSnapshot {
|
|||||||
}
|
}
|
||||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
let url = arg_str(&input, "url")?.to_string();
|
let url = arg_str(&input, "url")?.to_string();
|
||||||
|
// Local files only from the user's own folders.
|
||||||
|
if let Some(path) = url.strip_prefix("file://") {
|
||||||
|
ctx.resolve(path)?;
|
||||||
|
} else if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||||
|
bail!("only http(s) and file:// URLs are supported");
|
||||||
|
}
|
||||||
let label = slug(input["label"].as_str().unwrap_or(&url));
|
let label = slug(input["label"].as_str().unwrap_or(&url));
|
||||||
let widths: Vec<u32> = input["widths"]
|
let widths: Vec<u32> = input["widths"]
|
||||||
.as_array()
|
.as_array()
|
||||||
|
|||||||
+14
-5
@@ -46,6 +46,8 @@ impl Tool for Read {
|
|||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
"Read a file. Returns numbered lines (cat -n style). Use offset/limit for large files \
|
"Read a file. Returns numbered lines (cat -n style). Use offset/limit for large files \
|
||||||
(default: first 2000 lines). Images (png, jpg, gif, webp) are returned as images. \
|
(default: first 2000 lines). Images (png, jpg, gif, webp) are returned as images. \
|
||||||
|
Documents are converted to text: PDF (pages=\"3-7\", scanned pages come as images), \
|
||||||
|
Excel/ODS spreadsheets (sheet=\"Name\"), Word .docx and PowerPoint .pptx. \
|
||||||
Always read a file before editing or overwriting it."
|
Always read a file before editing or overwriting it."
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -55,7 +57,9 @@ impl Tool for Read {
|
|||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string", "description": "File path, absolute or relative to the working directory"},
|
"path": {"type": "string", "description": "File path, absolute or relative to the working directory"},
|
||||||
"offset": {"type": "integer", "description": "1-based line to start from"},
|
"offset": {"type": "integer", "description": "1-based line to start from"},
|
||||||
"limit": {"type": "integer", "description": "Number of lines to read"}
|
"limit": {"type": "integer", "description": "Number of lines to read"},
|
||||||
|
"pages": {"type": "string", "description": "PDF only: page or range, e.g. \"2-5\""},
|
||||||
|
"sheet": {"type": "string", "description": "Spreadsheets only: sheet name"}
|
||||||
},
|
},
|
||||||
"required": ["path"]
|
"required": ["path"]
|
||||||
})
|
})
|
||||||
@@ -67,10 +71,15 @@ impl Tool for Read {
|
|||||||
format!("Read {}", input["path"].as_str().unwrap_or("?"))
|
format!("Read {}", input["path"].as_str().unwrap_or("?"))
|
||||||
}
|
}
|
||||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
let path = ctx.resolve_read(arg_str(&input, "path")?)?;
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
bail!("{} is a directory – use the list tool", path.display());
|
bail!("{} is a directory – use the list tool", path.display());
|
||||||
}
|
}
|
||||||
|
if crate::docs::is_document(&path) {
|
||||||
|
ctx.app.mark_read(&ctx.session_id, &path);
|
||||||
|
let (text, images) = crate::docs::extract(&path, input["pages"].as_str(), input["sheet"].as_str()).await?;
|
||||||
|
return Ok(ToolOutput { content: truncate(&text, MAX_OUTPUT * 2), images, ..Default::default() });
|
||||||
|
}
|
||||||
let bytes = tokio::fs::read(&path).await?;
|
let bytes = tokio::fs::read(&path).await?;
|
||||||
ctx.app.mark_read(&ctx.session_id, &path);
|
ctx.app.mark_read(&ctx.session_id, &path);
|
||||||
if let Some(mt) = image_type(&path) {
|
if let Some(mt) = image_type(&path) {
|
||||||
@@ -273,7 +282,7 @@ impl Tool for List {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
let dir = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
|
let dir = ctx.resolve_read(input["path"].as_str().unwrap_or("."))?;
|
||||||
let depth = input["depth"].as_u64().unwrap_or(1).clamp(1, 5) as usize;
|
let depth = input["depth"].as_u64().unwrap_or(1).clamp(1, 5) as usize;
|
||||||
let cwd = ctx.cwd.clone();
|
let cwd = ctx.cwd.clone();
|
||||||
let out = tokio::task::spawn_blocking(move || {
|
let out = tokio::task::spawn_blocking(move || {
|
||||||
@@ -328,7 +337,7 @@ impl Tool for Glob {
|
|||||||
}
|
}
|
||||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
let pattern = arg_str(&input, "pattern")?.to_string();
|
let pattern = arg_str(&input, "pattern")?.to_string();
|
||||||
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
|
let root = ctx.resolve_read(input["path"].as_str().unwrap_or("."))?;
|
||||||
let out = tokio::task::spawn_blocking(move || -> Result<String> {
|
let out = tokio::task::spawn_blocking(move || -> Result<String> {
|
||||||
let matcher = globset::GlobBuilder::new(&pattern).literal_separator(true).build()?.compile_matcher();
|
let matcher = globset::GlobBuilder::new(&pattern).literal_separator(true).build()?.compile_matcher();
|
||||||
let mut hits = Vec::new();
|
let mut hits = Vec::new();
|
||||||
@@ -394,7 +403,7 @@ impl Tool for Grep {
|
|||||||
let re = regex::RegexBuilder::new(arg_str(&input, "pattern")?)
|
let re = regex::RegexBuilder::new(arg_str(&input, "pattern")?)
|
||||||
.case_insensitive(input["case_insensitive"].as_bool().unwrap_or(false))
|
.case_insensitive(input["case_insensitive"].as_bool().unwrap_or(false))
|
||||||
.build()?;
|
.build()?;
|
||||||
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
|
let root = ctx.resolve_read(input["path"].as_str().unwrap_or("."))?;
|
||||||
let glob = input["glob"]
|
let glob = input["glob"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.map(|g| globset::Glob::new(g).map(|g| g.compile_matcher()))
|
.map(|g| globset::Glob::new(g).map(|g| g.compile_matcher()))
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ pub struct ToolCtx {
|
|||||||
pub agent: String,
|
pub agent: String,
|
||||||
/// Directories this session may access (the owner's roots).
|
/// Directories this session may access (the owner's roots).
|
||||||
pub roots: Vec<PathBuf>,
|
pub roots: Vec<PathBuf>,
|
||||||
|
/// Shell sandbox of the session owner (None = run unsandboxed).
|
||||||
|
pub sandbox: Option<crate::sandbox::SandboxSpec>,
|
||||||
|
/// Craft clean room active for this session (no page sources, no binaries).
|
||||||
|
pub clean_room: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolCtx {
|
impl ToolCtx {
|
||||||
@@ -71,6 +75,17 @@ impl ToolCtx {
|
|||||||
bail!("path {} is outside your directories ({})", norm.display(), allowed.join(", "))
|
bail!("path {} is outside your directories ({})", norm.display(), allowed.join(", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `resolve`, but also allows the read-only built-in resources (skill templates).
|
||||||
|
pub fn resolve_read(&self, p: &str) -> Result<PathBuf> {
|
||||||
|
let builtin = normalize(&crate::prompts::builtin_resources());
|
||||||
|
let raw = if Path::new(p.trim()).is_absolute() { PathBuf::from(p.trim()) } else { self.cwd.join(p.trim()) };
|
||||||
|
let norm = normalize(&raw);
|
||||||
|
if norm.starts_with(&builtin) {
|
||||||
|
return Ok(norm);
|
||||||
|
}
|
||||||
|
self.resolve(p)
|
||||||
|
}
|
||||||
|
|
||||||
/// The root directory containing the working directory.
|
/// The root directory containing the working directory.
|
||||||
pub fn root(&self) -> PathBuf {
|
pub fn root(&self) -> PathBuf {
|
||||||
let mut roots = self.roots.clone();
|
let mut roots = self.roots.clone();
|
||||||
@@ -140,7 +155,10 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
|||||||
Arc::new(fs::Glob),
|
Arc::new(fs::Glob),
|
||||||
Arc::new(fs::Grep),
|
Arc::new(fs::Grep),
|
||||||
Arc::new(bash::Bash),
|
Arc::new(bash::Bash),
|
||||||
|
Arc::new(crate::shells::BashOutput),
|
||||||
|
Arc::new(crate::shells::KillShell),
|
||||||
Arc::new(web::WebFetch),
|
Arc::new(web::WebFetch),
|
||||||
|
Arc::new(web::WebSearch),
|
||||||
Arc::new(agent::TodoWrite),
|
Arc::new(agent::TodoWrite),
|
||||||
Arc::new(agent::AskUser),
|
Arc::new(agent::AskUser),
|
||||||
Arc::new(agent::Task),
|
Arc::new(agent::Task),
|
||||||
@@ -148,6 +166,8 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
|||||||
Arc::new(craft::ImageCompare),
|
Arc::new(craft::ImageCompare),
|
||||||
Arc::new(crate::prompts::SkillTool),
|
Arc::new(crate::prompts::SkillTool),
|
||||||
Arc::new(crate::images::GenerateImage),
|
Arc::new(crate::images::GenerateImage),
|
||||||
|
Arc::new(crate::reverse::BinaryInfo),
|
||||||
|
Arc::new(crate::reverse::Decompile),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+153
-3
@@ -43,10 +43,10 @@ impl Tool for WebFetch {
|
|||||||
bail!("only http(s) URLs are supported");
|
bail!("only http(s) URLs are supported");
|
||||||
}
|
}
|
||||||
let raw = input["raw"].as_bool().unwrap_or(false);
|
let raw = input["raw"].as_bool().unwrap_or(false);
|
||||||
if raw && ctx.agent == "craft" && ctx.app.config().craft.clean_room {
|
if raw && ctx.agent == "craft" && ctx.clean_room {
|
||||||
return Ok(ToolOutput::error(
|
return Ok(ToolOutput::error(
|
||||||
"Clean room mode: page source may not be read. Use browser_snapshot, or switch \
|
"Clean room mode: page source may not be read. Use browser_snapshot, or ask the user \
|
||||||
craft.clean_room to false (deep mode) in the settings.",
|
to switch this session to Deep mode.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let resp = crate::provider::http_client()
|
let resp = crate::provider::http_client()
|
||||||
@@ -73,3 +73,153 @@ impl Tool for WebFetch {
|
|||||||
Ok(if status.is_success() { ToolOutput::text(out) } else { ToolOutput::error(out) })
|
Ok(if status.is_success() { ToolOutput::text(out) } else { ToolOutput::error(out) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct WebSearch;
|
||||||
|
|
||||||
|
struct Hit {
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
snippet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_tags(s: &str) -> String {
|
||||||
|
let re = regex::Regex::new(r"<[^>]+>").unwrap();
|
||||||
|
html2text::from_read(re.replace_all(s, "").as_bytes(), 10_000).unwrap_or_default().trim().replace('\n', " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn duckduckgo(q: &str, n: usize) -> Result<Vec<Hit>> {
|
||||||
|
let html = crate::provider::http_client()
|
||||||
|
.post("https://html.duckduckgo.com/html/")
|
||||||
|
.form(&[("q", q)])
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (X11; Linux) CodeCraft")
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.text()
|
||||||
|
.await?;
|
||||||
|
let link = regex::Regex::new(r#"(?s)<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>"#).unwrap();
|
||||||
|
let snip = regex::Regex::new(r#"(?s)<a[^>]+class="result__snippet"[^>]*>(.*?)</a>"#).unwrap();
|
||||||
|
let snippets: Vec<String> = snip.captures_iter(&html).map(|c| strip_tags(&c[1])).collect();
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
for (i, c) in link.captures_iter(&html).enumerate() {
|
||||||
|
let mut url = c[1].replace("&", "&");
|
||||||
|
// DuckDuckGo wraps results in a redirect: //duckduckgo.com/l/?uddg=<encoded>&...
|
||||||
|
if let Some(enc) = url.split("uddg=").nth(1) {
|
||||||
|
let enc = enc.split('&').next().unwrap_or(enc);
|
||||||
|
url = urlencoding_decode(enc);
|
||||||
|
}
|
||||||
|
if url.contains("duckduckgo.com/y.js") {
|
||||||
|
continue; // ads
|
||||||
|
}
|
||||||
|
hits.push(Hit { title: strip_tags(&c[2]), url, snippet: snippets.get(i).cloned().unwrap_or_default() });
|
||||||
|
if hits.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hits.is_empty() && html.contains("anomaly") {
|
||||||
|
bail!("DuckDuckGo rejected the request (rate limit) – configure another search provider");
|
||||||
|
}
|
||||||
|
Ok(hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoding_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
match bytes[i] {
|
||||||
|
b'%' if i + 2 < bytes.len() => {
|
||||||
|
if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||||
|
out.push(b);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(b'%');
|
||||||
|
}
|
||||||
|
b'+' => out.push(b' '),
|
||||||
|
b => out.push(b),
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(cfg: &crate::config::SearchConfig, q: &str, n: usize) -> Result<Vec<Hit>> {
|
||||||
|
let client = crate::provider::http_client();
|
||||||
|
let key = crate::config::expand_env(&cfg.api_key);
|
||||||
|
let pick = |v: &Value, title: &str, url: &str, snippet: &str| Hit {
|
||||||
|
title: v[title].as_str().unwrap_or("").to_string(),
|
||||||
|
url: v[url].as_str().unwrap_or("").to_string(),
|
||||||
|
snippet: v[snippet].as_str().unwrap_or("").to_string(),
|
||||||
|
};
|
||||||
|
Ok(match cfg.provider.as_str() {
|
||||||
|
"searxng" => {
|
||||||
|
let base = crate::config::expand_env(&cfg.url);
|
||||||
|
let url = reqwest::Url::parse_with_params(&format!("{}/search", base.trim_end_matches('/')), &[("q", q), ("format", "json")])?;
|
||||||
|
let v: Value = client.get(url).timeout(Duration::from_secs(20)).send().await?.error_for_status()?.json().await?;
|
||||||
|
v["results"].as_array().cloned().unwrap_or_default().iter().take(n).map(|r| pick(r, "title", "url", "content")).collect()
|
||||||
|
}
|
||||||
|
"brave" => {
|
||||||
|
let v: Value = client
|
||||||
|
.get(reqwest::Url::parse_with_params("https://api.search.brave.com/res/v1/web/search", &[("q", q), ("count", &n.to_string())])?)
|
||||||
|
.header("X-Subscription-Token", key)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
v["web"]["results"].as_array().cloned().unwrap_or_default().iter().take(n).map(|r| pick(r, "title", "url", "description")).collect()
|
||||||
|
}
|
||||||
|
"tavily" => {
|
||||||
|
let v: Value = client
|
||||||
|
.post("https://api.tavily.com/search")
|
||||||
|
.json(&json!({"api_key": key, "query": q, "max_results": n}))
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
v["results"].as_array().cloned().unwrap_or_default().iter().take(n).map(|r| pick(r, "title", "url", "content")).collect()
|
||||||
|
}
|
||||||
|
_ => duckduckgo(q, n).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for WebSearch {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"web_search"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Search the web. Returns titles, URLs and snippets; read promising results with web_fetch. \
|
||||||
|
Use for current information, documentation, error messages and libraries."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({"type": "object", "properties": {"query": {"type": "string"}, "max_results": {"type": "integer", "description": "1-20, default 8"}}, "required": ["query"]})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
format!("Search: {}", input["query"].as_str().unwrap_or(""))
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let q = arg_str(&input, "query")?;
|
||||||
|
let n = input["max_results"].as_u64().unwrap_or(8).clamp(1, 20) as usize;
|
||||||
|
let hits = search(&ctx.app.config().search, q, n).await?;
|
||||||
|
if hits.is_empty() {
|
||||||
|
return Ok(ToolOutput::text("No results."));
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, h) in hits.iter().enumerate() {
|
||||||
|
out.push_str(&format!("{}. {}\n {}\n {}\n\n", i + 1, h.title, h.url, h.snippet));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput::text(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-1
@@ -124,6 +124,15 @@ pub struct Session {
|
|||||||
pub todos: Value,
|
pub todos: Value,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_usage: Option<Usage>,
|
pub last_usage: Option<Usage>,
|
||||||
|
/// Craft mode: clean room (true) or deep (false); None = server default.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub clean_room: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn clean_room(&self, default: bool) -> bool {
|
||||||
|
self.clean_room.unwrap_or(default)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Question forwarded from an agent (or an MCP server) to the user.
|
/// Question forwarded from an agent (or an MCP server) to the user.
|
||||||
@@ -167,6 +176,8 @@ pub enum Event {
|
|||||||
/// reason: "model:<provider/model>" or "subagent:<name>".
|
/// reason: "model:<provider/model>" or "subagent:<name>".
|
||||||
Waiting { session_id: String, reason: Option<String> },
|
Waiting { session_id: String, reason: Option<String> },
|
||||||
McpStatus { servers: Value },
|
McpStatus { servers: Value },
|
||||||
|
/// A model request started: timing and prompt size for the activity line.
|
||||||
|
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
|
||||||
/// Messages waiting for a busy session (full list).
|
/// Messages waiting for a busy session (full list).
|
||||||
QueueUpdated { session_id: String, items: Value },
|
QueueUpdated { session_id: String, items: Value },
|
||||||
}
|
}
|
||||||
@@ -189,7 +200,8 @@ impl Event {
|
|||||||
| QuestionResolved { session_id, .. }
|
| QuestionResolved { session_id, .. }
|
||||||
| Error { session_id, .. }
|
| Error { session_id, .. }
|
||||||
| Waiting { session_id, .. }
|
| Waiting { session_id, .. }
|
||||||
| QueueUpdated { session_id, .. } => Some(session_id),
|
| QueueUpdated { session_id, .. }
|
||||||
|
| StepStarted { session_id, .. } => Some(session_id),
|
||||||
McpStatus { .. } => None,
|
McpStatus { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+73
-1
@@ -16,15 +16,18 @@
|
|||||||
"@codemirror/view": "^6.43.15",
|
"@codemirror/view": "^6.43.15",
|
||||||
"@lezer/highlight": "^1.2.5",
|
"@lezer/highlight": "^1.2.5",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"occt-import-js": "^0.0.23",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"three": "^0.187.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@types/three": "^0.187.0",
|
||||||
"@vitejs/plugin-react": "^6.2.1",
|
"@vitejs/plugin-react": "^6.2.1",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.3.6",
|
"vite": "^8.3.6",
|
||||||
@@ -1998,6 +2001,13 @@
|
|||||||
"w3c-keyname": "^2.2.4"
|
"w3c-keyname": "^2.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||||
@@ -3024,6 +3034,13 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.13",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
@@ -3098,6 +3115,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.187.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.187.0.tgz",
|
||||||
|
"integrity": "sha512-v+vKNarUtk4n2QRZ4+CN5ygMM0ARUjXjIQl0R/unMUfmStFeVqGbgqarbrpoGTXRZoulSH16ePrVJVxPJEbMUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.3",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -3111,6 +3150,13 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
@@ -4427,6 +4473,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/filelist": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||||
@@ -6104,6 +6157,13 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromark": {
|
"node_modules/micromark": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
|
||||||
@@ -6793,6 +6853,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/occt-import-js": {
|
||||||
|
"version": "0.0.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.23.tgz",
|
||||||
|
"integrity": "sha512-RFfYQXYFX5C1mB1Aywm0ShcUKzXOr/VzTnlzhBSDJOR6YCAPt1HYCzeXWg1vwwjn/cUxwqRNhhtf1dlewoZYCQ==",
|
||||||
|
"license": "LGPL-2.1"
|
||||||
|
},
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
||||||
@@ -7835,6 +7901,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.187.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.187.0.tgz",
|
||||||
|
"integrity": "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
|||||||
+4
-1
@@ -18,15 +18,18 @@
|
|||||||
"@codemirror/view": "^6.43.15",
|
"@codemirror/view": "^6.43.15",
|
||||||
"@lezer/highlight": "^1.2.5",
|
"@lezer/highlight": "^1.2.5",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"occt-import-js": "^0.0.23",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"three": "^0.187.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@types/three": "^0.187.0",
|
||||||
"@vitejs/plugin-react": "^6.2.1",
|
"@vitejs/plugin-react": "^6.2.1",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.3.6",
|
"vite": "^8.3.6",
|
||||||
|
|||||||
+9
-1
@@ -12,6 +12,7 @@ import { Settings } from "./components/Settings";
|
|||||||
import { Stats } from "./components/Stats";
|
import { Stats } from "./components/Stats";
|
||||||
import { Images } from "./components/Images";
|
import { Images } from "./components/Images";
|
||||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||||
|
import { DialogHost } from "./dialogs";
|
||||||
|
|
||||||
// The file manager (editor, git) is loaded on demand to keep the chat small.
|
// The file manager (editor, git) is loaded on demand to keep the chat small.
|
||||||
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
|
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
|
||||||
@@ -58,7 +59,13 @@ export function App() {
|
|||||||
|
|
||||||
if (error) return <div className="empty">{error}</div>;
|
if (error) return <div className="empty">{error}</div>;
|
||||||
if (!info) return <div className="empty">{t("common.loading")}</div>;
|
if (!info) return <div className="empty">{t("common.loading")}</div>;
|
||||||
if (!info.user) return <Login info={info} onSuccess={refresh} />;
|
if (!info.user)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Login info={info} onSuccess={refresh} />
|
||||||
|
<DialogHost />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const [section, arg] = route;
|
const [section, arg] = route;
|
||||||
let content;
|
let content;
|
||||||
@@ -79,6 +86,7 @@ export function App() {
|
|||||||
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
||||||
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
|
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
|
||||||
<main className="main">{content}</main>
|
<main className="main">{content}</main>
|
||||||
|
<DialogHost />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useT } from "../i18n";
|
import { fmtCompact, useT } from "../i18n";
|
||||||
import { useStore, type Draft } from "../store";
|
import { useStore, type Draft } from "../store";
|
||||||
import type { Part } from "../types";
|
import type { Part } from "../types";
|
||||||
|
|
||||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
|
||||||
|
|
||||||
/** Animated three-dot pulse. */
|
/** Animated three-dot pulse. */
|
||||||
export function Dots() {
|
export function Dots() {
|
||||||
@@ -20,43 +19,113 @@ export function Spinner({ small }: { small?: boolean }) {
|
|||||||
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
|
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the agent is doing right now, derived from the streaming message. */
|
const STARS = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
|
||||||
|
|
||||||
|
/** Rotating star like a terminal agent's spinner. */
|
||||||
|
function Star() {
|
||||||
|
const [i, setI] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const h = setInterval(() => setI((x) => (x + 1) % STARS.length), 120);
|
||||||
|
return () => clearInterval(h);
|
||||||
|
}, []);
|
||||||
|
return <span className="star" aria-hidden="true">{STARS[i]}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNow(active: boolean) {
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const h = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(h);
|
||||||
|
}, [active]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtElapsed(ms: number) {
|
||||||
|
const s = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated tokens so far, estimated from the streamed characters. */
|
||||||
|
function generatedTokens(draft?: Draft) {
|
||||||
|
if (!draft) return 0;
|
||||||
|
let chars = 0;
|
||||||
|
for (const p of draft.parts) {
|
||||||
|
if (p.type === "text" || p.type === "reasoning") chars += p.text.length;
|
||||||
|
else if (p.type === "tool_call") chars += JSON.stringify(p.input ?? {}).length;
|
||||||
|
}
|
||||||
|
return Math.round(chars / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the agent is doing right now – playful text, elapsed time and tokens. */
|
||||||
export function ActivityLine({
|
export function ActivityLine({
|
||||||
|
sessionId,
|
||||||
draft,
|
draft,
|
||||||
results,
|
pendingTool,
|
||||||
waiting,
|
waiting,
|
||||||
permissionPending,
|
permissionPending,
|
||||||
}: {
|
}: {
|
||||||
|
sessionId: string;
|
||||||
draft?: Draft;
|
draft?: Draft;
|
||||||
results: Map<string, ToolResult>;
|
/** A tool call of the last (finished) model step that has no result yet. */
|
||||||
|
pendingTool?: { name: string; input: any; since: number };
|
||||||
waiting?: string;
|
waiting?: string;
|
||||||
permissionPending: boolean;
|
permissionPending: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
let label: string;
|
const step = useStore((s) => s.steps[sessionId]);
|
||||||
|
const now = useNow((!!step || !!pendingTool) && !permissionPending);
|
||||||
|
|
||||||
if (permissionPending) {
|
if (permissionPending) {
|
||||||
label = t("activity.waitingForYou");
|
return (
|
||||||
} else if (waiting) {
|
<div className="activity paused" role="status">
|
||||||
|
<span className="pause-icon" aria-hidden="true" />
|
||||||
|
<span>{t("activity.waitingForYou")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (waiting) {
|
||||||
const [kind, ...rest] = waiting.split(":");
|
const [kind, ...rest] = waiting.split(":");
|
||||||
const what = rest.join(":");
|
const what = rest.join(":");
|
||||||
label = kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what });
|
return (
|
||||||
} else {
|
<div className="activity" role="status">
|
||||||
const last = draft?.parts[draft.parts.length - 1];
|
<Dots />
|
||||||
const runningTool = draft?.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as
|
<span>{kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what })}</span>
|
||||||
| Extract<Part, { type: "tool_call" }>
|
</div>
|
||||||
| undefined;
|
);
|
||||||
if (runningTool) {
|
|
||||||
label = runningTool.name === "task" ? t("activity.subagent", { name: runningTool.input?.description ?? "" }) : t("activity.tool", { tool: runningTool.name });
|
|
||||||
} else if (last?.type === "text") {
|
|
||||||
label = t("activity.writing");
|
|
||||||
} else {
|
|
||||||
label = t("activity.thinking");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A tool is running between two model steps.
|
||||||
|
if (!draft && pendingTool) {
|
||||||
|
const label = pendingTool.name === "task" ? t("activity.subagent", { name: pendingTool.input?.description ?? "" }) : t("activity.tool", { tool: pendingTool.name });
|
||||||
|
return (
|
||||||
|
<div className="activity" role="status" aria-live="polite">
|
||||||
|
<Star />
|
||||||
|
<span className="activity-text">{label}</span>
|
||||||
|
<span className="activity-meta">({fmtElapsed(now - pendingTool.since)})</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = draft && step && draft.id === step.messageId ? draft : undefined;
|
||||||
|
const generated = generatedTokens(live);
|
||||||
|
const phrases = t("activity.phrases").split("|");
|
||||||
|
// A stable starting phrase per step, changing every 4 seconds.
|
||||||
|
const seed = step ? [...step.messageId].reduce((a, c) => a + c.charCodeAt(0), 0) : 0;
|
||||||
|
const elapsed = step ? now - step.startedAt : 0;
|
||||||
|
const label = `${phrases[(seed + Math.floor(elapsed / 4000)) % phrases.length]}…`;
|
||||||
|
const prefill = step && generated === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`activity ${permissionPending ? "paused" : ""}`} role="status" aria-live="polite">
|
<div className="activity" role="status" aria-live="polite">
|
||||||
{permissionPending ? <span className="pause-icon" aria-hidden="true" /> : <Dots />}
|
<Star />
|
||||||
<span>{label}</span>
|
<span className="activity-text">{label}</span>
|
||||||
|
{step && (
|
||||||
|
<span className="activity-meta">
|
||||||
|
({fmtElapsed(elapsed)} · ↑ {step.estimated ? "~" : ""}
|
||||||
|
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · ↓ {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ import { ShareDialog } from "./ShareDialog";
|
|||||||
import { QueueList } from "./QueueList";
|
import { QueueList } from "./QueueList";
|
||||||
import { BudgetChip } from "./Budget";
|
import { BudgetChip } from "./Budget";
|
||||||
import { PermissionCard, QuestionCard } from "./Interaction";
|
import { PermissionCard, QuestionCard } from "./Interaction";
|
||||||
import { Markdown } from "./Markdown";
|
import { ImageRefs, Markdown } from "./Markdown";
|
||||||
|
import { UsageOverlay } from "./UsageOverlay";
|
||||||
|
import { RewindDialog } from "./RewindDialog";
|
||||||
|
import { ShellsChip } from "./ShellsPanel";
|
||||||
import { TodoList, ToolCard } from "./ToolCard";
|
import { TodoList, ToolCard } from "./ToolCard";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||||
|
|
||||||
@@ -53,7 +57,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssistantParts({ parts, results, running, live }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean }) {
|
function AssistantParts({ parts, results, running, live, baseDir }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean; baseDir: string }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{parts.map((p, i) => {
|
{parts.map((p, i) => {
|
||||||
@@ -61,13 +65,14 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
|
|||||||
case "text":
|
case "text":
|
||||||
return (
|
return (
|
||||||
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
|
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
|
||||||
<Markdown text={p.text} />
|
<Markdown text={p.text} baseDir={baseDir} />
|
||||||
|
{!live && <ImageRefs text={p.text} baseDir={baseDir} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "reasoning":
|
case "reasoning":
|
||||||
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
|
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} />;
|
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} baseDir={baseDir} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -102,46 +107,6 @@ function BackgroundResult({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatsPanel({ stats }: { stats: SessionStats }) {
|
|
||||||
const t = useT();
|
|
||||||
const a = stats.total;
|
|
||||||
return (
|
|
||||||
<div className="panel" style={{ margin: "8px 0" }}>
|
|
||||||
<table className="grid">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{t("stats.model")}</th>
|
|
||||||
<th className="num">{t("stats.requests")}</th>
|
|
||||||
<th className="num">{t("stats.input")}</th>
|
|
||||||
<th className="num">{t("stats.cacheRead")}</th>
|
|
||||||
<th className="num">{t("stats.output")}</th>
|
|
||||||
<th className="num">{t("stats.ttft")}</th>
|
|
||||||
<th className="num">{t("stats.outputSpeed")}</th>
|
|
||||||
<th className="num">{t("stats.cost")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{[...stats.by_model, { ...a, model: t("stats.total") }].map((r) => (
|
|
||||||
<tr key={r.model}>
|
|
||||||
<td>{r.model}</td>
|
|
||||||
<td className="num">{fmtNumber(r.requests)}</td>
|
|
||||||
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
|
|
||||||
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
|
|
||||||
<td className="num">{fmtCompact(r.output_tokens)}</td>
|
|
||||||
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
|
|
||||||
<td className="num">{fmtNumber(r.output_tps, 1)} tok/s</td>
|
|
||||||
<td className="num">{fmtCost(r.cost, stats.currency)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>
|
|
||||||
{t("stats.prefillSpeed")}: {fmtNumber(a.prefill_tps)} tok/s · {t("stats.cacheHit")}: {fmtPercent(a.cache_hit_rate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
|
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const session = useStore((s) => s.sessionById[sessionId]);
|
const session = useStore((s) => s.sessionById[sessionId]);
|
||||||
@@ -195,7 +160,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
try {
|
try {
|
||||||
await patch(`/sessions/${sessionId}`, p);
|
await patch(`/sessions/${sessionId}`, p);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
toast(e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,20 +175,21 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
setText("");
|
setText("");
|
||||||
stick.current = true;
|
stick.current = true;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
toast(e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rewind = async (m: Message) => {
|
const [rewinding, setRewinding] = useState<Message>();
|
||||||
if (!confirm(t("chat.rewindConfirm"))) return;
|
const rewind = (m: Message) => setRewinding(m);
|
||||||
const txt = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("");
|
const rewindDone = async () => {
|
||||||
await post(`/sessions/${sessionId}/rewind`, { message_id: m.id });
|
const m = rewinding!;
|
||||||
|
setRewinding(undefined);
|
||||||
await loadSession(sessionId);
|
await loadSession(sessionId);
|
||||||
setText(txt);
|
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!confirm(t("chat.deleteConfirm"))) return;
|
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
await del(`/sessions/${sessionId}`);
|
await del(`/sessions/${sessionId}`);
|
||||||
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
||||||
};
|
};
|
||||||
@@ -237,6 +203,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
const todos = Array.isArray(session.todos) ? session.todos : [];
|
const todos = Array.isArray(session.todos) ? session.todos : [];
|
||||||
const openTodos = todos.filter((x) => x.status !== "completed");
|
const openTodos = todos.filter((x) => x.status !== "completed");
|
||||||
const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant";
|
const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant";
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
const pendingCall = lastIsAssistant
|
||||||
|
? (lastMsg.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as Extract<Part, { type: "tool_call" }> | undefined)
|
||||||
|
: undefined;
|
||||||
|
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -279,6 +250,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
{fmtCost(stats.total.cost, stats.currency)}
|
{fmtCost(stats.total.cost, stats.currency)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<ShellsChip sessionId={sessionId} busy={busy} />
|
||||||
{isAdmin && <BudgetChip />}
|
{isAdmin && <BudgetChip />}
|
||||||
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
||||||
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
||||||
@@ -289,7 +261,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
⇄
|
⇄
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
|
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
||||||
⇊
|
⇊
|
||||||
</button>
|
</button>
|
||||||
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
||||||
@@ -308,13 +280,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="chat">
|
<div className="chat">
|
||||||
{showStats && stats && <StatsPanel stats={stats} />}
|
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
||||||
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
||||||
{messages.map((m) => {
|
{messages.map((m) => {
|
||||||
if (m.role === "assistant") {
|
if (m.role === "assistant") {
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className="msg">
|
<div key={m.id} className="msg">
|
||||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} />
|
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -367,11 +339,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
})}
|
})}
|
||||||
{draft && (
|
{draft && (
|
||||||
<div className="msg">
|
<div className="msg">
|
||||||
<AssistantParts parts={draft.parts} results={results} running={busy} live />
|
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(busy || waiting) && (
|
{(busy || waiting) && (
|
||||||
<ActivityLine draft={draft} results={results} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
||||||
)}
|
)}
|
||||||
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
||||||
<div className="todos">
|
<div className="todos">
|
||||||
@@ -409,6 +381,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||||
|
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||||
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
|
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAgents, useFetch } from "../hooks";
|
|||||||
import { useT, type Key } from "../i18n";
|
import { useT, type Key } from "../i18n";
|
||||||
import type { ImageData, Session } from "../types";
|
import type { ImageData, Session } from "../types";
|
||||||
import { ModelSelect } from "./ModelSelect";
|
import { ModelSelect } from "./ModelSelect";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
interface Suggestion {
|
interface Suggestion {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -119,7 +120,7 @@ export function Composer({
|
|||||||
const imgs = await Promise.all(all.filter((f) => f.type.startsWith("image/")).map(readImage));
|
const imgs = await Promise.all(all.filter((f) => f.type.startsWith("image/")).map(readImage));
|
||||||
setImages((cur) => [...cur, ...imgs]);
|
setImages((cur) => [...cur, ...imgs]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message ?? String(e));
|
toast(e.message ?? String(e));
|
||||||
}
|
}
|
||||||
// Everything else is uploaded into the project folder.
|
// Everything else is uploaded into the project folder.
|
||||||
for (const f of all.filter((f) => !f.type.startsWith("image/"))) {
|
for (const f of all.filter((f) => !f.type.startsWith("image/"))) {
|
||||||
@@ -135,7 +136,7 @@ export function Composer({
|
|||||||
if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
|
if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
|
||||||
setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]);
|
setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message ?? String(e));
|
toast(e.message ?? String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setUploading((n) => n - 1);
|
setUploading((n) => n - 1);
|
||||||
}
|
}
|
||||||
@@ -238,6 +239,16 @@ export function Composer({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{session.agent === "craft" && (
|
||||||
|
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
|
||||||
|
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
|
||||||
|
{t("craft.cleanRoom")}
|
||||||
|
</button>
|
||||||
|
<button className={session.clean_room === false ? "active deep" : ""} onClick={() => onUpdate({ clean_room: false })}>
|
||||||
|
{t("craft.deep")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<select
|
<select
|
||||||
value={session.permission_mode}
|
value={session.permission_mode}
|
||||||
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
|
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
|
import { get } from "../api";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { Spinner } from "./Activity";
|
||||||
|
|
||||||
|
const ModelViewer = lazy(() => import("./ModelViewer").then((m) => ({ default: m.ModelViewer })));
|
||||||
|
|
||||||
|
export type PreviewKind = "image" | "pdf" | "doc" | "audio" | "module" | "model";
|
||||||
|
|
||||||
|
const KINDS: [RegExp, PreviewKind][] = [
|
||||||
|
[/\.(png|jpe?g|gif|webp|bmp|svg)$/i, "image"],
|
||||||
|
[/\.pdf$/i, "pdf"],
|
||||||
|
[/\.(docx|pptx|xlsx|xlsm|xls|ods)$/i, "doc"],
|
||||||
|
[/\.(wav|mp3|ogg|oga|flac|m4a)$/i, "audio"],
|
||||||
|
[/\.(xm|mod|s3m|it)$/i, "module"],
|
||||||
|
[/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i, "model"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function previewKind(path: string): PreviewKind | undefined {
|
||||||
|
return KINDS.find(([re]) => re.test(path))?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = (p: string) => `path=${encodeURIComponent(p)}`;
|
||||||
|
|
||||||
|
function PdfPreview({ path }: { path: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const [pages, setPages] = useState<number>();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
get<{ pages: number }>(`/files/preview/pdf-info?${q(path)}`).then((r) => setPages(r.pages)).catch(() => setPages(1));
|
||||||
|
}, [path]);
|
||||||
|
return (
|
||||||
|
<div className="pdf-preview">
|
||||||
|
<div className="row pdf-nav">
|
||||||
|
<button className="small" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span>{t("preview.page", { n: page, total: pages ?? "…" })}</span>
|
||||||
|
<button className="small" disabled={!pages || page >= pages} onClick={() => setPage(page + 1)}>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<a className="button small" href={`/api/files/raw?${q(path)}`} target="_blank" rel="noreferrer">
|
||||||
|
{t("preview.openBrowser")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="pane-scroll image-preview">
|
||||||
|
<img key={page} src={`/api/files/preview/pdf-page?${q(path)}&page=${page}`} alt={`page ${page}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocData {
|
||||||
|
kind: "text" | "table";
|
||||||
|
text?: string;
|
||||||
|
sheets?: { name: string; rows: string[][]; total_rows: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocPreview({ path }: { path: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const [data, setData] = useState<DocData>();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [sheet, setSheet] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
setData(undefined);
|
||||||
|
setSheet(0);
|
||||||
|
get<DocData>(`/files/preview/doc?${q(path)}`).then(setData).catch((e) => setError(e.message));
|
||||||
|
}, [path]);
|
||||||
|
if (error) return <div className="notice err">{error}</div>;
|
||||||
|
if (!data) return <div className="empty"><Spinner /></div>;
|
||||||
|
if (data.kind === "text") return <pre className="pane-scroll doc-text">{data.text}</pre>;
|
||||||
|
const s = data.sheets?.[sheet];
|
||||||
|
return (
|
||||||
|
<div className="pdf-preview">
|
||||||
|
<div className="mode-tabs sheet-tabs">
|
||||||
|
{data.sheets?.map((x, i) => (
|
||||||
|
<button key={x.name} className={i === sheet ? "active" : ""} onClick={() => setSheet(i)}>
|
||||||
|
{x.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="pane-scroll">
|
||||||
|
{s && (
|
||||||
|
<table className="grid sheet">
|
||||||
|
<tbody>
|
||||||
|
{s.rows.map((r, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<th className="num">{i + 1}</th>
|
||||||
|
{r.map((c, j) => (
|
||||||
|
<td key={j}>{c}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{s && s.total_rows > s.rows.length && <div className="empty">{t("preview.moreRows", { n: s.total_rows - s.rows.length })}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilePreview({ path, kind }: { path: string; kind: PreviewKind }) {
|
||||||
|
const t = useT();
|
||||||
|
const raw = `/api/files/raw?${q(path)}`;
|
||||||
|
switch (kind) {
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<div className="pane-scroll image-preview">
|
||||||
|
<img src={raw} alt="" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pdf":
|
||||||
|
return <PdfPreview path={path} />;
|
||||||
|
case "doc":
|
||||||
|
return <DocPreview path={path} />;
|
||||||
|
case "audio":
|
||||||
|
return (
|
||||||
|
<div className="audio-preview">
|
||||||
|
<audio controls src={raw} preload="metadata" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "module":
|
||||||
|
return (
|
||||||
|
<div className="audio-preview">
|
||||||
|
<div className="muted">{t("preview.moduleHint")}</div>
|
||||||
|
<audio controls src={`/api/files/preview/module?${q(path)}`} preload="none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "model": {
|
||||||
|
const ext = path.split(".").pop()!.toLowerCase();
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
||||||
|
<ModelViewer url={raw} ext={ext} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { Spinner } from "./Activity";
|
|||||||
import { Diff } from "./Diff";
|
import { Diff } from "./Diff";
|
||||||
import { Markdown } from "./Markdown";
|
import { Markdown } from "./Markdown";
|
||||||
import { GitPanel, type GitStatus } from "./GitPanel";
|
import { GitPanel, type GitStatus } from "./GitPanel";
|
||||||
|
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||||
|
|
||||||
@@ -31,12 +33,13 @@ interface OpenFile {
|
|||||||
mtime?: number;
|
mtime?: number;
|
||||||
binary: boolean;
|
binary: boolean;
|
||||||
tooLarge?: boolean;
|
tooLarge?: boolean;
|
||||||
|
preview?: PreviewKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the right pane shows. */
|
/** What the right pane shows. */
|
||||||
type Pane = { kind: "file"; file: OpenFile } | { kind: "diff"; title: string; diff: string } | { kind: "none" };
|
type Pane = { kind: "file"; file: OpenFile } | { kind: "diff"; title: string; diff: string } | { kind: "none" };
|
||||||
|
|
||||||
const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
|
const IMAGE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
|
||||||
const rawUrl = (p: string, download = false) => `/api/files/raw?path=${encodeURIComponent(p)}${download ? "&download=true" : ""}`;
|
const rawUrl = (p: string, download = false) => `/api/files/raw?path=${encodeURIComponent(p)}${download ? "&download=true" : ""}`;
|
||||||
const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
|
const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
|
||||||
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
|
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
|
||||||
@@ -44,6 +47,9 @@ const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
|
|||||||
function icon(e: Entry) {
|
function icon(e: Entry) {
|
||||||
if (e.is_dir) return "📁";
|
if (e.is_dir) return "📁";
|
||||||
if (IMAGE.test(e.name)) return "🖼";
|
if (IMAGE.test(e.name)) return "🖼";
|
||||||
|
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return "🎵";
|
||||||
|
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return "🧊";
|
||||||
|
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return "📑";
|
||||||
if (/\.(md|txt)$/i.test(e.name)) return "📝";
|
if (/\.(md|txt)$/i.test(e.name)) return "📝";
|
||||||
return "📄";
|
return "📄";
|
||||||
}
|
}
|
||||||
@@ -112,17 +118,24 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
|
|
||||||
const dirty = pane.kind === "file" && pane.file.content !== pane.file.saved;
|
const dirty = pane.kind === "file" && pane.file.content !== pane.file.saved;
|
||||||
|
|
||||||
const confirmDiscard = () => !dirty || confirm(t("files.discardChanges"));
|
const confirmDiscard = async () => !dirty || (await confirmDialog({ message: t("files.discardChanges"), danger: true, confirmLabel: t("files.discard") }));
|
||||||
|
|
||||||
const openFile = async (path: string) => {
|
const openFile = async (path: string) => {
|
||||||
if (!confirmDiscard()) return;
|
if (!(await confirmDiscard())) return;
|
||||||
|
const kind = previewKind(path);
|
||||||
|
// Previewable files (except SVG, which can also be edited) are not loaded as text.
|
||||||
|
if (kind && !/\.svg$/i.test(path)) {
|
||||||
|
setPane({ kind: "file", file: { path, content: "", saved: "", binary: true, preview: kind } });
|
||||||
|
setMobileEditor(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const r = await get<{ content?: string; binary: boolean; mtime?: number; too_large?: boolean }>(`/files/read?path=${encodeURIComponent(path)}`);
|
const r = await get<{ content?: string; binary: boolean; mtime?: number; too_large?: boolean }>(`/files/read?path=${encodeURIComponent(path)}`);
|
||||||
setPane({ kind: "file", file: { path, content: r.content ?? "", saved: r.content ?? "", mtime: r.mtime, binary: r.binary, tooLarge: r.too_large } });
|
setPane({ kind: "file", file: { path, content: r.content ?? "", saved: r.content ?? "", mtime: r.mtime, binary: r.binary, tooLarge: r.too_large } });
|
||||||
setPreview(/\.md$/i.test(path));
|
setPreview(/\.(md|svg)$/i.test(path));
|
||||||
setMobileEditor(true);
|
setMobileEditor(true);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
toast(e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,8 +148,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
setPane({ kind: "file", file: { ...f, saved: f.content, mtime: r.mtime } });
|
setPane({ kind: "file", file: { ...f, saved: f.content, mtime: r.mtime } });
|
||||||
void refreshGit();
|
void refreshGit();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.status === 409 && confirm(t("files.conflict"))) return save(true);
|
if (e.status === 409 && (await confirmDialog({ message: t("files.conflict"), danger: true, confirmLabel: t("files.overwrite") }))) return save(true);
|
||||||
alert(e.message);
|
toast(e.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pane, refreshGit, t],
|
[pane, refreshGit, t],
|
||||||
@@ -148,12 +161,12 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
if (listing) await load(listing.path);
|
if (listing) await load(listing.path);
|
||||||
void refreshGit();
|
void refreshGit();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
toast(e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const newEntry = (isDir: boolean) => {
|
const newEntry = async (isDir: boolean) => {
|
||||||
const name = prompt(isDir ? t("files.newFolderName") : t("files.newFileName"));
|
const name = await promptDialog({ message: isDir ? t("files.newFolderName") : t("files.newFileName") });
|
||||||
if (!name || !listing) return;
|
if (!name || !listing) return;
|
||||||
const path = `${listing.path}/${name}`;
|
const path = `${listing.path}/${name}`;
|
||||||
void act(async () => {
|
void act(async () => {
|
||||||
@@ -162,18 +175,18 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const rename = (e: Entry) => {
|
const rename = async (e: Entry) => {
|
||||||
const name = prompt(t("files.renameTo"), e.name);
|
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
|
||||||
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
|
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const duplicate = (e: Entry) => {
|
const duplicate = async (e: Entry) => {
|
||||||
const name = prompt(t("files.copyTo"), `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}`);
|
const name = await promptDialog({ message: t("files.copyTo"), defaultValue: `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}` });
|
||||||
if (name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}`, copy: true }));
|
if (name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}`, copy: true }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = (e: Entry) => {
|
const remove = async (e: Entry) => {
|
||||||
if (confirm(t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }))) {
|
if (await confirmDialog({ message: t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }), danger: true, confirmLabel: t("common.delete") })) {
|
||||||
void act(() => del(`/files/delete?path=${encodeURIComponent(e.path)}`));
|
void act(() => del(`/files/delete?path=${encodeURIComponent(e.path)}`));
|
||||||
if (pane.kind === "file" && pane.file.path.startsWith(e.path)) setPane({ kind: "none" });
|
if (pane.kind === "file" && pane.file.path.startsWith(e.path)) setPane({ kind: "none" });
|
||||||
}
|
}
|
||||||
@@ -183,7 +196,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
if (!listing) return;
|
if (!listing) return;
|
||||||
for (const f of [...files]) {
|
for (const f of [...files]) {
|
||||||
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(`${listing.path}/${f.name}`)}`, { method: "PUT", body: f, credentials: "same-origin" });
|
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(`${listing.path}/${f.name}`)}`, { method: "PUT", body: f, credentials: "same-origin" });
|
||||||
if (!res.ok) alert(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
|
if (!res.ok) toast(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
|
||||||
}
|
}
|
||||||
await load(listing.path);
|
await load(listing.path);
|
||||||
void refreshGit();
|
void refreshGit();
|
||||||
@@ -220,7 +233,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
>
|
>
|
||||||
<div className="files-toolbar">
|
<div className="files-toolbar">
|
||||||
{roots.length > 1 && (
|
{roots.length > 1 && (
|
||||||
<select value={root?.path ?? ""} onChange={(e) => confirmDiscard() && load(e.target.value)} aria-label={t("images.folder")}>
|
<select value={root?.path ?? ""} onChange={(e) => { const v = e.target.value; void confirmDiscard().then((ok): unknown => ok && load(v)); }} aria-label={t("images.folder")}>
|
||||||
{roots.map((r) => (
|
{roots.map((r) => (
|
||||||
<option key={r.path} value={r.path}>
|
<option key={r.path} value={r.path}>
|
||||||
{r.name}
|
{r.name}
|
||||||
@@ -335,14 +348,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<div className="editor-pane">
|
<div className="editor-pane">
|
||||||
<div className="editor-bar">
|
<div className="editor-bar">
|
||||||
<button className="ghost small mobile-only" onClick={() => confirmDiscard() && setMobileEditor(false)}>
|
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
|
||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<b className="grow" title={f.path}>
|
<b className="grow" title={f.path}>
|
||||||
{baseName(f.path)}
|
{baseName(f.path)}
|
||||||
{dirty && <span className="dirty"> ●</span>}
|
{dirty && <span className="dirty"> ●</span>}
|
||||||
</b>
|
</b>
|
||||||
{isMd && (
|
{(isMd || isImage) && !f.preview && (
|
||||||
<button className="small" onClick={() => setPreview(!preview)}>
|
<button className="small" onClick={() => setPreview(!preview)}>
|
||||||
{preview ? t("files.edit") : t("files.preview")}
|
{preview ? t("files.edit") : t("files.preview")}
|
||||||
</button>
|
</button>
|
||||||
@@ -356,7 +369,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isImage ? (
|
{f.preview ? (
|
||||||
|
<FilePreview path={f.path} kind={f.preview} />
|
||||||
|
) : isImage && preview ? (
|
||||||
<div className="pane-scroll image-preview">
|
<div className="pane-scroll image-preview">
|
||||||
<img src={rawUrl(f.path)} alt={baseName(f.path)} />
|
<img src={rawUrl(f.path)} alt={baseName(f.path)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -422,10 +437,10 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="small"
|
className="small"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
const url = prompt(t("git.cloneUrl"));
|
const url = await promptDialog({ message: t("git.cloneUrl"), placeholder: "https://…/repo.git" });
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
const name = prompt(t("git.cloneFolder"), baseName(url).replace(/\.git$/, ""));
|
const name = await promptDialog({ message: t("git.cloneFolder"), defaultValue: baseName(url).replace(/\.git$/, "") });
|
||||||
if (name) void act(() => post("/git", { path: `${listing.path}/${name}`, action: "clone", url }));
|
if (name) void act(() => post("/git", { path: `${listing.path}/${name}`, action: "clone", url }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { post } from "../api";
|
import { post } from "../api";
|
||||||
import { fmtRelative, useT } from "../i18n";
|
import { fmtRelative, useT } from "../i18n";
|
||||||
import { Spinner } from "./Activity";
|
import { Spinner } from "./Activity";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
export interface GitFile {
|
export interface GitFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -90,7 +91,7 @@ export function GitPanel({
|
|||||||
const r = await call<{ diff: string }>("diff", { files: [abs(f)], staged: isStaged });
|
const r = await call<{ diff: string }>("diff", { files: [abs(f)], staged: isStaged });
|
||||||
showDiff(`${f.path}${isStaged ? ` (${t("git.staged")})` : ""}`, r.diff);
|
showDiff(`${f.path}${isStaged ? ` (${t("git.staged")})` : ""}`, r.diff);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
toast(e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ export function GitPanel({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{f.index !== "?" && (
|
{f.index !== "?" && (
|
||||||
<button className="small ghost danger" title={t("git.discard")} onClick={() => confirm(t("git.discardConfirm", { file: f.path })) && run("discard", () => call("discard", { files: [abs(f)] }))}>
|
<button className="small ghost danger" title={t("git.discard")} onClick={() => void confirmDialog({ message: t("git.discardConfirm", { file: f.path }), danger: true, confirmLabel: t("git.discard") }).then((ok): unknown => ok && run("discard", () => call("discard", { files: [abs(f)] })))}>
|
||||||
↺
|
↺
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -143,8 +144,8 @@ export function GitPanel({
|
|||||||
<button
|
<button
|
||||||
className="small"
|
className="small"
|
||||||
title={t("git.newBranch")}
|
title={t("git.newBranch")}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
const name = prompt(t("git.newBranchName"));
|
const name = await promptDialog({ message: t("git.newBranchName"), placeholder: "feature/…" });
|
||||||
if (name) void run("checkout", () => call("checkout", { branch: name, create: true }), true);
|
if (name) void run("checkout", () => call("checkout", { branch: name, create: true }), true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { del, post } from "../api";
|
|||||||
import { useFetch } from "../hooks";
|
import { useFetch } from "../hooks";
|
||||||
import { fmtCost, fmtDate, useT, type Key } from "../i18n";
|
import { fmtCost, fmtDate, useT, type Key } from "../i18n";
|
||||||
import { Spinner } from "./Activity";
|
import { Spinner } from "./Activity";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
interface ImageConfig {
|
interface ImageConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -148,7 +149,7 @@ export function Images({ onMenu }: { onMenu: () => void }) {
|
|||||||
<button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}>
|
<button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}>
|
||||||
⧉
|
⧉
|
||||||
</button>
|
</button>
|
||||||
<button className="small danger" title={t("common.delete")} onClick={() => confirm(t("images.deleteConfirm")) && del(`/images/${img.id}`).then(reload)}>
|
<button className="small danger" title={t("common.delete")} onClick={() => void confirmDialog({ message: t("images.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && del(`/images/${img.id}`).then(reload))}>
|
||||||
🗑
|
🗑
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,115 @@
|
|||||||
import { memo } from "react";
|
import { memo, useState, type ReactNode } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import rehypeHighlight from "rehype-highlight";
|
import rehypeHighlight from "rehype-highlight";
|
||||||
|
|
||||||
export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
|
||||||
|
|
||||||
|
/** URL for a file in the project (absolute or relative to baseDir). */
|
||||||
|
export function projectFileUrl(src: string, baseDir?: string): string | undefined {
|
||||||
|
if (/^(https?:|data:|blob:)/i.test(src)) return src;
|
||||||
|
if (!baseDir && !src.startsWith("/")) return undefined;
|
||||||
|
const parts = (src.startsWith("/") ? src : `${baseDir}/${src}`).split("/");
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const p of parts) {
|
||||||
|
if (p === "..") out.pop();
|
||||||
|
else if (p && p !== ".") out.push(p);
|
||||||
|
}
|
||||||
|
return `/api/files/raw?path=${encodeURIComponent("/" + out.join("/"))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOf(node: ReactNode): string {
|
||||||
|
if (typeof node === "string") return node;
|
||||||
|
if (Array.isArray(node)) return node.map(textOf).join("");
|
||||||
|
if (node && typeof node === "object" && "props" in node) return textOf((node as any).props.children);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SVG code shown as an image (img context: scripts never run), code on demand. */
|
||||||
|
export function SvgBlock({ code, children }: { code: string; children?: ReactNode }) {
|
||||||
|
const [showCode, setShowCode] = useState(false);
|
||||||
|
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(code)))}`;
|
||||||
|
return (
|
||||||
|
<div className="svg-block">
|
||||||
|
<img src={url} alt="SVG" />
|
||||||
|
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
|
||||||
|
{showCode ? "▾ SVG" : "▸ SVG"}
|
||||||
|
</button>
|
||||||
|
{showCode && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="markdown">
|
<div className="markdown">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
||||||
components={{ a: (props) => <a {...props} target="_blank" rel="noreferrer" /> }}
|
components={{
|
||||||
|
a: (props) => <a {...props} target="_blank" rel="noreferrer" />,
|
||||||
|
img: ({ src, alt }) => {
|
||||||
|
const url = typeof src === "string" ? projectFileUrl(src, baseDir) : undefined;
|
||||||
|
if (!url) return <span>{alt}</span>;
|
||||||
|
// Non-image files (3D models, documents) become links.
|
||||||
|
if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) {
|
||||||
|
return (
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
📎 {alt || src}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />;
|
||||||
|
},
|
||||||
|
pre: ({ children }) => {
|
||||||
|
const code = textOf(children);
|
||||||
|
const child: any = Array.isArray(children) ? children[0] : children;
|
||||||
|
const cls = String(child?.props?.className ?? "");
|
||||||
|
if (/language-svg/.test(cls) || (/language-(xml|html)/.test(cls) && code.trim().startsWith("<svg"))) {
|
||||||
|
return (
|
||||||
|
<SvgBlock code={code}>
|
||||||
|
<pre>{children}</pre>
|
||||||
|
</SvgBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <pre>{children}</pre>;
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Image files mentioned in a text (paths in backticks or plain). */
|
||||||
|
export function imagePathsIn(text: string): string[] {
|
||||||
|
const found = new Set<string>();
|
||||||
|
const re = /(?:^|[\s`(\["'])((?:\.{0,2}\/)?[\w\-./]+\.(?:png|jpe?g|gif|webp|bmp|svg))(?=$|[\s`)\]"',.:;!?])/gim;
|
||||||
|
for (const m of text.matchAll(re)) {
|
||||||
|
// Skip URLs and markdown images (rendered anyway).
|
||||||
|
const idx = m.index ?? 0;
|
||||||
|
const before = text.slice(Math.max(0, idx - 2), idx + 1);
|
||||||
|
if (m[1].includes("://") || before.includes("](") || text.slice(Math.max(0, idx - 10), idx + 1).includes("http")) continue;
|
||||||
|
found.add(m[1]);
|
||||||
|
}
|
||||||
|
return [...found].slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thumbnails for image files mentioned in a message; missing files hide themselves. */
|
||||||
|
export function ImageRefs({ text, baseDir }: { text: string; baseDir?: string }) {
|
||||||
|
const paths = imagePathsIn(text.replace(/!\[[^\]]*\]\([^)]*\)/g, ""));
|
||||||
|
if (paths.length === 0 || !baseDir) return null;
|
||||||
|
return (
|
||||||
|
<div className="image-refs">
|
||||||
|
{paths.map((p) => {
|
||||||
|
const url = projectFileUrl(p, baseDir)!;
|
||||||
|
return (
|
||||||
|
<a key={p} href={url} target="_blank" rel="noreferrer" title={p}>
|
||||||
|
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.parentElement as HTMLElement).style.display = "none")} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM).
|
||||||
|
// Loaded on demand only when such a file is opened.
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
||||||
|
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
||||||
|
import occtScriptUrl from "occt-import-js/dist/occt-import-js.js?url";
|
||||||
|
import occtWasmUrl from "occt-import-js/dist/occt-import-js.wasm?url";
|
||||||
|
|
||||||
|
let occtPromise: Promise<any> | undefined;
|
||||||
|
|
||||||
|
/** Load the OpenCascade importer once (script tag + wasm URL). */
|
||||||
|
function loadOcct(): Promise<any> {
|
||||||
|
occtPromise ??= new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.src = occtScriptUrl;
|
||||||
|
s.onload = () => (window as any).occtimportjs({ locateFile: () => occtWasmUrl }).then(resolve, reject);
|
||||||
|
s.onerror = () => reject(new Error("could not load the STEP importer"));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
return occtPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssColor(name: string, fallback: string) {
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return v || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadObject(url: string, ext: string): Promise<THREE.Object3D> {
|
||||||
|
const material = new THREE.MeshStandardMaterial({ color: cssColor("--blue", "#83a598"), metalness: 0.1, roughness: 0.6, side: THREE.DoubleSide });
|
||||||
|
switch (ext) {
|
||||||
|
case "glb":
|
||||||
|
case "gltf":
|
||||||
|
return (await new GLTFLoader().loadAsync(url)).scene;
|
||||||
|
case "stl": {
|
||||||
|
const geo = await new STLLoader().loadAsync(url);
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
return new THREE.Mesh(geo, material);
|
||||||
|
}
|
||||||
|
case "obj": {
|
||||||
|
const obj = await new OBJLoader().loadAsync(url);
|
||||||
|
obj.traverse((c) => c instanceof THREE.Mesh && (c.material = material));
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// STEP / IGES: tessellate with OpenCascade in the browser.
|
||||||
|
const [occt, buf] = await Promise.all([loadOcct(), fetch(url, { credentials: "same-origin" }).then((r) => r.arrayBuffer())]);
|
||||||
|
const data = new Uint8Array(buf);
|
||||||
|
const res = ext === "igs" || ext === "iges" ? occt.ReadIgesFile(data, null) : occt.ReadStepFile(data, null);
|
||||||
|
if (!res.success) throw new Error("the file could not be read");
|
||||||
|
const group = new THREE.Group();
|
||||||
|
for (const m of res.meshes) {
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute("position", new THREE.Float32BufferAttribute(m.attributes.position.array, 3));
|
||||||
|
if (m.attributes.normal) geo.setAttribute("normal", new THREE.Float32BufferAttribute(m.attributes.normal.array, 3));
|
||||||
|
else geo.computeVertexNormals();
|
||||||
|
if (m.index) geo.setIndex(new THREE.Uint32BufferAttribute(m.index.array, 1));
|
||||||
|
const mat = m.color ? material.clone() : material;
|
||||||
|
if (m.color) (mat as THREE.MeshStandardMaterial).color = new THREE.Color(m.color[0], m.color[1], m.color[2]);
|
||||||
|
group.add(new THREE.Mesh(geo, mat));
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelViewer({ url, ext }: { url: string; ext: string }) {
|
||||||
|
const host = useRef<HTMLDivElement>(null);
|
||||||
|
const [state, setState] = useState<{ loading: boolean; error?: string; info?: string }>({ loading: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = host.current!;
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
el.appendChild(renderer.domElement);
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(cssColor("--bg0", "#282828"));
|
||||||
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.6));
|
||||||
|
const sun = new THREE.DirectionalLight(0xffffff, 1.8);
|
||||||
|
sun.position.set(3, 5, 4);
|
||||||
|
scene.add(sun);
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1e6);
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const w = el.clientWidth || 300;
|
||||||
|
const h = el.clientHeight || 300;
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
};
|
||||||
|
const ro = new ResizeObserver(resize);
|
||||||
|
ro.observe(el);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
let alive = true;
|
||||||
|
let frame = 0;
|
||||||
|
const loop = () => {
|
||||||
|
if (!alive) return;
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
frame = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
|
||||||
|
loadObject(url, ext)
|
||||||
|
.then((obj) => {
|
||||||
|
if (!alive) return;
|
||||||
|
scene.add(obj);
|
||||||
|
// Frame the model.
|
||||||
|
const box = new THREE.Box3().setFromObject(obj);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const radius = Math.max(size.length() / 2, 1e-3);
|
||||||
|
camera.position.copy(center).add(new THREE.Vector3(radius * 1.4, radius * 1.0, radius * 1.8));
|
||||||
|
camera.near = radius / 100;
|
||||||
|
camera.far = radius * 100;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controls.target.copy(center);
|
||||||
|
let tris = 0;
|
||||||
|
obj.traverse((c) => {
|
||||||
|
if (c instanceof THREE.Mesh) tris += (c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count) / 3;
|
||||||
|
});
|
||||||
|
setState({ loading: false, info: `${Math.round(tris).toLocaleString()} triangles · ${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)}` });
|
||||||
|
})
|
||||||
|
.catch((e) => alive && setState({ loading: false, error: String(e?.message ?? e) }));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
ro.disconnect();
|
||||||
|
controls.dispose();
|
||||||
|
scene.traverse((c) => {
|
||||||
|
if (c instanceof THREE.Mesh) {
|
||||||
|
c.geometry.dispose();
|
||||||
|
(Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderer.dispose();
|
||||||
|
el.removeChild(renderer.domElement);
|
||||||
|
};
|
||||||
|
}, [url, ext]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="model-viewer">
|
||||||
|
<div ref={host} className="model-canvas" />
|
||||||
|
<div className="model-info">{state.loading ? "…" : state.error ? `⚠ ${state.error}` : state.info}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { del, post } from "../api";
|
|||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import type { QueueItem } from "../types";
|
import type { QueueItem } from "../types";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
const EMPTY: QueueItem[] = [];
|
const EMPTY: QueueItem[] = [];
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
|
|||||||
<div className="queue-head">
|
<div className="queue-head">
|
||||||
<span>{busy ? t("queue.pending", { n: items.length }) : t("queue.stopped", { n: items.length })}</span>
|
<span>{busy ? t("queue.pending", { n: items.length }) : t("queue.stopped", { n: items.length })}</span>
|
||||||
{!busy && (
|
{!busy && (
|
||||||
<button className="small primary" onClick={() => post(`/sessions/${sessionId}/queue/flush`).catch((e) => alert(e.message))}>
|
<button className="small primary" onClick={() => post(`/sessions/${sessionId}/queue/flush`).catch((e) => toast(e.message))}>
|
||||||
{t("queue.sendNow")}
|
{t("queue.sendNow")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -35,7 +36,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
|
|||||||
className="small ghost"
|
className="small ghost"
|
||||||
aria-label={t("queue.remove")}
|
aria-label={t("queue.remove")}
|
||||||
title={t("queue.remove")}
|
title={t("queue.remove")}
|
||||||
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => alert(e.message))}
|
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => toast(e.message))}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { get, post } from "../api";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
|
/** Rewind to a message, optionally restoring the files the agent changed since. */
|
||||||
|
export function RewindDialog({ sessionId, messageId, onDone, onClose }: { sessionId: string; messageId: string; onDone: () => void; onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [files, setFiles] = useState<{ path: string; created: boolean }[]>();
|
||||||
|
const [restore, setRestore] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
get<{ path: string; created: boolean }[]>(`/sessions/${sessionId}/changes?since_message=${messageId}`).then(setFiles).catch(() => setFiles([]));
|
||||||
|
}, [sessionId, messageId]);
|
||||||
|
const go = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await post(`/sessions/${sessionId}/rewind`, { message_id: messageId, restore_files: restore && !!files?.length });
|
||||||
|
onDone();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" role="dialog" aria-label={t("chat.rewind")} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{t("chat.rewind")}</h3>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--fg3)" }}>{t("rewind.intro")}</p>
|
||||||
|
{files === undefined ? (
|
||||||
|
<div className="empty">…</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13 }}>{t("rewind.noFiles")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="row" style={{ marginBottom: 6 }}>
|
||||||
|
<input type="checkbox" checked={restore} onChange={(e) => setRestore(e.target.checked)} /> {t("rewind.restore", { n: files.length })}
|
||||||
|
</label>
|
||||||
|
<ul className="rewind-files">
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f.path}>
|
||||||
|
<code>{f.path}</code> {f.created && <span className="chip">{t("rewind.willDelete")}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--muted)" }}>{t("rewind.bashNote")}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="row" style={{ justifyContent: "flex-end", marginTop: 10 }}>
|
||||||
|
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||||
|
<button className="primary" disabled={busy || files === undefined} onClick={go}>
|
||||||
|
{t("rewind.confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { get, patch as apiPatch, post, put } from "../api";
|
import { get, patch as apiPatch, post, put } from "../api";
|
||||||
import { useFetch, useModels } from "../hooks";
|
import { useFetch, useModels } from "../hooks";
|
||||||
import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import { applyTheme, getTheme, THEMES, type Theme } from "../theme";
|
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
|
||||||
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
|
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
|
||||||
import { ModelSelect } from "./ModelSelect";
|
import { ModelSelect } from "./ModelSelect";
|
||||||
import { Account } from "./settings/Account";
|
import { Account } from "./settings/Account";
|
||||||
import { AgentsEditor } from "./settings/AgentsEditor";
|
import { AgentsEditor } from "./settings/AgentsEditor";
|
||||||
import { Folders } from "./settings/Folders";
|
import { Folders } from "./settings/Folders";
|
||||||
import { Users } from "./settings/Users";
|
import { Users } from "./settings/Users";
|
||||||
|
import { Audit } from "./settings/Audit";
|
||||||
|
import { Tools } from "./settings/Tools";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
type Json = any;
|
type Json = any;
|
||||||
interface ConfigResponse {
|
interface ConfigResponse {
|
||||||
@@ -48,7 +51,9 @@ function useSaver(reload: () => void) {
|
|||||||
function InterfaceSettings() {
|
function InterfaceSettings() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const reasoning = useReasoningMode();
|
const reasoning = useReasoningMode();
|
||||||
const [theme, setThemeState] = useState<Theme>(getTheme());
|
const theme = useSyncExternalStore(onThemeChange, getTheme);
|
||||||
|
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
|
||||||
|
const themeInfo = THEMES.find((th) => th.id === theme)!;
|
||||||
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
|
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
|
||||||
return (
|
return (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
@@ -63,20 +68,36 @@ function InterfaceSettings() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<label>{t("settings.theme")}</label>
|
<label>{t("settings.theme")}</label>
|
||||||
<select
|
<div className="stack">
|
||||||
value={theme}
|
<select
|
||||||
onChange={(e) => {
|
value={theme}
|
||||||
const v = e.target.value as Theme;
|
onChange={(e) => {
|
||||||
setThemeState(v);
|
const v = e.target.value as Theme;
|
||||||
applyTheme(v);
|
applyTheme(v, mode);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{THEMES.map((th) => (
|
{THEMES.map((th) => (
|
||||||
<option key={th.id} value={th.id}>
|
<option key={th.id} value={th.id}>
|
||||||
{th.id === "system" ? t("settings.themeSystem") : th.name}
|
{th.name}
|
||||||
</option>
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="hint">{t("settings.themeSource", { source: themeInfo.source, license: themeInfo.license })}</div>
|
||||||
|
</div>
|
||||||
|
<label>{t("settings.themeMode")}</label>
|
||||||
|
<div className="segmented">
|
||||||
|
{THEME_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
className={`small ${m === mode ? "active" : "ghost"}`}
|
||||||
|
onClick={() => {
|
||||||
|
applyTheme(theme, m);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(m === "auto" ? "settings.themeAuto" : m === "light" ? "settings.themeLight" : "settings.themeDark")}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
<label>{t("settings.reasoningDisplay")}</label>
|
<label>{t("settings.reasoningDisplay")}</label>
|
||||||
<select value={reasoning} onChange={(e) => setReasoningMode(e.target.value as ReasoningMode)}>
|
<select value={reasoning} onChange={(e) => setReasoningMode(e.target.value as ReasoningMode)}>
|
||||||
<option value="collapsed">{t("settings.reasoningCollapsed")}</option>
|
<option value="collapsed">{t("settings.reasoningCollapsed")}</option>
|
||||||
@@ -114,6 +135,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
monthly_budget: s.monthly_budget ?? 0,
|
monthly_budget: s.monthly_budget ?? 0,
|
||||||
budget_warning: s.budget_warning ?? 0.8,
|
budget_warning: s.budget_warning ?? 0.8,
|
||||||
images_budget: cfg.config.images?.monthly_budget ?? "",
|
images_budget: cfg.config.images?.monthly_budget ?? "",
|
||||||
|
budget_action: s.budget_action ?? "warn",
|
||||||
|
sandbox_mode: cfg.config.sandbox?.mode ?? "auto",
|
||||||
clean_room: c.clean_room ?? true,
|
clean_room: c.clean_room ?? true,
|
||||||
chromium: c.chromium ?? "chromium",
|
chromium: c.chromium ?? "chromium",
|
||||||
widths: (c.widths ?? []).join(", "),
|
widths: (c.widths ?? []).join(", "),
|
||||||
@@ -134,6 +157,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
||||||
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
||||||
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
||||||
|
{ path: ["server", "budget_action"], value: form.budget_action },
|
||||||
|
{ path: ["sandbox", "mode"], value: form.sandbox_mode },
|
||||||
{ path: ["images", "monthly_budget"], value: form.images_budget === "" ? null : Number(form.images_budget) },
|
{ path: ["images", "monthly_budget"], value: form.images_budget === "" ? null : Number(form.images_budget) },
|
||||||
{ path: ["craft", "clean_room"], value: form.clean_room },
|
{ path: ["craft", "clean_room"], value: form.clean_room },
|
||||||
{ path: ["craft", "chromium"], value: form.chromium },
|
{ path: ["craft", "chromium"], value: form.chromium },
|
||||||
@@ -168,6 +193,18 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
||||||
<label>{t("settings.budgetWarning")}</label>
|
<label>{t("settings.budgetWarning")}</label>
|
||||||
<input type="number" min={0} max={1} step={0.05} value={form.budget_warning} onChange={(e) => setForm({ ...form, budget_warning: e.target.value })} />
|
<input type="number" min={0} max={1} step={0.05} value={form.budget_warning} onChange={(e) => setForm({ ...form, budget_warning: e.target.value })} />
|
||||||
|
<label>{t("settings.budgetAction")}</label>
|
||||||
|
<select value={form.budget_action} onChange={(e) => setForm({ ...form, budget_action: e.target.value })}>
|
||||||
|
<option value="warn">{t("settings.budgetWarn")}</option>
|
||||||
|
<option value="block">{t("settings.budgetBlock")}</option>
|
||||||
|
</select>
|
||||||
|
<label>{t("settings.sandbox")}</label>
|
||||||
|
<select value={form.sandbox_mode} onChange={(e) => setForm({ ...form, sandbox_mode: e.target.value })}>
|
||||||
|
<option value="auto">{t("settings.sandboxAuto")}</option>
|
||||||
|
<option value="always">{t("settings.sandboxAlways")}</option>
|
||||||
|
<option value="off">{t("settings.sandboxOff")}</option>
|
||||||
|
</select>
|
||||||
|
<div className="hint">{t("settings.sandboxHint")}</div>
|
||||||
<label>{t("settings.maxParallelSubagents")}</label>
|
<label>{t("settings.maxParallelSubagents")}</label>
|
||||||
<input type="number" min={0} value={form.max_parallel_subagents} onChange={(e) => setForm({ ...form, max_parallel_subagents: e.target.value })} />
|
<input type="number" min={0} value={form.max_parallel_subagents} onChange={(e) => setForm({ ...form, max_parallel_subagents: e.target.value })} />
|
||||||
<div className="hint">{t("settings.maxParallelSubagentsHint")}</div>
|
<div className="hint">{t("settings.maxParallelSubagentsHint")}</div>
|
||||||
@@ -305,8 +342,8 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
|||||||
return savePatches(patches);
|
return savePatches(patches);
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = () => {
|
const remove = async () => {
|
||||||
if (!confirm(t("settings.deleteProviderConfirm", { name }))) return;
|
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
void run(() => savePatches([{ path: ["providers", name], value: null }]));
|
void run(() => savePatches([{ path: ["providers", name], value: null }]));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -469,7 +506,7 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
|
|||||||
|
|
||||||
const add = () => {
|
const add = () => {
|
||||||
const n = name.trim() || preset;
|
const n = name.trim() || preset;
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||||
const p = PRESETS[preset];
|
const p = PRESETS[preset];
|
||||||
const value: Record<string, unknown> = { kind: p.kind, base_url: p.base_url, api_key: p.api_key, enabled: true, models: [] };
|
const value: Record<string, unknown> = { kind: p.kind, base_url: p.base_url, api_key: p.api_key, enabled: true, models: [] };
|
||||||
if (p.auth_prefix) value.auth_prefix = p.auth_prefix;
|
if (p.auth_prefix) value.auth_prefix = p.auth_prefix;
|
||||||
@@ -534,15 +571,18 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
|||||||
headers: mapToLines(m.headers, ": "),
|
headers: mapToLines(m.headers, ": "),
|
||||||
enabled: m.enabled ?? true,
|
enabled: m.enabled ?? true,
|
||||||
load: m.load ?? "auto",
|
load: m.load ?? "auto",
|
||||||
|
users: (m.users ?? []).join(", "),
|
||||||
});
|
});
|
||||||
const [open, setOpen] = useState(!!isNew);
|
const [open, setOpen] = useState(!!isNew);
|
||||||
const { run, view } = useSaver(reload);
|
const { run, view } = useSaver(reload);
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const n = form.name.trim();
|
const n = form.name.trim();
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||||
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
|
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
|
||||||
if (form.load !== "auto") value.load = form.load;
|
if (form.load !== "auto") value.load = form.load;
|
||||||
|
const users = form.users.split(/[\s,]+/).filter(Boolean);
|
||||||
|
if (users.length) value.users = users;
|
||||||
if (form.type === "stdio") {
|
if (form.type === "stdio") {
|
||||||
value.command = form.command;
|
value.command = form.command;
|
||||||
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
|
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
|
||||||
@@ -612,6 +652,9 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
|||||||
<option value="on_demand">{t("mcp.loadOnDemand")}</option>
|
<option value="on_demand">{t("mcp.loadOnDemand")}</option>
|
||||||
</select>
|
</select>
|
||||||
<div className="hint">{t("mcp.loadHint")}</div>
|
<div className="hint">{t("mcp.loadHint")}</div>
|
||||||
|
<label>{t("mcp.users")}</label>
|
||||||
|
<input value={form.users} onChange={(e) => setForm({ ...form, users: e.target.value })} placeholder={t("mcp.usersPlaceholder")} />
|
||||||
|
<div className="hint">{t("mcp.usersHint")}</div>
|
||||||
</div>
|
</div>
|
||||||
{status?.tools?.length ? (
|
{status?.tools?.length ? (
|
||||||
<details style={{ marginTop: 10 }}>
|
<details style={{ marginTop: 10 }}>
|
||||||
@@ -632,7 +675,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
|||||||
</button>
|
</button>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<button className="danger" onClick={() => confirm(t("settings.deleteMcpConfirm", { name })) && run(() => savePatches([{ path: ["mcp", name], value: null }]))}>
|
<button className="danger" onClick={() => void confirmDialog({ message: t("settings.deleteMcpConfirm", { name }), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && run(() => savePatches([{ path: ["mcp", name], value: null }])))}>
|
||||||
{t("common.delete")}
|
{t("common.delete")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -773,7 +816,7 @@ function Raw({ reload }: { reload: () => void }) {
|
|||||||
|
|
||||||
// --- page -------------------------------------------------------------------
|
// --- page -------------------------------------------------------------------
|
||||||
|
|
||||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "account", "raw"] as const;
|
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
|
||||||
const USER_TABS = ["general", "account"] as const;
|
const USER_TABS = ["general", "account"] as const;
|
||||||
|
|
||||||
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
|
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
|
||||||
@@ -808,6 +851,8 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
|||||||
{active === "account" && <Account />}
|
{active === "account" && <Account />}
|
||||||
{cfg && active === "users" && <Users />}
|
{cfg && active === "users" && <Users />}
|
||||||
{cfg && active === "folders" && <Folders />}
|
{cfg && active === "folders" && <Folders />}
|
||||||
|
{cfg && active === "audit" && <Audit />}
|
||||||
|
{cfg && active === "tools" && <Tools />}
|
||||||
{cfg && active === "general" && <General key={JSON.stringify(cfg.config.server)} cfg={cfg} reload={reload} />}
|
{cfg && active === "general" && <General key={JSON.stringify(cfg.config.server)} cfg={cfg} reload={reload} />}
|
||||||
{cfg && active === "providers" && <Providers cfg={cfg} reload={reload} />}
|
{cfg && active === "providers" && <Providers cfg={cfg} reload={reload} />}
|
||||||
{cfg && active === "mcp" && <Mcp cfg={cfg} reload={reload} />}
|
{cfg && active === "mcp" && <Mcp cfg={cfg} reload={reload} />}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { get, post } from "../api";
|
||||||
|
import { toast } from "../dialogs";
|
||||||
|
import { fmtRelative, useT } from "../i18n";
|
||||||
|
import { Spinner } from "./Activity";
|
||||||
|
|
||||||
|
interface ShellInfo {
|
||||||
|
id: string;
|
||||||
|
command: string;
|
||||||
|
started_at: number;
|
||||||
|
running: boolean;
|
||||||
|
exit_code: number | null;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header chip with the session's background shells; opens a live output popup. */
|
||||||
|
export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boolean }) {
|
||||||
|
const t = useT();
|
||||||
|
const [shells, setShells] = useState<ShellInfo[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => get<ShellInfo[]>(`/sessions/${sessionId}/shells`).then((s) => alive && setShells(s)).catch(() => undefined);
|
||||||
|
void load();
|
||||||
|
const anyRunning = shells.some((s) => s.running);
|
||||||
|
if (!open && !busy && !anyRunning) return () => void (alive = false);
|
||||||
|
const h = setInterval(load, open ? 1500 : 5000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(h);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sessionId, open, busy, shells.some((s) => s.running)]);
|
||||||
|
|
||||||
|
const running = shells.filter((s) => s.running).length;
|
||||||
|
if (shells.length === 0) return null;
|
||||||
|
const current = shells.find((s) => s.id === selected) ?? shells[0];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className={`chip ${running ? "warn" : ""}`} style={{ border: "none" }} onClick={() => setOpen(true)} title={t("shells.title")}>
|
||||||
|
{running ? <Spinner small /> : "▣"} {t("shells.chip", { n: running, total: shells.length })}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="modal-backdrop" onClick={() => setOpen(false)}>
|
||||||
|
<div className="modal wide" role="dialog" aria-label={t("shells.title")} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="row" style={{ marginBottom: 8 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>{t("shells.title")}</h3>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="small ghost" onClick={() => setOpen(false)} aria-label={t("common.close")}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mode-tabs" style={{ flexWrap: "wrap", marginBottom: 8 }}>
|
||||||
|
{shells.map((s) => (
|
||||||
|
<button key={s.id} className={s.id === current.id ? "active" : ""} onClick={() => setSelected(s.id)}>
|
||||||
|
{s.running ? "● " : "○ "}
|
||||||
|
{s.command.slice(0, 40)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ fontSize: 12.5, color: "var(--muted)", marginBottom: 6 }}>
|
||||||
|
<code>{current.command}</code>
|
||||||
|
<span>· {fmtRelative(current.started_at)}</span>
|
||||||
|
<span>· {current.running ? t("shells.running") : t("shells.exited", { code: current.exit_code ?? "?" })}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
{current.running && (
|
||||||
|
<button className="small danger" onClick={() => post(`/sessions/${sessionId}/shells/${current.id}/kill`).catch(toast)}>
|
||||||
|
■ {t("shells.stop")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<pre className="shell-output">{current.output || t("shells.noOutput")}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||||
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
import { post } from "../api";
|
import { post } from "../api";
|
||||||
import { fmtRelative, useT, type Key } from "../i18n";
|
import { fmtRelative, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
@@ -88,6 +89,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
{me?.display_name || me?.username}
|
{me?.display_name || me?.username}
|
||||||
{me?.role === "admin" && <span className="chip" style={{ marginLeft: 6 }}>admin</span>}
|
{me?.role === "admin" && <span className="chip" style={{ marginLeft: 6 }}>admin</span>}
|
||||||
</span>
|
</span>
|
||||||
|
<ThemeModeButton />
|
||||||
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
|
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
|
||||||
{t("nav.logout")}
|
{t("nav.logout")}
|
||||||
</button>
|
</button>
|
||||||
@@ -109,3 +111,16 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Quick light/dark/auto switcher. */
|
||||||
|
function ThemeModeButton() {
|
||||||
|
const t = useT();
|
||||||
|
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
|
||||||
|
const icon = mode === "auto" ? "◐" : mode === "light" ? "☀" : "☾";
|
||||||
|
const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark");
|
||||||
|
return (
|
||||||
|
<button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useStore } from "../store";
|
|||||||
import type { Part, Todo } from "../types";
|
import type { Part, Todo } from "../types";
|
||||||
import { Spinner } from "./Activity";
|
import { Spinner } from "./Activity";
|
||||||
import { Diff } from "./Diff";
|
import { Diff } from "./Diff";
|
||||||
|
import { IMAGE_EXT, projectFileUrl } from "./Markdown";
|
||||||
|
|
||||||
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||||
@@ -57,7 +58,7 @@ export function TodoList({ todos }: { todos: Todo[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCard({ call, result, running }: { call: ToolCall; result?: ToolResult; running: boolean }) {
|
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const progress = useStore((s) => s.progress[call.id]);
|
const progress = useStore((s) => s.progress[call.id]);
|
||||||
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||||||
@@ -77,6 +78,26 @@ export function ToolCard({ call, result, running }: { call: ToolCall; result?: T
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Image files written by the agent are shown as images (SVG too, rendered via <img>).
|
||||||
|
const writtenImage =
|
||||||
|
(call.name === "write" || call.name === "edit") && typeof call.input?.path === "string" && IMAGE_EXT.test(call.input.path) && result && !result.is_error
|
||||||
|
? projectFileUrl(call.input.path, baseDir)
|
||||||
|
: undefined;
|
||||||
|
if (writtenImage) {
|
||||||
|
return (
|
||||||
|
<div className="tool image-result">
|
||||||
|
<div className="tool-head">
|
||||||
|
<span className="tool-name">{call.name}</span>
|
||||||
|
<span className="tool-title">{call.input.path}</span>
|
||||||
|
<span className="status ok">✓</span>
|
||||||
|
</div>
|
||||||
|
<a href={writtenImage} target="_blank" rel="noreferrer">
|
||||||
|
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Short diffs are shown right away, like in a terminal agent.
|
// Short diffs are shown right away, like in a terminal agent.
|
||||||
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||||||
const openByDefault =
|
const openByDefault =
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useFetch, useModels } from "../hooks";
|
||||||
|
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, useT } from "../i18n";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import type { Aggregate, Session } from "../types";
|
||||||
|
|
||||||
|
interface SessionStats {
|
||||||
|
total: Aggregate;
|
||||||
|
by_model: (Aggregate & { model: string })[];
|
||||||
|
by_agent: (Aggregate & { key: string })[];
|
||||||
|
first: number | null;
|
||||||
|
last: number | null;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BudgetRow {
|
||||||
|
provider: string;
|
||||||
|
budget: number | null;
|
||||||
|
spent: number;
|
||||||
|
share: number | null;
|
||||||
|
warning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value, hint }: { label: string; value: React.ReactNode; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="kv" title={hint}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<b>{value}</b>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const perMillion = (a: Aggregate) => {
|
||||||
|
const tokens = a.input_tokens + a.cache_read_tokens + a.cache_write_tokens + a.output_tokens;
|
||||||
|
return tokens > 0 ? (a.cost / tokens) * 1_000_000 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Everything about the models used in a session: configuration, usage, speed, cost. */
|
||||||
|
export function UsageOverlay({ session, onClose }: { session: Session; onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const { data } = useFetch<SessionStats>(`/sessions/${session.id}/stats`);
|
||||||
|
const models = useModels();
|
||||||
|
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||||
|
const budget = useFetch<{ providers: BudgetRow[]; currency: string }>(isAdmin ? "/budget" : null).data;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const cur = data?.currency ?? "USD";
|
||||||
|
const info = (spec: string) => {
|
||||||
|
const [p, ...rest] = spec.split("/");
|
||||||
|
return models.find((x) => x.provider === p)?.models.find((m) => m.id === rest.join("/"));
|
||||||
|
};
|
||||||
|
const u = session.last_usage;
|
||||||
|
const ctxTokens = u ? u.input_tokens + u.cache_read_tokens + u.cache_write_tokens + u.output_tokens : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal wide" role="dialog" aria-label={t("usage.title")} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="row" style={{ marginBottom: 8 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>{t("usage.title")}</h3>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!data ? (
|
||||||
|
<div className="empty">{t("common.loading")}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="kpis">
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="k-label">{t("stats.cost")}</div>
|
||||||
|
<div className="k-value">{fmtCost(data.total.cost, cur)}</div>
|
||||||
|
<div className="k-sub">{t("stats.perRequest", { v: fmtCost(data.total.requests ? data.total.cost / data.total.requests : 0, cur) })}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="k-label">{t("stats.requests")}</div>
|
||||||
|
<div className="k-value">{fmtNumber(data.total.requests)}</div>
|
||||||
|
{data.total.errors > 0 && <div className="k-sub">{t("stats.errors", { n: data.total.errors })}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="k-label">{t("stats.tokens")}</div>
|
||||||
|
<div className="k-value">
|
||||||
|
{fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}↑ {fmtCompact(data.total.output_tokens)}↓
|
||||||
|
</div>
|
||||||
|
<div className="k-sub">
|
||||||
|
{t("stats.cacheHit")}: {fmtPercent(data.total.cache_hit_rate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="k-label">{t("usage.period")}</div>
|
||||||
|
<div className="k-value" style={{ fontSize: 15 }}>
|
||||||
|
{data.first ? fmtDate(data.first) : "–"}
|
||||||
|
</div>
|
||||||
|
<div className="k-sub">{data.last ? `→ ${fmtDate(data.last)}` : ""}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.by_model.map((m) => {
|
||||||
|
const mi = info(m.model);
|
||||||
|
const provider = m.model.split("/")[0];
|
||||||
|
const b = budget?.providers.find((x) => x.provider === provider);
|
||||||
|
const isCurrent = m.model === session.model;
|
||||||
|
return (
|
||||||
|
<div className="panel" key={m.model}>
|
||||||
|
<h3>
|
||||||
|
{mi?.name ?? m.model}
|
||||||
|
{isCurrent && <span className="chip">{t("usage.current")}</span>}
|
||||||
|
<span className="chip">{provider}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="kv-grid">
|
||||||
|
<div>
|
||||||
|
<div className="kv-title">{t("usage.model")}</div>
|
||||||
|
<Row label="ID" value={<code>{m.model}</code>} />
|
||||||
|
<Row label={t("settings.context")} value={mi?.context ? `${fmtCompact(mi.context)} tok` : "–"} />
|
||||||
|
<Row label={t("settings.maxOutput")} value={mi?.max_output ? `${fmtCompact(mi.max_output)} tok` : "–"} />
|
||||||
|
<Row label={t("settings.reasoning")} value={mi?.reasoning ? `✓${mi.effort ? ` (${mi.effort})` : ""}` : "–"} />
|
||||||
|
<Row label={t("settings.vision")} value={mi?.vision ? "✓" : "–"} />
|
||||||
|
{isCurrent && mi?.context ? (
|
||||||
|
<Row label={t("usage.contextNow")} value={`${fmtCompact(ctxTokens)} / ${fmtCompact(mi.context)} (${fmtPercent(ctxTokens / mi.context)})`} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="kv-title">{t("usage.prices")}</div>
|
||||||
|
<Row label={t("settings.priceIn")} value={mi?.input_price != null ? fmtCost(mi.input_price, cur) : "–"} />
|
||||||
|
<Row label={t("settings.priceOut")} value={mi?.output_price != null ? fmtCost(mi.output_price, cur) : "–"} />
|
||||||
|
<Row label={t("settings.priceCacheRead")} value={mi?.cache_read_price != null ? fmtCost(mi.cache_read_price, cur) : "–"} />
|
||||||
|
<Row label={t("settings.priceCacheWrite")} value={mi?.cache_write_price != null ? fmtCost(mi.cache_write_price, cur) : "–"} />
|
||||||
|
<Row label={t("stats.perMillion")} value={fmtCost(perMillion(m), cur)} hint={t("stats.perMillionHint")} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="kv-title">{t("usage.usage")}</div>
|
||||||
|
<Row label={t("stats.requests")} value={fmtNumber(m.requests)} />
|
||||||
|
<Row label={t("stats.input")} value={fmtNumber(m.input_tokens)} />
|
||||||
|
<Row label={t("stats.cacheRead")} value={fmtNumber(m.cache_read_tokens)} />
|
||||||
|
<Row label={t("usage.cacheWrite")} value={fmtNumber(m.cache_write_tokens)} />
|
||||||
|
<Row label={t("stats.output")} value={fmtNumber(m.output_tokens)} />
|
||||||
|
<Row label={t("stats.cacheHit")} value={fmtPercent(m.cache_hit_rate)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="kv-title">{t("usage.speed")}</div>
|
||||||
|
<Row label={t("stats.ttft")} value={`${fmtNumber(m.avg_ttft_ms)} ms`} />
|
||||||
|
<Row label={t("stats.prefillSpeed")} value={`${fmtNumber(m.prefill_tps)} tok/s`} />
|
||||||
|
<Row label={t("stats.outputSpeed")} value={`${fmtNumber(m.output_tps, 1)} tok/s`} />
|
||||||
|
<Row label={t("usage.totalTime")} value={`${fmtNumber(m.duration_ms / 1000, 1)} s`} />
|
||||||
|
<div className="kv-title" style={{ marginTop: 8 }}>{t("stats.cost")}</div>
|
||||||
|
<Row label={t("usage.inSession")} value={fmtCost(m.cost, cur)} />
|
||||||
|
{b && (
|
||||||
|
<Row
|
||||||
|
label={t("usage.providerMonth")}
|
||||||
|
value={
|
||||||
|
<span style={{ color: b.warning ? "var(--accent2)" : undefined }}>
|
||||||
|
{fmtCost(b.spent, budget!.currency)}
|
||||||
|
{b.budget ? ` / ${fmtCost(b.budget, budget!.currency)}` : ""}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{data.by_agent.length > 1 && (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("usage.byAgent")}</h3>
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("stats.agent")}</th>
|
||||||
|
<th className="num">{t("stats.requests")}</th>
|
||||||
|
<th className="num">{t("stats.tokens")}</th>
|
||||||
|
<th className="num">{t("stats.cost")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.by_agent.map((a) => (
|
||||||
|
<tr key={a.key}>
|
||||||
|
<td>{a.key}</td>
|
||||||
|
<td className="num">{fmtNumber(a.requests)}</td>
|
||||||
|
<td className="num">{fmtCompact(a.input_tokens + a.cache_read_tokens + a.cache_write_tokens + a.output_tokens)}</td>
|
||||||
|
<td className="num">{fmtCost(a.cost, cur)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useFetch } from "../../hooks";
|
|||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import type { AgentInfo } from "../../types";
|
import type { AgentInfo } from "../../types";
|
||||||
import { ModelSelect } from "../ModelSelect";
|
import { ModelSelect } from "../ModelSelect";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||||
|
|
||||||
type Patch = { path: string[]; value: unknown };
|
type Patch = { path: string[]; value: unknown };
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
if (!confirm(t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }))) return;
|
if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await patch("/config", { patches: [{ path: ["agents", a.name], value: null }] });
|
await patch("/config", { patches: [{ path: ["agents", a.name], value: null }] });
|
||||||
onSaved(true, t("settings.saved"));
|
onSaved(true, t("settings.saved"));
|
||||||
@@ -135,7 +136,7 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
|||||||
|
|
||||||
const add = async (template: "writer" | "blank") => {
|
const add = async (template: "writer" | "blank") => {
|
||||||
const n = name.trim() || (template === "writer" ? "writer" : "");
|
const n = name.trim() || (template === "writer" ? "writer" : "");
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||||
const value =
|
const value =
|
||||||
template === "writer"
|
template === "writer"
|
||||||
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
|
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useFetch } from "../../hooks";
|
||||||
|
import { fmtDate, useT } from "../../i18n";
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
ts: number;
|
||||||
|
user: string;
|
||||||
|
action: string;
|
||||||
|
detail: string;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: audit log and backup download. */
|
||||||
|
export function Audit() {
|
||||||
|
const t = useT();
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const { data, reload } = useFetch<Entry[]>(`/audit?limit=500${filter ? `&action=${encodeURIComponent(filter)}` : ""}`);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("audit.backup")}</h3>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 8 }}>{t("audit.backupHint")}</div>
|
||||||
|
<a className="button primary" href="/api/backup">
|
||||||
|
{t("audit.download")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
{t("audit.title")}
|
||||||
|
<span className="spacer" />
|
||||||
|
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||||
|
<option value="">{t("audit.all")}</option>
|
||||||
|
{["login", "permission", "config", "user", "folder", "session", "git", "backup"].map((a) => (
|
||||||
|
<option key={a} value={a}>
|
||||||
|
{a}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="small" onClick={reload}>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("audit.time")}</th>
|
||||||
|
<th>{t("stats.user")}</th>
|
||||||
|
<th>{t("audit.action")}</th>
|
||||||
|
<th>{t("audit.detail")}</th>
|
||||||
|
<th>IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data?.map((e, i) => (
|
||||||
|
<tr key={i} className={e.action.includes("failed") || e.action.includes("blocked") || e.action.includes("reject") ? "audit-warn" : ""}>
|
||||||
|
<td style={{ whiteSpace: "nowrap" }}>{fmtDate(e.ts)}</td>
|
||||||
|
<td>{e.user || "–"}</td>
|
||||||
|
<td>
|
||||||
|
<code>{e.action}</code>
|
||||||
|
</td>
|
||||||
|
<td style={{ wordBreak: "break-word" }}>{e.detail}</td>
|
||||||
|
<td>{e.ip}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{data?.length === 0 && <div className="empty">–</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { del, post, put } from "../../api";
|
|||||||
import { useFetch } from "../../hooks";
|
import { useFetch } from "../../hooks";
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import type { User } from "../../types";
|
import type { User } from "../../types";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||||
|
|
||||||
interface Folder {
|
interface Folder {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,7 +45,7 @@ export function Folders() {
|
|||||||
<h3>
|
<h3>
|
||||||
📁 <code>{f.path.slice(ws.length) || "/"}</code>
|
📁 <code>{f.path.slice(ws.length) || "/"}</code>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button className="small danger" onClick={() => confirm(t("folders.removeConfirm")) && run(() => del(`/folders/${f.id}`))}>
|
<button className="small danger" onClick={() => void confirmDialog({ message: t("folders.removeConfirm"), danger: true, confirmLabel: t("folders.remove") }).then((ok): unknown => ok && run(() => del(`/folders/${f.id}`)))}>
|
||||||
{t("folders.remove")}
|
{t("folders.remove")}
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { post } from "../../api";
|
||||||
|
import { useFetch } from "../../hooks";
|
||||||
|
import { useT } from "../../i18n";
|
||||||
|
import { Spinner } from "../Activity";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||||
|
|
||||||
|
interface ToolsStatus {
|
||||||
|
ghidra: { installed: boolean; version?: string | null; running: boolean; log: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: optional tools installed into the data volume. */
|
||||||
|
export function Tools() {
|
||||||
|
const t = useT();
|
||||||
|
const { data, reload } = useFetch<ToolsStatus>("/tools");
|
||||||
|
// Poll while an installation runs.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.ghidra.running) return;
|
||||||
|
const h = setInterval(reload, 3000);
|
||||||
|
return () => clearInterval(h);
|
||||||
|
}, [data?.ghidra.running, reload]);
|
||||||
|
const g = data?.ghidra;
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
Ghidra
|
||||||
|
{g?.installed && <span className="chip">{g.version ?? t("tools.installed")}</span>}
|
||||||
|
</h3>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 10 }}>{t("tools.ghidraHint")}</div>
|
||||||
|
{g?.running ? (
|
||||||
|
<div className="row">
|
||||||
|
<Spinner /> {t("tools.installing")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="primary" onClick={() => post("/tools/ghidra/install").then(reload).catch((e) => toast(e.message))}>
|
||||||
|
{g?.installed ? t("tools.reinstall") : t("tools.install")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{g?.log && <pre className="git-output" style={{ marginTop: 10 }}>{g.log}</pre>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useFetch } from "../../hooks";
|
|||||||
import { fmtDate, useT } from "../../i18n";
|
import { fmtDate, useT } from "../../i18n";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { User } from "../../types";
|
import type { User } from "../../types";
|
||||||
|
import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||||
|
|
||||||
export function Users() {
|
export function Users() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -22,8 +23,8 @@ export function Users() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetPassword = (u: User) => {
|
const resetPassword = async (u: User) => {
|
||||||
const pw = prompt(t("users.newPasswordFor", { name: u.username }));
|
const pw = await promptDialog({ message: t("users.newPasswordFor", { name: u.username }), password: true });
|
||||||
if (pw) void run(() => patch(`/users/${u.id}`, { password: pw }));
|
if (pw) void run(() => patch(`/users/${u.id}`, { password: pw }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export function Users() {
|
|||||||
<th>{t("users.displayName")}</th>
|
<th>{t("users.displayName")}</th>
|
||||||
<th>{t("users.role")}</th>
|
<th>{t("users.role")}</th>
|
||||||
<th>{t("users.login")}</th>
|
<th>{t("users.login")}</th>
|
||||||
|
<th>{t("users.quota")}</th>
|
||||||
<th>{t("users.created")}</th>
|
<th>{t("users.created")}</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -60,6 +62,19 @@ export function Users() {
|
|||||||
<td>
|
<td>
|
||||||
{u.has_password && <span className="chip">{t("users.password")}</span>} {u.oidc && <span className="chip">SSO</span>}
|
{u.has_password && <span className="chip">{t("users.password")}</span>} {u.oidc && <span className="chip">SSO</span>}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="small ghost"
|
||||||
|
title={t("users.quotaHint")}
|
||||||
|
onClick={async () => {
|
||||||
|
const v = await promptDialog({ message: t("users.quotaPrompt", { name: u.username }), defaultValue: u.monthly_quota != null ? String(u.monthly_quota) : "" });
|
||||||
|
if (v === null) return;
|
||||||
|
void run(() => patch(`/users/${u.id}`, { monthly_quota: v.trim() === "" ? -1 : Number(v.replace(",", ".")) }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.monthly_quota != null ? u.monthly_quota : "∞"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td>{fmtDate(u.created_at, false)}</td>
|
<td>{fmtDate(u.created_at, false)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="row" style={{ flexWrap: "nowrap" }}>
|
<div className="row" style={{ flexWrap: "nowrap" }}>
|
||||||
@@ -70,7 +85,7 @@ export function Users() {
|
|||||||
{u.disabled ? t("users.enable") : t("users.disable")}
|
{u.disabled ? t("users.enable") : t("users.disable")}
|
||||||
</button>
|
</button>
|
||||||
{u.id !== me?.id && (
|
{u.id !== me?.id && (
|
||||||
<button className="small danger" onClick={() => confirm(t("users.deleteConfirm", { name: u.username })) && run(() => del(`/users/${u.id}`))}>
|
<button className="small danger" onClick={() => void confirmDialog({ message: t("users.deleteConfirm", { name: u.username }), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && run(() => del(`/users/${u.id}`)))}>
|
||||||
{t("common.delete")}
|
{t("common.delete")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// In-app dialogs and toasts instead of the browser's alert/confirm/prompt.
|
||||||
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
|
||||||
|
interface ConfirmOpts {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
/** Destructive action: red button. */
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PromptOpts {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
password?: boolean;
|
||||||
|
confirmLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dialog =
|
||||||
|
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
|
||||||
|
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
kind: "error" | "info" | "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
let dialogs: Dialog[] = [];
|
||||||
|
let toasts: Toast[] = [];
|
||||||
|
let seq = 0;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const emit = () => listeners.forEach((l) => l());
|
||||||
|
const subscribe = (l: () => void) => (listeners.add(l), () => listeners.delete(l));
|
||||||
|
|
||||||
|
export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
|
||||||
|
const o = typeof opts === "string" ? { message: opts } : opts;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialogs = [...dialogs, { kind: "confirm", id: ++seq, resolve, ...o }];
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
|
||||||
|
const o = typeof opts === "string" ? { message: opts } : opts;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialogs = [...dialogs, { kind: "prompt", id: ++seq, resolve, ...o }];
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toast(message: unknown, kind: Toast["kind"] = "error") {
|
||||||
|
const text = message instanceof Error ? message.message : typeof message === "string" ? message : String((message as any)?.message ?? message);
|
||||||
|
const id = ++seq;
|
||||||
|
toasts = [...toasts, { id, message: text, kind }];
|
||||||
|
emit();
|
||||||
|
setTimeout(() => {
|
||||||
|
toasts = toasts.filter((x) => x.id !== id);
|
||||||
|
emit();
|
||||||
|
}, kind === "error" ? 7000 : 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(d: Dialog, value: any) {
|
||||||
|
dialogs = dialogs.filter((x) => x.id !== d.id);
|
||||||
|
emit();
|
||||||
|
(d.resolve as (v: any) => void)(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogView({ d }: { d: Dialog }) {
|
||||||
|
const [value, setValue] = useState(d.kind === "prompt" ? d.defaultValue ?? "" : "");
|
||||||
|
const input = useRef<HTMLInputElement>(null);
|
||||||
|
const ok = useRef<HTMLButtonElement>(null);
|
||||||
|
const cancelValue = d.kind === "confirm" ? false : null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (d.kind === "prompt") {
|
||||||
|
input.current?.focus();
|
||||||
|
input.current?.select();
|
||||||
|
} else ok.current?.focus();
|
||||||
|
const h = (e: KeyboardEvent) => e.key === "Escape" && close(d, cancelValue);
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
const submit = () => close(d, d.kind === "confirm" ? true : value);
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={() => close(d, cancelValue)}>
|
||||||
|
<form
|
||||||
|
className="modal dialog"
|
||||||
|
role={d.kind === "confirm" ? "alertdialog" : "dialog"}
|
||||||
|
aria-label={d.title ?? d.message}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={(e) => (e.preventDefault(), submit())}
|
||||||
|
>
|
||||||
|
{d.title && <h3>{d.title}</h3>}
|
||||||
|
<p className="dialog-message">{d.message}</p>
|
||||||
|
{d.kind === "prompt" && (
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
type={d.password ? "password" : "text"}
|
||||||
|
value={value}
|
||||||
|
placeholder={d.placeholder}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
autoComplete={d.password ? "new-password" : "off"}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="row dialog-actions">
|
||||||
|
<button type="button" onClick={() => close(d, cancelValue)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
|
||||||
|
{d.confirmLabel ?? t("common.ok")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders open dialogs and toasts; mounted once in App. */
|
||||||
|
export function DialogHost() {
|
||||||
|
const state = useSyncExternalStore(subscribe, () => dialogs);
|
||||||
|
const ts = useSyncExternalStore(subscribe, () => toasts);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{state.map((d) => (
|
||||||
|
<DialogView key={d.id} d={d} />
|
||||||
|
))}
|
||||||
|
<div className="toasts" role="status" aria-live="polite">
|
||||||
|
{ts.map((x) => (
|
||||||
|
<div key={x.id} className={`toast ${x.kind}`} onClick={() => ((toasts = toasts.filter((y) => y.id !== x.id)), emit())}>
|
||||||
|
{x.kind === "error" ? "⚠ " : x.kind === "success" ? "✓ " : ""}
|
||||||
|
{x.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+75
-2
@@ -125,6 +125,74 @@ export const de: Messages = {
|
|||||||
"git.noDiff": "Keine Unterschiede.",
|
"git.noDiff": "Keine Unterschiede.",
|
||||||
"composer.attachedFiles": "Angehängte Dateien (im Projekt, mit deinen Tools lesen):",
|
"composer.attachedFiles": "Angehängte Dateien (im Projekt, mit deinen Tools lesen):",
|
||||||
"composer.uploading": "Lade {n} hoch…",
|
"composer.uploading": "Lade {n} hoch…",
|
||||||
|
"usage.title": "Modelle und Verbrauch dieser Session",
|
||||||
|
"usage.period": "Zeitraum",
|
||||||
|
"usage.current": "aktuell",
|
||||||
|
"usage.model": "Modell",
|
||||||
|
"usage.prices": "Preise pro 1 Mio. Tokens",
|
||||||
|
"usage.usage": "Nutzung",
|
||||||
|
"usage.speed": "Geschwindigkeit",
|
||||||
|
"usage.cacheWrite": "Cache geschrieben",
|
||||||
|
"usage.contextNow": "Kontext jetzt",
|
||||||
|
"usage.totalTime": "Gesamtzeit",
|
||||||
|
"usage.inSession": "In dieser Session",
|
||||||
|
"usage.providerMonth": "Provider diesen Monat",
|
||||||
|
"usage.byAgent": "Nach Agent und Modell",
|
||||||
|
"preview.page": "Seite {n} / {total}",
|
||||||
|
"preview.openBrowser": "Im Browser öffnen",
|
||||||
|
"preview.moreRows": "{n} weitere Zeilen nicht angezeigt",
|
||||||
|
"preview.moduleHint": "Tracker-Modul – wird auf dem Server zu Audio gerendert (der erste Start kann kurz dauern).",
|
||||||
|
"activity.phrases": "Grübelt|Tüftelt|Werkelt|Brütet|Sinniert|Knobelt|Schmiedet|Rätselt|Zaubert|Bastelt|Puzzelt|Feilt|Köchelt|Hirnt|Fummelt|Sortiert Gedanken|Dreht Schrauben|Entwirrt|Klamüsert|Ausbaldowert|Fuchst sich rein|Wuselt|Tüftelt weiter|Kalibriert",
|
||||||
|
"activity.prefill": "Prefill",
|
||||||
|
"activity.generated": "generiert",
|
||||||
|
"activity.prefilling": "liest Prompt…",
|
||||||
|
"mcp.users": "Erlaubte Benutzer",
|
||||||
|
"mcp.usersPlaceholder": "nur Admins",
|
||||||
|
"mcp.usersHint": "Benutzernamen mit Komma getrennt oder * für alle. Admins haben immer Zugriff.",
|
||||||
|
"settings.budgetAction": "Wenn ein Budget aufgebraucht ist",
|
||||||
|
"settings.budgetWarn": "Nur warnen",
|
||||||
|
"settings.budgetBlock": "Neue Anfragen blockieren",
|
||||||
|
"settings.sandbox": "Shell-Sandbox",
|
||||||
|
"settings.sandboxAuto": "Benutzer (nicht Admins)",
|
||||||
|
"settings.sandboxAlways": "Alle",
|
||||||
|
"settings.sandboxOff": "Aus",
|
||||||
|
"settings.sandboxHint": "Shell-Befehle und Git laufen in einer bubblewrap-Sandbox, in der nur die Ordner des Benutzers beschreibbar sind.",
|
||||||
|
"users.quota": "Monatskontingent",
|
||||||
|
"users.quotaHint": "Monatliches Kostenlimit (leer = unbegrenzt)",
|
||||||
|
"users.quotaPrompt": "Monatliches Kostenlimit für {name} (leer = unbegrenzt):",
|
||||||
|
"settings.tab.audit": "Audit",
|
||||||
|
"audit.title": "Audit-Log",
|
||||||
|
"audit.all": "Alle Aktionen",
|
||||||
|
"audit.time": "Zeit",
|
||||||
|
"audit.action": "Aktion",
|
||||||
|
"audit.detail": "Details",
|
||||||
|
"audit.backup": "Sicherung",
|
||||||
|
"audit.backupHint": "ZIP mit der Konfiguration und einer konsistenten Kopie der Datenbank (Sessions, Benutzer, Statistik). Als ${VAR} referenzierte Secrets sind nicht enthalten.",
|
||||||
|
"audit.download": "Sicherung herunterladen",
|
||||||
|
"craft.mode": "Craft-Modus",
|
||||||
|
"craft.modeHint": "Clean Room: nur, was ein Benutzer sieht. Deep: auch Seitenquelltext und Programme (Ghidra) – zum Nachbilden, nicht zum Kopieren.",
|
||||||
|
"craft.cleanRoom": "Clean Room",
|
||||||
|
"craft.deep": "Deep",
|
||||||
|
"settings.tab.tools": "Werkzeuge",
|
||||||
|
"tools.installed": "installiert",
|
||||||
|
"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.",
|
||||||
|
"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",
|
||||||
|
"rewind.willDelete": "wird gelöscht (neu)",
|
||||||
|
"rewind.bashNote": "Änderungen durch Shell-Befehle (z. B. installierte Pakete) werden nicht zurückgesetzt.",
|
||||||
|
"rewind.confirm": "Zurückspulen",
|
||||||
|
"files.discard": "Verwerfen",
|
||||||
|
"files.overwrite": "Überschreiben",
|
||||||
|
"shells.title": "Hintergrund-Shells",
|
||||||
|
"shells.chip": "{n}/{total} Shells",
|
||||||
|
"shells.running": "läuft",
|
||||||
|
"shells.exited": "beendet ({code})",
|
||||||
|
"shells.stop": "Stoppen",
|
||||||
|
"shells.noOutput": "(noch keine Ausgabe)",
|
||||||
"login.prompt": "Melde dich an, um fortzufahren.",
|
"login.prompt": "Melde dich an, um fortzufahren.",
|
||||||
"login.invalid": "Ungültiges Token.",
|
"login.invalid": "Ungültiges Token.",
|
||||||
"login.submit": "Anmelden",
|
"login.submit": "Anmelden",
|
||||||
@@ -173,7 +241,6 @@ export const de: Messages = {
|
|||||||
"chat.statsHint": "Tokens und Kosten dieser Session (Klick für Details)",
|
"chat.statsHint": "Tokens und Kosten dieser Session (Klick für Details)",
|
||||||
"chat.renameHint": "Doppelklick zum Umbenennen",
|
"chat.renameHint": "Doppelklick zum Umbenennen",
|
||||||
"chat.rewind": "Ab hier bearbeiten und neu senden",
|
"chat.rewind": "Ab hier bearbeiten und neu senden",
|
||||||
"chat.rewindConfirm": "Diese Nachricht und alles danach entfernen? Dateiänderungen werden nicht rückgängig gemacht.",
|
|
||||||
"chat.delete": "Session löschen",
|
"chat.delete": "Session löschen",
|
||||||
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
||||||
|
|
||||||
@@ -231,7 +298,12 @@ export const de: Messages = {
|
|||||||
"settings.interface": "Oberfläche (dieses Gerät)",
|
"settings.interface": "Oberfläche (dieses Gerät)",
|
||||||
"settings.language": "Sprache",
|
"settings.language": "Sprache",
|
||||||
"settings.theme": "Design",
|
"settings.theme": "Design",
|
||||||
"settings.themeSystem": "System",
|
"settings.themeMode": "Modus",
|
||||||
|
"settings.themeAuto": "Auto",
|
||||||
|
"settings.themeLight": "Hell",
|
||||||
|
"settings.themeDark": "Dunkel",
|
||||||
|
"settings.themeSource": "Farben: {source} ({license})",
|
||||||
|
"settings.themeToggle": "Hell/Dunkel/Auto umschalten",
|
||||||
"settings.notifications": "Benachrichtigungen",
|
"settings.notifications": "Benachrichtigungen",
|
||||||
"settings.notificationsEnable": "Aktivieren",
|
"settings.notificationsEnable": "Aktivieren",
|
||||||
"settings.notificationsOn": "Aktiv",
|
"settings.notificationsOn": "Aktiv",
|
||||||
@@ -353,6 +425,7 @@ export const de: Messages = {
|
|||||||
"stats.other": "Sonstige",
|
"stats.other": "Sonstige",
|
||||||
"stats.noData": "Keine Anfragen in diesem Zeitraum.",
|
"stats.noData": "Keine Anfragen in diesem Zeitraum.",
|
||||||
|
|
||||||
|
"common.ok": "OK",
|
||||||
"common.cancel": "Abbrechen",
|
"common.cancel": "Abbrechen",
|
||||||
"nav.logout": "Abmelden",
|
"nav.logout": "Abmelden",
|
||||||
"login.username": "Benutzername",
|
"login.username": "Benutzername",
|
||||||
|
|||||||
+75
-2
@@ -124,6 +124,74 @@ export const en = {
|
|||||||
"git.noDiff": "No differences.",
|
"git.noDiff": "No differences.",
|
||||||
"composer.attachedFiles": "Attached files (in the project, read them with your tools):",
|
"composer.attachedFiles": "Attached files (in the project, read them with your tools):",
|
||||||
"composer.uploading": "Uploading {n}…",
|
"composer.uploading": "Uploading {n}…",
|
||||||
|
"usage.title": "Models and usage in this session",
|
||||||
|
"usage.period": "Period",
|
||||||
|
"usage.current": "current",
|
||||||
|
"usage.model": "Model",
|
||||||
|
"usage.prices": "Prices per 1M tokens",
|
||||||
|
"usage.usage": "Usage",
|
||||||
|
"usage.speed": "Speed",
|
||||||
|
"usage.cacheWrite": "Cache write",
|
||||||
|
"usage.contextNow": "Context now",
|
||||||
|
"usage.totalTime": "Total time",
|
||||||
|
"usage.inSession": "In this session",
|
||||||
|
"usage.providerMonth": "Provider this month",
|
||||||
|
"usage.byAgent": "By agent and model",
|
||||||
|
"preview.page": "Page {n} / {total}",
|
||||||
|
"preview.openBrowser": "Open in browser",
|
||||||
|
"preview.moreRows": "{n} more rows not shown",
|
||||||
|
"preview.moduleHint": "Tracker module – rendered to audio on the server (first playback may take a moment).",
|
||||||
|
"activity.phrases": "Pondering|Tinkering|Noodling|Brewing|Conjuring|Mulling|Crafting|Percolating|Ruminating|Whittling|Forging|Musing|Scheming|Untangling|Cogitating|Simmering|Wrangling|Hatching|Spelunking|Finagling|Concocting|Calibrating|Divining|Marinating",
|
||||||
|
"activity.prefill": "prefill",
|
||||||
|
"activity.generated": "generated",
|
||||||
|
"activity.prefilling": "reading prompt…",
|
||||||
|
"mcp.users": "Allowed users",
|
||||||
|
"mcp.usersPlaceholder": "admins only",
|
||||||
|
"mcp.usersHint": "Usernames separated by commas, or * for everybody. Admins always have access.",
|
||||||
|
"settings.budgetAction": "When a budget is used up",
|
||||||
|
"settings.budgetWarn": "Warn only",
|
||||||
|
"settings.budgetBlock": "Block new requests",
|
||||||
|
"settings.sandbox": "Shell sandbox",
|
||||||
|
"settings.sandboxAuto": "Users (not admins)",
|
||||||
|
"settings.sandboxAlways": "Everybody",
|
||||||
|
"settings.sandboxOff": "Off",
|
||||||
|
"settings.sandboxHint": "Shell commands and git run in a bubblewrap sandbox with only the user's folders writable.",
|
||||||
|
"users.quota": "Monthly quota",
|
||||||
|
"users.quotaHint": "Monthly cost limit (empty = unlimited)",
|
||||||
|
"users.quotaPrompt": "Monthly cost limit for {name} (empty = unlimited):",
|
||||||
|
"settings.tab.audit": "Audit",
|
||||||
|
"audit.title": "Audit log",
|
||||||
|
"audit.all": "All actions",
|
||||||
|
"audit.time": "Time",
|
||||||
|
"audit.action": "Action",
|
||||||
|
"audit.detail": "Details",
|
||||||
|
"audit.backup": "Backup",
|
||||||
|
"audit.backupHint": "ZIP with the configuration and a consistent copy of the database (sessions, users, statistics). Secrets referenced as ${VAR} are not included.",
|
||||||
|
"audit.download": "Download backup",
|
||||||
|
"craft.mode": "Craft mode",
|
||||||
|
"craft.modeHint": "Clean room: only what a user sees. Deep: also page sources and programs (Ghidra) – for re-creating, not copying.",
|
||||||
|
"craft.cleanRoom": "Clean room",
|
||||||
|
"craft.deep": "Deep",
|
||||||
|
"settings.tab.tools": "Tools",
|
||||||
|
"tools.installed": "installed",
|
||||||
|
"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.",
|
||||||
|
"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",
|
||||||
|
"rewind.willDelete": "will be deleted (new)",
|
||||||
|
"rewind.bashNote": "Changes made by shell commands (e.g. installed packages) are not restored.",
|
||||||
|
"rewind.confirm": "Rewind",
|
||||||
|
"files.discard": "Discard",
|
||||||
|
"files.overwrite": "Overwrite",
|
||||||
|
"shells.title": "Background shells",
|
||||||
|
"shells.chip": "{n}/{total} shells",
|
||||||
|
"shells.running": "running",
|
||||||
|
"shells.exited": "exited ({code})",
|
||||||
|
"shells.stop": "Stop",
|
||||||
|
"shells.noOutput": "(no output yet)",
|
||||||
"login.prompt": "Sign in to continue.",
|
"login.prompt": "Sign in to continue.",
|
||||||
"login.invalid": "Invalid token.",
|
"login.invalid": "Invalid token.",
|
||||||
"login.submit": "Sign in",
|
"login.submit": "Sign in",
|
||||||
@@ -172,7 +240,6 @@ export const en = {
|
|||||||
"chat.statsHint": "Tokens and cost of this session (click for details)",
|
"chat.statsHint": "Tokens and cost of this session (click for details)",
|
||||||
"chat.renameHint": "Double-click to rename",
|
"chat.renameHint": "Double-click to rename",
|
||||||
"chat.rewind": "Edit and resend from here",
|
"chat.rewind": "Edit and resend from here",
|
||||||
"chat.rewindConfirm": "Remove this message and everything after it? File changes are not undone.",
|
|
||||||
"chat.delete": "Delete session",
|
"chat.delete": "Delete session",
|
||||||
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
||||||
|
|
||||||
@@ -230,7 +297,12 @@ export const en = {
|
|||||||
"settings.interface": "Interface (this device)",
|
"settings.interface": "Interface (this device)",
|
||||||
"settings.language": "Language",
|
"settings.language": "Language",
|
||||||
"settings.theme": "Theme",
|
"settings.theme": "Theme",
|
||||||
"settings.themeSystem": "System",
|
"settings.themeMode": "Mode",
|
||||||
|
"settings.themeAuto": "Auto",
|
||||||
|
"settings.themeLight": "Light",
|
||||||
|
"settings.themeDark": "Dark",
|
||||||
|
"settings.themeSource": "Colours: {source} ({license})",
|
||||||
|
"settings.themeToggle": "Switch light/dark/auto",
|
||||||
"settings.notifications": "Notifications",
|
"settings.notifications": "Notifications",
|
||||||
"settings.notificationsEnable": "Enable",
|
"settings.notificationsEnable": "Enable",
|
||||||
"settings.notificationsOn": "Enabled",
|
"settings.notificationsOn": "Enabled",
|
||||||
@@ -352,6 +424,7 @@ export const en = {
|
|||||||
"stats.other": "Other",
|
"stats.other": "Other",
|
||||||
"stats.noData": "No requests in this period.",
|
"stats.noData": "No requests in this period.",
|
||||||
|
|
||||||
|
"common.ok": "OK",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
"nav.logout": "Sign out",
|
"nav.logout": "Sign out",
|
||||||
"login.username": "Username",
|
"login.username": "Username",
|
||||||
|
|||||||
+14
-1
@@ -39,6 +39,8 @@ export interface State {
|
|||||||
questions: QuestionRequest[];
|
questions: QuestionRequest[];
|
||||||
errors: Record<string, SessionError[]>;
|
errors: Record<string, SessionError[]>;
|
||||||
mcp: Record<string, McpServerStatus>;
|
mcp: Record<string, McpServerStatus>;
|
||||||
|
/** The model request currently running per session (activity line). */
|
||||||
|
steps: Record<string, { messageId: string; startedAt: number; promptTokens: number; estimated: boolean } | undefined>;
|
||||||
/** Messages waiting for a busy session. */
|
/** Messages waiting for a busy session. */
|
||||||
queue: Record<string, QueueItem[]>;
|
queue: Record<string, QueueItem[]>;
|
||||||
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
|
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
|
||||||
@@ -60,6 +62,7 @@ let state: State = {
|
|||||||
errors: {},
|
errors: {},
|
||||||
mcp: {},
|
mcp: {},
|
||||||
queue: {},
|
queue: {},
|
||||||
|
steps: {},
|
||||||
waiting: {},
|
waiting: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,6 +117,7 @@ export function applyEvent(ev: ServerEvent) {
|
|||||||
setState((s) => ({
|
setState((s) => ({
|
||||||
busy: { ...s.busy, [ev.session_id]: ev.busy },
|
busy: { ...s.busy, [ev.session_id]: ev.busy },
|
||||||
drafts: ev.busy ? s.drafts : { ...s.drafts, [ev.session_id]: undefined },
|
drafts: ev.busy ? s.drafts : { ...s.drafts, [ev.session_id]: undefined },
|
||||||
|
steps: ev.busy ? s.steps : { ...s.steps, [ev.session_id]: undefined },
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
case "message_added":
|
case "message_added":
|
||||||
@@ -170,6 +174,11 @@ export function applyEvent(ev: ServerEvent) {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
case "step_started":
|
||||||
|
setState((s) => ({
|
||||||
|
steps: { ...s.steps, [ev.session_id]: { messageId: ev.message_id, startedAt: ev.started_at, promptTokens: ev.prompt_tokens, estimated: ev.estimated } },
|
||||||
|
}));
|
||||||
|
break;
|
||||||
case "queue_updated":
|
case "queue_updated":
|
||||||
setState((s) => ({ queue: { ...s.queue, [ev.session_id]: ev.items } }));
|
setState((s) => ({ queue: { ...s.queue, [ev.session_id]: ev.items } }));
|
||||||
break;
|
break;
|
||||||
@@ -207,7 +216,7 @@ export async function loadSessions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSession(id: string) {
|
export async function loadSession(id: string) {
|
||||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[] }>(`/sessions/${id}`);
|
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any }>(`/sessions/${id}`);
|
||||||
setState((s) => ({
|
setState((s) => ({
|
||||||
...upsertSession(s, data.session),
|
...upsertSession(s, data.session),
|
||||||
sessionById: { ...s.sessionById, [id]: data.session },
|
sessionById: { ...s.sessionById, [id]: data.session },
|
||||||
@@ -215,6 +224,10 @@ export async function loadSession(id: string) {
|
|||||||
busy: { ...s.busy, [id]: data.busy },
|
busy: { ...s.busy, [id]: data.busy },
|
||||||
readOnly: { ...s.readOnly, [id]: !data.can_write },
|
readOnly: { ...s.readOnly, [id]: !data.can_write },
|
||||||
queue: { ...s.queue, [id]: data.queue ?? [] },
|
queue: { ...s.queue, [id]: data.queue ?? [] },
|
||||||
|
steps: {
|
||||||
|
...s.steps,
|
||||||
|
[id]: data.step ? { messageId: data.step.message_id, startedAt: data.step.started_at, promptTokens: data.step.prompt_tokens, estimated: data.step.estimated } : s.steps[id],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1826,3 +1826,267 @@ button.entry {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- images in chat ------------------------------------------------------------ */
|
||||||
|
.markdown img.inline-image,
|
||||||
|
.svg-block img,
|
||||||
|
.image-result img {
|
||||||
|
max-width: min(100%, 640px);
|
||||||
|
max-height: 480px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
margin: 6px 0;
|
||||||
|
background: repeating-conic-gradient(var(--bg1) 0% 25%, var(--bg0) 0% 50%) 50% / 16px 16px;
|
||||||
|
}
|
||||||
|
.svg-block {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.image-refs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.image-refs img {
|
||||||
|
height: 140px;
|
||||||
|
max-width: 260px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
background: repeating-conic-gradient(var(--bg1) 0% 25%, var(--bg0) 0% 50%) 50% / 16px 16px;
|
||||||
|
}
|
||||||
|
.image-result {
|
||||||
|
padding: 0 10px 8px;
|
||||||
|
}
|
||||||
|
.tool-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
/* --- usage overlay ------------------------------------------------------------- */
|
||||||
|
.modal.wide {
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
.kv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 6px 20px;
|
||||||
|
}
|
||||||
|
.kv-title {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 4px 0 2px;
|
||||||
|
}
|
||||||
|
.kv {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px dashed var(--bg2);
|
||||||
|
}
|
||||||
|
.kv span {
|
||||||
|
color: var(--fg3);
|
||||||
|
}
|
||||||
|
.kv b {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg0);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- file previews ------------------------------------------------------------- */
|
||||||
|
.pdf-preview {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.pdf-nav {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--bg2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pdf-preview .image-preview img {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.doc-text {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sheet-tabs {
|
||||||
|
margin: 6px 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
table.grid.sheet td,
|
||||||
|
table.grid.sheet th {
|
||||||
|
white-space: nowrap;
|
||||||
|
border-right: 1px solid var(--bg2);
|
||||||
|
}
|
||||||
|
.audio-preview {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.audio-preview audio {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
}
|
||||||
|
.model-viewer {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.model-canvas {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 300px;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.model-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-top: 1px solid var(--bg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star {
|
||||||
|
color: var(--accent2);
|
||||||
|
font-size: 15px;
|
||||||
|
width: 1em;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.activity-text {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
.activity-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.audit-warn td {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
a.button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tabs button.active.deep {
|
||||||
|
background: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewind-files {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- in-app dialogs and toasts --------------------------------------------------- */
|
||||||
|
.modal.dialog {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.dialog-message {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--fg1);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.dialog-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
button.danger-solid {
|
||||||
|
background: var(--red);
|
||||||
|
border-color: var(--red);
|
||||||
|
color: var(--bg0);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.toasts {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 50;
|
||||||
|
max-width: min(420px, calc(100vw - 32px));
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
border-left: 4px solid var(--blue);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
animation: toast-in 0.18s ease-out;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.toast.error {
|
||||||
|
border-left-color: var(--red);
|
||||||
|
}
|
||||||
|
.toast.success {
|
||||||
|
border-left-color: var(--green);
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
transform: translateY(8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-output {
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
max-height: 55vh;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.segmented button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
.sidebar-user .theme-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 15px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.form .stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
+57
-18
@@ -1,38 +1,77 @@
|
|||||||
import "./themes";
|
import "./themes";
|
||||||
|
|
||||||
const KEY = "codecraft.theme";
|
const KEY = "codecraft.theme";
|
||||||
|
const MODE_KEY = "codecraft.themeMode";
|
||||||
|
|
||||||
/** Available themes; "system" follows the OS light/dark preference. */
|
/**
|
||||||
|
* Base themes. Each has a dark and a light variant (CSS files
|
||||||
|
* `themes/<id>-dark.css` / `<id>-light.css`); the mode picks the variant.
|
||||||
|
*/
|
||||||
export const THEMES = [
|
export const THEMES = [
|
||||||
{ id: "system", name: "System", dark: true },
|
{ id: "gruvbox", name: "Gruvbox", source: "morhetz/gruvbox", license: "MIT" },
|
||||||
{ id: "gruvbox-dark", name: "Gruvbox Dark", dark: true },
|
{ id: "ash", name: "Ash", source: "zink/trilium-themes", license: "MIT" },
|
||||||
{ id: "gruvbox-light", name: "Gruvbox Light", dark: false },
|
{ id: "harbor", name: "Harbor", source: "HANCORE-linux/omarchy-harbor-theme", license: "MIT" },
|
||||||
|
{ id: "coffee", name: "Coffee", source: "megabyte0x/omarchy-coffee-theme", license: "MIT" },
|
||||||
|
{ id: "solarized", name: "Solarized", source: "Ethan Schoonover", license: "MIT" },
|
||||||
|
{ id: "tokyonight", name: "Tokyo Night", source: "folke/tokyonight.nvim", license: "Apache-2.0" },
|
||||||
|
{ id: "nord", name: "Nord", source: "nordtheme/nord", license: "MIT" },
|
||||||
|
{ id: "vscode", name: "VS Code", source: "microsoft/vscode", license: "MIT" },
|
||||||
|
{ id: "github", name: "GitHub", source: "primer/github-vscode-theme", license: "MIT" },
|
||||||
|
{ id: "catppuccin", name: "Catppuccin", source: "catppuccin/catppuccin", license: "MIT" },
|
||||||
|
{ id: "one", name: "One Dark / Light", source: "atom/atom", license: "MIT" },
|
||||||
|
{ id: "dracula", name: "Dracula / Alucard", source: "dracula/dracula-theme", license: "MIT" },
|
||||||
|
{ id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" },
|
||||||
|
{ id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" },
|
||||||
|
{ id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Theme = (typeof THEMES)[number]["id"];
|
export type Theme = (typeof THEMES)[number]["id"];
|
||||||
|
export type ThemeMode = "auto" | "light" | "dark";
|
||||||
/** Themes used for "system" in dark and light OS mode. */
|
export const THEME_MODES: ThemeMode[] = ["auto", "light", "dark"];
|
||||||
const SYSTEM_DARK = "gruvbox-dark";
|
|
||||||
const SYSTEM_LIGHT = "gruvbox-light";
|
|
||||||
|
|
||||||
export function getTheme(): Theme {
|
export function getTheme(): Theme {
|
||||||
const saved = localStorage.getItem(KEY);
|
const saved = localStorage.getItem(KEY) ?? "";
|
||||||
// Migrate the old values "dark"/"light".
|
// Migrate old values like "gruvbox-dark", "dark", "system".
|
||||||
if (saved === "dark") return "gruvbox-dark";
|
const base = saved.replace(/-(dark|light)$/, "");
|
||||||
if (saved === "light") return "gruvbox-light";
|
return THEMES.some((t) => t.id === base) ? (base as Theme) : "gruvbox";
|
||||||
return THEMES.some((t) => t.id === saved) ? (saved as Theme) : "system";
|
}
|
||||||
|
|
||||||
|
export function getThemeMode(): ThemeMode {
|
||||||
|
const saved = localStorage.getItem(MODE_KEY);
|
||||||
|
if (saved === "light" || saved === "dark" || saved === "auto") return saved;
|
||||||
|
// Migrate the old combined setting.
|
||||||
|
const old = localStorage.getItem(KEY) ?? "";
|
||||||
|
if (old.endsWith("dark")) return "dark";
|
||||||
|
if (old.endsWith("light")) return "light";
|
||||||
|
return "auto";
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = matchMedia("(prefers-color-scheme: dark)");
|
const media = matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
export function applyTheme(theme: Theme = getTheme()) {
|
export function onThemeChange(fn: () => void) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => void listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme = getTheme(), mode: ThemeMode = getThemeMode()) {
|
||||||
localStorage.setItem(KEY, theme);
|
localStorage.setItem(KEY, theme);
|
||||||
const resolved = theme === "system" ? (media.matches ? SYSTEM_DARK : SYSTEM_LIGHT) : theme;
|
localStorage.setItem(MODE_KEY, mode);
|
||||||
document.documentElement.dataset.theme = resolved;
|
const dark = mode === "auto" ? media.matches : mode === "dark";
|
||||||
const color = getComputedStyle(document.documentElement).getPropertyValue("--theme-color").trim();
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = `${theme}-${dark ? "dark" : "light"}`;
|
||||||
|
root.dataset.mode = dark ? "dark" : "light";
|
||||||
|
const color = getComputedStyle(root).getPropertyValue("--theme-color").trim();
|
||||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", color || "#282828");
|
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", color || "#282828");
|
||||||
|
listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cycle auto → light → dark (quick switcher). */
|
||||||
|
export function cycleThemeMode() {
|
||||||
|
const modes = THEME_MODES;
|
||||||
|
applyTheme(getTheme(), modes[(modes.indexOf(getThemeMode()) + 1) % modes.length]);
|
||||||
}
|
}
|
||||||
|
|
||||||
media.addEventListener("change", () => {
|
media.addEventListener("change", () => {
|
||||||
if (getTheme() === "system") applyTheme("system");
|
if (getThemeMode() === "auto") applyTheme();
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Richard Zink
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Ash Dark
|
||||||
|
* Original Ash palette (Gruvbox-derived) from zink/trilium-themes (MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="ash-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #1c1b1f;
|
||||||
|
|
||||||
|
--bg0: #1c1b1f;
|
||||||
|
--bg1: #232226;
|
||||||
|
--bg2: #29282c;
|
||||||
|
--bg3: #3a383d;
|
||||||
|
--fg0: #f3ede1;
|
||||||
|
--fg1: #e6dfd1;
|
||||||
|
--fg2: #cfc7b8;
|
||||||
|
--fg3: #b0a89b;
|
||||||
|
--muted: #999187;
|
||||||
|
|
||||||
|
--accent: #e0914f;
|
||||||
|
--accent2: #d6b46a;
|
||||||
|
--on-accent: #1c1b1f;
|
||||||
|
--red: #c15c46;
|
||||||
|
--green: #8f9a5c;
|
||||||
|
--aqua: #8aa89a;
|
||||||
|
--blue: #7c9aa1;
|
||||||
|
--purple: #b47e99;
|
||||||
|
|
||||||
|
--diff-add: rgba(143, 154, 92, 0.16);
|
||||||
|
--diff-del: rgba(193, 92, 70, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #c15c46;
|
||||||
|
--syntax-string: #8f9a5c;
|
||||||
|
--syntax-number: #a585ac;
|
||||||
|
--syntax-comment: #6d6a63;
|
||||||
|
--syntax-function: #7c9aa1;
|
||||||
|
--syntax-type: #d6b46a;
|
||||||
|
--syntax-variable: #e6dfd1;
|
||||||
|
--syntax-meta: #e0914f;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Ash Light
|
||||||
|
* Original Ash palette (Gruvbox-derived) from zink/trilium-themes (MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="ash-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #e9e3d5;
|
||||||
|
|
||||||
|
--bg0: #e9e3d5;
|
||||||
|
--bg1: #dcd5c3;
|
||||||
|
--bg2: #d2cab7;
|
||||||
|
--bg3: #c9c1ad;
|
||||||
|
--fg0: #1e1c18;
|
||||||
|
--fg1: #2a2822;
|
||||||
|
--fg2: #433f37;
|
||||||
|
--fg3: #5a554b;
|
||||||
|
--muted: #6f685c;
|
||||||
|
|
||||||
|
--accent: #a85f1d;
|
||||||
|
--accent2: #8a6a24;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #96392a;
|
||||||
|
--green: #5c6631;
|
||||||
|
--aqua: #3d6b72;
|
||||||
|
--blue: #3d6b72;
|
||||||
|
--purple: #7c4a66;
|
||||||
|
|
||||||
|
--diff-add: rgba(92, 102, 49, 0.16);
|
||||||
|
--diff-del: rgba(150, 57, 42, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #96392a;
|
||||||
|
--syntax-string: #5c6631;
|
||||||
|
--syntax-number: #6a4c72;
|
||||||
|
--syntax-comment: #8a8274;
|
||||||
|
--syntax-function: #3d6b72;
|
||||||
|
--syntax-type: #8a6a24;
|
||||||
|
--syntax-variable: #2a2822;
|
||||||
|
--syntax-meta: #a85f1d;
|
||||||
|
}
|
||||||
@@ -1,3 +1,40 @@
|
|||||||
|
/* Chart palettes per light/dark mode (validated for colour-vision deficiencies). */
|
||||||
|
:root,
|
||||||
|
:root[data-mode="dark"] {
|
||||||
|
--series-1: #3987e5;
|
||||||
|
--series-2: #d95926;
|
||||||
|
--series-3: #199e70;
|
||||||
|
--series-4: #c98500;
|
||||||
|
--series-5: #d55181;
|
||||||
|
--series-6: #008300;
|
||||||
|
--series-7: #9085e9;
|
||||||
|
--series-8: #e66767;
|
||||||
|
--seq-0: var(--bg1);
|
||||||
|
--seq-1: #104281;
|
||||||
|
--seq-2: #184f95;
|
||||||
|
--seq-3: #256abf;
|
||||||
|
--seq-4: #3987e5;
|
||||||
|
--seq-5: #6da7ec;
|
||||||
|
--seq-6: #9ec5f4;
|
||||||
|
}
|
||||||
|
:root[data-mode="light"] {
|
||||||
|
--series-1: #2a78d6;
|
||||||
|
--series-2: #eb6834;
|
||||||
|
--series-3: #1baf7a;
|
||||||
|
--series-4: #eda100;
|
||||||
|
--series-5: #e87ba4;
|
||||||
|
--series-6: #008300;
|
||||||
|
--series-7: #6250d6;
|
||||||
|
--series-8: #e34948;
|
||||||
|
--seq-0: var(--bg1);
|
||||||
|
--seq-1: #cde2fb;
|
||||||
|
--seq-2: #9ec5f4;
|
||||||
|
--seq-3: #6da7ec;
|
||||||
|
--seq-4: #3987e5;
|
||||||
|
--seq-5: #256abf;
|
||||||
|
--seq-6: #184f95;
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme-independent tokens. */
|
/* Theme-independent tokens. */
|
||||||
:root {
|
:root {
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Catppuccin Mocha
|
||||||
|
* Palette: Catppuccin Mocha (catppuccin/catppuccin, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="catppuccin-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #1e1e2e;
|
||||||
|
|
||||||
|
--bg0: #1e1e2e;
|
||||||
|
--bg1: #181825;
|
||||||
|
--bg2: #313244;
|
||||||
|
--bg3: #45475a;
|
||||||
|
--fg0: #cdd6f4;
|
||||||
|
--fg1: #cdd6f4;
|
||||||
|
--fg2: #bac2de;
|
||||||
|
--fg3: #a6adc8;
|
||||||
|
--muted: #9399b2;
|
||||||
|
|
||||||
|
--accent: #cba6f7;
|
||||||
|
--accent2: #fab387;
|
||||||
|
--on-accent: #1e1e2e;
|
||||||
|
--red: #f38ba8;
|
||||||
|
--green: #a6e3a1;
|
||||||
|
--aqua: #94e2d5;
|
||||||
|
--blue: #89b4fa;
|
||||||
|
--purple: #f5c2e7;
|
||||||
|
|
||||||
|
--diff-add: rgba(166, 227, 161, 0.16);
|
||||||
|
--diff-del: rgba(243, 139, 168, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #cba6f7;
|
||||||
|
--syntax-string: #a6e3a1;
|
||||||
|
--syntax-number: #fab387;
|
||||||
|
--syntax-comment: #7f849c;
|
||||||
|
--syntax-function: #89b4fa;
|
||||||
|
--syntax-type: #f9e2af;
|
||||||
|
--syntax-variable: #cdd6f4;
|
||||||
|
--syntax-meta: #f5c2e7;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Catppuccin Latte
|
||||||
|
* Palette: Catppuccin Latte (catppuccin/catppuccin, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="catppuccin-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #eff1f5;
|
||||||
|
|
||||||
|
--bg0: #eff1f5;
|
||||||
|
--bg1: #e6e9ef;
|
||||||
|
--bg2: #ccd0da;
|
||||||
|
--bg3: #bcc0cc;
|
||||||
|
--fg0: #4c4f69;
|
||||||
|
--fg1: #4c4f69;
|
||||||
|
--fg2: #5c5f77;
|
||||||
|
--fg3: #6c6f85;
|
||||||
|
--muted: #6c6f85;
|
||||||
|
|
||||||
|
--accent: #8839ef;
|
||||||
|
--accent2: #fe640b;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #d20f39;
|
||||||
|
--green: #40a02b;
|
||||||
|
--aqua: #179299;
|
||||||
|
--blue: #1e66f5;
|
||||||
|
--purple: #ea76cb;
|
||||||
|
|
||||||
|
--diff-add: rgba(64, 160, 43, 0.16);
|
||||||
|
--diff-del: rgba(210, 15, 57, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #8839ef;
|
||||||
|
--syntax-string: #40a02b;
|
||||||
|
--syntax-number: #fe640b;
|
||||||
|
--syntax-comment: #8c8fa1;
|
||||||
|
--syntax-function: #1e66f5;
|
||||||
|
--syntax-type: #df8e1d;
|
||||||
|
--syntax-variable: #4c4f69;
|
||||||
|
--syntax-meta: #ea76cb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Coffee Dark
|
||||||
|
* Palette: megabyte0x/omarchy-coffee-theme (MIT), via zink/trilium-themes (MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="coffee-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #1d150f;
|
||||||
|
|
||||||
|
--bg0: #1d150f;
|
||||||
|
--bg1: #2d2118;
|
||||||
|
--bg2: #3a2a20;
|
||||||
|
--bg3: #4a372a;
|
||||||
|
--fg0: #f8eee0;
|
||||||
|
--fg1: #f0e2d0;
|
||||||
|
--fg2: #d8c6b0;
|
||||||
|
--fg3: #b09a84;
|
||||||
|
--muted: #8f7a67;
|
||||||
|
|
||||||
|
--accent: #e0a15c;
|
||||||
|
--accent2: #ffda92;
|
||||||
|
--on-accent: #1d150f;
|
||||||
|
--red: #d78a6f;
|
||||||
|
--green: #b5b27a;
|
||||||
|
--aqua: #9fb8a8;
|
||||||
|
--blue: #a3b4c7;
|
||||||
|
--purple: #cf90a2;
|
||||||
|
|
||||||
|
--diff-add: rgba(181, 178, 122, 0.16);
|
||||||
|
--diff-del: rgba(215, 138, 111, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #d78a6f;
|
||||||
|
--syntax-string: #ffda92;
|
||||||
|
--syntax-number: #cf90a2;
|
||||||
|
--syntax-comment: #8f7a67;
|
||||||
|
--syntax-function: #e0a15c;
|
||||||
|
--syntax-type: #ec8f89;
|
||||||
|
--syntax-variable: #f0e2d0;
|
||||||
|
--syntax-meta: #ec8f89;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Coffee Latte
|
||||||
|
* Palette: megabyte0x/omarchy-coffee-latte-theme (MIT), via zink/trilium-themes (MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="coffee-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #e8e3c3;
|
||||||
|
|
||||||
|
--bg0: #e8e3c3;
|
||||||
|
--bg1: #ddd8b8;
|
||||||
|
--bg2: #d1ccad;
|
||||||
|
--bg3: #c5c1a6;
|
||||||
|
--fg0: #1f1013;
|
||||||
|
--fg1: #2d1a1d;
|
||||||
|
--fg2: #45302f;
|
||||||
|
--fg3: #5d4744;
|
||||||
|
--muted: #6e5a55;
|
||||||
|
|
||||||
|
--accent: #8e2d53;
|
||||||
|
--accent2: #b25d76;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #a4405e;
|
||||||
|
--green: #4f6b00;
|
||||||
|
--aqua: #3f6b5e;
|
||||||
|
--blue: #8e2d53;
|
||||||
|
--purple: #7a003b;
|
||||||
|
|
||||||
|
--diff-add: rgba(79, 107, 0, 0.16);
|
||||||
|
--diff-del: rgba(164, 64, 94, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #a4405e;
|
||||||
|
--syntax-string: #4f6b00;
|
||||||
|
--syntax-number: #7a003b;
|
||||||
|
--syntax-comment: #8a7d70;
|
||||||
|
--syntax-function: #8e2d53;
|
||||||
|
--syntax-type: #b25d76;
|
||||||
|
--syntax-variable: #2d1a1d;
|
||||||
|
--syntax-meta: #b25d76;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Dracula
|
||||||
|
* Palette: Dracula (dracula/dracula-theme, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="dracula-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #282a36;
|
||||||
|
|
||||||
|
--bg0: #282a36;
|
||||||
|
--bg1: #21222c;
|
||||||
|
--bg2: #343746;
|
||||||
|
--bg3: #44475a;
|
||||||
|
--fg0: #f8f8f2;
|
||||||
|
--fg1: #f8f8f2;
|
||||||
|
--fg2: #e2e2dc;
|
||||||
|
--fg3: #c0c0ba;
|
||||||
|
--muted: #9aa3c9;
|
||||||
|
|
||||||
|
--accent: #bd93f9;
|
||||||
|
--accent2: #ffb86c;
|
||||||
|
--on-accent: #282a36;
|
||||||
|
--red: #ff5555;
|
||||||
|
--green: #50fa7b;
|
||||||
|
--aqua: #8be9fd;
|
||||||
|
--blue: #8be9fd;
|
||||||
|
--purple: #ff79c6;
|
||||||
|
|
||||||
|
--diff-add: rgba(80, 250, 123, 0.16);
|
||||||
|
--diff-del: rgba(255, 85, 85, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #ff79c6;
|
||||||
|
--syntax-string: #f1fa8c;
|
||||||
|
--syntax-number: #bd93f9;
|
||||||
|
--syntax-comment: #6272a4;
|
||||||
|
--syntax-function: #50fa7b;
|
||||||
|
--syntax-type: #8be9fd;
|
||||||
|
--syntax-variable: #f8f8f2;
|
||||||
|
--syntax-meta: #ffb86c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Alucard
|
||||||
|
* Palette: Alucard, the light Dracula variant (dracula/dracula-theme, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="dracula-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #fffbeb;
|
||||||
|
|
||||||
|
--bg0: #fffbeb;
|
||||||
|
--bg1: #f5f1dc;
|
||||||
|
--bg2: #efeddc;
|
||||||
|
--bg3: #cfcfde;
|
||||||
|
--fg0: #1f1f1f;
|
||||||
|
--fg1: #1f1f1f;
|
||||||
|
--fg2: #3a3a3a;
|
||||||
|
--fg3: #6c664b;
|
||||||
|
--muted: #6c664b;
|
||||||
|
|
||||||
|
--accent: #644ac9;
|
||||||
|
--accent2: #a34d14;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #cb3a2a;
|
||||||
|
--green: #14710a;
|
||||||
|
--aqua: #036a96;
|
||||||
|
--blue: #036a96;
|
||||||
|
--purple: #a3144d;
|
||||||
|
|
||||||
|
--diff-add: rgba(20, 113, 10, 0.16);
|
||||||
|
--diff-del: rgba(203, 58, 42, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #a3144d;
|
||||||
|
--syntax-string: #846e15;
|
||||||
|
--syntax-number: #644ac9;
|
||||||
|
--syntax-comment: #6c664b;
|
||||||
|
--syntax-function: #14710a;
|
||||||
|
--syntax-type: #036a96;
|
||||||
|
--syntax-variable: #1f1f1f;
|
||||||
|
--syntax-meta: #a34d14;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Everforest Dark
|
||||||
|
* Palette: Everforest by sainnhe (sainnhe/everforest, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="everforest-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #2d353b;
|
||||||
|
|
||||||
|
--bg0: #2d353b;
|
||||||
|
--bg1: #343f44;
|
||||||
|
--bg2: #3d484d;
|
||||||
|
--bg3: #475258;
|
||||||
|
--fg0: #e5dcc4;
|
||||||
|
--fg1: #d3c6aa;
|
||||||
|
--fg2: #c0b597;
|
||||||
|
--fg3: #9da9a0;
|
||||||
|
--muted: #859289;
|
||||||
|
|
||||||
|
--accent: #a7c080;
|
||||||
|
--accent2: #e69875;
|
||||||
|
--on-accent: #2d353b;
|
||||||
|
--red: #e67e80;
|
||||||
|
--green: #a7c080;
|
||||||
|
--aqua: #83c092;
|
||||||
|
--blue: #7fbbb3;
|
||||||
|
--purple: #d699b6;
|
||||||
|
|
||||||
|
--diff-add: rgba(167, 192, 128, 0.16);
|
||||||
|
--diff-del: rgba(230, 126, 128, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #e67e80;
|
||||||
|
--syntax-string: #dbbc7f;
|
||||||
|
--syntax-number: #d699b6;
|
||||||
|
--syntax-comment: #859289;
|
||||||
|
--syntax-function: #a7c080;
|
||||||
|
--syntax-type: #7fbbb3;
|
||||||
|
--syntax-variable: #d3c6aa;
|
||||||
|
--syntax-meta: #e69875;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Everforest Light
|
||||||
|
* Palette: Everforest by sainnhe (sainnhe/everforest, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="everforest-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #fdf6e3;
|
||||||
|
|
||||||
|
--bg0: #fdf6e3;
|
||||||
|
--bg1: #f4f0d9;
|
||||||
|
--bg2: #efebd4;
|
||||||
|
--bg3: #e0dcc7;
|
||||||
|
--fg0: #4a565c;
|
||||||
|
--fg1: #5c6a72;
|
||||||
|
--fg2: #66757c;
|
||||||
|
--fg3: #748086;
|
||||||
|
--muted: #7a8478;
|
||||||
|
|
||||||
|
--accent: #8da101;
|
||||||
|
--accent2: #f57d26;
|
||||||
|
--on-accent: #2d353b;
|
||||||
|
--red: #f85552;
|
||||||
|
--green: #8da101;
|
||||||
|
--aqua: #35a77c;
|
||||||
|
--blue: #3a94c5;
|
||||||
|
--purple: #df69ba;
|
||||||
|
|
||||||
|
--diff-add: rgba(141, 161, 1, 0.16);
|
||||||
|
--diff-del: rgba(248, 85, 82, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #f85552;
|
||||||
|
--syntax-string: #dfa000;
|
||||||
|
--syntax-number: #df69ba;
|
||||||
|
--syntax-comment: #939f91;
|
||||||
|
--syntax-function: #8da101;
|
||||||
|
--syntax-type: #3a94c5;
|
||||||
|
--syntax-variable: #5c6a72;
|
||||||
|
--syntax-meta: #f57d26;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* GitHub Dark
|
||||||
|
* Palette: GitHub Primer / github-vscode-theme (primer, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="github-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #0d1117;
|
||||||
|
|
||||||
|
--bg0: #0d1117;
|
||||||
|
--bg1: #161b22;
|
||||||
|
--bg2: #21262d;
|
||||||
|
--bg3: #30363d;
|
||||||
|
--fg0: #f0f6fc;
|
||||||
|
--fg1: #e6edf3;
|
||||||
|
--fg2: #c9d1d9;
|
||||||
|
--fg3: #9198a1;
|
||||||
|
--muted: #8b949e;
|
||||||
|
|
||||||
|
--accent: #4493f8;
|
||||||
|
--accent2: #d29922;
|
||||||
|
--on-accent: #0d1117;
|
||||||
|
--red: #f85149;
|
||||||
|
--green: #3fb950;
|
||||||
|
--aqua: #39c5cf;
|
||||||
|
--blue: #58a6ff;
|
||||||
|
--purple: #bc8cff;
|
||||||
|
|
||||||
|
--diff-add: rgba(63, 185, 80, 0.16);
|
||||||
|
--diff-del: rgba(248, 81, 73, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #ff7b72;
|
||||||
|
--syntax-string: #a5d6ff;
|
||||||
|
--syntax-number: #79c0ff;
|
||||||
|
--syntax-comment: #8b949e;
|
||||||
|
--syntax-function: #d2a8ff;
|
||||||
|
--syntax-type: #ffa657;
|
||||||
|
--syntax-variable: #e6edf3;
|
||||||
|
--syntax-meta: #7ee787;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* GitHub Light
|
||||||
|
* Palette: GitHub Primer / github-vscode-theme (primer, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="github-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #ffffff;
|
||||||
|
|
||||||
|
--bg0: #ffffff;
|
||||||
|
--bg1: #f6f8fa;
|
||||||
|
--bg2: #eaeef2;
|
||||||
|
--bg3: #d0d7de;
|
||||||
|
--fg0: #1f2328;
|
||||||
|
--fg1: #1f2328;
|
||||||
|
--fg2: #31363c;
|
||||||
|
--fg3: #59636e;
|
||||||
|
--muted: #59636e;
|
||||||
|
|
||||||
|
--accent: #0969da;
|
||||||
|
--accent2: #9a6700;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #d1242f;
|
||||||
|
--green: #1a7f37;
|
||||||
|
--aqua: #1b7c83;
|
||||||
|
--blue: #0969da;
|
||||||
|
--purple: #8250df;
|
||||||
|
|
||||||
|
--diff-add: rgba(26, 127, 55, 0.16);
|
||||||
|
--diff-del: rgba(209, 36, 47, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #cf222e;
|
||||||
|
--syntax-string: #0a3069;
|
||||||
|
--syntax-number: #0550ae;
|
||||||
|
--syntax-comment: #59636e;
|
||||||
|
--syntax-function: #8250df;
|
||||||
|
--syntax-type: #953800;
|
||||||
|
--syntax-variable: #1f2328;
|
||||||
|
--syntax-meta: #116329;
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
/* Gruvbox Dark – also the default before a theme is applied. */
|
/*
|
||||||
|
* Gruvbox Dark – also the default before a theme is applied.
|
||||||
|
* Palette: morhetz/gruvbox (MIT), via zink/trilium-themes (MIT).
|
||||||
|
*/
|
||||||
:root,
|
:root,
|
||||||
:root[data-theme="gruvbox-dark"] {
|
:root[data-theme="gruvbox-dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -36,20 +39,4 @@
|
|||||||
--syntax-variable: #83a598;
|
--syntax-variable: #83a598;
|
||||||
--syntax-meta: #fe8019;
|
--syntax-meta: #fe8019;
|
||||||
|
|
||||||
/* Validated categorical palette (dark steps on #282828). */
|
|
||||||
--series-1: #3987e5;
|
|
||||||
--series-2: #d95926;
|
|
||||||
--series-3: #199e70;
|
|
||||||
--series-4: #c98500;
|
|
||||||
--series-5: #d55181;
|
|
||||||
--series-6: #008300;
|
|
||||||
--series-7: #9085e9;
|
|
||||||
--series-8: #e66767;
|
|
||||||
--seq-0: #32302f;
|
|
||||||
--seq-1: #104281;
|
|
||||||
--seq-2: #184f95;
|
|
||||||
--seq-3: #256abf;
|
|
||||||
--seq-4: #3987e5;
|
|
||||||
--seq-5: #6da7ec;
|
|
||||||
--seq-6: #9ec5f4;
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
/* Gruvbox Light */
|
/*
|
||||||
|
* Gruvbox Light
|
||||||
|
* Palette: morhetz/gruvbox (MIT), via zink/trilium-themes (MIT).
|
||||||
|
*/
|
||||||
:root[data-theme="gruvbox-light"] {
|
:root[data-theme="gruvbox-light"] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--theme-color: #fbf1c7;
|
--theme-color: #fbf1c7;
|
||||||
@@ -15,7 +18,7 @@
|
|||||||
|
|
||||||
--accent: #b57614;
|
--accent: #b57614;
|
||||||
--accent2: #af3a03;
|
--accent2: #af3a03;
|
||||||
--on-accent: #fbf1c7;
|
--on-accent: #282828;
|
||||||
--red: #9d0006;
|
--red: #9d0006;
|
||||||
--green: #79740e;
|
--green: #79740e;
|
||||||
--aqua: #427b58;
|
--aqua: #427b58;
|
||||||
@@ -35,20 +38,4 @@
|
|||||||
--syntax-variable: #076678;
|
--syntax-variable: #076678;
|
||||||
--syntax-meta: #af3a03;
|
--syntax-meta: #af3a03;
|
||||||
|
|
||||||
/* Validated categorical palette (light steps on #fbf1c7). */
|
|
||||||
--series-1: #2a78d6;
|
|
||||||
--series-2: #eb6834;
|
|
||||||
--series-3: #1baf7a;
|
|
||||||
--series-4: #eda100;
|
|
||||||
--series-5: #e87ba4;
|
|
||||||
--series-6: #008300;
|
|
||||||
--series-7: #6250d6;
|
|
||||||
--series-8: #e34948;
|
|
||||||
--seq-0: #f2e5bc;
|
|
||||||
--seq-1: #cde2fb;
|
|
||||||
--seq-2: #9ec5f4;
|
|
||||||
--seq-3: #6da7ec;
|
|
||||||
--seq-4: #3987e5;
|
|
||||||
--seq-5: #256abf;
|
|
||||||
--seq-6: #184f95;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Harbor Dark
|
||||||
|
* Palette: HANCORE-linux/omarchy-harbordark-theme (MIT), via zink/trilium-themes (MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="harbor-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #1b1b1b;
|
||||||
|
|
||||||
|
--bg0: #1b1b1b;
|
||||||
|
--bg1: #232323;
|
||||||
|
--bg2: #2e2e2e;
|
||||||
|
--bg3: #3f3f3f;
|
||||||
|
--fg0: #f7f4ea;
|
||||||
|
--fg1: #efebdc;
|
||||||
|
--fg2: #d6d1c0;
|
||||||
|
--fg3: #c0b79f;
|
||||||
|
--muted: #a99b7a;
|
||||||
|
|
||||||
|
--accent: #e75a50;
|
||||||
|
--accent2: #e1ce98;
|
||||||
|
--on-accent: #1b1b1b;
|
||||||
|
--red: #f44336;
|
||||||
|
--green: #a99b7a;
|
||||||
|
--aqua: #8a9ba6;
|
||||||
|
--blue: #77838a;
|
||||||
|
--purple: #e58980;
|
||||||
|
|
||||||
|
--diff-add: rgba(169, 155, 122, 0.16);
|
||||||
|
--diff-del: rgba(244, 67, 54, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #e75a50;
|
||||||
|
--syntax-string: #e1ce98;
|
||||||
|
--syntax-number: #e58980;
|
||||||
|
--syntax-comment: #77838a;
|
||||||
|
--syntax-function: #a99b7a;
|
||||||
|
--syntax-type: #e1ce98;
|
||||||
|
--syntax-variable: #efebdc;
|
||||||
|
--syntax-meta: #e58980;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Harbor Light
|
||||||
|
* Palette: HANCORE-linux/omarchy-harbor-theme (MIT), via zink/trilium-themes (MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="harbor-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #dfe4c4;
|
||||||
|
|
||||||
|
--bg0: #dfe4c4;
|
||||||
|
--bg1: #d0d6b4;
|
||||||
|
--bg2: #c3c9a6;
|
||||||
|
--bg3: #b3b994;
|
||||||
|
--fg0: #101c18;
|
||||||
|
--fg1: #1c2d28;
|
||||||
|
--fg2: #2f413b;
|
||||||
|
--fg3: #435650;
|
||||||
|
--muted: #556753;
|
||||||
|
|
||||||
|
--accent: #4c6c94;
|
||||||
|
--accent2: #dc8164;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #b14752;
|
||||||
|
--green: #556753;
|
||||||
|
--aqua: #3d727d;
|
||||||
|
--blue: #4c6c94;
|
||||||
|
--purple: #8a5b81;
|
||||||
|
|
||||||
|
--diff-add: rgba(85, 103, 83, 0.16);
|
||||||
|
--diff-del: rgba(177, 71, 82, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #b14752;
|
||||||
|
--syntax-string: #556753;
|
||||||
|
--syntax-number: #8a5b81;
|
||||||
|
--syntax-comment: #6f7d6c;
|
||||||
|
--syntax-function: #3d727d;
|
||||||
|
--syntax-type: #4c6c94;
|
||||||
|
--syntax-variable: #1c2d28;
|
||||||
|
--syntax-meta: #b06247;
|
||||||
|
}
|
||||||
@@ -2,3 +2,31 @@
|
|||||||
import "./base.css";
|
import "./base.css";
|
||||||
import "./gruvbox-dark.css";
|
import "./gruvbox-dark.css";
|
||||||
import "./gruvbox-light.css";
|
import "./gruvbox-light.css";
|
||||||
|
import "./ash-dark.css";
|
||||||
|
import "./ash-light.css";
|
||||||
|
import "./harbor-dark.css";
|
||||||
|
import "./harbor-light.css";
|
||||||
|
import "./coffee-dark.css";
|
||||||
|
import "./coffee-light.css";
|
||||||
|
import "./solarized-dark.css";
|
||||||
|
import "./solarized-light.css";
|
||||||
|
import "./tokyonight-dark.css";
|
||||||
|
import "./tokyonight-light.css";
|
||||||
|
import "./nord-dark.css";
|
||||||
|
import "./nord-light.css";
|
||||||
|
import "./vscode-dark.css";
|
||||||
|
import "./vscode-light.css";
|
||||||
|
import "./github-dark.css";
|
||||||
|
import "./github-light.css";
|
||||||
|
import "./catppuccin-dark.css";
|
||||||
|
import "./catppuccin-light.css";
|
||||||
|
import "./one-dark.css";
|
||||||
|
import "./one-light.css";
|
||||||
|
import "./dracula-dark.css";
|
||||||
|
import "./dracula-light.css";
|
||||||
|
import "./rosepine-dark.css";
|
||||||
|
import "./rosepine-light.css";
|
||||||
|
import "./everforest-dark.css";
|
||||||
|
import "./everforest-light.css";
|
||||||
|
import "./kanagawa-dark.css";
|
||||||
|
import "./kanagawa-light.css";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Kanagawa Wave
|
||||||
|
* Palette: Kanagawa by rebelot (rebelot/kanagawa.nvim, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="kanagawa-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #1f1f28;
|
||||||
|
|
||||||
|
--bg0: #1f1f28;
|
||||||
|
--bg1: #16161d;
|
||||||
|
--bg2: #2a2a37;
|
||||||
|
--bg3: #363646;
|
||||||
|
--fg0: #dcd7ba;
|
||||||
|
--fg1: #dcd7ba;
|
||||||
|
--fg2: #c8c093;
|
||||||
|
--fg3: #a6a69c;
|
||||||
|
--muted: #8f8e85;
|
||||||
|
|
||||||
|
--accent: #7e9cd8;
|
||||||
|
--accent2: #ffa066;
|
||||||
|
--on-accent: #1f1f28;
|
||||||
|
--red: #e46876;
|
||||||
|
--green: #98bb6c;
|
||||||
|
--aqua: #7aa89f;
|
||||||
|
--blue: #7fb4ca;
|
||||||
|
--purple: #957fb8;
|
||||||
|
|
||||||
|
--diff-add: rgba(152, 187, 108, 0.16);
|
||||||
|
--diff-del: rgba(228, 104, 118, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #957fb8;
|
||||||
|
--syntax-string: #98bb6c;
|
||||||
|
--syntax-number: #d27e99;
|
||||||
|
--syntax-comment: #727169;
|
||||||
|
--syntax-function: #7e9cd8;
|
||||||
|
--syntax-type: #7aa89f;
|
||||||
|
--syntax-variable: #e6c384;
|
||||||
|
--syntax-meta: #ffa066;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Kanagawa Lotus
|
||||||
|
* Palette: Kanagawa Lotus by rebelot (rebelot/kanagawa.nvim, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="kanagawa-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #f2ecbc;
|
||||||
|
|
||||||
|
--bg0: #f2ecbc;
|
||||||
|
--bg1: #e5ddb0;
|
||||||
|
--bg2: #dcd5ac;
|
||||||
|
--bg3: #d5cea3;
|
||||||
|
--fg0: #43436c;
|
||||||
|
--fg1: #545464;
|
||||||
|
--fg2: #5f5f72;
|
||||||
|
--fg3: #6d6d7c;
|
||||||
|
--muted: #716e61;
|
||||||
|
|
||||||
|
--accent: #4d699b;
|
||||||
|
--accent2: #cc6d00;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #c84053;
|
||||||
|
--green: #6f894e;
|
||||||
|
--aqua: #597b75;
|
||||||
|
--blue: #4d699b;
|
||||||
|
--purple: #624c83;
|
||||||
|
|
||||||
|
--diff-add: rgba(111, 137, 78, 0.16);
|
||||||
|
--diff-del: rgba(200, 64, 83, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #624c83;
|
||||||
|
--syntax-string: #6f894e;
|
||||||
|
--syntax-number: #b35b79;
|
||||||
|
--syntax-comment: #8a8980;
|
||||||
|
--syntax-function: #4d699b;
|
||||||
|
--syntax-type: #597b75;
|
||||||
|
--syntax-variable: #545464;
|
||||||
|
--syntax-meta: #cc6d00;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Nord
|
||||||
|
* Palette: Nord by Arctic Ice Studio / nordtheme (MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="nord-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #2e3440;
|
||||||
|
|
||||||
|
--bg0: #2e3440;
|
||||||
|
--bg1: #3b4252;
|
||||||
|
--bg2: #434c5e;
|
||||||
|
--bg3: #4c566a;
|
||||||
|
--fg0: #eceff4;
|
||||||
|
--fg1: #e5e9f0;
|
||||||
|
--fg2: #d8dee9;
|
||||||
|
--fg3: #b8c0cf;
|
||||||
|
--muted: #8a93a5;
|
||||||
|
|
||||||
|
--accent: #88c0d0;
|
||||||
|
--accent2: #d08770;
|
||||||
|
--on-accent: #2e3440;
|
||||||
|
--red: #bf616a;
|
||||||
|
--green: #a3be8c;
|
||||||
|
--aqua: #8fbcbb;
|
||||||
|
--blue: #81a1c1;
|
||||||
|
--purple: #b48ead;
|
||||||
|
|
||||||
|
--diff-add: rgba(163, 190, 140, 0.16);
|
||||||
|
--diff-del: rgba(191, 97, 106, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #81a1c1;
|
||||||
|
--syntax-string: #a3be8c;
|
||||||
|
--syntax-number: #b48ead;
|
||||||
|
--syntax-comment: #616e88;
|
||||||
|
--syntax-function: #88c0d0;
|
||||||
|
--syntax-type: #8fbcbb;
|
||||||
|
--syntax-variable: #d8dee9;
|
||||||
|
--syntax-meta: #d08770;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Nord Light
|
||||||
|
* Palette: Nord by Arctic Ice Studio / nordtheme (MIT), Snow Storm as background.
|
||||||
|
*/
|
||||||
|
:root[data-theme="nord-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #eceff4;
|
||||||
|
|
||||||
|
--bg0: #eceff4;
|
||||||
|
--bg1: #e5e9f0;
|
||||||
|
--bg2: #d8dee9;
|
||||||
|
--bg3: #c2c9d6;
|
||||||
|
--fg0: #2e3440;
|
||||||
|
--fg1: #3b4252;
|
||||||
|
--fg2: #434c5e;
|
||||||
|
--fg3: #4c566a;
|
||||||
|
--muted: #5f6b80;
|
||||||
|
|
||||||
|
--accent: #5e81ac;
|
||||||
|
--accent2: #b55f45;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #a9434d;
|
||||||
|
--green: #5f7d48;
|
||||||
|
--aqua: #4d8a89;
|
||||||
|
--blue: #5e81ac;
|
||||||
|
--purple: #8f6a8a;
|
||||||
|
|
||||||
|
--diff-add: rgba(95, 125, 72, 0.16);
|
||||||
|
--diff-del: rgba(169, 67, 77, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #5e81ac;
|
||||||
|
--syntax-string: #5f7d48;
|
||||||
|
--syntax-number: #8f6a8a;
|
||||||
|
--syntax-comment: #7b88a1;
|
||||||
|
--syntax-function: #4c7a90;
|
||||||
|
--syntax-type: #4d8a89;
|
||||||
|
--syntax-variable: #3b4252;
|
||||||
|
--syntax-meta: #b55f45;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* One Dark
|
||||||
|
* Palette: Atom One Dark (atom/atom, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="one-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #282c34;
|
||||||
|
|
||||||
|
--bg0: #282c34;
|
||||||
|
--bg1: #21252b;
|
||||||
|
--bg2: #2c313a;
|
||||||
|
--bg3: #3e4451;
|
||||||
|
--fg0: #d7dae0;
|
||||||
|
--fg1: #abb2bf;
|
||||||
|
--fg2: #9da5b4;
|
||||||
|
--fg3: #8b929e;
|
||||||
|
--muted: #7f848e;
|
||||||
|
|
||||||
|
--accent: #61afef;
|
||||||
|
--accent2: #d19a66;
|
||||||
|
--on-accent: #282c34;
|
||||||
|
--red: #e06c75;
|
||||||
|
--green: #98c379;
|
||||||
|
--aqua: #56b6c2;
|
||||||
|
--blue: #61afef;
|
||||||
|
--purple: #c678dd;
|
||||||
|
|
||||||
|
--diff-add: rgba(152, 195, 121, 0.16);
|
||||||
|
--diff-del: rgba(224, 108, 117, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #c678dd;
|
||||||
|
--syntax-string: #98c379;
|
||||||
|
--syntax-number: #d19a66;
|
||||||
|
--syntax-comment: #7f848e;
|
||||||
|
--syntax-function: #61afef;
|
||||||
|
--syntax-type: #e5c07b;
|
||||||
|
--syntax-variable: #e06c75;
|
||||||
|
--syntax-meta: #56b6c2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* One Light
|
||||||
|
* Palette: Atom One Light (atom/atom, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="one-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #fafafa;
|
||||||
|
|
||||||
|
--bg0: #fafafa;
|
||||||
|
--bg1: #f0f0f1;
|
||||||
|
--bg2: #e5e5e6;
|
||||||
|
--bg3: #d4d4d6;
|
||||||
|
--fg0: #232324;
|
||||||
|
--fg1: #383a42;
|
||||||
|
--fg2: #4f525e;
|
||||||
|
--fg3: #696c77;
|
||||||
|
--muted: #696c77;
|
||||||
|
|
||||||
|
--accent: #4078f2;
|
||||||
|
--accent2: #c18401;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #e45649;
|
||||||
|
--green: #50a14f;
|
||||||
|
--aqua: #0184bc;
|
||||||
|
--blue: #4078f2;
|
||||||
|
--purple: #a626a4;
|
||||||
|
|
||||||
|
--diff-add: rgba(80, 161, 79, 0.16);
|
||||||
|
--diff-del: rgba(228, 86, 73, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #a626a4;
|
||||||
|
--syntax-string: #50a14f;
|
||||||
|
--syntax-number: #986801;
|
||||||
|
--syntax-comment: #a0a1a7;
|
||||||
|
--syntax-function: #4078f2;
|
||||||
|
--syntax-type: #c18401;
|
||||||
|
--syntax-variable: #e45649;
|
||||||
|
--syntax-meta: #0184bc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Rosé Pine
|
||||||
|
* Palette: Rosé Pine (rose-pine/rose-pine-theme, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="rosepine-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #191724;
|
||||||
|
|
||||||
|
--bg0: #191724;
|
||||||
|
--bg1: #1f1d2e;
|
||||||
|
--bg2: #26233a;
|
||||||
|
--bg3: #403d52;
|
||||||
|
--fg0: #e0def4;
|
||||||
|
--fg1: #e0def4;
|
||||||
|
--fg2: #c9c6e0;
|
||||||
|
--fg3: #908caa;
|
||||||
|
--muted: #908caa;
|
||||||
|
|
||||||
|
--accent: #c4a7e7;
|
||||||
|
--accent2: #ebbcba;
|
||||||
|
--on-accent: #191724;
|
||||||
|
--red: #eb6f92;
|
||||||
|
--green: #9ccfd8;
|
||||||
|
--aqua: #9ccfd8;
|
||||||
|
--blue: #31748f;
|
||||||
|
--purple: #c4a7e7;
|
||||||
|
|
||||||
|
--diff-add: rgba(156, 207, 216, 0.16);
|
||||||
|
--diff-del: rgba(235, 111, 146, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--syntax-keyword: #31748f;
|
||||||
|
--syntax-string: #f6c177;
|
||||||
|
--syntax-number: #f6c177;
|
||||||
|
--syntax-comment: #6e6a86;
|
||||||
|
--syntax-function: #ebbcba;
|
||||||
|
--syntax-type: #9ccfd8;
|
||||||
|
--syntax-variable: #e0def4;
|
||||||
|
--syntax-meta: #c4a7e7;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Rosé Pine Dawn
|
||||||
|
* Palette: Rosé Pine Dawn (rose-pine/rose-pine-theme, MIT).
|
||||||
|
*/
|
||||||
|
:root[data-theme="rosepine-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #faf4ed;
|
||||||
|
|
||||||
|
--bg0: #faf4ed;
|
||||||
|
--bg1: #f2e9e1;
|
||||||
|
--bg2: #dfdad9;
|
||||||
|
--bg3: #cecacd;
|
||||||
|
--fg0: #464261;
|
||||||
|
--fg1: #575279;
|
||||||
|
--fg2: #6a6585;
|
||||||
|
--fg3: #797593;
|
||||||
|
--muted: #797593;
|
||||||
|
|
||||||
|
--accent: #286983;
|
||||||
|
--accent2: #d7827e;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #b4637a;
|
||||||
|
--green: #56949f;
|
||||||
|
--aqua: #56949f;
|
||||||
|
--blue: #286983;
|
||||||
|
--purple: #907aa9;
|
||||||
|
|
||||||
|
--diff-add: rgba(86, 148, 159, 0.16);
|
||||||
|
--diff-del: rgba(180, 99, 122, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
|
||||||
|
|
||||||
|
--syntax-keyword: #286983;
|
||||||
|
--syntax-string: #ea9d34;
|
||||||
|
--syntax-number: #ea9d34;
|
||||||
|
--syntax-comment: #9893a5;
|
||||||
|
--syntax-function: #d7827e;
|
||||||
|
--syntax-type: #56949f;
|
||||||
|
--syntax-variable: #575279;
|
||||||
|
--syntax-meta: #907aa9;
|
||||||
|
}
|
||||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user