Compare commits
19
Commits
No files matched your search
+100
@@ -3,6 +3,106 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00008] – 2026-11-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Instructions per chat:** *⋯ → Instructions for this chat* (Telegram: `/prompt`) sets rules
|
||||||
|
that apply to every message of that chat on top of the mode – language, tone, the part of the
|
||||||
|
project to work on. A chip in the header shows when a chat has them; forks keep them.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- OpenAI-compatible providers: tool results no longer carry a `name` field. Gateways that
|
||||||
|
translate to Anthropic (opencode) rejected the request with "anthropic-messages cannot
|
||||||
|
preserve named conversation participants".
|
||||||
|
- Telegram: the token field is no longer filled in by the browser's password manager, the bot
|
||||||
|
token is found in whatever is pasted (also the whole BotFather message), and a wrong format
|
||||||
|
gets a clear message instead of "HTTP 404".
|
||||||
|
|
||||||
|
## [v202611-00007] – 2026-11-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Telegram bot as a chat interface:** every user can connect their own bot (*Settings →
|
||||||
|
Account → Telegram*: token from @BotFather, then `/start <code>` pairs it with their Telegram
|
||||||
|
account – other chats are ignored). Messages, photos and files go to the current session; the
|
||||||
|
agent's steps show live, the answer comes formatted, images from tools come as photos, and
|
||||||
|
approvals and questions have buttons. Commands like the terminal client: `/sessions`,
|
||||||
|
`/session`, `/new`, `/clear`, `/mode`, `/model`, `/permissions`, `/effort`, `/stop`, `/status`,
|
||||||
|
`/costs`, `/memory`, `/jobs`, `/compact`, `/help`. The server polls Telegram – no public
|
||||||
|
webhook needed.
|
||||||
|
|
||||||
|
## [v202611-00006] – 2026-11-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The image contains the Docker CLI (static client). With the host's `/var/run/docker.sock`
|
||||||
|
mounted (and its group), admins' agents and terminals can use the host's Docker;
|
||||||
|
`scripts/move-server.sh --docker-sock` sets this up on a new host.
|
||||||
|
- `scripts/move-server.sh --tools-dir DIR`: agent home, users' homes and `/tmp` on a separate disk.
|
||||||
|
|
||||||
|
## [v202611-00005] – 2026-11-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Commits by agents, terminals and the git panel always use the name and e-mail from
|
||||||
|
*Settings → Account → Git* – also in repositories that have their own `user.name`/`user.email`
|
||||||
|
(agents had set those as a workaround while no identity was configured). Agents are told not
|
||||||
|
to change the git identity.
|
||||||
|
|
||||||
|
## [v202611-00004] – 2026-11-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Chats side by side:** *Open beside* in a chat's sidebar menu shows up to three chats as
|
||||||
|
columns, each with its own tabs and composer (desktop).
|
||||||
|
- **My modes:** every user can create personal modes under *Settings → My modes* (kind, tools,
|
||||||
|
read-only, prompt, model); they appear only for that user.
|
||||||
|
- **Budget warnings** as push notifications for admins (warning share and 100 %) for the total,
|
||||||
|
provider and image budgets and the opencode Go windows; the status overlay shows all budgets.
|
||||||
|
- `scripts/move-server.sh` moves an installation to another Docker host with bind mounts
|
||||||
|
(e.g. Unraid), `scripts/smoke-test.sh` checks a running instance.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- File manager tabs of a chat are stored on the server, so they are the same on every device.
|
||||||
|
|
||||||
|
## [v202611-00003] – 2026-11-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The Docker image ships the terminal client again (static Linux x86_64 and aarch64 binaries):
|
||||||
|
`curl -fsSL https://<your-server>/cli/install.sh | sh`, download links in the settings and
|
||||||
|
`codecraft update` work without building from source.
|
||||||
|
- File manager: *Open chat here* in a folder's menu starts a chat in that folder.
|
||||||
|
- **Tabs in a chat:** open terminals and file managers next to the chat (topbar buttons or
|
||||||
|
"+" in the tab strip), as many as needed. Terminals of a chat keep running on the server until
|
||||||
|
they are closed, so they are there again after a reload or on another device (output is
|
||||||
|
replayed); they end when the chat is deleted or the server restarts.
|
||||||
|
- **Chat mode:** *New session → Just chat* (and mode `chat`) for plain conversation – a lean
|
||||||
|
prompt with date and personal memory, web search and fetch only, no project or files.
|
||||||
|
- **Own modes:** *Settings → Agents & commands → New mode* with kind (coding or conversation),
|
||||||
|
read-only switch, tool checkboxes, prompt, model and review loop; custom modes show up in
|
||||||
|
*New session* and the mode selector.
|
||||||
|
- The chat shows when the conversation is being compacted – automatically because the context
|
||||||
|
is nearly full (with tokens and share of the limit) or on request – and labels the summary.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The mode selector in the composer is a dropdown.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- *Settings → Config file* no longer offers to move `${NAME}` values into the configuration (a
|
||||||
|
one-time migration); `${NAME}` references keep working.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Session updates from background tasks (title, token usage, phase, todos) and from the UI
|
||||||
|
no longer overwrite each other. Often the first answer's token usage was lost, so automatic
|
||||||
|
compaction and the context display started one turn late.
|
||||||
|
|
||||||
## [v202611-00002] – 2026-11-09
|
## [v202611-00002] – 2026-11-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+1
@@ -3040,6 +3040,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
|
|||||||
+28
-2
@@ -1,6 +1,6 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Multi-arch: the web UI and the server are built once on the build
|
# Multi-arch: the web UI, the server and the terminal client are built once on the build
|
||||||
# machine (cross-compiled with cargo-zigbuild); only the runtime stage runs
|
# machine (cross-compiled with cargo-zigbuild); only the runtime stage runs
|
||||||
# per target platform.
|
# per target platform.
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ ARG ZIG_VERSION=0.17.1
|
|||||||
RUN curl -fsSL https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz | tar -xJ -C /opt \
|
RUN curl -fsSL https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz | tar -xJ -C /opt \
|
||||||
&& ln -s /opt/zig-*/zig /usr/local/bin/zig \
|
&& ln -s /opt/zig-*/zig /usr/local/bin/zig \
|
||||||
&& cargo install --locked cargo-zigbuild \
|
&& cargo install --locked cargo-zigbuild \
|
||||||
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
|
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-musl
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# --- server (for the target platform, glibc of Debian bookworm) ----------------
|
# --- server (for the target platform, glibc of Debian bookworm) ----------------
|
||||||
@@ -52,6 +52,29 @@ RUN touch core/src/lib.rs server/src/main.rs \
|
|||||||
&& cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \
|
&& cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \
|
||||||
&& cp "target/$(cat /target)/release/codecraft" /codecraft
|
&& cp "target/$(cat /target)/release/codecraft" /codecraft
|
||||||
|
|
||||||
|
# --- terminal client (static Linux binaries for x86_64 and aarch64) -----------
|
||||||
|
# Served by the server for download (/cli/install.sh, codecraft update).
|
||||||
|
FROM toolchain AS cli
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY core ./core
|
||||||
|
COPY server/Cargo.toml ./server/
|
||||||
|
COPY server/resources ./server/resources
|
||||||
|
COPY cli ./cli
|
||||||
|
ARG CODECRAFT_VERSION=""
|
||||||
|
ENV CODECRAFT_VERSION=${CODECRAFT_VERSION}
|
||||||
|
RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
|
||||||
|
&& cargo zigbuild --release -p codecraft-cli --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \
|
||||||
|
&& mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \
|
||||||
|
&& cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64
|
||||||
|
|
||||||
|
# --- Docker CLI (static client; the daemon is the host's via the mounted socket) --
|
||||||
|
FROM toolchain AS dockercli
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG DOCKER_VERSION=29.9.3
|
||||||
|
RUN case "$TARGETARCH" in amd64) arch=x86_64 ;; arm64) arch=aarch64 ;; *) echo "unsupported $TARGETARCH" >&2; exit 1 ;; esac \
|
||||||
|
&& curl -fsSL "https://download.docker.com/linux/static/stable/$arch/docker-$DOCKER_VERSION.tgz" | tar -xz -C /tmp docker/docker \
|
||||||
|
&& install -m 0755 /tmp/docker/docker /docker
|
||||||
|
|
||||||
# --- runtime ------------------------------------------------------------------
|
# --- runtime ------------------------------------------------------------------
|
||||||
# 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
|
||||||
@@ -69,6 +92,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
|
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
|
||||||
COPY --from=server /codecraft /usr/local/bin/codecraft
|
COPY --from=server /codecraft /usr/local/bin/codecraft
|
||||||
COPY --from=web /web/dist /app/web
|
COPY --from=web /web/dist /app/web
|
||||||
|
COPY --from=cli /out /app/cli
|
||||||
|
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
||||||
|
COPY --from=dockercli /docker /usr/local/bin/docker
|
||||||
COPY server/ghidra /app/ghidra
|
COPY server/ghidra /app/ghidra
|
||||||
COPY server/resources /app/resources
|
COPY server/resources /app/resources
|
||||||
# The agent's home lives on the data volume: tools it installs in user space
|
# The agent's home lives on the data volume: tools it installs in user space
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00002" src="https://img.shields.io/badge/version-v202611--00002-d69e2e?style=flat-square">
|
<img alt="Version v202611-00008" src="https://img.shields.io/badge/version-v202611--00008-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
@@ -82,8 +82,7 @@ docker compose -f docker-compose.release.yml logs codecraft # one-time setup c
|
|||||||
Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a
|
Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a
|
||||||
provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys,
|
provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys,
|
||||||
tokens and everything else are set in the UI and stored in the configuration file
|
tokens and everything else are set in the UI and stored in the configuration file
|
||||||
(`${NAME}` references to environment variables still work, and *Settings → Config file* moves
|
(`${NAME}` references to environment variables still work).
|
||||||
their values into the file).
|
|
||||||
|
|
||||||
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
|
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
|
||||||
Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with
|
Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with
|
||||||
@@ -98,6 +97,17 @@ volume `codecraft-data`; recreating the container keeps it. Your projects are mo
|
|||||||
WebSocket support) and set `auth.public_url` – installing the app and push notifications
|
WebSocket support) and set `auth.public_url` – installing the app and push notifications
|
||||||
require HTTPS.
|
require HTTPS.
|
||||||
|
|
||||||
|
**Moving to another host** (for example Unraid with bind mounts instead of the Docker volume):
|
||||||
|
`scripts/move-server.sh root@newhost:/mnt/user/appdata/codecraft --workspace /mnt/user/projects`
|
||||||
|
copies the workspace, stops CodeCraft, streams the data volume to `<dir>/data`, writes a
|
||||||
|
`docker-compose.yml` (and a `start.sh` for hosts without Compose) with bind mounts, starts the
|
||||||
|
published image there and runs `scripts/smoke-test.sh`. The old container is only stopped, so
|
||||||
|
you can roll back. `--help` lists the options (owner, port, bind address, dry run).
|
||||||
|
`--tools-dir /mnt/disks/ssd/codecraft` puts everything agents install or download – the agent
|
||||||
|
home (`/data/home`: toolchains, pip/npm/cargo packages, caches), the users' homes and `/tmp` – on
|
||||||
|
a separate disk; the data folder keeps only the database, configuration and backups. Step by step,
|
||||||
|
with rollback and troubleshooting: [docs/MOVING.md](docs/MOVING.md).
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
CodeCraft runs on your server, next to your projects. You talk to it from a browser, the
|
CodeCraft runs on your server, next to your projects. You talk to it from a browser, the
|
||||||
@@ -155,6 +165,9 @@ output limit, reasoning effort, extra request options and prices per million tok
|
|||||||
model's fallback.
|
model's fallback.
|
||||||
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
|
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
|
||||||
process one at a time; waiting time is shown and never counted as model latency.
|
process one at a time; waiting time is shown and never counted as model latency.
|
||||||
|
- **Budget warnings:** admins get a push notification when the total, a provider's or the image
|
||||||
|
budget, or an opencode Go window reaches the warning share (default 80 %) and when it is used
|
||||||
|
up; the status overlay shows all budgets with bars.
|
||||||
- **Prices:** *Fetch prices* fills in prices for OpenRouter and opencode (Zen and Go), including
|
- **Prices:** *Fetch prices* fills in prices for OpenRouter and opencode (Zen and Go), including
|
||||||
long-context tiers and peak-hour factors, which are applied per request.
|
long-context tiers and peak-hour factors, which are applied per request.
|
||||||
- **Status:** every five minutes each provider's model list is fetched (no tokens used), so
|
- **Status:** every five minutes each provider's model list is fetched (no tokens used), so
|
||||||
@@ -174,15 +187,14 @@ Anthropic, OpenAI, Gemini, OpenRouter, opencode or a local model server (Ollama,
|
|||||||
llama.cpp, vLLM – detected automatically); both can be mixed.
|
llama.cpp, vLLM – detected automatically); both can be mixed.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli
|
curl -fsSL https://<your-codecraft-server>/cli/install.sh | sh # Linux x86_64 / aarch64
|
||||||
codecraft login # server URL, user name, password → creates an API token
|
codecraft login # server URL, user name, password → creates an API token
|
||||||
codecraft # start in the current folder; /help lists the commands
|
codecraft # start in the current folder; /help lists the commands
|
||||||
```
|
```
|
||||||
|
|
||||||
There are no prebuilt binaries yet, so the client is installed from source (needs a Rust
|
The Docker image ships static binaries for Linux x86_64 and aarch64; the server offers them
|
||||||
toolchain). A server that has binaries in `CODECRAFT_CLI_DIR` (`codecraft-linux-x86_64`,
|
for download (*Settings → Account*) and `codecraft update` fetches new versions from it. Other
|
||||||
`codecraft-linux-aarch64`) also offers `curl -fsSL https://<your-server>/cli/install.sh | sh`
|
platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli`.
|
||||||
and `codecraft update`.
|
|
||||||
|
|
||||||
## Automations
|
## Automations
|
||||||
|
|
||||||
@@ -201,6 +213,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t
|
|||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
|
- **Telegram:** connect your own bot (*Settings → Account → Telegram*) and use CodeCraft from
|
||||||
|
Telegram – sessions, modes, models, approvals with buttons, photos and files; `/help` in the
|
||||||
|
bot lists the commands.
|
||||||
- **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client.
|
- **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client.
|
||||||
- **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
|
- **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
|
||||||
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
|
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
|
||||||
@@ -336,7 +351,7 @@ web/ React + Vite PWA
|
|||||||
src/i18n English (reference) and German
|
src/i18n English (reference) and German
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker build cross-compiles the server with
|
The Docker build cross-compiles the server and the terminal client with
|
||||||
[cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild), so a multi-arch image builds
|
[cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild), so a multi-arch image builds
|
||||||
quickly on either architecture:
|
quickly on either architecture:
|
||||||
`docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/codecraft:<version> --push .`
|
`docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/codecraft:<version> --push .`
|
||||||
@@ -354,7 +369,7 @@ Translations: copy `web/src/i18n/en.ts`, translate the values and register the l
|
|||||||
|
|
||||||
Early, but in daily use. See the [changelog](CHANGELOG.md). Planned next:
|
Early, but in daily use. See the [changelog](CHANGELOG.md). Planned next:
|
||||||
|
|
||||||
- Prebuilt terminal client binaries (Linux, macOS, Windows)
|
- Terminal client builds for macOS and Windows
|
||||||
- MCP OAuth, resources and prompts
|
- MCP OAuth, resources and prompts
|
||||||
- The OpenAI Responses API
|
- The OpenAI Responses API
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -508,7 +508,7 @@ impl Agent {
|
|||||||
let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
||||||
for p in &m.parts {
|
for p in &m.parts {
|
||||||
let line = match p {
|
let line = match p {
|
||||||
Part::Text { text } | Part::Summary { text } => text.clone(),
|
Part::Text { text } | Part::Summary { text, .. } => text.clone(),
|
||||||
Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::<String>()),
|
Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::<String>()),
|
||||||
Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::<String>()),
|
Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::<String>()),
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@@ -524,7 +524,7 @@ impl Agent {
|
|||||||
if summary.trim().is_empty() {
|
if summary.trim().is_empty() {
|
||||||
return Err(anyhow!("compaction produced no summary"));
|
return Err(anyhow!("compaction produced no summary"));
|
||||||
}
|
}
|
||||||
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary }]));
|
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary, auto: false, tokens: None }]));
|
||||||
self.session.context_tokens = 0;
|
self.session.context_tokens = 0;
|
||||||
self.read_files.lock().unwrap().clear();
|
self.read_files.lock().unwrap().clear();
|
||||||
let _ = self.session.save();
|
let _ = self.session.save();
|
||||||
|
|||||||
+1
-2
@@ -10,8 +10,7 @@
|
|||||||
#
|
#
|
||||||
# Keys and tokens are entered in the web UI (Settings) and stored here, readable
|
# Keys and tokens are entered in the web UI (Settings) and stored here, readable
|
||||||
# for admins and included in backups. Any string value may also reference an
|
# for admins and included in backups. Any string value may also reference an
|
||||||
# environment variable as ${NAME}; Settings → Config file moves such values
|
# environment variable as ${NAME}.
|
||||||
# into this file.
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent w
|
|||||||
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
|
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
|
||||||
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
|
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
|
||||||
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
||||||
|
- Git identity and credentials come from the user's settings: never set user.name, user.email or credential helpers yourself (no `git config user.*`, no `-c user.*`).
|
||||||
- Reference code as path:line.
|
- Reference code as path:line.
|
||||||
- Answer in the language the user writes in."#;
|
- Answer in the language the user writes in."#;
|
||||||
|
|
||||||
@@ -34,6 +35,9 @@ Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pa
|
|||||||
pub 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).";
|
pub 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).";
|
||||||
pub 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.";
|
pub 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.";
|
||||||
|
|
||||||
|
/// Plain conversation (mode "chat"): no project, no coding workflow.
|
||||||
|
pub const CHAT_PROMPT: &str = "You are a helpful, knowledgeable assistant having a conversation with the user. Answer directly and naturally in the user's language; keep answers as short as the question allows and go into depth when asked. Use Markdown only where it helps (lists, code, tables). You can search the web (web_search) and read pages (web_fetch) for current or specific facts – say where information comes from. You have no access to files or a shell in this mode; if the user wants something built or changed in a project, suggest switching to build mode.";
|
||||||
|
|
||||||
pub 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.";
|
pub 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.";
|
||||||
pub 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.";
|
pub 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.";
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ pub fn to_wire_messages(messages: &[Message]) -> Vec<Value> {
|
|||||||
Part::Text { text } if !text.is_empty() => {
|
Part::Text { text } if !text.is_empty() => {
|
||||||
content.push(json!({"type": "text", "text": text}))
|
content.push(json!({"type": "text", "text": text}))
|
||||||
}
|
}
|
||||||
Part::Summary { text } => content.push(json!({
|
Part::Summary { text, .. } => content.push(json!({
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -40,17 +40,20 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
|
|||||||
Part::Text { text } if !text.is_empty() => {
|
Part::Text { text } if !text.is_empty() => {
|
||||||
parts.push(json!({"type": "text", "text": text}))
|
parts.push(json!({"type": "text", "text": text}))
|
||||||
}
|
}
|
||||||
Part::Summary { text } => parts.push(json!({
|
Part::Summary { text, .. } => parts.push(json!({
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
||||||
})),
|
})),
|
||||||
Part::Image { image } => parts.push(json!({
|
Part::Image { image } => parts.push(json!({
|
||||||
"type": "image_url", "image_url": {"url": data_url(image)}
|
"type": "image_url", "image_url": {"url": data_url(image)}
|
||||||
})),
|
})),
|
||||||
Part::ToolResult { call_id, name, content, images, is_error, .. } => {
|
Part::ToolResult { call_id, content, images, is_error, .. } => {
|
||||||
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
|
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
|
||||||
|
// No "name": it is not part of the tool message in the Chat
|
||||||
|
// Completions API, and gateways that translate to Anthropic
|
||||||
|
// (opencode) reject named participants.
|
||||||
out.push(json!({
|
out.push(json!({
|
||||||
"role": "tool", "tool_call_id": call_id, "name": name,
|
"role": "tool", "tool_call_id": call_id,
|
||||||
"content": if text.is_empty() { "(no output)".into() } else { text }
|
"content": if text.is_empty() { "(no output)".into() } else { text }
|
||||||
}));
|
}));
|
||||||
tool_images.extend(images.iter().cloned());
|
tool_images.extend(images.iter().cloned());
|
||||||
@@ -328,6 +331,22 @@ mod tests {
|
|||||||
(text, reasoning)
|
(text, reasoning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_messages_have_no_name() {
|
||||||
|
use crate::types::{Message, Part, Role};
|
||||||
|
let call = Part::ToolCall { id: "c1".into(), name: "bash".into(), input: serde_json::json!({"command": "ls"}), extra: None };
|
||||||
|
let result = Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "ok".into(), images: vec![], is_error: false, meta: None };
|
||||||
|
let msgs = vec![
|
||||||
|
Message::new(Role::User, vec![Part::Text { text: "hi".into() }]),
|
||||||
|
Message::new(Role::Assistant, vec![call]),
|
||||||
|
Message::new(Role::User, vec![result]),
|
||||||
|
];
|
||||||
|
let wire = super::to_wire_messages("sys", &msgs);
|
||||||
|
let tool = wire.iter().find(|m| m["role"] == "tool").expect("tool message");
|
||||||
|
assert_eq!(tool["tool_call_id"], "c1");
|
||||||
|
assert!(wire.iter().all(|m| m.get("name").is_none()), "no message may carry a name: {wire:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn splits_think_tags_across_chunks() {
|
fn splits_think_tags_across_chunks() {
|
||||||
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
|
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
|
||||||
|
|||||||
+14
-1
@@ -57,6 +57,12 @@ pub enum Part {
|
|||||||
/// Marks the start of a compacted history; the text is the summary.
|
/// Marks the start of a compacted history; the text is the summary.
|
||||||
Summary {
|
Summary {
|
||||||
text: String,
|
text: String,
|
||||||
|
/// Started automatically because the context was nearly full.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
auto: bool,
|
||||||
|
/// Context size (tokens) when it was compacted.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
tokens: Option<u64>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +166,9 @@ pub struct Session {
|
|||||||
/// User-defined group in the session list.
|
/// User-defined group in the session list.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub folder: Option<String>,
|
pub folder: Option<String>,
|
||||||
|
/// Instructions the user set for this chat; added to the system prompt of every turn.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub instructions: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
@@ -213,6 +222,9 @@ pub enum Event {
|
|||||||
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
|
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 },
|
||||||
|
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
|
||||||
|
/// a nearly full context (tokens of limit).
|
||||||
|
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Event {
|
impl Event {
|
||||||
@@ -234,7 +246,8 @@ impl Event {
|
|||||||
| Error { session_id, .. }
|
| Error { session_id, .. }
|
||||||
| Waiting { session_id, .. }
|
| Waiting { session_id, .. }
|
||||||
| QueueUpdated { session_id, .. }
|
| QueueUpdated { session_id, .. }
|
||||||
| StepStarted { session_id, .. } => Some(session_id),
|
| StepStarted { session_id, .. }
|
||||||
|
| Compaction { session_id, .. } => Some(session_id),
|
||||||
McpStatus { .. } => None,
|
McpStatus { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
# Moving CodeCraft to another host (Unraid)
|
||||||
|
|
||||||
|
This guide moves a running installation – Docker volume `codecraft-data` on the current host –
|
||||||
|
to another Docker host that keeps its data in **bind mounts**, for example Unraid. The script
|
||||||
|
`scripts/move-server.sh` does the work; this page explains what goes where, what to prepare and
|
||||||
|
how to check and roll back.
|
||||||
|
|
||||||
|
## What lives where
|
||||||
|
|
||||||
|
| Part | Contents | Today (Docker volume) | On the new host |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Data** | database `codecraft.db`, `config.toml` (incl. API keys), daily backups | `codecraft-data:/data` | `<dir>/data` → `/data` |
|
||||||
|
| **Agent installs** | the agent's home `/data/home`: toolchains (Rust, …), pip/pipx/npm/cargo packages, downloaded programs (`~/opt`), caches | inside the volume | `<tools-dir>/home` → `/data/home` |
|
||||||
|
| **Users' homes** | homes of sandboxed users `/data/users/<name>/home` (their installs, git settings) | inside the volume | `<tools-dir>/users` → `/data/users` |
|
||||||
|
| **Temporary files** | `/tmp` of agents, builds, downloads | container layer | `<tools-dir>/tmp` → `/tmp` |
|
||||||
|
| **Workspace** | your projects | `WORKSPACE` from `.env` | `--workspace` path → `/workspace` |
|
||||||
|
| **Image** | the program | registry image | registry image |
|
||||||
|
|
||||||
|
The image never changes: the container runs without root (no `apt`, no `sudo`), so agents can
|
||||||
|
only install into their home directory. With `--tools-dir` everything agents install or
|
||||||
|
download lands on a separate disk (e.g. an SSD); the data folder keeps only the database,
|
||||||
|
configuration and backups. If the tools disk is lost, agents simply install what they need
|
||||||
|
again – nothing important is on it. (Backups contain the database and configuration, not the
|
||||||
|
agent home.)
|
||||||
|
|
||||||
|
Sizes on 2026-11-09 for orientation: agent home 2.2 GB (of which downloaded programs 1.3 GB,
|
||||||
|
Rust toolchain 0.8 GB), database/config/backups about 1.2 GB.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
On the **new host** (Unraid):
|
||||||
|
|
||||||
|
- **SSH with a key** from the current host, without a password prompt. Check from the current
|
||||||
|
host: `ssh root@unraid true` (no output, no question = fine).
|
||||||
|
- **docker**, **rsync** and **tar** available (all three are on Unraid by default).
|
||||||
|
- **Docker Compose** is optional: without it the script starts the container with the generated
|
||||||
|
`start.sh` (Unraid: the "Docker Compose Manager" plugin adds Compose).
|
||||||
|
- The **tools disk** is mounted, e.g. an SSD via Unassigned Devices under `/mnt/disks/ssd`.
|
||||||
|
- **Registry access**: the image comes from `git.familie-zink.org/zink/codecraft`. If the pull is
|
||||||
|
refused, run `docker login git.familie-zink.org` on Unraid once.
|
||||||
|
|
||||||
|
Decide:
|
||||||
|
|
||||||
|
| Question | Option | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Folder for data, compose file and scripts | target `root@host:/dir` | `/mnt/user/appdata/codecraft` |
|
||||||
|
| Folder for agent installs and `/tmp` | `--tools-dir` | `/mnt/disks/ssd/codecraft` |
|
||||||
|
| Your projects | `--workspace` | `/mnt/user/projects` |
|
||||||
|
| Owner of files (the container runs as this user) | `--uid`, `--gid` | Unraid default `99`/`100` (nobody:users); today `1000`/`1000` |
|
||||||
|
| Docker of the new host for agents and terminals | `--docker-sock` | bind mount of `/var/run/docker.sock` plus its group (see below) |
|
||||||
|
| Port and address | `--port`, `--bind` | `8787`; `0.0.0.0` if the reverse proxy runs on another machine or in another Docker network, `127.0.0.1` if it runs on the same host with host networking |
|
||||||
|
|
||||||
|
## Moving
|
||||||
|
|
||||||
|
On the **current host**, in the repository folder (where `.env` is):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Look at the steps first (changes nothing)
|
||||||
|
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||||
|
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
|
||||||
|
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock --dry-run
|
||||||
|
|
||||||
|
# 2. Move (same command without --dry-run)
|
||||||
|
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||||
|
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
|
||||||
|
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock
|
||||||
|
```
|
||||||
|
|
||||||
|
What happens:
|
||||||
|
|
||||||
|
1. **Checks**: docker/tar/rsync on the target; the target data folder (and `home/`, `users/` in
|
||||||
|
the tools folder) must be empty – the script never overwrites.
|
||||||
|
2. **Workspace, first pass** with rsync while CodeCraft keeps running (the long part).
|
||||||
|
3. **Stops CodeCraft here.** Running agents and terminals end. The container is only stopped,
|
||||||
|
not removed.
|
||||||
|
4. **Data volume** is streamed with tar over SSH into `<dir>/data`; with `--tools-dir` the agent
|
||||||
|
home and the users' homes are then moved to the tools folder.
|
||||||
|
5. **Workspace, second pass**: only what changed since step 2.
|
||||||
|
6. **Owner** of data, tools and workspace set to `--uid:--gid` (needs root on the target).
|
||||||
|
7. **Writes** `<dir>/docker-compose.yml`, `<dir>/.env` (your `.env` with the new paths, port and
|
||||||
|
owner), `<dir>/start.sh` (the same as `docker run`, for hosts without Compose) and
|
||||||
|
`<dir>/docker/seccomp-sandbox.json`.
|
||||||
|
8. **Starts** the image `CODECRAFT_VERSION` from `.env` (Compose, else `start.sh`).
|
||||||
|
9. **Smoke test** on the target (`scripts/smoke-test.sh`): health, version, web UI, terminal
|
||||||
|
client download.
|
||||||
|
|
||||||
|
Afterwards:
|
||||||
|
|
||||||
|
- **Reverse proxy**: point the proxy host for `code.familie-zink.org` (Nginx Proxy Manager) to the
|
||||||
|
new host and port; keep WebSocket support on.
|
||||||
|
- **Check in the web UI**: sign in, open a chat, check the status LEDs (providers, MCP servers),
|
||||||
|
let an agent run `git status` in a project (credentials, identity), open a terminal tab.
|
||||||
|
Models or MCP servers on the old host's LAN address or `localhost` need new addresses.
|
||||||
|
- **Clean up** on the old host when everything works: `docker rm codecraft`, later
|
||||||
|
`docker volume rm codecraft-data`.
|
||||||
|
|
||||||
|
## Docker socket (`--docker-sock`)
|
||||||
|
|
||||||
|
With `--docker-sock` the container gets the new host's Docker: `/var/run/docker.sock` is bind
|
||||||
|
mounted and the container joins the socket's group (the script reads its group id on the
|
||||||
|
target). This is **root-equivalent access to the host**: whoever can start containers can mount
|
||||||
|
any host folder.
|
||||||
|
|
||||||
|
- Admins' agents and terminals (they run without sandbox) can use it.
|
||||||
|
- Sandboxed users do not see the socket (the sandbox does not mount `/var/run`).
|
||||||
|
- `docker` and `docker-compose` commands always need an approval, except in *bypass* mode.
|
||||||
|
- The image contains the Docker CLI (static client); the daemon is the host's.
|
||||||
|
|
||||||
|
The current host has the same setup in its local `docker-compose.override.yml` (group 997).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Nothing is deleted on the old host. On Unraid stop the new container (`docker stop codecraft`),
|
||||||
|
on the old host start the old one (`docker start codecraft`) and point the reverse proxy back.
|
||||||
|
Changes made on the new host in the meantime (chats, settings) are not in the old installation.
|
||||||
|
|
||||||
|
## Running it on the new host
|
||||||
|
|
||||||
|
In `<dir>` on Unraid:
|
||||||
|
|
||||||
|
| Task | With Compose | Without Compose |
|
||||||
|
|---|---|---|
|
||||||
|
| Update | set `CODECRAFT_VERSION` in `.env`, then `docker compose pull && docker compose up -d` | set `CODECRAFT_VERSION` in `.env`, `docker pull …:<version>`, then `./start.sh` |
|
||||||
|
| Restart | `docker compose restart` | `docker restart codecraft` |
|
||||||
|
| Logs | `docker compose logs -f` | `docker logs -f codecraft` |
|
||||||
|
| Check | `bash smoke-test.sh http://127.0.0.1:8787 <version>` | same |
|
||||||
|
|
||||||
|
Releases are published from the development machine with `scripts/release.sh`; the new host
|
||||||
|
only pulls them.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **`data on the target is not empty`**: an earlier attempt left files. Move `<dir>/data` (and
|
||||||
|
`home/`, `users/` in the tools folder) away and run the script again.
|
||||||
|
- **Image pull refused**: `docker login git.familie-zink.org` on the target, then
|
||||||
|
`cd <dir> && docker compose up -d` (or `./start.sh`).
|
||||||
|
- **Smoke test fails**: `docker logs codecraft` on the target. The old installation is stopped but
|
||||||
|
intact – `docker start codecraft` on the old host brings it back.
|
||||||
|
- **`could not chown`**: the SSH user is not root; set the owner by hand
|
||||||
|
(`chown -R 99:100 <dir>/data <tools-dir> <workspace>`).
|
||||||
|
- **Permission denied inside the container**: data, tools and workspace must belong to the
|
||||||
|
`UID`/`GID` in `<dir>/.env`.
|
||||||
|
- **Port already in use**: change `PORT` in `<dir>/.env` and start again.
|
||||||
Executable
+226
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Move this CodeCraft installation to another Docker host (e.g. Unraid) that keeps its
|
||||||
|
# data in bind mounts instead of the Docker volume "codecraft-data".
|
||||||
|
#
|
||||||
|
# Run it on the CURRENT host, in the repository folder (where .env lives):
|
||||||
|
#
|
||||||
|
# scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||||
|
# --workspace /mnt/user/projects --uid 99 --gid 100 --bind 0.0.0.0
|
||||||
|
#
|
||||||
|
# What it does:
|
||||||
|
# 1. copies the workspace while CodeCraft keeps running (rsync, first pass),
|
||||||
|
# 2. stops the CodeCraft container here (it is kept for a rollback),
|
||||||
|
# 3. streams the data volume (database, config, backups, agent home) to <dir>/data,
|
||||||
|
# 4. syncs the workspace again (only what changed),
|
||||||
|
# 5. writes <dir>/docker-compose.yml, <dir>/.env and <dir>/start.sh (docker run, for hosts
|
||||||
|
# without Compose) with bind mounts, and starts the published image there,
|
||||||
|
# 6. runs scripts/smoke-test.sh on the new host.
|
||||||
|
# Afterwards point the reverse proxy to the new host. To roll back: start the old container
|
||||||
|
# here again (docker compose start) – nothing is deleted on this host.
|
||||||
|
#
|
||||||
|
# Target: user@host:/dir (SSH; rsync and docker on the target), or local:/dir to try it on
|
||||||
|
# this host (use --name/--port so it does not clash with the running installation).
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --workspace DIR project folder on the target (default <dir>/workspace)
|
||||||
|
# --tools-dir DIR separate disk for what agents install and download: the agent home
|
||||||
|
# (/data/home), the users' homes (/data/users) and /tmp are bind
|
||||||
|
# mounts from DIR/home, DIR/users and DIR/tmp (default: inside <dir>/data)
|
||||||
|
# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env)
|
||||||
|
# --docker-sock give the container the target's Docker (bind mount of
|
||||||
|
# /var/run/docker.sock plus its group). Root-equivalent access to the
|
||||||
|
# target host for admins' agents and terminals; sandboxed users do not
|
||||||
|
# see it, and docker commands still need approval
|
||||||
|
# --port N host port on the target (default: PORT of .env)
|
||||||
|
# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1)
|
||||||
|
# --name NAME container name on the target (default codecraft)
|
||||||
|
# --source-container C container to stop here (default codecraft)
|
||||||
|
# --source-volume V data volume here (default codecraft-data)
|
||||||
|
# --source-workspace D project folder here (default: WORKSPACE of .env)
|
||||||
|
# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
|
||||||
|
# --dry-run show the steps only
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
|
||||||
|
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--workspace) ws_target="$2"; shift ;;
|
||||||
|
--tools-dir) tools_dir="$2"; shift ;;
|
||||||
|
--docker-sock) docker_sock=1 ;;
|
||||||
|
--uid) uid="$2"; shift ;;
|
||||||
|
--gid) gid="$2"; shift ;;
|
||||||
|
--port) port="$2"; shift ;;
|
||||||
|
--bind) bind="$2"; shift ;;
|
||||||
|
--name) name="$2"; shift ;;
|
||||||
|
--source-container) src_container="$2"; shift ;;
|
||||||
|
--source-volume) src_volume="$2"; shift ;;
|
||||||
|
--source-workspace) ws_source="$2"; shift ;;
|
||||||
|
--no-stop) stop=0 ;;
|
||||||
|
--dry-run) dry=1 ;;
|
||||||
|
-h|--help) sed -n '2,36p' "$0"; exit 0 ;;
|
||||||
|
-*) echo "unknown option $1" >&2; exit 2 ;;
|
||||||
|
*) target="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
[[ "$target" == *:/* ]] || { echo "usage: $0 user@host:/dir | local:/dir [options] (see --help)" >&2; exit 2; }
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
|
||||||
|
env_get() { sed -n "s/^$1=//p" .env | tail -1; }
|
||||||
|
|
||||||
|
host="${target%%:*}"
|
||||||
|
dir="${target#*:}"
|
||||||
|
dir="${dir%/}"
|
||||||
|
ws_target="${ws_target:-$dir/workspace}"
|
||||||
|
uid="${uid:-$(env_get UID)}"; uid="${uid:-1000}"
|
||||||
|
gid="${gid:-$(env_get GID)}"; gid="${gid:-1000}"
|
||||||
|
port="${port:-$(env_get PORT)}"; port="${port:-8787}"
|
||||||
|
bind="${bind:-$(env_get BIND_ADDR)}"; bind="${bind:-127.0.0.1}"
|
||||||
|
version="$(env_get CODECRAFT_VERSION)"; version="${version:-latest}"
|
||||||
|
ws_source="${ws_source:-$(env_get WORKSPACE)}"; ws_source="${ws_source:-./workspace}"
|
||||||
|
image="$(docker inspect "$src_container" --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||||
|
image_repo="${image%:*}"
|
||||||
|
[[ -n "$image_repo" && "$image_repo" != codecraft ]] || image_repo="git.familie-zink.org/zink/codecraft"
|
||||||
|
|
||||||
|
step() { printf '\n==> %s\n' "$*"; }
|
||||||
|
fail() { echo "error: $*" >&2; exit 1; }
|
||||||
|
# Run a shell command on the target.
|
||||||
|
remote() {
|
||||||
|
if (( dry )); then echo "[dry-run] on $host: $1"; return 0; fi
|
||||||
|
if [[ "$host" == local ]]; then bash -c "$1"; else ssh -o BatchMode=yes "$host" "$1"; fi
|
||||||
|
}
|
||||||
|
q() { printf '%q' "$1"; }
|
||||||
|
sync_workspace() {
|
||||||
|
if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi
|
||||||
|
if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/"
|
||||||
|
else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
|
||||||
|
}
|
||||||
|
put_file() { # put_file <remote path> < content
|
||||||
|
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
|
||||||
|
if [[ "$host" == local ]]; then cat >"$1"; else ssh -o BatchMode=yes "$host" "cat > $(q "$1")"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
step "Checking source and target"
|
||||||
|
docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
|
||||||
|
[[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
|
||||||
|
echo "source: container $src_container, volume $src_volume, workspace $ws_source"
|
||||||
|
tools_dir="${tools_dir%/}"
|
||||||
|
echo "target: $host:$dir (data in $dir/data, workspace $ws_target${tools_dir:+, agent installs and /tmp in $tools_dir}), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version"
|
||||||
|
remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync"
|
||||||
|
remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \
|
||||||
|
|| fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)"
|
||||||
|
if [[ -n "$tools_dir" ]]; then
|
||||||
|
remote "mkdir -p $(q "$tools_dir") && test ! -e $(q "$tools_dir/home") && test ! -e $(q "$tools_dir/users")" \
|
||||||
|
|| fail "$tools_dir already has home/ or users/ – refusing to overwrite (move them away first)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Copying the workspace (first pass, CodeCraft keeps running)"
|
||||||
|
sync_workspace
|
||||||
|
|
||||||
|
if (( stop )); then
|
||||||
|
step "Stopping $src_container here (running agents and terminals end)"
|
||||||
|
(( dry )) && echo "[dry-run] docker stop $src_container" || docker stop "$src_container" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Copying the data volume"
|
||||||
|
if (( dry )); then
|
||||||
|
echo "[dry-run] tar of volume $src_volume -> $host:$dir/data"
|
||||||
|
else
|
||||||
|
docker run --rm --entrypoint tar --user 0:0 -v "$src_volume:/data:ro" "${image:-$image_repo:$version}" -C /data -cf - . \
|
||||||
|
| remote "tar -C $(q "$dir/data") -xpf -"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$tools_dir" ]]; then
|
||||||
|
step "Moving the agent home and the users' homes to $tools_dir"
|
||||||
|
remote "mkdir -p $(q "$tools_dir/tmp") $(q "$dir/data/home") $(q "$dir/data/users") \
|
||||||
|
&& mv $(q "$dir/data/home") $(q "$tools_dir/home") && mv $(q "$dir/data/users") $(q "$tools_dir/users") \
|
||||||
|
&& mkdir -p $(q "$dir/data/home") $(q "$dir/data/users") && chmod 1777 $(q "$tools_dir/tmp")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Copying the workspace (changes since the first pass)"
|
||||||
|
sync_workspace
|
||||||
|
remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") ${tools_dir:+$(q "$tools_dir/home") $(q "$tools_dir/users")} 2>/dev/null || echo 'note: could not chown (not root) – check file owners'"
|
||||||
|
# Bind mounts for the tools disk and the Docker socket (compose lines and docker run flags).
|
||||||
|
tool_mounts="" tool_flags="" sock_compose="" sock_flags=""
|
||||||
|
if (( docker_sock )); then
|
||||||
|
if (( dry )); then sock_gid=0; else
|
||||||
|
sock_gid=$(remote "stat -c %g /var/run/docker.sock") || fail "no /var/run/docker.sock on the target"
|
||||||
|
fi
|
||||||
|
sock_compose=$(printf ' - /var/run/docker.sock:/var/run/docker.sock\n group_add:\n - "%s"\n' "$sock_gid")
|
||||||
|
sock_flags="-v /var/run/docker.sock:/var/run/docker.sock --group-add $sock_gid "
|
||||||
|
fi
|
||||||
|
if [[ -n "$tools_dir" ]]; then
|
||||||
|
tool_mounts=$(printf ' - %s/home:/data/home\n - %s/users:/data/users\n - %s/tmp:/tmp\n' "$tools_dir" "$tools_dir" "$tools_dir")
|
||||||
|
tool_flags="-v $tools_dir/home:/data/home -v $tools_dir/users:/data/users -v $tools_dir/tmp:/tmp "
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Writing docker-compose.yml, .env and start.sh"
|
||||||
|
put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json
|
||||||
|
grep -vE '^(COMPOSE_FILE|WORKSPACE|UID|GID|PORT|BIND_ADDR)=' .env | {
|
||||||
|
cat
|
||||||
|
printf '\n# Written by scripts/move-server.sh\nWORKSPACE=%s\nUID=%s\nGID=%s\nPORT=%s\nBIND_ADDR=%s\n' "$ws_target" "$uid" "$gid" "$port" "$bind"
|
||||||
|
} | put_file "$dir/.env"
|
||||||
|
put_file "$dir/docker-compose.yml" <<EOF
|
||||||
|
# CodeCraft with bind mounts (written by scripts/move-server.sh).
|
||||||
|
# Update: set CODECRAFT_VERSION in .env, then docker compose pull && docker compose up -d
|
||||||
|
services:
|
||||||
|
codecraft:
|
||||||
|
image: $image_repo:\${CODECRAFT_VERSION:-latest}
|
||||||
|
container_name: $name
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "\${UID}:\${GID}"
|
||||||
|
ports:
|
||||||
|
- "\${BIND_ADDR}:\${PORT}:8787"
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
volumes:
|
||||||
|
- $dir/data:/data
|
||||||
|
- \${WORKSPACE}:/workspace
|
||||||
|
$tool_mounts
|
||||||
|
$sock_compose
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
shm_size: "1gb"
|
||||||
|
security_opt:
|
||||||
|
- seccomp=$dir/docker/seccomp-sandbox.json
|
||||||
|
- apparmor=unconfined
|
||||||
|
- systempaths=unconfined
|
||||||
|
EOF
|
||||||
|
put_file "$dir/start.sh" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
# Same as docker-compose.yml, for hosts without Docker Compose.
|
||||||
|
set -e
|
||||||
|
cd "\$(dirname "\$0")"
|
||||||
|
# Read values instead of sourcing .env: UID is read-only in bash.
|
||||||
|
val() { sed -n "s/^\$1=//p" .env | tail -1; }
|
||||||
|
docker rm -f $name >/dev/null 2>&1 || true
|
||||||
|
exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
|
||||||
|
-p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
|
||||||
|
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" $tool_flags$sock_flags\\
|
||||||
|
--add-host host.docker.internal:host-gateway --shm-size 1g \\
|
||||||
|
--security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
|
||||||
|
--security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
|
||||||
|
$image_repo:\$(val CODECRAFT_VERSION)
|
||||||
|
EOF
|
||||||
|
remote "chmod +x $(q "$dir/start.sh")"
|
||||||
|
|
||||||
|
step "Starting CodeCraft on the target"
|
||||||
|
remote "cd $(q "$dir") && docker pull -q $image_repo:$version >/dev/null && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" \
|
||||||
|
|| fail "start failed – if the image pull was refused, run 'docker login ${image_repo%%/*}' on the target and start again with: cd $dir && docker compose up -d (or ./start.sh)"
|
||||||
|
|
||||||
|
step "Smoke test on the target"
|
||||||
|
put_file "$dir/smoke-test.sh" <scripts/smoke-test.sh
|
||||||
|
remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${version#v}" || fail "smoke test failed – the old installation here is stopped but intact (docker start $src_container)"
|
||||||
|
|
||||||
|
step "Done"
|
||||||
|
cat <<EOF
|
||||||
|
CodeCraft runs on $host ($bind:$port). Next:
|
||||||
|
- point the reverse proxy (e.g. Nginx Proxy Manager) for your domain to the new host and port,
|
||||||
|
- check the agents' tools in a chat (git credentials, MCP servers, LAN addresses),
|
||||||
|
- when everything works, remove the old container here: docker rm $src_container
|
||||||
|
(the volume $src_volume stays until you delete it: docker volume rm $src_volume).
|
||||||
|
Rollback: on the target 'docker stop $name', here 'docker start $src_container'.
|
||||||
|
EOF
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Quick check of a running CodeCraft: health, version, web UI, terminal client download.
|
||||||
|
#
|
||||||
|
# Usage: scripts/smoke-test.sh [URL] [EXPECTED_VERSION]
|
||||||
|
# URL base URL (default http://127.0.0.1:8787)
|
||||||
|
# EXPECTED_VERSION e.g. 202611-00003 (without "v"); checked when given
|
||||||
|
#
|
||||||
|
# Exit code 0 when everything answers, 1 otherwise.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
url="${1:-http://127.0.0.1:8787}"
|
||||||
|
url="${url%/}"
|
||||||
|
want="${2:-}"
|
||||||
|
want="${want#v}"
|
||||||
|
fail=0
|
||||||
|
check() { if [[ "$2" == ok ]]; then printf ' ok %s\n' "$1"; else printf ' FAIL %s (%s)\n' "$1" "$2"; fail=1; fi; }
|
||||||
|
|
||||||
|
echo "smoke test: $url"
|
||||||
|
health=""
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
health=$(curl -fsS -m 5 "$url/api/health" 2>/dev/null) && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[[ -n "$health" ]] && check "health" ok || check "health" "no answer after 2 minutes"
|
||||||
|
if [[ -n "$health" ]]; then
|
||||||
|
version=$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' <<<"$health")
|
||||||
|
if [[ -n "$want" && "$version" != "$want" ]]; then check "version $version" "expected $want"; else check "version ${version:-?}" ok; fi
|
||||||
|
grep -q '"ok":true' <<<"$health" && check "server reports ok" ok || check "server reports ok" "$health"
|
||||||
|
fi
|
||||||
|
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/")
|
||||||
|
[[ "$code" == 200 ]] && check "web UI" ok || check "web UI" "HTTP $code"
|
||||||
|
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/cli/install.sh")
|
||||||
|
[[ "$code" == 200 ]] && check "terminal client install script" ok || check "terminal client install script" "HTTP $code"
|
||||||
|
code=$(curl -s -o /dev/null -m 60 -w '%{http_code}' "$url/cli/download/linux-x86_64")
|
||||||
|
[[ "$code" == 200 ]] && check "terminal client download" ok || check "terminal client download" "HTTP $code"
|
||||||
|
exit $fail
|
||||||
+1
-1
@@ -28,7 +28,7 @@ shell-words = "1"
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form", "multipart"] }
|
||||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
+181
-49
@@ -15,7 +15,7 @@ use axum::middleware;
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{delete, get, post, put};
|
use axum::routing::{delete, get, post, put};
|
||||||
use axum::{Extension, Json, Router};
|
use axum::{Extension, Json, Router};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -66,10 +66,10 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/folders/{id}/access", put(set_folder_access))
|
.route("/folders/{id}/access", put(set_folder_access))
|
||||||
.route("/config", get(get_config).patch(patch_config))
|
.route("/config", get(get_config).patch(patch_config))
|
||||||
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
||||||
.route("/config/inline-env", get(inline_env_preview).post(inline_env_apply))
|
|
||||||
.route("/providers/reload", post(reload_providers))
|
.route("/providers/reload", post(reload_providers))
|
||||||
.route("/models", get(models))
|
.route("/models", get(models))
|
||||||
.route("/agents", get(agents))
|
.route("/agents", get(agents))
|
||||||
|
.route("/agents/tools", get(agent_tools))
|
||||||
.route("/agents/{name}", delete(delete_agent))
|
.route("/agents/{name}", delete(delete_agent))
|
||||||
.route("/commands", get(commands))
|
.route("/commands", get(commands))
|
||||||
.route("/sessions", get(list_sessions).post(create_session))
|
.route("/sessions", get(list_sessions).post(create_session))
|
||||||
@@ -78,6 +78,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions/{id}/title", post(suggest_title))
|
.route("/sessions/{id}/title", post(suggest_title))
|
||||||
.route("/sessions/{id}/regenerate", post(regenerate))
|
.route("/sessions/{id}/regenerate", post(regenerate))
|
||||||
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
|
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
|
||||||
|
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
||||||
.route("/sessions/{id}/export", get(export_session))
|
.route("/sessions/{id}/export", get(export_session))
|
||||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||||
.route("/sessions/{id}/prompt", post(prompt))
|
.route("/sessions/{id}/prompt", post(prompt))
|
||||||
@@ -136,6 +137,8 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
||||||
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
||||||
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
||||||
|
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
|
||||||
|
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
|
||||||
.route("/templates", get(crate::cli_api::get_templates))
|
.route("/templates", get(crate::cli_api::get_templates))
|
||||||
.route("/templates/{scope}", put(crate::cli_api::put_templates))
|
.route("/templates/{scope}", put(crate::cli_api::put_templates))
|
||||||
.route("/cli/config", get(crate::cli_api::config))
|
.route("/cli/config", get(crate::cli_api::config))
|
||||||
@@ -290,24 +293,6 @@ struct RawBody {
|
|||||||
text: String,
|
text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which ${VAR} references the config still has (and whether they are set).
|
|
||||||
async fn inline_env_preview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
|
||||||
require_admin(&user)?;
|
|
||||||
let (_, set, unset) = config::inline_env(&app.config_text()?)?;
|
|
||||||
Ok(Json(json!({"set": set, "unset": unset})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the values of set environment variables into the config file.
|
|
||||||
async fn inline_env_apply(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
|
||||||
require_admin(&user)?;
|
|
||||||
let (text, set, unset) = config::inline_env(&app.config_text()?)?;
|
|
||||||
if !set.is_empty() {
|
|
||||||
app.save_config(&text).await?;
|
|
||||||
app.audit(Some(&user), "config.inline_env", set.join(", "), "");
|
|
||||||
}
|
|
||||||
Ok(Json(json!({"set": set, "unset": unset})))
|
|
||||||
}
|
|
||||||
|
|
||||||
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?;
|
||||||
@@ -340,7 +325,86 @@ fn scope(app: &App, user: &User, cwd: Option<String>) -> (PathBuf, PathBuf) {
|
|||||||
|
|
||||||
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||||
Json(json!(prompts::agents(&app, &root, &cwd)))
|
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Telegram bot of the user: bot name, pairing state, errors (the token stays hidden).
|
||||||
|
async fn get_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
let s = crate::telegram::load(&app, &user.id);
|
||||||
|
let (running, error) = crate::telegram::status(&user.id);
|
||||||
|
Ok(Json(json!({
|
||||||
|
"configured": !s.token.is_empty(),
|
||||||
|
"bot": s.bot,
|
||||||
|
"paired": s.chat_id.is_some(),
|
||||||
|
"pair_code": if s.chat_id.is_none() { s.pair_code } else { String::new() },
|
||||||
|
"running": running,
|
||||||
|
"error": error,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TelegramBody {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect a bot: check the token, start polling and create a pairing code.
|
||||||
|
async fn put_telegram(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<TelegramBody>) -> ApiResult {
|
||||||
|
let token = crate::telegram::extract_token(&b.token)
|
||||||
|
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "that is not a bot token – it looks like 123456789:AAH… (paste only the token from @BotFather)".into()))?;
|
||||||
|
let bot = crate::telegram::check_token(&token).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("token rejected by Telegram: {e:#}")))?;
|
||||||
|
let s = crate::telegram::Settings { token, bot, pair_code: crate::telegram::new_pair_code(), ..Default::default() };
|
||||||
|
app.store.set_user_setting(&user.id, "telegram", &s)?;
|
||||||
|
crate::telegram::restart(&app, &user.id);
|
||||||
|
app.audit(Some(&user), "telegram.connect", &s.bot, "");
|
||||||
|
get_telegram(Extension(user), State(app)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
crate::telegram::stop(&user.id);
|
||||||
|
app.store.set_user_setting(&user.id, "telegram", &crate::telegram::Settings::default())?;
|
||||||
|
app.audit(Some(&user), "telegram.disconnect", "", "");
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's personal modes (name -> settings).
|
||||||
|
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the user's personal modes. Names of built-in, admin or project modes are
|
||||||
|
/// taken; tools and read-only work like in admin modes (the user's sandbox still applies).
|
||||||
|
async fn put_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>, Json(modes): Json<std::collections::BTreeMap<String, crate::config::AgentConfig>>) -> ApiResult {
|
||||||
|
if modes.len() > 30 {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "too many personal modes (max 30)".into()));
|
||||||
|
}
|
||||||
|
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||||
|
let taken: Vec<String> = prompts::agents(&app, &root, &root).into_iter().map(|a| a.name).collect();
|
||||||
|
let mut clean = std::collections::BTreeMap::new();
|
||||||
|
for (name, mut m) in modes {
|
||||||
|
if name.is_empty() || name.len() > 40 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid mode name '{name}' (letters, digits, - and _)")));
|
||||||
|
}
|
||||||
|
if taken.contains(&name) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, format!("the name '{name}' is already used by another mode")));
|
||||||
|
}
|
||||||
|
if m.prompt.as_deref().is_some_and(|p| p.len() > 20_000) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "prompt too long (max 20,000 characters)".into()));
|
||||||
|
}
|
||||||
|
m.mode = Some("primary".into());
|
||||||
|
m.max_parallel = None;
|
||||||
|
clean.insert(name, m);
|
||||||
|
}
|
||||||
|
app.store.set_user_setting(&user.id, "modes", &clean)?;
|
||||||
|
Ok(Json(json!(clean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-in tools for the mode editors (name, read-only, first line of the description).
|
||||||
|
async fn agent_tools() -> ApiResult {
|
||||||
|
let list: Vec<Value> = crate::tools::builtin_tools()
|
||||||
|
.iter()
|
||||||
|
.map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()}))
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!(list)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
||||||
@@ -510,6 +574,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
instructions: String::new(),
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
@@ -718,17 +783,29 @@ struct UpdateSession {
|
|||||||
archived: Option<bool>,
|
archived: Option<bool>,
|
||||||
/// "" removes the folder.
|
/// "" removes the folder.
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
|
/// Instructions for this chat ("" clears them).
|
||||||
|
instructions: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||||
let mut s = writable(&app, &user, &id)?;
|
writable(&app, &user, &id)?;
|
||||||
// Organising the list must not move the session to "today".
|
// Organising the list must not move the session to "today".
|
||||||
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none();
|
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none() && body.instructions.is_none();
|
||||||
|
if body.instructions.as_ref().is_some_and(|i| i.len() > 20_000) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "instructions too long (max 20,000 characters)".into()));
|
||||||
|
}
|
||||||
|
if let Some(m) = &body.model {
|
||||||
|
app.registry().resolve(m)?;
|
||||||
|
}
|
||||||
|
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||||
|
}
|
||||||
|
// Atomic: a running turn updates the same session (usage, title, todos) meanwhile.
|
||||||
|
let s = app.modify_session(&id, !organise_only, |s| {
|
||||||
if let Some(t) = body.title {
|
if let Some(t) = body.title {
|
||||||
s.title = t;
|
s.title = t;
|
||||||
}
|
}
|
||||||
if let Some(m) = body.model {
|
if let Some(m) = body.model {
|
||||||
app.registry().resolve(&m)?;
|
|
||||||
s.model = m;
|
s.model = m;
|
||||||
}
|
}
|
||||||
if let Some(a) = body.agent {
|
if let Some(a) = body.agent {
|
||||||
@@ -750,18 +827,48 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
let f = f.trim().chars().take(60).collect::<String>();
|
let f = f.trim().chars().take(60).collect::<String>();
|
||||||
s.folder = (!f.is_empty()).then_some(f);
|
s.folder = (!f.is_empty()).then_some(f);
|
||||||
}
|
}
|
||||||
if organise_only {
|
|
||||||
app.store.save_session(&s)?;
|
|
||||||
app.emit(Event::SessionUpdated { session: s.clone() });
|
|
||||||
return Ok(Json(json!(s)));
|
|
||||||
}
|
|
||||||
if let Some(p) = body.permission_mode {
|
if let Some(p) = body.permission_mode {
|
||||||
if !crate::permission::MODES.contains(&p.as_str()) {
|
|
||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
|
||||||
}
|
|
||||||
s.permission_mode = p;
|
s.permission_mode = p;
|
||||||
}
|
}
|
||||||
Ok(Json(json!(app.save_session(s)?)))
|
if let Some(i) = body.instructions {
|
||||||
|
s.instructions = i.trim().to_string();
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(Json(json!(s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
struct FileTab {
|
||||||
|
id: String,
|
||||||
|
dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_tabs_key(session_id: &str) -> String {
|
||||||
|
format!("file_tabs:{session_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tabs of a chat for this user: its terminals and file managers (kept on the server,
|
||||||
|
/// so they are the same on every device).
|
||||||
|
async fn session_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
let files: Vec<FileTab> = app.store.user_setting(&user.id, &file_tabs_key(&id))?;
|
||||||
|
Ok(Json(json!({"terminals": app.terminals.for_session(&id, &user.id), "files": files})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(tabs): Json<Vec<FileTab>>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
if tabs.len() > 20 {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "too many file manager tabs".into()));
|
||||||
|
}
|
||||||
|
let roots = app.user_roots(Some(&user.id));
|
||||||
|
for t in &tabs {
|
||||||
|
let p = crate::tools::normalize(&PathBuf::from(&t.dir));
|
||||||
|
if t.id.len() > 64 || !roots.iter().any(|r| p.starts_with(r)) {
|
||||||
|
return Err(ApiError(StatusCode::FORBIDDEN, "folder is outside your folders".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.store.set_user_setting(&user.id, &file_tabs_key(&id), &tabs)?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
@@ -770,6 +877,7 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
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);
|
crate::shells::kill_session(&app, &id);
|
||||||
|
app.terminals.kill_session(&id);
|
||||||
app.store.delete_session(&id)?;
|
app.store.delete_session(&id)?;
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
@@ -798,41 +906,48 @@ fn set_turn_effort(app: &App, id: &str, effort: Option<&str>) -> Result<(), ApiE
|
|||||||
|
|
||||||
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
||||||
let session = writable(&app, &user, &id)?;
|
let session = writable(&app, &user, &id)?;
|
||||||
let text = body.text.trim().to_string();
|
send_prompt(&app, session, body.text, body.images, body.effort.as_deref()).map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a user message for a session (web UI and Telegram): built-in commands,
|
||||||
|
/// slash command expansion, then start or queue the turn.
|
||||||
|
pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text: String, images: Vec<ImageData>, effort: Option<&str>) -> Result<Value, ApiError> {
|
||||||
|
let id = session.id.clone();
|
||||||
|
let text = text.trim().to_string();
|
||||||
// Built-in commands.
|
// Built-in commands.
|
||||||
if text == "/compact" {
|
if text == "/compact" {
|
||||||
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()));
|
||||||
}
|
}
|
||||||
tokio::spawn(do_compact(app.clone(), id.clone()));
|
tokio::spawn(do_compact(app.clone(), id.clone()));
|
||||||
return Ok(Json(json!({"ok": true, "command": "compact"})));
|
return Ok(json!({"ok": true, "command": "compact"}));
|
||||||
}
|
}
|
||||||
let cwd = PathBuf::from(&session.cwd);
|
let cwd = PathBuf::from(&session.cwd);
|
||||||
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, clean_room)) = 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 agent.is_some() || clean_room.is_some() {
|
if agent.is_some() || clean_room.is_some() {
|
||||||
let mut s = session.clone();
|
app.update_session(&id, |s| {
|
||||||
if let Some(a) = agent {
|
if let Some(a) = agent {
|
||||||
s.agent = a;
|
s.agent = a;
|
||||||
}
|
}
|
||||||
if clean_room.is_some() {
|
if clean_room.is_some() {
|
||||||
s.clean_room = clean_room;
|
s.clean_room = clean_room;
|
||||||
}
|
}
|
||||||
app.save_session(s)?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
set_turn_effort(&app, &id, body.effort.as_deref())?;
|
set_turn_effort(app, &id, effort)?;
|
||||||
let mut parts = vec![Part::Text { text: prompt_text }];
|
let mut parts = vec![Part::Text { text: prompt_text }];
|
||||||
for image in body.images {
|
for image in images {
|
||||||
parts.push(Part::Image { image });
|
parts.push(Part::Image { image });
|
||||||
}
|
}
|
||||||
match runner::submit(app.clone(), id, parts, "user")? {
|
match runner::submit(app.clone(), id, parts, "user")? {
|
||||||
runner::Submitted::Started => Ok(Json(json!({"ok": true}))),
|
runner::Submitted::Started => Ok(json!({"ok": true})),
|
||||||
runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))),
|
runner::Submitted::Queued(item) => Ok(json!({"ok": true, "queued": item})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,11 +971,11 @@ async fn flush_queue(Extension(user): Extension<User>, State(app): State<AppStat
|
|||||||
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
|
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn do_compact(app: AppState, id: String) {
|
pub(crate) async fn do_compact(app: AppState, id: String) {
|
||||||
let cancel = tokio_util::sync::CancellationToken::new();
|
let cancel = tokio_util::sync::CancellationToken::new();
|
||||||
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
|
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
|
||||||
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
|
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
|
||||||
if let Err(e) = runner::compact(app.clone(), &id, cancel).await {
|
if let Err(e) = runner::compact(app.clone(), &id, cancel, None).await {
|
||||||
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
|
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
|
||||||
}
|
}
|
||||||
app.with_runtime(&id, |r| r.cancel = None);
|
app.with_runtime(&id, |r| r.cancel = None);
|
||||||
@@ -1232,7 +1347,9 @@ async fn status_overview(Extension(user): Extension<User>, State(app): State<App
|
|||||||
(name, v)
|
(name, v)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp}))
|
// Budgets and plan windows (admins: all; plan windows are shown per provider anyway).
|
||||||
|
let budgets = if admin { crate::budget::meters(&app).unwrap_or_default() } else { vec![] };
|
||||||
|
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp, "budgets": budgets, "currency": cfg.server.currency}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
|
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
|
||||||
@@ -1563,20 +1680,35 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
|
|||||||
|
|
||||||
// --- web terminal ------------------------------------------------------------------
|
// --- web terminal ------------------------------------------------------------------
|
||||||
|
|
||||||
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
#[derive(Deserialize)]
|
||||||
Ok(Json(json!(app.terminals.list(&user.id))))
|
struct TerminalQuery {
|
||||||
|
session: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All terminals of the user, or the tabs of one chat (?session=…).
|
||||||
|
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<TerminalQuery>) -> ApiResult {
|
||||||
|
Ok(Json(json!(match q.session {
|
||||||
|
Some(s) => app.terminals.for_session(&s, &user.id),
|
||||||
|
None => app.terminals.list(&user.id),
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct OpenTerminal {
|
struct OpenTerminal {
|
||||||
cwd: Option<String>,
|
cwd: Option<String>,
|
||||||
|
/// Open as a tab of this chat (in its folder unless `cwd` is given).
|
||||||
|
session_id: Option<String>,
|
||||||
cols: Option<u16>,
|
cols: Option<u16>,
|
||||||
rows: Option<u16>,
|
rows: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn open_terminal(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<OpenTerminal>) -> ApiResult {
|
async fn open_terminal(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<OpenTerminal>) -> ApiResult {
|
||||||
let cwd = validate_cwd(&app, &user, b.cwd)?;
|
let session = match &b.session_id {
|
||||||
let term = crate::terminal::spawn(&app, &user, &cwd, b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
Some(id) => Some(writable(&app, &user, id)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let cwd = validate_cwd(&app, &user, b.cwd.or_else(|| session.as_ref().map(|s| s.cwd.clone())))?;
|
||||||
|
let term = crate::terminal::spawn(&app, &user, &cwd, session.map(|s| s.id), b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
app.audit(Some(&user), "terminal.open", cwd.display().to_string(), &crate::auth::client_ip(&headers));
|
app.audit(Some(&user), "terminal.open", cwd.display().to_string(), &crate::auth::client_ip(&headers));
|
||||||
Ok(Json(term.info()))
|
Ok(Json(term.info()))
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-3
@@ -46,6 +46,8 @@ pub struct Runtime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
|
/// Serializes read-modify-write of sessions (see `update_session`).
|
||||||
|
session_write: Mutex<()>,
|
||||||
config_path: PathBuf,
|
config_path: PathBuf,
|
||||||
config: RwLock<Arc<Config>>,
|
config: RwLock<Arc<Config>>,
|
||||||
registry: RwLock<Arc<Registry>>,
|
registry: RwLock<Arc<Registry>>,
|
||||||
@@ -101,6 +103,7 @@ impl App {
|
|||||||
let registry = Registry::build(&cfg).await;
|
let registry = Registry::build(&cfg).await;
|
||||||
let (events, _) = broadcast::channel(4096);
|
let (events, _) = broadcast::channel(4096);
|
||||||
let app = Arc::new(Self {
|
let app = Arc::new(Self {
|
||||||
|
session_write: Mutex::new(()),
|
||||||
config_path,
|
config_path,
|
||||||
config: RwLock::new(Arc::new(cfg)),
|
config: RwLock::new(Arc::new(cfg)),
|
||||||
registry: RwLock::new(Arc::new(registry)),
|
registry: RwLock::new(Arc::new(registry)),
|
||||||
@@ -326,10 +329,27 @@ impl App {
|
|||||||
Ok(s)
|
Ok(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load, change and save a session atomically: background tasks (title, usage, phase,
|
||||||
|
/// todos) change different fields at the same time and must not undo each other.
|
||||||
|
pub fn update_session(&self, id: &str, f: impl FnOnce(&mut Session)) -> Result<Session> {
|
||||||
|
self.modify_session(id, true, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `update_session`; `touch = false` keeps `updated_at` (organising the list).
|
||||||
|
pub fn modify_session(&self, id: &str, touch: bool, f: impl FnOnce(&mut Session)) -> Result<Session> {
|
||||||
|
let _guard = self.session_write.lock().unwrap();
|
||||||
|
let mut s = self.session(id)?;
|
||||||
|
f(&mut s);
|
||||||
|
if touch {
|
||||||
|
return self.save_session(s);
|
||||||
|
}
|
||||||
|
self.store.save_session(&s)?;
|
||||||
|
self.emit(Event::SessionUpdated { session: s.clone() });
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> {
|
pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> {
|
||||||
let mut s = self.session(session_id)?;
|
self.update_session(session_id, |s| s.todos = todos)?;
|
||||||
s.todos = todos;
|
|
||||||
self.save_session(s)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,124 @@ pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One budget gauge: what is used of a limit (monthly budgets, Go plan windows).
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct Meter {
|
||||||
|
/// Stable id for alert state, e.g. "provider:anthropic" or "plan:opencode-go:5h".
|
||||||
|
pub key: String,
|
||||||
|
pub provider: String,
|
||||||
|
/// "total", "provider", "images" or the plan window ("5h", "week", "month").
|
||||||
|
pub kind: String,
|
||||||
|
pub used: f64,
|
||||||
|
pub limit: f64,
|
||||||
|
pub ratio: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All configured budgets and plan windows with their current use (no remote calls).
|
||||||
|
pub fn meters(app: &App) -> Result<Vec<Meter>> {
|
||||||
|
let cfg = app.config();
|
||||||
|
let spent = app.store.cost_by_provider(month_start())?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut push = |key: String, provider: &str, kind: &str, used: f64, limit: f64| {
|
||||||
|
if limit > 0.0 {
|
||||||
|
out.push(Meter { key, provider: provider.into(), kind: kind.into(), used, limit, ratio: used / limit });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
push("total".into(), "total", "total", spent.values().sum(), cfg.server.monthly_budget);
|
||||||
|
for (name, p) in cfg.providers.iter().filter(|(_, p)| p.enabled) {
|
||||||
|
push(format!("provider:{name}"), name, "provider", spent.get(name).copied().unwrap_or(0.0), p.monthly_budget.unwrap_or(0.0));
|
||||||
|
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
|
||||||
|
let plan = plan_usage(app, name, limit)?;
|
||||||
|
for w in plan["windows"].as_array().into_iter().flatten() {
|
||||||
|
let window = w["window"].as_str().unwrap_or("");
|
||||||
|
push(format!("plan:{name}:{window}"), name, window, w["used"].as_f64().unwrap_or(0.0), w["limit"].as_f64().unwrap_or(0.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.images.enabled {
|
||||||
|
push("images".into(), "fal", "images", spent.get("fal").copied().unwrap_or(0.0), cfg.images.monthly_budget.unwrap_or(0.0));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALERT_STATE: &str = "budget_alert_state";
|
||||||
|
/// Alert state is global; it is kept under a pseudo user id.
|
||||||
|
const SYSTEM: &str = "_system";
|
||||||
|
|
||||||
|
fn alert_text(m: &Meter, level: u8, de: bool) -> String {
|
||||||
|
let pct = (m.ratio * 100.0).round() as i64;
|
||||||
|
let what = match (m.kind.as_str(), de) {
|
||||||
|
("total", true) => "Gesamtbudget des Monats".to_string(),
|
||||||
|
("total", false) => "Monthly total budget".to_string(),
|
||||||
|
("provider", true) => format!("Monatsbudget {}", m.provider),
|
||||||
|
("provider", false) => format!("Monthly budget of {}", m.provider),
|
||||||
|
("images", true) => "Bildbudget des Monats".to_string(),
|
||||||
|
("images", false) => "Monthly image budget".to_string(),
|
||||||
|
("5h", true) => format!("{}: 5-Stunden-Limit", m.provider),
|
||||||
|
("5h", false) => format!("{}: 5-hour limit", m.provider),
|
||||||
|
("week", true) => format!("{}: Wochenlimit", m.provider),
|
||||||
|
("week", false) => format!("{}: weekly limit", m.provider),
|
||||||
|
(_, true) => format!("{}: Monatslimit des Plans", m.provider),
|
||||||
|
(_, false) => format!("{}: monthly plan limit", m.provider),
|
||||||
|
};
|
||||||
|
match (level, de) {
|
||||||
|
(2, true) => format!("{what} ausgeschöpft ({pct} %)"),
|
||||||
|
(2, false) => format!("{what} used up ({pct} %)"),
|
||||||
|
(_, true) => format!("{what} zu {pct} % verbraucht"),
|
||||||
|
(_, false) => format!("{what} {pct} % used"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push admins when a budget or plan window crosses the warning share or 100 %. Each
|
||||||
|
/// level is sent once; dropping below (new month, rolling window) re-arms it.
|
||||||
|
pub async fn check_alerts(app: &App) -> Result<()> {
|
||||||
|
let warn = app.config().server.budget_warning;
|
||||||
|
let mut state: std::collections::BTreeMap<String, u8> = app.store.user_setting(SYSTEM, ALERT_STATE)?;
|
||||||
|
let mut alerts = Vec::new();
|
||||||
|
let meters = meters(app)?;
|
||||||
|
for m in &meters {
|
||||||
|
let level = if m.ratio >= 1.0 { 2 } else if warn > 0.0 && m.ratio >= warn { 1 } else { 0 };
|
||||||
|
let before = state.get(&m.key).copied().unwrap_or(0);
|
||||||
|
if level > before {
|
||||||
|
alerts.push((m.clone(), level));
|
||||||
|
}
|
||||||
|
state.insert(m.key.clone(), level);
|
||||||
|
}
|
||||||
|
state.retain(|k, _| meters.iter().any(|m| &m.key == k));
|
||||||
|
app.store.set_user_setting(SYSTEM, ALERT_STATE, &state)?;
|
||||||
|
if alerts.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for user in app.store.users()?.into_iter().filter(|u| u.is_admin() && !u.disabled) {
|
||||||
|
let prefs: Value = app.store.user_setting(&user.id, "prefs").unwrap_or_default();
|
||||||
|
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
|
||||||
|
let body = alerts.iter().map(|(m, l)| alert_text(m, *l, de)).collect::<Vec<_>>().join("\n");
|
||||||
|
let used_up = alerts.iter().any(|(_, l)| *l == 2);
|
||||||
|
let title = match (used_up, de) {
|
||||||
|
(true, true) => "CodeCraft – Budget ausgeschöpft",
|
||||||
|
(true, false) => "CodeCraft – budget used up",
|
||||||
|
(false, true) => "CodeCraft – Budget fast verbraucht",
|
||||||
|
(false, false) => "CodeCraft – budget nearly used",
|
||||||
|
};
|
||||||
|
let payload = json!({"kind": "budget", "title": title, "body": body, "tag": "budget", "url": "/#/stats"});
|
||||||
|
let (sent, _) = crate::push::send_to_user(app, &user.id, &payload).await;
|
||||||
|
tracing::info!(user = %user.username, sent, alerts = alerts.len(), "budget alert");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_alerts(app: std::sync::Arc<App>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(120)).await;
|
||||||
|
loop {
|
||||||
|
if let Err(e) = check_alerts(&app).await {
|
||||||
|
tracing::warn!("budget alerts: {e:#}");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(600)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn overview(app: &App) -> Result<Value> {
|
pub async fn overview(app: &App) -> Result<Value> {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let spent = app.store.cost_by_provider(month_start())?;
|
let spent = app.store.cost_by_provider(month_start())?;
|
||||||
@@ -384,6 +502,14 @@ mod tests {
|
|||||||
assert_eq!(mc.price_tiers[0].above_context, 200000);
|
assert_eq!(mc.price_tiers[0].above_context, 200000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alert_texts() {
|
||||||
|
let m = super::Meter { key: "plan:go:5h".into(), provider: "opencode-go".into(), kind: "5h".into(), used: 1.7, limit: 2.0, ratio: 0.85 };
|
||||||
|
assert_eq!(super::alert_text(&m, 1, true), "opencode-go: 5-Stunden-Limit zu 85 % verbraucht");
|
||||||
|
let t = super::Meter { key: "total".into(), provider: "total".into(), kind: "total".into(), used: 10.0, limit: 10.0, ratio: 1.0 };
|
||||||
|
assert_eq!(super::alert_text(&t, 2, false), "Monthly total budget used up (100 %)");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn budget_entry_flags() {
|
fn budget_entry_flags() {
|
||||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||||
let agents: Vec<Value> = crate::prompts::agents(&app, &root, &root)
|
let agents: Vec<Value> = crate::prompts::agents_for(&app, &root, &root, Some(&user.id))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
let mut v = json!(a);
|
let mut v = json!(a);
|
||||||
|
|||||||
+7
-71
@@ -385,6 +385,13 @@ pub struct AgentConfig {
|
|||||||
pub tools: Option<Vec<String>>,
|
pub tools: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prompt: Option<String>,
|
pub prompt: Option<String>,
|
||||||
|
/// Only read-only tools (like plan mode).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub read_only: Option<bool>,
|
||||||
|
/// "coding" (default: project context and coding instructions) or "chat" (plain
|
||||||
|
/// conversation with only this prompt, like the built-in chat mode).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub style: Option<String>,
|
||||||
/// Max. instances of this subagent running at the same time.
|
/// Max. instances of this subagent running at the same time.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub max_parallel: Option<usize>,
|
pub max_parallel: Option<usize>,
|
||||||
@@ -470,57 +477,6 @@ pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
|
|||||||
Ok((cfg, text))
|
Ok((cfg, text))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace `${NAME}` references in all string values with the environment's values,
|
|
||||||
/// so the settings live in the config only. Comments and formatting stay; references
|
|
||||||
/// to unset variables are kept. Returns (new text, replaced names, unset names).
|
|
||||||
pub fn inline_env(text: &str) -> Result<(String, Vec<String>, Vec<String>)> {
|
|
||||||
use std::collections::BTreeSet;
|
|
||||||
let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?;
|
|
||||||
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
|
|
||||||
let (mut done, mut missing) = (BTreeSet::new(), BTreeSet::new());
|
|
||||||
let mut fix = |s: &str| -> Option<String> {
|
|
||||||
if !re.is_match(s) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let out = re.replace_all(s, |c: ®ex::Captures| match std::env::var(&c[1]).ok().filter(|v| !v.is_empty()) {
|
|
||||||
Some(v) => {
|
|
||||||
done.insert(c[1].to_string());
|
|
||||||
v
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
missing.insert(c[1].to_string());
|
|
||||||
c[0].to_string()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
(out != s).then(|| out.into_owned())
|
|
||||||
};
|
|
||||||
fn walk_value(v: &mut toml_edit::Value, fix: &mut dyn FnMut(&str) -> Option<String>) {
|
|
||||||
match v {
|
|
||||||
toml_edit::Value::String(f) => {
|
|
||||||
if let Some(new) = fix(f.value()) {
|
|
||||||
let decor = f.decor().clone();
|
|
||||||
let mut nv = toml_edit::Formatted::new(new);
|
|
||||||
*nv.decor_mut() = decor;
|
|
||||||
*f = nv;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toml_edit::Value::Array(a) => a.iter_mut().for_each(|x| walk_value(x, fix)),
|
|
||||||
toml_edit::Value::InlineTable(t) => t.iter_mut().for_each(|(_, x)| walk_value(x, fix)),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn walk_item(i: &mut toml_edit::Item, fix: &mut dyn FnMut(&str) -> Option<String>) {
|
|
||||||
match i {
|
|
||||||
toml_edit::Item::Value(v) => walk_value(v, fix),
|
|
||||||
toml_edit::Item::Table(t) => t.iter_mut().for_each(|(_, x)| walk_item(x, fix)),
|
|
||||||
toml_edit::Item::ArrayOfTables(a) => a.iter_mut().for_each(|t| t.iter_mut().for_each(|(_, x)| walk_item(x, fix))),
|
|
||||||
toml_edit::Item::None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk_item(doc.as_item_mut(), &mut fix);
|
|
||||||
Ok((doc.to_string(), done.into_iter().collect(), missing.into_iter().collect()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
|
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ConfigPatch {
|
pub struct ConfigPatch {
|
||||||
@@ -667,26 +623,6 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod inline_env_tests {
|
|
||||||
#[test]
|
|
||||||
fn replaces_set_variables_and_keeps_comments() {
|
|
||||||
// SAFETY: test-only, the variable names are unique to this test.
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("CC_TEST_INLINE_KEY", "sk-123");
|
|
||||||
std::env::remove_var("CC_TEST_INLINE_UNSET");
|
|
||||||
}
|
|
||||||
let text = "# keys: use ${NAME}\n[providers.a]\napi_key = \"${CC_TEST_INLINE_KEY}\" # stored\nheaders = { Authorization = \"Bearer ${CC_TEST_INLINE_KEY}\" }\nother = \"${CC_TEST_INLINE_UNSET}\"\n";
|
|
||||||
let (out, done, missing) = super::inline_env(text).unwrap();
|
|
||||||
assert!(out.contains("api_key = \"sk-123\" # stored"), "{out}");
|
|
||||||
assert!(out.contains("Bearer sk-123"));
|
|
||||||
assert!(out.contains("# keys: use ${NAME}"), "comments untouched");
|
|
||||||
assert!(out.contains("${CC_TEST_INLINE_UNSET}"));
|
|
||||||
assert_eq!(done, vec!["CC_TEST_INLINE_KEY"]);
|
|
||||||
assert_eq!(missing, vec!["CC_TEST_INLINE_UNSET"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ pub fn markdown(s: &Session, messages: &[Message]) -> String {
|
|||||||
for p in &m.parts {
|
for p in &m.parts {
|
||||||
match p {
|
match p {
|
||||||
Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())),
|
Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())),
|
||||||
Part::Summary { text } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
|
Part::Summary { text, .. } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
|
||||||
Part::Reasoning { text, .. } if !text.trim().is_empty() => {
|
Part::Reasoning { text, .. } if !text.trim().is_empty() => {
|
||||||
out.push_str(&format!("<details><summary>Thinking</summary>\n\n{}\n\n</details>\n\n", text.trim()))
|
out.push_str(&format!("<details><summary>Thinking</summary>\n\n{}\n\n</details>\n\n", text.trim()))
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -44,6 +44,8 @@ tokio::task_local! {
|
|||||||
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
||||||
/// The user's git config (credentials, identity) for unsandboxed runs.
|
/// The user's git config (credentials, identity) for unsandboxed runs.
|
||||||
static GITCONFIG: Option<std::path::PathBuf>;
|
static GITCONFIG: Option<std::path::PathBuf>;
|
||||||
|
/// The user's commit identity (overrides a repository's own user.name/email).
|
||||||
|
static GITENV: Vec<(String, String)>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||||
@@ -63,6 +65,7 @@ async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
|||||||
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
|
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
|
||||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||||
}
|
}
|
||||||
|
c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default());
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -171,7 +174,8 @@ 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));
|
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
||||||
let cfg = crate::gitcreds::global_config(&app, &user);
|
let cfg = crate::gitcreds::global_config(&app, &user);
|
||||||
SANDBOX.scope(spec, GITCONFIG.scope(cfg, handle_inner(user, app, b))).await
|
let env = crate::gitcreds::identity_env(&app, &user.id);
|
||||||
|
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
||||||
@@ -388,8 +392,9 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
|
|||||||
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
||||||
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
||||||
let cfg = crate::gitcreds::global_config(app, user);
|
let cfg = crate::gitcreds::global_config(app, user);
|
||||||
|
let env = crate::gitcreds::identity_env(app, &user.id);
|
||||||
let s = session.clone();
|
let s = session.clone();
|
||||||
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
|
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
|
||||||
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
||||||
session.cwd = cwd.to_string();
|
session.cwd = cwd.to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,24 @@ pub struct GitSettings {
|
|||||||
pub credentials: Vec<GitCredential>,
|
pub credentials: Vec<GitCredential>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's commit identity as environment variables. They take precedence over a
|
||||||
|
/// repository's own `user.name`/`user.email`, so commits made by agents, terminals and
|
||||||
|
/// the git panel always carry the identity from the settings.
|
||||||
|
pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
|
||||||
|
let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default();
|
||||||
|
let mut env = Vec::new();
|
||||||
|
let (name, email) = (s.name.trim(), s.email.trim());
|
||||||
|
if !name.is_empty() {
|
||||||
|
env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string()));
|
||||||
|
env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string()));
|
||||||
|
}
|
||||||
|
if !email.is_empty() {
|
||||||
|
env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string()));
|
||||||
|
env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string()));
|
||||||
|
}
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
||||||
app.data_dir().join("users").join(&user.username).join("home")
|
app.data_dir().join("users").join(&user.username).join("home")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
instructions: String::new(),
|
||||||
};
|
};
|
||||||
let session = app.save_session(session)?;
|
let session = app.save_session(session)?;
|
||||||
app.store.job_started(&job.id, &session.id, now)?;
|
app.store.job_started(&job.id, &session.id, now)?;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ mod runner;
|
|||||||
mod sandbox;
|
mod sandbox;
|
||||||
mod shells;
|
mod shells;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod telegram;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
mod tools;
|
mod tools;
|
||||||
mod types;
|
mod types;
|
||||||
@@ -72,6 +73,8 @@ async fn main() -> Result<()> {
|
|||||||
update::spawn_checker(app.clone());
|
update::spawn_checker(app.clone());
|
||||||
health::spawn_checker(app.clone());
|
health::spawn_checker(app.clone());
|
||||||
digest::spawn(app.clone());
|
digest::spawn(app.clone());
|
||||||
|
budget::spawn_alerts(app.clone());
|
||||||
|
telegram::spawn(app.clone());
|
||||||
backup::spawn(app.clone());
|
backup::spawn(app.clone());
|
||||||
let router = api::router(app);
|
let router = api::router(app);
|
||||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||||
|
|||||||
+74
-9
@@ -23,6 +23,8 @@ pub struct Agent {
|
|||||||
/// Allowed tools; None = all.
|
/// Allowed tools; None = all.
|
||||||
pub tools: Option<Vec<String>>,
|
pub tools: Option<Vec<String>>,
|
||||||
pub read_only: bool,
|
pub read_only: bool,
|
||||||
|
/// "coding" or "chat" (plain conversation prompt).
|
||||||
|
pub style: String,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
/// "builtin", "config" or "file".
|
/// "builtin", "config" or "file".
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -41,11 +43,12 @@ pub use codecraft_core::prompts::*;
|
|||||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||||
let craft = craft_prompt(clean_room);
|
let craft = craft_prompt(clean_room);
|
||||||
vec![
|
vec![
|
||||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||||
|
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +116,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
|
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
|
||||||
prompt: body,
|
prompt: body,
|
||||||
tools,
|
tools,
|
||||||
read_only: false,
|
read_only: meta.get("read_only").is_some_and(|v| v == "true"),
|
||||||
|
style: meta.get("style").filter(|v| *v == "chat").cloned().unwrap_or_else(|| "coding".into()),
|
||||||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||||
source: "file".into(),
|
source: "file".into(),
|
||||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||||
@@ -131,7 +135,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
|
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
|
||||||
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
|
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
|
||||||
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
|
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
|
||||||
read_only: base.as_ref().is_some_and(|b| b.read_only),
|
read_only: a.read_only.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.read_only)),
|
||||||
|
style: a.style.clone().filter(|s| s == "chat" || s == "coding").or(base.as_ref().map(|b| b.style.clone())).unwrap_or_else(|| "coding".into()),
|
||||||
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
|
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
|
||||||
source: "config".into(),
|
source: "config".into(),
|
||||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||||
@@ -142,8 +147,40 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
map.into_values().collect()
|
map.into_values().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent {
|
/// Personal modes a user created for themselves (user setting "modes").
|
||||||
agents(app, root, cwd)
|
pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap<String, crate::config::AgentConfig> {
|
||||||
|
app.store.user_setting(user_id, "modes").unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All agents plus the user's personal modes. Personal modes never replace built-in,
|
||||||
|
/// admin or project ones of the same name.
|
||||||
|
pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> Vec<Agent> {
|
||||||
|
let mut list = agents(app, root, cwd);
|
||||||
|
let Some(uid) = user_id else { return list };
|
||||||
|
for (name, a) in personal_modes(app, uid) {
|
||||||
|
if list.iter().any(|x| x.name == name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
list.push(Agent {
|
||||||
|
name,
|
||||||
|
description: a.description.unwrap_or_default(),
|
||||||
|
mode: "primary".into(),
|
||||||
|
prompt: a.prompt.unwrap_or_default(),
|
||||||
|
tools: a.tools,
|
||||||
|
read_only: a.read_only.unwrap_or(false),
|
||||||
|
style: a.style.filter(|s| s == "chat").unwrap_or_else(|| "coding".into()),
|
||||||
|
model: a.model.filter(|m| !m.is_empty()),
|
||||||
|
source: "personal".into(),
|
||||||
|
max_parallel: None,
|
||||||
|
refine: a.refine,
|
||||||
|
path: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_agent_for(app: &App, root: &Path, cwd: &Path, name: &str, user_id: Option<&str>) -> Agent {
|
||||||
|
agents_for(app, root, cwd, user_id)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|a| a.name == name)
|
.find(|a| a.name == name)
|
||||||
.unwrap_or_else(|| builtin_agents(true).remove(0))
|
.unwrap_or_else(|| builtin_agents(true).remove(0))
|
||||||
@@ -177,7 +214,35 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The plain chat mode: no coding instructions, project files or memory – only the
|
||||||
|
/// date, the model and what the user keeps in their personal memory.
|
||||||
|
/// Add the instructions the user set for this chat (they come last, so they win).
|
||||||
|
pub fn with_chat_instructions(mut system: String, instructions: &str) -> String {
|
||||||
|
if !instructions.trim().is_empty() {
|
||||||
|
system.push_str(&format!(
|
||||||
|
"\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n",
|
||||||
|
instructions.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
system
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
|
||||||
|
let mut s = agent.prompt.clone();
|
||||||
|
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
|
||||||
|
if let Some(u) = owner {
|
||||||
|
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
|
||||||
|
if !memory.trim().is_empty() {
|
||||||
|
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
|
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
|
||||||
|
if agent.style == "chat" {
|
||||||
|
return chat_prompt(app, agent, model, owner);
|
||||||
|
}
|
||||||
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);
|
||||||
|
|||||||
+33
-19
@@ -268,7 +268,7 @@ 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 mut agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
|
let mut agent = prompts::find_agent_for(&app, &root, &cwd, &session.agent, session.owner_id.as_deref());
|
||||||
let clean_room = session.clean_room(app.config().craft.clean_room);
|
let clean_room = session.clean_room(app.config().craft.clean_room);
|
||||||
// The craft prompt follows the session's clean room / deep switch.
|
// The craft prompt follows the session's clean room / deep switch.
|
||||||
if agent.name == "craft" && agent.source == "builtin" {
|
if agent.name == "craft" && agent.source == "builtin" {
|
||||||
@@ -284,7 +284,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
|
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
|
||||||
let threshold = app.config().server.auto_compact_threshold;
|
let threshold = app.config().server.auto_compact_threshold;
|
||||||
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
|
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
|
||||||
compact(app.clone(), session_id, cancel.clone()).await?;
|
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
let tools = available_tools(&app, &agent, owner.as_ref());
|
let tools = available_tools(&app, &agent, owner.as_ref());
|
||||||
let history = active_history(app.store.messages(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, owner.as_ref()),
|
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
|
||||||
messages: history,
|
messages: history,
|
||||||
tools: tools.iter().map(|t| t.def()).collect(),
|
tools: tools.iter().map(|t| t.def()).collect(),
|
||||||
max_tokens: model.max_output.unwrap_or(16_000),
|
max_tokens: model.max_output.unwrap_or(16_000),
|
||||||
@@ -667,7 +667,7 @@ pub async fn run_subagent(app: Arc<App>, ctx: &ToolCtx, agent: &str, description
|
|||||||
fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> {
|
fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> {
|
||||||
let parent = app.session(&ctx.session_id)?;
|
let parent = app.session(&ctx.session_id)?;
|
||||||
let root = ctx.root();
|
let root = ctx.root();
|
||||||
let parent_agent = prompts::find_agent(app, &root, &ctx.cwd, &parent.agent);
|
let parent_agent = prompts::find_agent_for(app, &root, &ctx.cwd, &parent.agent, parent.owner_id.as_deref());
|
||||||
let agents = prompts::agents(app, &root, &ctx.cwd);
|
let agents = prompts::agents(app, &root, &ctx.cwd);
|
||||||
let mut agent = agents
|
let mut agent = agents
|
||||||
.iter()
|
.iter()
|
||||||
@@ -704,6 +704,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
|
instructions: String::new(),
|
||||||
};
|
};
|
||||||
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 }]);
|
||||||
@@ -746,7 +747,7 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
|
|||||||
for m in &req.messages {
|
for m in &req.messages {
|
||||||
for p in &m.parts {
|
for p in &m.parts {
|
||||||
match p {
|
match p {
|
||||||
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(),
|
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => chars += text.len(),
|
||||||
Part::ToolCall { input, .. } => chars += input.to_string().len(),
|
Part::ToolCall { input, .. } => chars += input.to_string().len(),
|
||||||
Part::ToolResult { content, images: imgs, .. } => {
|
Part::ToolResult { content, images: imgs, .. } => {
|
||||||
chars += content.len();
|
chars += content.len();
|
||||||
@@ -913,9 +914,7 @@ async fn stream_step(
|
|||||||
app.store.add_message(&session.id, &message)?;
|
app.store.add_message(&session.id, &message)?;
|
||||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||||
}
|
}
|
||||||
let mut s = app.session(&session.id)?;
|
app.update_session(&session.id, |s| s.last_usage = Some(usage))?;
|
||||||
s.last_usage = Some(usage);
|
|
||||||
app.save_session(s)?;
|
|
||||||
return Ok(Step { message, stop_reason });
|
return Ok(Step { message, stop_reason });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -995,11 +994,12 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
|||||||
Ok((title, usage)) => {
|
Ok((title, usage)) => {
|
||||||
record_side_usage(&app, &session, &spec, &model, &usage, "title");
|
record_side_usage(&app, &session, &spec, &model, &usage, "title");
|
||||||
let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string();
|
let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string();
|
||||||
if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) {
|
if !title.is_empty() {
|
||||||
|
let _ = app.update_session(&session.id, |s| {
|
||||||
if s.title.is_empty() {
|
if s.title.is_empty() {
|
||||||
s.title = title.chars().take(80).collect();
|
s.title = title.chars().take(80).collect();
|
||||||
let _ = app.save_session(s);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::debug!("title generation failed: {e:#}"),
|
Err(e) => tracing::debug!("title generation failed: {e:#}"),
|
||||||
@@ -1096,7 +1096,7 @@ fn infer_phase(tool: &str, input: &Value, deep: bool) -> Option<&'static str> {
|
|||||||
/// Move the craft phase forward (never backwards); `only_from` restricts the transition.
|
/// Move the craft phase forward (never backwards); `only_from` restricts the transition.
|
||||||
fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) {
|
fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) {
|
||||||
let root = app.root_session_id(session_id);
|
let root = app.root_session_id(session_id);
|
||||||
let Ok(mut s) = app.session(&root) else { return };
|
let Ok(s) = app.session(&root) else { return };
|
||||||
let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p));
|
let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p));
|
||||||
if let Some(from) = only_from {
|
if let Some(from) = only_from {
|
||||||
if s.phase.as_deref() != Some(from) {
|
if s.phase.as_deref() != Some(from) {
|
||||||
@@ -1113,8 +1113,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
|
|||||||
if s.phase.as_deref() == Some(phase) {
|
if s.phase.as_deref() == Some(phase) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
s.phase = Some(phase.to_string());
|
let _ = app.update_session(&root, |s| s.phase = Some(phase.to_string()));
|
||||||
let _ = app.save_session(s);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Review the last turn against the user's request. Returns feedback for the
|
/// Review the last turn against the user's request. Returns feedback for the
|
||||||
@@ -1217,7 +1216,7 @@ pub fn transcript_of(history: &[Message]) -> String {
|
|||||||
for p in &m.parts {
|
for p in &m.parts {
|
||||||
match p {
|
match p {
|
||||||
Part::Text { text } => body.push_str(&format!("{text}\n")),
|
Part::Text { text } => body.push_str(&format!("{text}\n")),
|
||||||
Part::Summary { text } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
|
Part::Summary { text, .. } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
|
||||||
Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))),
|
Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))),
|
||||||
Part::ToolResult { name, content, is_error, .. } => {
|
Part::ToolResult { name, content, is_error, .. } => {
|
||||||
body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000)))
|
body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000)))
|
||||||
@@ -1234,7 +1233,23 @@ pub fn transcript_of(history: &[Message]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the active history with a summary (the old messages stay visible in the UI).
|
/// Replace the active history with a summary (the old messages stay visible in the UI).
|
||||||
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<()> {
|
/// Summarize the history. `auto` = (context tokens, context limit) when triggered by a
|
||||||
|
/// nearly full context; the chat shows the progress and the reason.
|
||||||
|
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
|
||||||
|
let event = |state: &str| Event::Compaction {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
state: state.into(),
|
||||||
|
auto: auto.is_some(),
|
||||||
|
tokens: auto.map(|a| a.0),
|
||||||
|
limit: auto.map(|a| a.1),
|
||||||
|
};
|
||||||
|
app.emit(event("started"));
|
||||||
|
let res = compact_inner(app.clone(), session_id, cancel, auto).await;
|
||||||
|
app.emit(event(if res.is_ok() { "done" } else { "failed" }));
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
|
||||||
let session = app.session(session_id)?;
|
let session = app.session(session_id)?;
|
||||||
let history = active_history(app.store.messages(session_id)?);
|
let history = active_history(app.store.messages(session_id)?);
|
||||||
if history.is_empty() {
|
if history.is_empty() {
|
||||||
@@ -1273,12 +1288,11 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
|||||||
if summary.trim().is_empty() {
|
if summary.trim().is_empty() {
|
||||||
bail!("compaction produced an empty summary");
|
bail!("compaction produced an empty summary");
|
||||||
}
|
}
|
||||||
save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?;
|
let tokens = auto.map(|a| a.0).or_else(|| session.last_usage.as_ref().map(|u| u.context_tokens()));
|
||||||
let mut s = app.session(session_id)?;
|
save_user_parts(&app, session_id, vec![Part::Summary { text: summary, auto: auto.is_some(), tokens }])?;
|
||||||
s.last_usage = None;
|
app.update_session(session_id, |s| s.last_usage = None)?;
|
||||||
// Files must be re-read after compaction before editing.
|
// Files must be re-read after compaction before editing.
|
||||||
app.with_runtime(session_id, |r| r.read_files.clear());
|
app.with_runtime(session_id, |r| r.read_files.clear());
|
||||||
app.save_session(s)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -18,6 +18,8 @@ pub struct SandboxSpec {
|
|||||||
pub roots: Vec<PathBuf>,
|
pub roots: Vec<PathBuf>,
|
||||||
/// Host directory mounted as the user's home.
|
/// Host directory mounted as the user's home.
|
||||||
pub home: PathBuf,
|
pub home: PathBuf,
|
||||||
|
/// Extra environment (the user's git identity).
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SandboxSpec {
|
impl SandboxSpec {
|
||||||
@@ -69,7 +71,7 @@ pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
|
|||||||
}
|
}
|
||||||
let home = app.data_dir().join("users").join(&user.username).join("home");
|
let home = app.data_dir().join("users").join(&user.username).join("home");
|
||||||
let _ = std::fs::create_dir_all(&home);
|
let _ = std::fs::create_dir_all(&home);
|
||||||
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
|
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home, env: crate::gitcreds::identity_env(app, &user.id) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a command that runs `program args` inside the sandbox in `cwd`.
|
/// Build a command that runs `program args` inside the sandbox in `cwd`.
|
||||||
@@ -148,9 +150,31 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
|
|||||||
] {
|
] {
|
||||||
push(&[&"--setenv", &k, &v]);
|
push(&[&"--setenv", &k, &v]);
|
||||||
}
|
}
|
||||||
|
for (k, v) in &spec.env {
|
||||||
|
push(&[&"--setenv", k, v]);
|
||||||
|
}
|
||||||
push(&[&"--chdir", &cwd, &program]);
|
push(&[&"--chdir", &cwd, &program]);
|
||||||
for a in args {
|
for a in args {
|
||||||
push(&[a]);
|
push(&[a]);
|
||||||
}
|
}
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_identity_reaches_the_sandbox() {
|
||||||
|
let spec = SandboxSpec {
|
||||||
|
username: "ann".into(),
|
||||||
|
roots: vec![],
|
||||||
|
home: PathBuf::from("/tmp/ann"),
|
||||||
|
env: vec![("GIT_AUTHOR_NAME".into(), "Ann Example".into())],
|
||||||
|
};
|
||||||
|
let args: Vec<String> = bwrap_args(&spec, Path::new("/tmp"), &[], true, "bash", &[]).into_iter().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
|
let i = args.iter().position(|a| a == "GIT_AUTHOR_NAME").expect("identity set");
|
||||||
|
assert_eq!(args[i - 1], "--setenv");
|
||||||
|
assert_eq!(args[i + 1], "Ann Example");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -239,7 +239,7 @@ impl Store {
|
|||||||
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
|
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
|
||||||
for p in &m.parts {
|
for p in &m.parts {
|
||||||
let text = match p {
|
let text = match p {
|
||||||
Part::Text { text } | Part::Summary { text } | Part::Reasoning { text, .. } => text.as_str(),
|
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||||
Part::ToolResult { content, .. } => content.as_str(),
|
Part::ToolResult { content, .. } => content.as_str(),
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+39
-5
@@ -18,13 +18,16 @@ use tokio::sync::broadcast;
|
|||||||
|
|
||||||
/// Output kept for re-attaching clients.
|
/// Output kept for re-attaching clients.
|
||||||
const SCROLLBACK: usize = 256 * 1024;
|
const SCROLLBACK: usize = 256 * 1024;
|
||||||
/// Detached terminals are stopped after this time.
|
/// Detached terminals outside a chat are stopped after this time; terminals that
|
||||||
|
/// belong to a chat stay open until they are closed, exit or the chat is deleted.
|
||||||
const DETACHED_TTL: Duration = Duration::from_secs(30 * 60);
|
const DETACHED_TTL: Duration = Duration::from_secs(30 * 60);
|
||||||
const MAX_PER_USER: usize = 8;
|
const MAX_PER_USER: usize = 16;
|
||||||
|
|
||||||
pub struct Term {
|
pub struct Term {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub owner_id: String,
|
pub owner_id: String,
|
||||||
|
/// Chat the terminal is a tab of.
|
||||||
|
pub session_id: Option<String>,
|
||||||
pub cwd: PathBuf,
|
pub cwd: PathBuf,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
|
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
|
||||||
@@ -57,6 +60,7 @@ impl Term {
|
|||||||
json!({
|
json!({
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"cwd": self.cwd,
|
"cwd": self.cwd,
|
||||||
|
"session_id": self.session_id,
|
||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
"alive": self.alive(),
|
"alive": self.alive(),
|
||||||
"attached": *self.attached.lock().unwrap() > 0,
|
"attached": *self.attached.lock().unwrap() > 0,
|
||||||
@@ -76,6 +80,30 @@ impl Terminals {
|
|||||||
v.into_iter().map(|(_, i)| i).collect()
|
v.into_iter().map(|(_, i)| i).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Terminals of one chat (oldest first).
|
||||||
|
pub fn for_session(&self, session_id: &str, owner_id: &str) -> Vec<Value> {
|
||||||
|
let mut v: Vec<_> = self
|
||||||
|
.map
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.filter(|t| t.owner_id == owner_id && t.session_id.as_deref() == Some(session_id))
|
||||||
|
.map(|t| (t.created_at, t.info()))
|
||||||
|
.collect();
|
||||||
|
v.sort_by_key(|(c, _)| *c);
|
||||||
|
v.into_iter().map(|(_, i)| i).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close all terminals of a deleted chat.
|
||||||
|
pub fn kill_session(&self, session_id: &str) {
|
||||||
|
let gone: Vec<Arc<Term>> = {
|
||||||
|
let mut map = self.map.lock().unwrap();
|
||||||
|
let ids: Vec<String> = map.values().filter(|t| t.session_id.as_deref() == Some(session_id)).map(|t| t.id.clone()).collect();
|
||||||
|
ids.iter().filter_map(|id| map.remove(id)).collect()
|
||||||
|
};
|
||||||
|
gone.iter().for_each(|t| t.kill());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get(&self, id: &str, owner_id: &str) -> Option<Arc<Term>> {
|
pub fn get(&self, id: &str, owner_id: &str) -> Option<Arc<Term>> {
|
||||||
self.map.lock().unwrap().get(id).filter(|t| t.owner_id == owner_id).cloned()
|
self.map.lock().unwrap().get(id).filter(|t| t.owner_id == owner_id).cloned()
|
||||||
}
|
}
|
||||||
@@ -100,7 +128,7 @@ impl Terminals {
|
|||||||
pub fn reap(&self) {
|
pub fn reap(&self) {
|
||||||
let mut map = self.map.lock().unwrap();
|
let mut map = self.map.lock().unwrap();
|
||||||
map.retain(|_, t| {
|
map.retain(|_, t| {
|
||||||
let stale = t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
|
let stale = t.session_id.is_none() && t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
|
||||||
let gone = !t.alive() && *t.attached.lock().unwrap() == 0;
|
let gone = !t.alive() && *t.attached.lock().unwrap() == 0;
|
||||||
if stale || gone {
|
if stale || gone {
|
||||||
t.kill();
|
t.kill();
|
||||||
@@ -113,7 +141,7 @@ impl Terminals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start a shell for `user` in `cwd` (already access-checked).
|
/// Start a shell for `user` in `cwd` (already access-checked).
|
||||||
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> Result<Arc<Term>> {
|
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>, cols: u16, rows: u16) -> Result<Arc<Term>> {
|
||||||
if app.terminals.list(&user.id).iter().filter(|t| t["alive"] == true).count() >= MAX_PER_USER {
|
if app.terminals.list(&user.id).iter().filter(|t| t["alive"] == true).count() >= MAX_PER_USER {
|
||||||
anyhow::bail!("too many open terminals (max {MAX_PER_USER}) – close one first");
|
anyhow::bail!("too many open terminals (max {MAX_PER_USER}) – close one first");
|
||||||
}
|
}
|
||||||
@@ -129,7 +157,12 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
|
|||||||
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
|
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let c = pty_process::Command::new(shell[0]).args(&shell[1..]).current_dir(cwd).env("TERM", "xterm-256color").env("CODECRAFT", "1");
|
let c = pty_process::Command::new(shell[0])
|
||||||
|
.args(&shell[1..])
|
||||||
|
.current_dir(cwd)
|
||||||
|
.env("TERM", "xterm-256color")
|
||||||
|
.env("CODECRAFT", "1")
|
||||||
|
.envs(crate::gitcreds::identity_env(app, &user.id));
|
||||||
match crate::gitcreds::global_config(app, user) {
|
match crate::gitcreds::global_config(app, user) {
|
||||||
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
||||||
None => c,
|
None => c,
|
||||||
@@ -142,6 +175,7 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
|
|||||||
let term = Arc::new(Term {
|
let term = Arc::new(Term {
|
||||||
id: crate::types::new_id(),
|
id: crate::types::new_id(),
|
||||||
owner_id: user.id.clone(),
|
owner_id: user.id.clone(),
|
||||||
|
session_id,
|
||||||
cwd: cwd.to_path_buf(),
|
cwd: cwd.to_path_buf(),
|
||||||
created_at: crate::types::now_ms(),
|
created_at: crate::types::now_ms(),
|
||||||
writer: tokio::sync::Mutex::new(writer),
|
writer: tokio::sync::Mutex::new(writer),
|
||||||
|
|||||||
@@ -150,9 +150,12 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
|||||||
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||||
// The session owner's git credentials and identity.
|
// The session owner's git credentials and identity.
|
||||||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
|
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
|
||||||
if let Some(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) {
|
if let Some(u) = owner {
|
||||||
|
if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) {
|
||||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||||
}
|
}
|
||||||
|
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
|
||||||
|
}
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+14
-3
@@ -1,7 +1,7 @@
|
|||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
||||||
import { useT } from "./i18n";
|
import { useT } from "./i18n";
|
||||||
import { navigate, useRoute } from "./router";
|
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
|
||||||
import { applyEvent, loadSessions, resync, setState } from "./store";
|
import { applyEvent, loadSessions, resync, setState } from "./store";
|
||||||
import type { Info } from "./types";
|
import type { Info } from "./types";
|
||||||
import { Sidebar } from "./components/Sidebar";
|
import { Sidebar } from "./components/Sidebar";
|
||||||
@@ -80,7 +80,18 @@ export function App() {
|
|||||||
|
|
||||||
const [section, arg] = route;
|
const [section, arg] = route;
|
||||||
let content;
|
let content;
|
||||||
if (section === "s" && arg) content = <ChatView key={arg} sessionId={arg} onMenu={() => setMenuOpen(true)} />;
|
const chatIds = section === "s" && arg ? arg.split("+").filter(Boolean).slice(0, MAX_PANES) : [];
|
||||||
|
if (chatIds.length === 1) content = <ChatView key={chatIds[0]} sessionId={chatIds[0]} onMenu={() => setMenuOpen(true)} />;
|
||||||
|
else if (chatIds.length > 1)
|
||||||
|
content = (
|
||||||
|
<div className="split-view">
|
||||||
|
{chatIds.map((id) => (
|
||||||
|
<div key={id} className="split-pane">
|
||||||
|
<ChatView sessionId={id} onMenu={() => setMenuOpen(true)} onClosePane={() => closePane(id)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||||
@@ -102,7 +113,7 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="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} activeIds={chatIds} section={section ?? ""} />
|
||||||
<main className="main">{content}</main>
|
<main className="main">{content}</main>
|
||||||
<WhatsNew version={info.version} />
|
<WhatsNew version={info.version} />
|
||||||
<Palette />
|
<Palette />
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ interface BudgetData {
|
|||||||
providers: BudgetRow[];
|
providers: BudgetRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
|
export function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
|
||||||
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
|
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
|
||||||
return (
|
return (
|
||||||
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
|
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { del, get, post, put } from "../api";
|
||||||
|
import { confirmDialog, toast } from "../dialogs";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import type { TermInfo } from "./Terminal";
|
||||||
|
|
||||||
|
// xterm and the editor are large: load them only when such a tab is shown.
|
||||||
|
const TermView = lazy(() => import("./Terminal").then((m) => ({ default: m.TermView })));
|
||||||
|
const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files })));
|
||||||
|
|
||||||
|
const POLL_MS = 15_000;
|
||||||
|
const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`;
|
||||||
|
|
||||||
|
/** A file manager tab (stored per user and chat on the server). */
|
||||||
|
interface FileTab {
|
||||||
|
id: string;
|
||||||
|
dir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabs of a chat: the chat itself, its terminals and file managers. Both are kept on the
|
||||||
|
* server, so they show up again after a reload or on another device (the list is
|
||||||
|
* refreshed periodically); which tab is active is per browser tab.
|
||||||
|
*/
|
||||||
|
export function useChatTabs(sessionId: string, cwd: string) {
|
||||||
|
const t = useT();
|
||||||
|
const [terms, setTerms] = useState<TermInfo[]>([]);
|
||||||
|
const [files, setFilesState] = useState<FileTab[]>([]);
|
||||||
|
// Local file tab changes not yet saved: polling must not undo them.
|
||||||
|
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
const [tab, setTabState] = useState<string>(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
||||||
|
|
||||||
|
const setTab = useCallback(
|
||||||
|
(v: string) => {
|
||||||
|
setTabState(v);
|
||||||
|
sessionStorage.setItem(tabKey(sessionId), v);
|
||||||
|
},
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const setFiles = useCallback(
|
||||||
|
(update: (f: FileTab[]) => FileTab[], delay = 0) =>
|
||||||
|
setFilesState((cur) => {
|
||||||
|
const next = update(cur);
|
||||||
|
clearTimeout(pending.current);
|
||||||
|
pending.current = setTimeout(() => {
|
||||||
|
pending.current = undefined;
|
||||||
|
void put(`/sessions/${sessionId}/tabs`, next).catch((e) => toast(e));
|
||||||
|
}, delay);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reload = useCallback(
|
||||||
|
() =>
|
||||||
|
get<{ terminals: TermInfo[]; files: FileTab[] }>(`/sessions/${sessionId}/tabs`)
|
||||||
|
.then((r) => {
|
||||||
|
setTerms(r.terminals);
|
||||||
|
if (!pending.current) setFilesState(r.files);
|
||||||
|
})
|
||||||
|
.catch(() => undefined),
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
|
||||||
|
setFilesState([]);
|
||||||
|
setTerms([]);
|
||||||
|
void reload();
|
||||||
|
const id = setInterval(reload, POLL_MS);
|
||||||
|
const onFocus = () => void reload();
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
return () => {
|
||||||
|
clearInterval(id);
|
||||||
|
window.removeEventListener("focus", onFocus);
|
||||||
|
};
|
||||||
|
}, [sessionId, reload]);
|
||||||
|
|
||||||
|
// A tab closed elsewhere (or a stale stored tab) falls back to the chat.
|
||||||
|
const activeTerm = terms.find((x) => x.id === tab);
|
||||||
|
const activeFiles = files.find((f) => f.id === tab);
|
||||||
|
const current = activeTerm || activeFiles ? tab : "chat";
|
||||||
|
|
||||||
|
const openTerminal = async () => {
|
||||||
|
try {
|
||||||
|
const info = await post<TermInfo>("/terminals", { session_id: sessionId, cols: 100, rows: 30 });
|
||||||
|
await reload();
|
||||||
|
setTab(info.id);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeTerminal = async (x: TermInfo, n: number) => {
|
||||||
|
if (x.alive && !(await confirmDialog({ message: t("terminal.closeConfirm", { name: t("terminal.tab", { n }) }), danger: true, confirmLabel: t("terminal.close") }))) return;
|
||||||
|
await del(`/terminals/${x.id}`).catch(() => undefined);
|
||||||
|
if (tab === x.id) setTab("chat");
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFiles = () => {
|
||||||
|
const id = `files:${Date.now().toString(36)}`;
|
||||||
|
setFiles((f) => [...f, { id, dir: cwd }]);
|
||||||
|
setTab(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFiles = (id: string) => {
|
||||||
|
setFiles((f) => f.filter((x) => x.id !== id));
|
||||||
|
if (tab === id) setTab("chat");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Browsing saves the folder after a short pause, not on every click.
|
||||||
|
const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x)), 1000), [setFiles]);
|
||||||
|
|
||||||
|
return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tabs = ReturnType<typeof useChatTabs>;
|
||||||
|
|
||||||
|
const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/";
|
||||||
|
|
||||||
|
/** Tab strip above the chat: the chat, one tab per terminal and file manager, and "+". */
|
||||||
|
export function ChatTabBar({ state }: { state: Tabs }) {
|
||||||
|
const t = useT();
|
||||||
|
const { terms, files, tab, setTab, openTerminal, closeTerminal, openFiles, closeFiles } = state;
|
||||||
|
if (terms.length === 0 && files.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="chat-tabs" role="tablist">
|
||||||
|
<button role="tab" aria-selected={tab === "chat"} className={`chat-tab ${tab === "chat" ? "active" : ""}`} onClick={() => setTab("chat")}>
|
||||||
|
<Icon name="sparkle" /> {t("terminal.chatTab")}
|
||||||
|
</button>
|
||||||
|
{terms.map((x, i) => (
|
||||||
|
<span key={x.id} className={`chat-tab ${tab === x.id ? "active" : ""} ${x.alive ? "" : "dead"}`}>
|
||||||
|
<button role="tab" aria-selected={tab === x.id} className="ghost" onClick={() => setTab(x.id)} title={x.cwd}>
|
||||||
|
<Icon name="terminal" /> {t("terminal.tab", { n: i + 1 })}
|
||||||
|
</button>
|
||||||
|
<button className="ghost close" onClick={() => void closeTerminal(x, i + 1)} aria-label={t("terminal.close")} title={t("terminal.close")}>
|
||||||
|
<Icon name="close" size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{files.map((f) => (
|
||||||
|
<span key={f.id} className={`chat-tab ${tab === f.id ? "active" : ""}`}>
|
||||||
|
<button role="tab" aria-selected={tab === f.id} className="ghost" onClick={() => setTab(f.id)} title={f.dir}>
|
||||||
|
<Icon name="folder" /> {basename(f.dir)}
|
||||||
|
</button>
|
||||||
|
<button className="ghost close" onClick={() => closeFiles(f.id)} aria-label={t("files.closeTab")} title={t("files.closeTab")}>
|
||||||
|
<Icon name="close" size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button className="ghost chat-tab-add" onClick={() => void openTerminal()} title={t("terminal.new")} aria-label={t("terminal.new")}>
|
||||||
|
<Icon name="plus" /> <Icon name="terminal" />
|
||||||
|
</button>
|
||||||
|
<button className="ghost chat-tab-add" onClick={openFiles} title={t("files.newTab")} aria-label={t("files.newTab")}>
|
||||||
|
<Icon name="plus" /> <Icon name="folder" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab contents besides the chat. The active terminal is mounted (it re-attaches and
|
||||||
|
* replays its output); file managers stay mounted while hidden, so unsaved edits survive.
|
||||||
|
*/
|
||||||
|
export function ChatTabPanes({ state }: { state: Tabs }) {
|
||||||
|
const t = useT();
|
||||||
|
const { tab, activeTerm, files, fileDirChanged } = state;
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="empty">{t("common.loading")}</div>}>
|
||||||
|
{activeTerm && <TermView key={activeTerm.id} info={activeTerm} />}
|
||||||
|
{files.map((f) => (
|
||||||
|
<div key={f.id} className="chat-pane chat-files" hidden={tab !== f.id}>
|
||||||
|
<Files embed={{ startDir: f.dir, onDirChange: (d) => fileDirChanged(f.id, d) }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i
|
|||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||||
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
||||||
import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
|
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||||
import { useReasoningMode } from "../prefs";
|
import { useReasoningMode } from "../prefs";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
import { RenameDialog } from "./RenameDialog";
|
import { RenameDialog } from "./RenameDialog";
|
||||||
|
import { InstructionsDialog } from "./InstructionsDialog";
|
||||||
import { RegenerateButton } from "./Regenerate";
|
import { RegenerateButton } from "./Regenerate";
|
||||||
import { WorktreeChip } from "./WorktreeChip";
|
import { WorktreeChip } from "./WorktreeChip";
|
||||||
import { QueueList } from "./QueueList";
|
import { QueueList } from "./QueueList";
|
||||||
@@ -24,6 +25,7 @@ import { RewindDialog } from "./RewindDialog";
|
|||||||
import { ShellsChip } from "./ShellsPanel";
|
import { ShellsChip } from "./ShellsPanel";
|
||||||
import { TodoList, ToolCard } from "./ToolCard";
|
import { TodoList, ToolCard } from "./ToolCard";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||||
|
|
||||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||||
|
|
||||||
@@ -135,7 +137,7 @@ function RefineFeedback({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
|
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const session = useStore((s) => s.sessionById[sessionId]);
|
const session = useStore((s) => s.sessionById[sessionId]);
|
||||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||||
@@ -145,9 +147,12 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
const allPermissions = useStore((s) => s.permissions);
|
const allPermissions = useStore((s) => s.permissions);
|
||||||
const allQuestions = useStore((s) => s.questions);
|
const allQuestions = useStore((s) => s.questions);
|
||||||
const waiting = useStore((s) => s.waiting[sessionId]);
|
const waiting = useStore((s) => s.waiting[sessionId]);
|
||||||
|
const compacting = useStore((s) => s.compacting[sessionId]);
|
||||||
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
|
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
|
||||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||||
|
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [editingInstructions, setEditingInstructions] = useState(false);
|
||||||
const models = useModels();
|
const models = useModels();
|
||||||
const [loadError, setLoadError] = useState<string>();
|
const [loadError, setLoadError] = useState<string>();
|
||||||
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
||||||
@@ -304,6 +309,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
<ShellsChip sessionId={sessionId} busy={busy} />
|
<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>}
|
||||||
|
{!!session.instructions && (
|
||||||
|
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
|
||||||
|
<Icon name="edit" /> {t("instructions.chip")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
@@ -314,14 +324,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="small ghost hide-mobile"
|
className="small ghost hide-mobile"
|
||||||
onClick={() => {
|
onClick={() => void tabs.openTerminal()}
|
||||||
sessionStorage.setItem("codecraft.termCwd", session.cwd);
|
|
||||||
navigate("/terminal/new");
|
|
||||||
}}
|
|
||||||
title={t("terminal.here")}
|
title={t("terminal.here")}
|
||||||
>
|
>
|
||||||
<Icon name="terminal" />
|
<Icon name="terminal" />
|
||||||
</button>
|
</button>
|
||||||
|
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||||
|
<Icon name="folder" />
|
||||||
|
</button>
|
||||||
{!session.parent_id && (
|
{!session.parent_id && (
|
||||||
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||||
<Icon name="fork" />
|
<Icon name="fork" />
|
||||||
@@ -332,6 +342,16 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
<Icon name="more" />
|
<Icon name="more" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="menu-pop-body">
|
<div className="menu-pop-body">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||||
|
setEditingInstructions(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="edit" /> {t("instructions.title")}
|
||||||
|
</a>
|
||||||
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
||||||
<Icon name="download" /> {t("chat.exportMd")}
|
<Icon name="download" /> {t("chat.exportMd")}
|
||||||
</a>
|
</a>
|
||||||
@@ -365,7 +385,15 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{onClosePane && (
|
||||||
|
<button className="small ghost" onClick={onClosePane} title={t("chat.closePane")} aria-label={t("chat.closePane")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ChatTabBar state={tabs} />
|
||||||
|
<ChatTabPanes state={tabs} />
|
||||||
|
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
||||||
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
||||||
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
||||||
<div
|
<div
|
||||||
@@ -402,7 +430,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
if (summary && summary.type === "summary") {
|
if (summary && summary.type === "summary") {
|
||||||
return (
|
return (
|
||||||
<details key={m.id} className="summary-divider">
|
<details key={m.id} className="summary-divider">
|
||||||
<summary>{t("chat.compacted")}</summary>
|
<summary>
|
||||||
|
{summary.auto
|
||||||
|
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||||
|
: summary.tokens
|
||||||
|
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||||
|
: t("chat.compacted")}
|
||||||
|
</summary>
|
||||||
<Markdown text={summary.text} />
|
<Markdown text={summary.text} />
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
@@ -452,6 +486,19 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{compacting && (
|
||||||
|
<div className={`compacting-card ${compacting.auto ? "auto" : ""}`} role="status">
|
||||||
|
<Spinner small />
|
||||||
|
<div>
|
||||||
|
<b>{t(compacting.auto ? "chat.compactingAuto" : "chat.compactingManual")}</b>
|
||||||
|
{compacting.auto && compacting.tokens && compacting.limit && (
|
||||||
|
<div className="hint">
|
||||||
|
{t("chat.compactingDetail", { tokens: fmtCompact(compacting.tokens), limit: fmtCompact(compacting.limit), pct: fmtPercent(compacting.tokens / compacting.limit) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(busy || waiting) && (
|
{(busy || waiting) && (
|
||||||
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} 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} />
|
||||||
)}
|
)}
|
||||||
@@ -497,6 +544,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
|
||||||
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||||
@@ -514,6 +562,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
onUpdate={update}
|
onUpdate={update}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,20 +288,20 @@ export function Composer({
|
|||||||
/>
|
/>
|
||||||
<div className="composer-bar">
|
<div className="composer-bar">
|
||||||
<div className="composer-options">
|
<div className="composer-options">
|
||||||
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
|
<select
|
||||||
{agents.map((a) => (
|
className="mode-select"
|
||||||
<button
|
value={session.agent}
|
||||||
key={a.name}
|
onChange={(e) => onUpdate({ agent: e.target.value })}
|
||||||
role="tab"
|
aria-label={t("composer.mode")}
|
||||||
aria-selected={session.agent === a.name}
|
title={agents.find((a) => a.name === session.agent)?.description ?? t("composer.mode")}
|
||||||
className={session.agent === a.name ? "active" : ""}
|
|
||||||
title={a.description}
|
|
||||||
onClick={() => onUpdate({ agent: a.name })}
|
|
||||||
>
|
>
|
||||||
|
{!agents.some((a) => a.name === session.agent) && <option value={session.agent}>{session.agent}</option>}
|
||||||
|
{agents.map((a) => (
|
||||||
|
<option key={a.name} value={a.name} title={a.description}>
|
||||||
{a.name}
|
{a.name}
|
||||||
</button>
|
</option>
|
||||||
))}
|
))}
|
||||||
</div>
|
</select>
|
||||||
{session.agent === "craft" && (
|
{session.agent === "craft" && (
|
||||||
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
|
<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 })}>
|
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
|
|||||||
import { GitPanel, type GitStatus } from "./GitPanel";
|
import { GitPanel, type GitStatus } from "./GitPanel";
|
||||||
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
|
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
|
||||||
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||||
|
|
||||||
@@ -55,10 +56,16 @@ function icon(e: Entry) {
|
|||||||
return <Icon name="file" className="ficon" />;
|
return <Icon name="file" className="ficon" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Files({ onMenu }: { onMenu: () => void }) {
|
/** Embedded in a chat tab: own start folder, no page header, folder kept by the caller. */
|
||||||
|
export interface FilesEmbed {
|
||||||
|
startDir: string;
|
||||||
|
onDirChange?: (dir: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Files({ onMenu, embed }: { onMenu?: () => void; embed?: FilesEmbed }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||||
const [dir, setDir] = useState<string>(() => localStorage.getItem("codecraft.filesDir") ?? "");
|
const [dir, setDir] = useState<string>(() => embed?.startDir ?? localStorage.getItem("codecraft.filesDir") ?? "");
|
||||||
const [listing, setListing] = useState<Listing>();
|
const [listing, setListing] = useState<Listing>();
|
||||||
const [hidden, setHidden] = useState(false);
|
const [hidden, setHidden] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
@@ -78,7 +85,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
|
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
|
||||||
setListing(l);
|
setListing(l);
|
||||||
setDir(l.path);
|
setDir(l.path);
|
||||||
localStorage.setItem("codecraft.filesDir", l.path);
|
if (embed) embed.onDirChange?.(l.path);
|
||||||
|
else localStorage.setItem("codecraft.filesDir", l.path);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
@@ -142,7 +150,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
|
|
||||||
// "Edit file" links from other pages (e.g. automations) open the file directly.
|
// "Edit file" links from other pages (e.g. automations) open the file directly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = sessionStorage.getItem("codecraft.openFile");
|
const target = embed ? null : sessionStorage.getItem("codecraft.openFile");
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
sessionStorage.removeItem("codecraft.openFile");
|
sessionStorage.removeItem("codecraft.openFile");
|
||||||
setDir(target.slice(0, target.lastIndexOf("/")) || "/");
|
setDir(target.slice(0, target.lastIndexOf("/")) || "/");
|
||||||
@@ -186,6 +194,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** New chat with the folder as working directory (default model and mode). */
|
||||||
|
const chatHere = async (e: Entry) => {
|
||||||
|
try {
|
||||||
|
const s = await post<{ id: string }>("/sessions", { cwd: e.path });
|
||||||
|
navigate(`/s/${s.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const rename = async (e: Entry) => {
|
const rename = async (e: Entry) => {
|
||||||
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: 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}` }));
|
||||||
@@ -324,6 +342,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
<details className="entry-menu">
|
<details className="entry-menu">
|
||||||
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
|
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
|
||||||
<div className="menu">
|
<div className="menu">
|
||||||
|
{e.is_dir && (
|
||||||
|
<button onClick={() => void chatHere(e)}>{t("files.chatHere")}</button>
|
||||||
|
)}
|
||||||
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
||||||
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
||||||
{!e.is_dir && (
|
{!e.is_dir && (
|
||||||
@@ -418,12 +439,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className={embed ? "files-embed-bar" : "topbar"}>
|
||||||
|
{!embed && (
|
||||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
<Icon name="menu" />
|
<Icon name="menu" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<h1>{t("nav.files")}</h1>
|
{!embed && <h1>{t("nav.files")}</h1>}
|
||||||
<div className="path">{listing?.path}</div>
|
<div className="path">{listing?.path}</div>
|
||||||
</div>
|
</div>
|
||||||
{listing?.repo && (
|
{listing?.repo && (
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { patch } from "../api";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
|
/** Instructions for one chat: added to the system prompt of every turn. */
|
||||||
|
export function InstructionsDialog({ sessionId, value, onClose }: { sessionId: string; value: string; onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [text, setText] = useState(value);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const save = async (next: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await patch(`/sessions/${sessionId}`, { instructions: next });
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal instructions-modal" role="dialog" aria-label={t("instructions.title")} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{t("instructions.title")}</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 8 }}>
|
||||||
|
{t("instructions.hint")}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
rows={10}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
value={text}
|
||||||
|
placeholder={t("instructions.placeholder")}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => (e.ctrlKey || e.metaKey) && e.key === "Enter" && !busy && void save(text)}
|
||||||
|
/>
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
<div className="row" style={{ marginTop: 10, justifyContent: "flex-end", gap: 6 }}>
|
||||||
|
{value && (
|
||||||
|
<button className="danger" onClick={() => void save("")} disabled={busy}>
|
||||||
|
{t("instructions.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="spacer" />
|
||||||
|
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||||
|
<button className="primary" onClick={() => void save(text)} disabled={busy || text.trim() === value.trim()}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -103,6 +103,7 @@ const ICONS: Record<string, ReactNode> = {
|
|||||||
),
|
),
|
||||||
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
|
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
|
||||||
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
|
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
|
||||||
|
chat: svg(<path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm4 6h.01M12 11h.01M16 11h.01" />),
|
||||||
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
|
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
const [creating, setCreating] = useState<string>();
|
const [creating, setCreating] = useState<string>();
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
|
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
|
||||||
const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name));
|
const custom = agents.filter((a) => !["build", "plan", "craft", "chat"].includes(a.name));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!model && config?.default_model) setModel(config.default_model);
|
if (!model && config?.default_model) setModel(config.default_model);
|
||||||
@@ -244,7 +245,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
const canStart =
|
const canStart =
|
||||||
!creating &&
|
!creating &&
|
||||||
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
|
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
|
||||||
(folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
|
(task === "chat" ? !!rootPath : folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
|
||||||
|
|
||||||
/** The first message(s) for the agent. */
|
/** The first message(s) for the agent. */
|
||||||
const prompts = (uploaded: string[]): string[] => {
|
const prompts = (uploaded: string[]): string[] => {
|
||||||
@@ -275,7 +276,10 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
try {
|
try {
|
||||||
// 1. Folder
|
// 1. Folder
|
||||||
let cwd = picked;
|
let cwd = picked;
|
||||||
if (folderMode === "git") {
|
if (task === "chat") {
|
||||||
|
// A plain conversation needs no project: it runs in the user's own folder.
|
||||||
|
cwd = rootPath;
|
||||||
|
} else if (folderMode === "git") {
|
||||||
setCreating(t("wizard.cloning"));
|
setCreating(t("wizard.cloning"));
|
||||||
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
|
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
|
||||||
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
|
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
|
||||||
@@ -295,7 +299,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
}
|
}
|
||||||
if (!cwd) throw new Error("could not create the folder");
|
if (!cwd) throw new Error("could not create the folder");
|
||||||
}
|
}
|
||||||
localStorage.setItem(LAST_DIR, cwd);
|
if (task !== "chat") localStorage.setItem(LAST_DIR, cwd);
|
||||||
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
|
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
|
||||||
const uploaded: string[] = [];
|
const uploaded: string[] = [];
|
||||||
const mkdirs = async (dir: string) => {
|
const mkdirs = async (dir: string) => {
|
||||||
@@ -327,7 +331,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
agent: task === "cad" ? "build" : task,
|
agent: task === "cad" ? "build" : task,
|
||||||
clean_room: isCraft ? !deep : undefined,
|
clean_room: isCraft ? !deep : undefined,
|
||||||
});
|
});
|
||||||
if (isolated && folderMode !== "new") {
|
if (isolated && folderMode !== "new" && task !== "chat") {
|
||||||
setCreating(t("worktree.creating"));
|
setCreating(t("worktree.creating"));
|
||||||
await post(`/sessions/${s.id}/worktree`, { action: "create" });
|
await post(`/sessions/${s.id}/worktree`, { action: "create" });
|
||||||
}
|
}
|
||||||
@@ -359,6 +363,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
<div className="panel wiz-panel">
|
<div className="panel wiz-panel">
|
||||||
<Step n={n++} title={t("wizard.whatToDo")}>
|
<Step n={n++} title={t("wizard.whatToDo")}>
|
||||||
<div className="wiz-cards">
|
<div className="wiz-cards">
|
||||||
|
<Card icon="chat" title={t("wizard.task.chat")} desc={t("wizard.task.chatDesc")} active={task === "chat"} onClick={() => setTask("chat")} />
|
||||||
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
|
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
|
||||||
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
|
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
|
||||||
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
|
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
|
||||||
@@ -454,7 +459,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : "wizard.placeholder.task")}
|
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : task === "chat" ? "wizard.placeholder.chat" : "wizard.placeholder.task")}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
if (e.clipboardData.files.length) {
|
if (e.clipboardData.files.length) {
|
||||||
@@ -512,9 +517,11 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
|
|
||||||
<div className="wiz-foot">
|
<div className="wiz-foot">
|
||||||
<div className="wiz-foot-row">
|
<div className="wiz-foot-row">
|
||||||
<label>{t("wizard.builtBy")}</label>
|
<label>{task === "chat" ? t("composer.model") : t("wizard.builtBy")}</label>
|
||||||
<ModelSelect value={model} onChange={setModel} />
|
<ModelSelect value={model} onChange={setModel} />
|
||||||
</div>
|
</div>
|
||||||
|
{task !== "chat" && (
|
||||||
|
<>
|
||||||
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
||||||
{(["new", "existing", "git"] as const).map((m) => (
|
{(["new", "existing", "git"] as const).map((m) => (
|
||||||
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
||||||
@@ -564,6 +571,8 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{error && <div className="notice err">{error}</div>}
|
{error && <div className="notice err">{error}</div>}
|
||||||
<div className="wiz-start-row">
|
<div className="wiz-start-row">
|
||||||
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
|
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function recent() {
|
|||||||
/** Move to the next/previous session relative to the open one. */
|
/** Move to the next/previous session relative to the open one. */
|
||||||
function step(dir: 1 | -1) {
|
function step(dir: 1 | -1) {
|
||||||
const list = recent();
|
const list = recent();
|
||||||
const cur = location.hash.match(/^#\/s\/([^/]+)/)?.[1];
|
const cur = location.hash.match(/^#\/s\/([^/+]+)/)?.[1];
|
||||||
const i = list.findIndex((s) => s.id === cur);
|
const i = list.findIndex((s) => s.id === cur);
|
||||||
const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)];
|
const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)];
|
||||||
if (next) navigate(`/s/${next.id}`);
|
if (next) navigate(`/s/${next.id}`);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 { Templates } from "./settings/Templates";
|
import { Templates } from "./settings/Templates";
|
||||||
|
import { PersonalModes } from "./settings/PersonalModes";
|
||||||
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";
|
||||||
@@ -1014,56 +1015,6 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
|
|||||||
|
|
||||||
// --- raw ----------------------------------------------------------------------
|
// --- raw ----------------------------------------------------------------------
|
||||||
|
|
||||||
/** ${VAR} references left in the config: move their values from the environment into it. */
|
|
||||||
function EnvRefs({ onDone }: { onDone: () => void }) {
|
|
||||||
const t = useT();
|
|
||||||
const { data, reload } = useFetch<{ set: string[]; unset: string[] }>("/config/inline-env");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
if (!data || (data.set.length === 0 && data.unset.length === 0)) return null;
|
|
||||||
const apply = async () => {
|
|
||||||
if (!(await confirmDialog({ title: t("settings.envTitle"), message: t("settings.envConfirm", { names: data.set.join(", ") }), confirmLabel: t("settings.envApply") }))) return;
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const r = await post<{ set: string[] }>("/config/inline-env");
|
|
||||||
toast(t("settings.envDone", { n: r.set.length }), "success");
|
|
||||||
reload();
|
|
||||||
onDone();
|
|
||||||
} catch (e: any) {
|
|
||||||
toast(e.message);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="panel" style={{ marginBottom: 12 }}>
|
|
||||||
<h3>
|
|
||||||
<Icon name="file-text" /> {t("settings.envTitle")}
|
|
||||||
</h3>
|
|
||||||
<div className="hint" style={{ marginBottom: 8 }}>
|
|
||||||
{t("settings.envHint")}
|
|
||||||
</div>
|
|
||||||
{data.set.length > 0 && (
|
|
||||||
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
||||||
{data.set.map((n) => (
|
|
||||||
<span key={n} className="chip mono">
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<span className="spacer" />
|
|
||||||
<button className="primary small" onClick={apply} disabled={busy}>
|
|
||||||
{t("settings.envApply")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.unset.length > 0 && (
|
|
||||||
<div className="hint" style={{ marginTop: 8 }}>
|
|
||||||
{t("settings.envUnset")} <span className="mono">{data.unset.join(", ")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Raw({ reload }: { reload: () => void }) {
|
function Raw({ reload }: { reload: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [text, setText] = useState<string>();
|
const [text, setText] = useState<string>();
|
||||||
@@ -1074,7 +1025,6 @@ function Raw({ reload }: { reload: () => void }) {
|
|||||||
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EnvRefs onDone={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
|
|
||||||
<div className="notice">{t("settings.rawHint")}</div>
|
<div className="notice">{t("settings.rawHint")}</div>
|
||||||
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
{view}
|
{view}
|
||||||
@@ -1090,8 +1040,8 @@ function Raw({ reload }: { reload: () => void }) {
|
|||||||
|
|
||||||
// --- page -------------------------------------------------------------------
|
// --- page -------------------------------------------------------------------
|
||||||
|
|
||||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "account", "raw"] as const;
|
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "modes", "account", "raw"] as const;
|
||||||
const USER_TABS = ["general", "templates", "account"] as const;
|
const USER_TABS = ["general", "templates", "modes", "account"] as const;
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
general: "gear",
|
general: "gear",
|
||||||
providers: "brain",
|
providers: "brain",
|
||||||
@@ -1103,6 +1053,7 @@ const TAB_ICONS: Record<string, string> = {
|
|||||||
tools: "toolbox",
|
tools: "toolbox",
|
||||||
audit: "scroll",
|
audit: "scroll",
|
||||||
templates: "template",
|
templates: "template",
|
||||||
|
modes: "sparkle",
|
||||||
account: "user",
|
account: "user",
|
||||||
raw: "file-text",
|
raw: "file-text",
|
||||||
};
|
};
|
||||||
@@ -1139,6 +1090,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
|||||||
{!isAdmin && active === "general" && <InterfaceSettings />}
|
{!isAdmin && active === "general" && <InterfaceSettings />}
|
||||||
{active === "account" && <Account />}
|
{active === "account" && <Account />}
|
||||||
{active === "templates" && <Templates />}
|
{active === "templates" && <Templates />}
|
||||||
|
{active === "modes" && <PersonalModes />}
|
||||||
{cfg && active === "users" && <Users />}
|
{cfg && active === "users" && <Users />}
|
||||||
{cfg && active === "folders" && <Folders />}
|
{cfg && active === "folders" && <Folders />}
|
||||||
{cfg && active === "audit" && <Audit />}
|
{cfg && active === "audit" && <Audit />}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|||||||
import { get, patch, post } from "../api";
|
import { get, patch, post } from "../api";
|
||||||
import { promptDialog, toast } from "../dialogs";
|
import { promptDialog, toast } from "../dialogs";
|
||||||
import { fmtRelative, useT, type Key } from "../i18n";
|
import { fmtRelative, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate, openBeside } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import type { Session } from "../types";
|
import type { Session } from "../types";
|
||||||
|
|
||||||
@@ -82,6 +82,9 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
|
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button role="menuitem" className="hide-mobile" onClick={() => (setMenu(false), openBeside(s.id))}>
|
||||||
|
<Icon name="external" /> {t("sidebar.openBeside")}
|
||||||
|
</button>
|
||||||
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
|
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
|
||||||
<Icon name="folder" /> {t("sidebar.moveTo")}
|
<Icon name="folder" /> {t("sidebar.moveTo")}
|
||||||
</button>
|
</button>
|
||||||
@@ -99,7 +102,7 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
|
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const sessions = useStore((s) => s.sessions);
|
const sessions = useStore((s) => s.sessions);
|
||||||
const busy = useStore((s) => s.busy);
|
const busy = useStore((s) => s.busy);
|
||||||
@@ -210,7 +213,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
|
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
|
||||||
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
|
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
|
||||||
{hits.map((h) => (
|
{hits.map((h) => (
|
||||||
<button key={h.id} className={`session-item ${h.id === activeId ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
<button key={h.id} className={`session-item ${activeIds.includes(h.id) ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
||||||
<div className="title">
|
<div className="title">
|
||||||
<span>{h.title || t("session.untitled")}</span>
|
<span>{h.title || t("session.untitled")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +245,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
</button>
|
</button>
|
||||||
{!collapsed.has(g.id) &&
|
{!collapsed.has(g.id) &&
|
||||||
g.items.map((s) => (
|
g.items.map((s) => (
|
||||||
<SessionRow key={s.id} s={s} active={s.id === activeId} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { get, post } from "../api";
|
import { get, post } from "../api";
|
||||||
import { fmtPercent, useT, type Key } from "../i18n";
|
import { fmtCost, fmtPercent, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import type { Plan } from "./Budget";
|
import { Bar, type Plan } from "./Budget";
|
||||||
|
|
||||||
type Level = "ok" | "warn" | "err" | "off";
|
type Level = "ok" | "warn" | "err" | "off";
|
||||||
|
|
||||||
@@ -21,12 +21,25 @@ interface McpEntry {
|
|||||||
tools?: number;
|
tools?: number;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
interface Meter {
|
||||||
|
key: string;
|
||||||
|
provider: string;
|
||||||
|
kind: "total" | "provider" | "images" | "5h" | "week" | "month";
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
ratio: number;
|
||||||
|
}
|
||||||
interface Overview {
|
interface Overview {
|
||||||
version: string;
|
version: string;
|
||||||
providers: Record<string, ProviderState>;
|
providers: Record<string, ProviderState>;
|
||||||
mcp: Record<string, McpEntry>;
|
mcp: Record<string, McpEntry>;
|
||||||
|
/** Budgets and plan windows (admins only). */
|
||||||
|
budgets?: Meter[];
|
||||||
|
currency?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const meterLevel = (m: Meter): Level => (m.ratio >= 1 ? "err" : m.ratio >= 0.8 ? "warn" : "ok");
|
||||||
|
|
||||||
const POLL_MS = 30_000;
|
const POLL_MS = 30_000;
|
||||||
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
|
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
|
||||||
const RECONNECT_GRACE_MS = 10_000;
|
const RECONNECT_GRACE_MS = 10_000;
|
||||||
@@ -197,6 +210,22 @@ export function StatusLeds() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!!data?.budgets?.length && (
|
||||||
|
<div className="status-section">
|
||||||
|
<div className="status-head">
|
||||||
|
<Led level={combine(data.budgets.map(meterLevel))} /> {t("status.budgets")}
|
||||||
|
</div>
|
||||||
|
{data.budgets.map((m) => (
|
||||||
|
<div key={m.key} className="status-row status-meter" title={`${fmtCost(m.used, data.currency ?? "USD")} / ${fmtCost(m.limit, data.currency ?? "USD")}`}>
|
||||||
|
<Led level={meterLevel(m)} />
|
||||||
|
<span className="grow">{m.kind === "total" ? t("stats.total") : m.kind === "images" ? t("nav.images") : m.kind === "provider" ? m.provider : `${m.provider} · ${t(`budget.window.${m.kind}` as Key)}`}</span>
|
||||||
|
<Bar share={m.ratio} warning={m.ratio >= 0.8} exceeded={m.ratio >= 1} />
|
||||||
|
<span className="hint pct">{fmtPercent(m.ratio)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{admin && (
|
{admin && (
|
||||||
<div className="status-foot">
|
<div className="status-foot">
|
||||||
<button className="small ghost" onClick={() => go("providers")}>
|
<button className="small ghost" onClick={() => go("providers")}>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { onThemeChange } from "../theme";
|
|||||||
import { toast } from "../dialogs";
|
import { toast } from "../dialogs";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
interface TermInfo {
|
export interface TermInfo {
|
||||||
id: string;
|
id: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
session_id?: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
alive: boolean;
|
alive: boolean;
|
||||||
attached: boolean;
|
attached: boolean;
|
||||||
@@ -132,7 +133,7 @@ export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TermView({ info }: { info: TermInfo }) {
|
export function TermView({ info }: { info: TermInfo }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const host = useRef<HTMLDivElement>(null);
|
const host = useRef<HTMLDivElement>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
|
|||||||
import { ApiTokens } from "./ApiTokens";
|
import { ApiTokens } from "./ApiTokens";
|
||||||
import { CliInstall } from "./CliInstall";
|
import { CliInstall } from "./CliInstall";
|
||||||
import { Memory } from "./Memory";
|
import { Memory } from "./Memory";
|
||||||
|
import { Telegram } from "./Telegram";
|
||||||
|
|
||||||
export function Account() {
|
export function Account() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -51,6 +52,7 @@ export function Account() {
|
|||||||
<Memory />
|
<Memory />
|
||||||
<GitAccount />
|
<GitAccount />
|
||||||
<CliInstall />
|
<CliInstall />
|
||||||
|
<Telegram />
|
||||||
<ApiTokens />
|
<ApiTokens />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,25 +9,60 @@ import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
|||||||
|
|
||||||
type Patch = { path: string[]; value: unknown };
|
type Patch = { path: string[]; value: unknown };
|
||||||
|
|
||||||
|
export interface ToolInfo {
|
||||||
|
name: string;
|
||||||
|
read_only: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tool checkboxes: nothing checked = all tools. */
|
||||||
|
export function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
|
||||||
|
const t = useT();
|
||||||
|
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
|
||||||
|
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
|
||||||
|
const extra = value.filter((v) => !tools.some((x) => x.name === v));
|
||||||
|
return (
|
||||||
|
<div className="tool-picker">
|
||||||
|
<div className="hint">{value.length ? t("agents.toolsSome", { n: value.length }) : t("agents.toolsAll")}</div>
|
||||||
|
<div className="tool-grid">
|
||||||
|
{tools.map((x) => (
|
||||||
|
<label key={x.name} className={`tool-item ${readOnly && !x.read_only ? "disabled" : ""}`} title={x.description}>
|
||||||
|
<input type="checkbox" checked={value.includes(x.name)} disabled={readOnly && !x.read_only} onChange={() => toggle(x.name)} />
|
||||||
|
<span className="mono">{x.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{extra.map((n) => (
|
||||||
|
<label key={n} className="tool-item">
|
||||||
|
<input type="checkbox" checked onChange={() => toggle(n)} />
|
||||||
|
<span className="mono">{n}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
|
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
|
||||||
documentation, summaries, step-by-step guides and translations.
|
documentation, summaries, step-by-step guides and translations.
|
||||||
Return only the finished text.`;
|
Return only the finished text.`;
|
||||||
|
|
||||||
function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: boolean, text: string) => void }) {
|
function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: ToolInfo[]; onSaved: (ok: boolean, text: string) => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(!!raw && !raw.prompt && !raw.description && raw.mode === "primary");
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
model: a.model ?? "",
|
model: a.model ?? "",
|
||||||
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
|
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
|
||||||
description: raw?.description ?? "",
|
description: raw?.description ?? "",
|
||||||
tools: (raw?.tools ?? []).join(", "),
|
tools: (raw?.tools ?? []) as string[],
|
||||||
|
read_only: !!(raw?.read_only ?? a.read_only),
|
||||||
|
style: (raw?.style ?? a.style ?? "coding") as "coding" | "chat",
|
||||||
prompt: raw?.prompt ?? "",
|
prompt: raw?.prompt ?? "",
|
||||||
mode: raw?.mode ?? a.mode,
|
mode: raw?.mode ?? a.mode,
|
||||||
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
||||||
refine_model: raw?.refine?.model ?? "",
|
refine_model: raw?.refine?.model ?? "",
|
||||||
refine_criteria: raw?.refine?.criteria ?? "",
|
refine_criteria: raw?.refine?.criteria ?? "",
|
||||||
});
|
});
|
||||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
|
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const base = ["agents", a.name];
|
const base = ["agents", a.name];
|
||||||
@@ -45,7 +80,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
|||||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||||
if (!builtin) {
|
if (!builtin) {
|
||||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||||
patches.push({ path: [...base, "tools"], value: form.tools.trim() ? form.tools.split(/[\s,]+/).filter(Boolean) : null });
|
patches.push({ path: [...base, "tools"], value: form.tools.length ? form.tools : null });
|
||||||
|
patches.push({ path: [...base, "read_only"], value: form.read_only || null });
|
||||||
|
patches.push({ path: [...base, "style"], value: form.style === "chat" ? "chat" : null });
|
||||||
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
|
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -133,8 +170,23 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
|||||||
<option value="subagent">{t("agents.modeSubagent")}</option>
|
<option value="subagent">{t("agents.modeSubagent")}</option>
|
||||||
<option value="primary">{t("agents.modePrimary")}</option>
|
<option value="primary">{t("agents.modePrimary")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
{form.mode === "primary" && (
|
||||||
|
<>
|
||||||
|
<label>{t("agents.style")}</label>
|
||||||
|
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
|
||||||
|
<option value="coding">{t("agents.styleCoding")}</option>
|
||||||
|
<option value="chat">{t("agents.styleChat")}</option>
|
||||||
|
</select>
|
||||||
|
<div className="hint">{t("agents.styleHint")}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label>{t("agents.readOnlyLabel")}</label>
|
||||||
|
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||||
|
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
|
||||||
|
<span className="hint">{t("agents.readOnlyHint")}</span>
|
||||||
|
</label>
|
||||||
<label>{t("agents.tools")}</label>
|
<label>{t("agents.tools")}</label>
|
||||||
<input value={form.tools} placeholder={t("agents.toolsAll")} onChange={(e) => setForm({ ...form, tools: e.target.value })} className="mono" />
|
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
|
||||||
<label>{t("agents.prompt")}</label>
|
<label>{t("agents.prompt")}</label>
|
||||||
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||||
</>
|
</>
|
||||||
@@ -159,7 +211,10 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
|||||||
|
|
||||||
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
|
const { data: all, reload } = useFetch<AgentInfo[]>("/agents");
|
||||||
|
// Personal modes are managed under "My modes".
|
||||||
|
const agents = all?.filter((a) => a.source !== "personal");
|
||||||
|
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||||||
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
||||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -172,12 +227,14 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const add = async (template: "writer" | "blank") => {
|
const add = async (template: "writer" | "blank" | "mode") => {
|
||||||
const n = name.trim() || (template === "writer" ? "writer" : "");
|
const n = name.trim() || (template === "writer" ? "writer" : "");
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(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 }
|
||||||
|
: template === "mode"
|
||||||
|
? { mode: "primary" }
|
||||||
: { mode: "subagent", description: "", prompt: "" };
|
: { mode: "subagent", description: "", prompt: "" };
|
||||||
try {
|
try {
|
||||||
await patch("/config", { patches: [{ path: ["agents", n], value }] });
|
await patch("/config", { patches: [{ path: ["agents", n], value }] });
|
||||||
@@ -194,13 +251,16 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
|||||||
<div className="notice">{t("agents.intro")}</div>
|
<div className="notice">{t("agents.intro")}</div>
|
||||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||||
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
|
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
|
||||||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||||||
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
|
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
|
||||||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<h3>{t("agents.add")}</h3>
|
<h3>{t("agents.add")}</h3>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
|
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<button className="primary" onClick={() => add("mode")} disabled={!name.trim()}>
|
||||||
|
+ {t("agents.addMode")}
|
||||||
|
</button>
|
||||||
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
|
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
|
||||||
<button onClick={() => add("blank")} disabled={!name.trim()}>
|
<button onClick={() => add("blank")} disabled={!name.trim()}>
|
||||||
+ {t("agents.addBlank")}
|
+ {t("agents.addBlank")}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { get, put } from "../../api";
|
||||||
|
import { confirmDialog, toast } from "../../dialogs";
|
||||||
|
import { useFetch } from "../../hooks";
|
||||||
|
import { useT } from "../../i18n";
|
||||||
|
import { Icon } from "../Icon";
|
||||||
|
import { ModelSelect } from "../ModelSelect";
|
||||||
|
import { ToolPicker, type ToolInfo } from "./AgentsEditor";
|
||||||
|
|
||||||
|
interface Mode {
|
||||||
|
description?: string | null;
|
||||||
|
prompt?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
tools?: string[] | null;
|
||||||
|
read_only?: boolean | null;
|
||||||
|
style?: "coding" | "chat" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modes a user creates for themselves; they appear in New session and the mode selector. */
|
||||||
|
export function PersonalModes() {
|
||||||
|
const t = useT();
|
||||||
|
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
|
||||||
|
const [modes, setModes] = useState<Record<string, Mode>>();
|
||||||
|
const [open, setOpen] = useState<string>();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
get<Record<string, Mode>>("/account/modes")
|
||||||
|
.then(setModes)
|
||||||
|
.catch((e) => toast(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = async (next: Record<string, Mode>) => {
|
||||||
|
try {
|
||||||
|
setModes(await put<Record<string, Mode>>("/account/modes", next));
|
||||||
|
toast(t("settings.saved"), "success");
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
toast(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
const n = name.trim();
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||||
|
if (modes?.[n]) return toast(t("modes.exists", { name: n }));
|
||||||
|
if (await save({ ...modes, [n]: { style: "chat", description: "", prompt: "" } })) {
|
||||||
|
setName("");
|
||||||
|
setOpen(n);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (n: string) => {
|
||||||
|
if (!(await confirmDialog({ message: t("agents.deleteConfirm", { name: n }), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
|
const next = { ...modes };
|
||||||
|
delete next[n];
|
||||||
|
void save(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!modes) return <div className="empty">{t("common.loading")}</div>;
|
||||||
|
const names = Object.keys(modes).sort();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="notice">{t("modes.intro")}</div>
|
||||||
|
{names.map((n) => (
|
||||||
|
<ModeRow key={n + JSON.stringify(modes[n])} name={n} mode={modes[n]} tools={tools} open={open === n} onToggle={() => setOpen(open === n ? undefined : n)} onSave={(m) => save({ ...modes, [n]: m })} onRemove={() => remove(n)} />
|
||||||
|
))}
|
||||||
|
{names.length === 0 && <div className="empty">{t("modes.none")}</div>}
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("agents.addMode")}</h3>
|
||||||
|
<div className="row">
|
||||||
|
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && void add()} />
|
||||||
|
<button className="primary" onClick={() => void add()} disabled={!name.trim()}>
|
||||||
|
+ {t("agents.addMode")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeRow({ name, mode, tools, open, onToggle, onSave, onRemove }: { name: string; mode: Mode; tools: ToolInfo[]; open: boolean; onToggle: () => void; onSave: (m: Mode) => Promise<boolean>; onRemove: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
description: mode.description ?? "",
|
||||||
|
prompt: mode.prompt ?? "",
|
||||||
|
model: mode.model ?? "",
|
||||||
|
tools: mode.tools ?? [],
|
||||||
|
read_only: !!mode.read_only,
|
||||||
|
style: (mode.style ?? "coding") as "coding" | "chat",
|
||||||
|
});
|
||||||
|
const submit = () =>
|
||||||
|
void onSave({
|
||||||
|
description: form.description || null,
|
||||||
|
prompt: form.prompt || null,
|
||||||
|
model: form.model || null,
|
||||||
|
tools: form.tools.length ? form.tools : null,
|
||||||
|
read_only: form.read_only || null,
|
||||||
|
style: form.style === "chat" ? "chat" : null,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
{name}
|
||||||
|
<span className="chip">{form.style === "chat" ? t("agents.styleChatShort") : t("agents.styleCodingShort")}</span>
|
||||||
|
{form.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="small ghost danger" onClick={onRemove} title={t("common.delete")} aria-label={t("common.delete")}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
<button className="small" onClick={onToggle}>
|
||||||
|
{open ? t("common.collapse") : t("common.edit")}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
{!open && mode.description && <div className="hint">{mode.description}</div>}
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="form">
|
||||||
|
<label>{t("agents.description")}</label>
|
||||||
|
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
|
<label>{t("agents.style")}</label>
|
||||||
|
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
|
||||||
|
<option value="coding">{t("agents.styleCoding")}</option>
|
||||||
|
<option value="chat">{t("agents.styleChat")}</option>
|
||||||
|
</select>
|
||||||
|
<label>{t("agents.model")}</label>
|
||||||
|
<ModelSelect value={form.model} onChange={(v) => setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} />
|
||||||
|
<label>{t("agents.readOnlyLabel")}</label>
|
||||||
|
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||||
|
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
|
||||||
|
<span className="hint">{t("agents.readOnlyHint")}</span>
|
||||||
|
</label>
|
||||||
|
<label>{t("agents.tools")}</label>
|
||||||
|
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
|
||||||
|
<label>{t("agents.prompt")}</label>
|
||||||
|
<textarea rows={8} value={form.prompt} placeholder={t("modes.promptPlaceholder")} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ marginTop: 12 }}>
|
||||||
|
<button className="primary" onClick={submit}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { del, put } from "../../api";
|
||||||
|
import { confirmDialog, toast } from "../../dialogs";
|
||||||
|
import { useFetch } from "../../hooks";
|
||||||
|
import { useT } from "../../i18n";
|
||||||
|
import { Icon } from "../Icon";
|
||||||
|
|
||||||
|
interface Status {
|
||||||
|
configured: boolean;
|
||||||
|
bot: string;
|
||||||
|
paired: boolean;
|
||||||
|
pair_code: string;
|
||||||
|
running: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect a personal Telegram bot as a chat interface. */
|
||||||
|
export function Telegram() {
|
||||||
|
const t = useT();
|
||||||
|
const { data, reload } = useFetch<Status>("/account/telegram");
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await put("/account/telegram", { token: token.trim() });
|
||||||
|
setToken("");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const disconnect = async () => {
|
||||||
|
if (!(await confirmDialog({ message: t("telegram.disconnectConfirm"), danger: true, confirmLabel: t("telegram.disconnect") }))) return;
|
||||||
|
await del("/account/telegram").catch((e) => toast(e));
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
<Icon name="share" /> {t("telegram.title")}
|
||||||
|
<span className="spacer" />
|
||||||
|
{data?.configured && (
|
||||||
|
<button className="small ghost danger" onClick={disconnect}>
|
||||||
|
{t("telegram.disconnect")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 10 }}>
|
||||||
|
{t("telegram.intro")}
|
||||||
|
</div>
|
||||||
|
{data && !data.configured && (
|
||||||
|
<>
|
||||||
|
<ol className="cli-steps">
|
||||||
|
<li>{t("telegram.step1")}</li>
|
||||||
|
<li>{t("telegram.step2")}</li>
|
||||||
|
</ol>
|
||||||
|
<div className="row" style={{ gap: 6 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="telegram-bot-token"
|
||||||
|
value={token}
|
||||||
|
placeholder="123456789:AAH…"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="mono"
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button className="primary" onClick={() => void connect()} disabled={busy || !token.trim()}>
|
||||||
|
{busy ? t("telegram.checking") : t("telegram.connect")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data?.configured && (
|
||||||
|
<>
|
||||||
|
<div className="row" style={{ gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<span className="chip">@{data.bot}</span>
|
||||||
|
<span className={`chip ${data.running && !data.error ? "ok" : "warn"}`}>{data.running && !data.error ? t("telegram.running") : t("telegram.stopped")}</span>
|
||||||
|
{data.paired && <span className="chip ok">{t("telegram.paired")}</span>}
|
||||||
|
</div>
|
||||||
|
{!data.paired && data.pair_code && (
|
||||||
|
<div className="notice" style={{ marginTop: 10 }}>
|
||||||
|
{t("telegram.pairHint", { bot: data.bot })}{" "}
|
||||||
|
<a href={`https://t.me/${data.bot}?start=${data.pair_code}`} target="_blank" rel="noreferrer">
|
||||||
|
t.me/{data.bot}
|
||||||
|
</a>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<code className="token-value">/start {data.pair_code}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.error && <div className="notice err" style={{ marginTop: 10 }}>{data.error}</div>}
|
||||||
|
{data.paired && <div className="hint" style={{ marginTop: 10 }}>{t("telegram.commands")}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+52
-7
@@ -65,12 +65,6 @@ export const de: Messages = {
|
|||||||
"settings.budgetWarning": "Warnen ab",
|
"settings.budgetWarning": "Warnen ab",
|
||||||
"settings.adminKey": "Admin-Key (optional)",
|
"settings.adminKey": "Admin-Key (optional)",
|
||||||
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
||||||
"settings.envTitle": "Einstellungen aus Umgebungsvariablen",
|
|
||||||
"settings.envHint": "Diese ${NAME}-Verweise holen ihre Werte aus der Server-Umgebung (.env). Übernimm die Werte in die Konfiguration, damit alles hier eingestellt ist – danach können die Variablen aus der .env raus.",
|
|
||||||
"settings.envApply": "In die Konfiguration übernehmen",
|
|
||||||
"settings.envConfirm": "Die aktuellen Werte von {names} in die Konfigurationsdatei schreiben? Die Keys liegen dann in der Config (und in ihren Backups) statt in der Umgebung.",
|
|
||||||
"settings.envDone": "{n} Werte in die Konfiguration übernommen. Du kannst sie jetzt aus der .env entfernen.",
|
|
||||||
"settings.envUnset": "Verwendet, aber nicht gesetzt (bleiben unverändert):",
|
|
||||||
"settings.fetchPrices": "Preise abrufen",
|
"settings.fetchPrices": "Preise abrufen",
|
||||||
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
||||||
"stats.perMillion": "pro 1 Mio. Tok.",
|
"stats.perMillion": "pro 1 Mio. Tok.",
|
||||||
@@ -91,6 +85,9 @@ export const de: Messages = {
|
|||||||
"files.newFolderName": "Ordnername:",
|
"files.newFolderName": "Ordnername:",
|
||||||
"files.renameTo": "Neuer Name:",
|
"files.renameTo": "Neuer Name:",
|
||||||
"files.copyTo": "Name der Kopie:",
|
"files.copyTo": "Name der Kopie:",
|
||||||
|
"files.chatHere": "Chat hier öffnen",
|
||||||
|
"files.newTab": "Dateimanager öffnen",
|
||||||
|
"files.closeTab": "Dateimanager schließen",
|
||||||
"files.rename": "Umbenennen",
|
"files.rename": "Umbenennen",
|
||||||
"files.duplicate": "Duplizieren",
|
"files.duplicate": "Duplizieren",
|
||||||
"files.download": "Herunterladen",
|
"files.download": "Herunterladen",
|
||||||
@@ -218,6 +215,7 @@ export const de: Messages = {
|
|||||||
"sidebar.week": "Letzte 7 Tage",
|
"sidebar.week": "Letzte 7 Tage",
|
||||||
"sidebar.older": "Älter",
|
"sidebar.older": "Älter",
|
||||||
"status.title": "Status",
|
"status.title": "Status",
|
||||||
|
"status.budgets": "Budgets",
|
||||||
"status.server": "Server",
|
"status.server": "Server",
|
||||||
"status.models": "Modelle",
|
"status.models": "Modelle",
|
||||||
"status.mcp": "MCP",
|
"status.mcp": "MCP",
|
||||||
@@ -241,6 +239,9 @@ export const de: Messages = {
|
|||||||
"wizard.title": "Was möchtest du machen?",
|
"wizard.title": "Was möchtest du machen?",
|
||||||
"wizard.subtitle": "Aufgabe wählen, beschreiben, was du willst, dann auf Start drücken.",
|
"wizard.subtitle": "Aufgabe wählen, beschreiben, was du willst, dann auf Start drücken.",
|
||||||
"wizard.whatToDo": "Was möchtest du machen?",
|
"wizard.whatToDo": "Was möchtest du machen?",
|
||||||
|
"wizard.task.chat": "Einfach chatten",
|
||||||
|
"wizard.task.chatDesc": "Fragen, besprechen, nachschlagen – ohne Projekt und Dateien",
|
||||||
|
"wizard.placeholder.chat": "Worüber möchtest du sprechen?",
|
||||||
"wizard.task.craft": "Etwas craften",
|
"wizard.task.craft": "Etwas craften",
|
||||||
"wizard.task.craftDesc": "Website, App, Spiel oder altes Programm nachbauen – oder etwas Neues bauen",
|
"wizard.task.craftDesc": "Website, App, Spiel oder altes Programm nachbauen – oder etwas Neues bauen",
|
||||||
"wizard.task.build": "Am Code arbeiten",
|
"wizard.task.build": "Am Code arbeiten",
|
||||||
@@ -308,6 +309,9 @@ export const de: Messages = {
|
|||||||
"rename.generate": "Generieren",
|
"rename.generate": "Generieren",
|
||||||
"nav.terminal": "Terminal",
|
"nav.terminal": "Terminal",
|
||||||
"terminal.new": "Neues Terminal",
|
"terminal.new": "Neues Terminal",
|
||||||
|
"terminal.chatTab": "Chat",
|
||||||
|
"terminal.tab": "Terminal {n}",
|
||||||
|
"terminal.closeConfirm": "{name} schließen? Die Shell und alles, was darin läuft, wird beendet.",
|
||||||
"terminal.close": "Terminal schließen",
|
"terminal.close": "Terminal schließen",
|
||||||
"terminal.here": "Terminal in diesem Ordner öffnen",
|
"terminal.here": "Terminal in diesem Ordner öffnen",
|
||||||
"terminal.connecting": "Verbinde…",
|
"terminal.connecting": "Verbinde…",
|
||||||
@@ -401,6 +405,19 @@ export const de: Messages = {
|
|||||||
"tokens.title": "API-Tokens",
|
"tokens.title": "API-Tokens",
|
||||||
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
|
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
|
||||||
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
|
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
|
||||||
|
"telegram.title": "Telegram",
|
||||||
|
"telegram.intro": "CodeCraft über Telegram mit deinem eigenen Bot nutzen: Nachrichten gehen an deine aktuelle Sitzung, Befehle wechseln Sitzungen, Modi und Modelle, Freigaben und Rückfragen kommen mit Knöpfen. Nur dein Telegram-Konto kann den Bot nutzen.",
|
||||||
|
"telegram.step1": "In Telegram @BotFather öffnen, /newbot senden und den Schritten folgen.",
|
||||||
|
"telegram.step2": "Das Token von BotFather hier einfügen.",
|
||||||
|
"telegram.connect": "Verbinden",
|
||||||
|
"telegram.checking": "Prüfe…",
|
||||||
|
"telegram.running": "läuft",
|
||||||
|
"telegram.stopped": "läuft nicht",
|
||||||
|
"telegram.paired": "gekoppelt",
|
||||||
|
"telegram.pairHint": "Letzter Schritt: deinen Bot öffnen und den Code senden (oder den Link antippen) – danach bedient er nur noch dein Telegram-Konto:",
|
||||||
|
"telegram.commands": "In Telegram: /help zeigt die Befehle – /sessions, /new, /mode, /model, /stop, /status, /costs …",
|
||||||
|
"telegram.disconnect": "Trennen",
|
||||||
|
"telegram.disconnectConfirm": "Telegram-Bot trennen? Er antwortet dann nicht mehr; mit seinem Token kannst du ihn wieder verbinden.",
|
||||||
"tokens.copy": "Kopieren",
|
"tokens.copy": "Kopieren",
|
||||||
"tokens.copied": "Kopiert",
|
"tokens.copied": "Kopiert",
|
||||||
"tokens.name": "Name",
|
"tokens.name": "Name",
|
||||||
@@ -451,6 +468,13 @@ export const de: Messages = {
|
|||||||
"shareIn.newChat": "Neuen Chat starten",
|
"shareIn.newChat": "Neuen Chat starten",
|
||||||
"shareIn.appendTo": "oder an eine letzte Session anhängen",
|
"shareIn.appendTo": "oder an eine letzte Session anhängen",
|
||||||
"common.back": "Zurück",
|
"common.back": "Zurück",
|
||||||
|
"settings.tab.modes": "Meine Modi",
|
||||||
|
"modes.intro": "Deine eigenen Modi: ein Prompt, eine Art (Programmieren oder Unterhaltung), Werkzeuge und optional ein Modell. Sie erscheinen in „Neue Sitzung“ und in der Modus-Auswahl deiner Chats – nur für dich.",
|
||||||
|
"modes.none": "Du hast noch keine eigenen Modi.",
|
||||||
|
"modes.exists": "Ein Modus namens {name} existiert schon.",
|
||||||
|
"modes.promptPlaceholder": "z. B. Du bist ein geduldiger Englischlehrer. Korrigiere meine Sätze und erkläre kurz, warum.",
|
||||||
|
"agents.styleChatShort": "Unterhaltung",
|
||||||
|
"agents.styleCodingShort": "Programmieren",
|
||||||
"settings.tab.templates": "Vorlagen",
|
"settings.tab.templates": "Vorlagen",
|
||||||
"templates.title": "Prompt-Vorlagen",
|
"templates.title": "Prompt-Vorlagen",
|
||||||
"templates.filter": "Vorlagen durchsuchen…",
|
"templates.filter": "Vorlagen durchsuchen…",
|
||||||
@@ -483,6 +507,8 @@ export const de: Messages = {
|
|||||||
"sidebar.organise": "Session organisieren",
|
"sidebar.organise": "Session organisieren",
|
||||||
"sidebar.pin": "Oben anpinnen",
|
"sidebar.pin": "Oben anpinnen",
|
||||||
"sidebar.unpin": "Nicht mehr anpinnen",
|
"sidebar.unpin": "Nicht mehr anpinnen",
|
||||||
|
"sidebar.openBeside": "Daneben öffnen",
|
||||||
|
"chat.closePane": "Spalte schließen",
|
||||||
"sidebar.moveTo": "In Ordner verschieben…",
|
"sidebar.moveTo": "In Ordner verschieben…",
|
||||||
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
||||||
"sidebar.archiveOne": "Archivieren",
|
"sidebar.archiveOne": "Archivieren",
|
||||||
@@ -562,6 +588,16 @@ export const de: Messages = {
|
|||||||
"chat.exportMd": "Markdown (.md)",
|
"chat.exportMd": "Markdown (.md)",
|
||||||
"chat.exportJson": "JSON (.json)",
|
"chat.exportJson": "JSON (.json)",
|
||||||
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
||||||
|
"chat.compactedAuto": "Kontext fast voll ({tokens} Tokens) – Unterhaltung automatisch komprimiert · Zusammenfassung",
|
||||||
|
"chat.compactedManual": "Unterhaltung bei {tokens} Tokens komprimiert · Zusammenfassung",
|
||||||
|
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
||||||
|
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
||||||
|
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
|
||||||
|
"instructions.title": "Anweisungen für diesen Chat",
|
||||||
|
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
|
||||||
|
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
|
||||||
|
"instructions.clear": "Entfernen",
|
||||||
|
"instructions.chip": "Anweisungen",
|
||||||
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||||
"chat.context": "Kontext",
|
"chat.context": "Kontext",
|
||||||
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
||||||
@@ -838,6 +874,7 @@ export const de: Messages = {
|
|||||||
"agents.modePrimary": "Modus",
|
"agents.modePrimary": "Modus",
|
||||||
"agents.modeSubagent": "Subagent",
|
"agents.modeSubagent": "Subagent",
|
||||||
"agents.source.builtin": "eingebaut",
|
"agents.source.builtin": "eingebaut",
|
||||||
|
"agents.source.personal": "persönlich",
|
||||||
"agents.source.config": "Config",
|
"agents.source.config": "Config",
|
||||||
"agents.source.file": "Datei",
|
"agents.source.file": "Datei",
|
||||||
"agents.model": "Modell",
|
"agents.model": "Modell",
|
||||||
@@ -848,8 +885,16 @@ export const de: Messages = {
|
|||||||
"agents.description": "Beschreibung",
|
"agents.description": "Beschreibung",
|
||||||
"agents.descriptionHint": "Anhand dieser Beschreibung entscheidet der Hauptagent, wann er delegiert.",
|
"agents.descriptionHint": "Anhand dieser Beschreibung entscheidet der Hauptagent, wann er delegiert.",
|
||||||
"agents.mode": "Art",
|
"agents.mode": "Art",
|
||||||
|
"agents.addMode": "Neuer Modus",
|
||||||
|
"agents.style": "Art",
|
||||||
|
"agents.styleCoding": "Programmieren – Projekt-Kontext und Coding-Ablauf",
|
||||||
|
"agents.styleChat": "Unterhaltung – nur dieser Prompt, kein Projekt",
|
||||||
|
"agents.styleHint": "Unterhaltungs-Modi bekommen nur deinen Prompt plus Datum und persönliches Gedächtnis, wie der eingebaute Chat-Modus.",
|
||||||
|
"agents.readOnlyLabel": "Nur lesen",
|
||||||
|
"agents.readOnlyHint": "Nur Werkzeuge, die nichts verändern (wie der Plan-Modus)",
|
||||||
|
"agents.toolsSome": "{n} Werkzeuge ausgewählt – nur diese sind verfügbar",
|
||||||
"agents.tools": "Tools",
|
"agents.tools": "Tools",
|
||||||
"agents.toolsAll": "alle Tools (z. B. read, glob, grep)",
|
"agents.toolsAll": "Nichts ausgewählt = alle Werkzeuge",
|
||||||
"agents.prompt": "Anweisungen",
|
"agents.prompt": "Anweisungen",
|
||||||
"agents.reset": "Zurücksetzen",
|
"agents.reset": "Zurücksetzen",
|
||||||
"agents.resetConfirm": "„{name}“ auf die eingebauten Standardwerte zurücksetzen?",
|
"agents.resetConfirm": "„{name}“ auf die eingebauten Standardwerte zurücksetzen?",
|
||||||
|
|||||||
+52
-7
@@ -64,12 +64,6 @@ export const en = {
|
|||||||
"settings.budgetWarning": "Warn at",
|
"settings.budgetWarning": "Warn at",
|
||||||
"settings.adminKey": "Admin key (optional)",
|
"settings.adminKey": "Admin key (optional)",
|
||||||
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
||||||
"settings.envTitle": "Settings from environment variables",
|
|
||||||
"settings.envHint": "These ${NAME} references take their values from the server's environment (.env). Move the values into the configuration so everything is set here – then remove the variables from .env.",
|
|
||||||
"settings.envApply": "Move into the configuration",
|
|
||||||
"settings.envConfirm": "Write the current values of {names} into the configuration file? Keys are then stored in the config (and in its backups) instead of the environment.",
|
|
||||||
"settings.envDone": "{n} values moved into the configuration. You can now remove them from .env.",
|
|
||||||
"settings.envUnset": "Referenced but not set (left as they are):",
|
|
||||||
"settings.fetchPrices": "Fetch prices",
|
"settings.fetchPrices": "Fetch prices",
|
||||||
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
||||||
"stats.perMillion": "per 1M tok",
|
"stats.perMillion": "per 1M tok",
|
||||||
@@ -90,6 +84,9 @@ export const en = {
|
|||||||
"files.newFolderName": "Folder name:",
|
"files.newFolderName": "Folder name:",
|
||||||
"files.renameTo": "New name:",
|
"files.renameTo": "New name:",
|
||||||
"files.copyTo": "Name of the copy:",
|
"files.copyTo": "Name of the copy:",
|
||||||
|
"files.chatHere": "Open chat here",
|
||||||
|
"files.newTab": "Open file manager",
|
||||||
|
"files.closeTab": "Close file manager",
|
||||||
"files.rename": "Rename",
|
"files.rename": "Rename",
|
||||||
"files.duplicate": "Duplicate",
|
"files.duplicate": "Duplicate",
|
||||||
"files.download": "Download",
|
"files.download": "Download",
|
||||||
@@ -217,6 +214,7 @@ export const en = {
|
|||||||
"sidebar.week": "Last 7 days",
|
"sidebar.week": "Last 7 days",
|
||||||
"sidebar.older": "Older",
|
"sidebar.older": "Older",
|
||||||
"status.title": "Status",
|
"status.title": "Status",
|
||||||
|
"status.budgets": "Budgets",
|
||||||
"status.server": "Server",
|
"status.server": "Server",
|
||||||
"status.models": "Models",
|
"status.models": "Models",
|
||||||
"status.mcp": "MCP",
|
"status.mcp": "MCP",
|
||||||
@@ -240,6 +238,9 @@ export const en = {
|
|||||||
"wizard.title": "What do you want to do?",
|
"wizard.title": "What do you want to do?",
|
||||||
"wizard.subtitle": "Pick a task, say what you want, then press Start.",
|
"wizard.subtitle": "Pick a task, say what you want, then press Start.",
|
||||||
"wizard.whatToDo": "What do you want to do?",
|
"wizard.whatToDo": "What do you want to do?",
|
||||||
|
"wizard.task.chat": "Just chat",
|
||||||
|
"wizard.task.chatDesc": "Ask, discuss, look things up – no project, no files",
|
||||||
|
"wizard.placeholder.chat": "What would you like to talk about?",
|
||||||
"wizard.task.craft": "Craft something",
|
"wizard.task.craft": "Craft something",
|
||||||
"wizard.task.craftDesc": "Recreate a website, app, game or old program – or build something new",
|
"wizard.task.craftDesc": "Recreate a website, app, game or old program – or build something new",
|
||||||
"wizard.task.build": "Work on code",
|
"wizard.task.build": "Work on code",
|
||||||
@@ -307,6 +308,9 @@ export const en = {
|
|||||||
"rename.generate": "Generate",
|
"rename.generate": "Generate",
|
||||||
"nav.terminal": "Terminal",
|
"nav.terminal": "Terminal",
|
||||||
"terminal.new": "New terminal",
|
"terminal.new": "New terminal",
|
||||||
|
"terminal.chatTab": "Chat",
|
||||||
|
"terminal.tab": "Terminal {n}",
|
||||||
|
"terminal.closeConfirm": "Close {name}? The shell and everything running in it are stopped.",
|
||||||
"terminal.close": "Close terminal",
|
"terminal.close": "Close terminal",
|
||||||
"terminal.here": "Open a terminal in this folder",
|
"terminal.here": "Open a terminal in this folder",
|
||||||
"terminal.connecting": "Connecting…",
|
"terminal.connecting": "Connecting…",
|
||||||
@@ -400,6 +404,19 @@ export const en = {
|
|||||||
"tokens.title": "API tokens",
|
"tokens.title": "API tokens",
|
||||||
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
|
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
|
||||||
"tokens.copyNow": "Copy the token now – it is shown only once:",
|
"tokens.copyNow": "Copy the token now – it is shown only once:",
|
||||||
|
"telegram.title": "Telegram",
|
||||||
|
"telegram.intro": "Use CodeCraft from Telegram with your own bot: messages go to your current session, commands switch sessions, modes and models, approvals and questions come with buttons. Only your Telegram account can use the bot.",
|
||||||
|
"telegram.step1": "In Telegram, open @BotFather, send /newbot and follow the steps.",
|
||||||
|
"telegram.step2": "Paste the token BotFather gives you here.",
|
||||||
|
"telegram.connect": "Connect",
|
||||||
|
"telegram.checking": "Checking…",
|
||||||
|
"telegram.running": "running",
|
||||||
|
"telegram.stopped": "not running",
|
||||||
|
"telegram.paired": "paired",
|
||||||
|
"telegram.pairHint": "Last step: open your bot and send the code (or tap the link) – from then on only your Telegram account is served:",
|
||||||
|
"telegram.commands": "In Telegram: /help lists the commands – /sessions, /new, /mode, /model, /stop, /status, /costs …",
|
||||||
|
"telegram.disconnect": "Disconnect",
|
||||||
|
"telegram.disconnectConfirm": "Disconnect the Telegram bot? It stops answering; you can connect it again with its token.",
|
||||||
"tokens.copy": "Copy",
|
"tokens.copy": "Copy",
|
||||||
"tokens.copied": "Copied",
|
"tokens.copied": "Copied",
|
||||||
"tokens.name": "Name",
|
"tokens.name": "Name",
|
||||||
@@ -450,6 +467,13 @@ export const en = {
|
|||||||
"shareIn.newChat": "Start a new chat",
|
"shareIn.newChat": "Start a new chat",
|
||||||
"shareIn.appendTo": "or add to a recent session",
|
"shareIn.appendTo": "or add to a recent session",
|
||||||
"common.back": "Back",
|
"common.back": "Back",
|
||||||
|
"settings.tab.modes": "My modes",
|
||||||
|
"modes.intro": "Your own modes: a prompt, a kind (coding or conversation), tools and an optional model. They appear in New session and in the mode selector of your chats – only for you.",
|
||||||
|
"modes.none": "You have no modes of your own yet.",
|
||||||
|
"modes.exists": "A mode named {name} already exists.",
|
||||||
|
"modes.promptPlaceholder": "e.g. You are a patient German teacher. Correct my sentences and explain briefly why.",
|
||||||
|
"agents.styleChatShort": "conversation",
|
||||||
|
"agents.styleCodingShort": "coding",
|
||||||
"settings.tab.templates": "Templates",
|
"settings.tab.templates": "Templates",
|
||||||
"templates.title": "Prompt templates",
|
"templates.title": "Prompt templates",
|
||||||
"templates.filter": "Search templates…",
|
"templates.filter": "Search templates…",
|
||||||
@@ -482,6 +506,8 @@ export const en = {
|
|||||||
"sidebar.organise": "Organise session",
|
"sidebar.organise": "Organise session",
|
||||||
"sidebar.pin": "Pin to top",
|
"sidebar.pin": "Pin to top",
|
||||||
"sidebar.unpin": "Unpin",
|
"sidebar.unpin": "Unpin",
|
||||||
|
"sidebar.openBeside": "Open beside",
|
||||||
|
"chat.closePane": "Close this column",
|
||||||
"sidebar.moveTo": "Move to folder…",
|
"sidebar.moveTo": "Move to folder…",
|
||||||
"sidebar.removeFromFolder": "Remove from folder",
|
"sidebar.removeFromFolder": "Remove from folder",
|
||||||
"sidebar.archiveOne": "Archive",
|
"sidebar.archiveOne": "Archive",
|
||||||
@@ -561,6 +587,16 @@ export const en = {
|
|||||||
"chat.exportMd": "Markdown (.md)",
|
"chat.exportMd": "Markdown (.md)",
|
||||||
"chat.exportJson": "JSON (.json)",
|
"chat.exportJson": "JSON (.json)",
|
||||||
"chat.empty": "Send a message to start.",
|
"chat.empty": "Send a message to start.",
|
||||||
|
"chat.compactedAuto": "Context nearly full ({tokens} tokens) – conversation compacted automatically · summary",
|
||||||
|
"chat.compactedManual": "Conversation compacted at {tokens} tokens · summary",
|
||||||
|
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
||||||
|
"chat.compactingManual": "Compacting the conversation",
|
||||||
|
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
|
||||||
|
"instructions.title": "Instructions for this chat",
|
||||||
|
"instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.",
|
||||||
|
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
|
||||||
|
"instructions.clear": "Remove",
|
||||||
|
"instructions.chip": "Instructions",
|
||||||
"chat.compacted": "Conversation compacted – summary",
|
"chat.compacted": "Conversation compacted – summary",
|
||||||
"chat.context": "Context",
|
"chat.context": "Context",
|
||||||
"chat.contextHint": "{tokens} tokens in the last request",
|
"chat.contextHint": "{tokens} tokens in the last request",
|
||||||
@@ -837,6 +873,7 @@ export const en = {
|
|||||||
"agents.modePrimary": "mode",
|
"agents.modePrimary": "mode",
|
||||||
"agents.modeSubagent": "subagent",
|
"agents.modeSubagent": "subagent",
|
||||||
"agents.source.builtin": "built-in",
|
"agents.source.builtin": "built-in",
|
||||||
|
"agents.source.personal": "personal",
|
||||||
"agents.source.config": "config",
|
"agents.source.config": "config",
|
||||||
"agents.source.file": "file",
|
"agents.source.file": "file",
|
||||||
"agents.model": "Model",
|
"agents.model": "Model",
|
||||||
@@ -847,8 +884,16 @@ export const en = {
|
|||||||
"agents.description": "Description",
|
"agents.description": "Description",
|
||||||
"agents.descriptionHint": "The main agent decides by this description when to delegate.",
|
"agents.descriptionHint": "The main agent decides by this description when to delegate.",
|
||||||
"agents.mode": "Type",
|
"agents.mode": "Type",
|
||||||
|
"agents.addMode": "New mode",
|
||||||
|
"agents.style": "Kind",
|
||||||
|
"agents.styleCoding": "Coding – project context and coding workflow",
|
||||||
|
"agents.styleChat": "Conversation – only this prompt, no project",
|
||||||
|
"agents.styleHint": "Conversation modes get just your prompt plus date and personal memory, like the built-in chat mode.",
|
||||||
|
"agents.readOnlyLabel": "Read-only",
|
||||||
|
"agents.readOnlyHint": "Only tools that change nothing (like plan mode)",
|
||||||
|
"agents.toolsSome": "{n} tools selected – only these are available",
|
||||||
"agents.tools": "Tools",
|
"agents.tools": "Tools",
|
||||||
"agents.toolsAll": "all tools (e.g. read, glob, grep)",
|
"agents.toolsAll": "Nothing selected = all tools",
|
||||||
"agents.prompt": "Instructions",
|
"agents.prompt": "Instructions",
|
||||||
"agents.reset": "Reset",
|
"agents.reset": "Reset",
|
||||||
"agents.resetConfirm": "Reset \"{name}\" to the built-in defaults?",
|
"agents.resetConfirm": "Reset \"{name}\" to the built-in defaults?",
|
||||||
|
|||||||
@@ -15,3 +15,23 @@ export function useRoute(): string[] {
|
|||||||
export function navigate(path: string) {
|
export function navigate(path: string) {
|
||||||
location.hash = path.startsWith("/") ? path : `/${path}`;
|
location.hash = path.startsWith("/") ? path : `/${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Chats shown side by side: #/s/<id>+<id>+<id> (at most 3). */
|
||||||
|
export const MAX_PANES = 3;
|
||||||
|
|
||||||
|
export function openChats(): string[] {
|
||||||
|
const m = location.hash.match(/^#\/s\/([^/]+)/);
|
||||||
|
return m ? m[1].split("+").filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a chat as another column (replaces the last one when all columns are used). */
|
||||||
|
export function openBeside(id: string) {
|
||||||
|
const ids = openChats().filter((x) => x !== id);
|
||||||
|
if (ids.length >= MAX_PANES) ids.pop();
|
||||||
|
navigate(`/s/${[...ids, id].join("+")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePane(id: string) {
|
||||||
|
const ids = openChats().filter((x) => x !== id);
|
||||||
|
navigate(ids.length ? `/s/${ids.join("+")}` : "/");
|
||||||
|
}
|
||||||
@@ -47,6 +47,8 @@ export interface State {
|
|||||||
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>". */
|
||||||
waiting: Record<string, string | undefined>;
|
waiting: Record<string, string | undefined>;
|
||||||
|
/** Compaction running in a session (auto = context nearly full). */
|
||||||
|
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let state: State = {
|
let state: State = {
|
||||||
@@ -67,6 +69,7 @@ let state: State = {
|
|||||||
queue: {},
|
queue: {},
|
||||||
steps: {},
|
steps: {},
|
||||||
waiting: {},
|
waiting: {},
|
||||||
|
compacting: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
@@ -188,6 +191,14 @@ export function applyEvent(ev: ServerEvent) {
|
|||||||
case "waiting":
|
case "waiting":
|
||||||
setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } }));
|
setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } }));
|
||||||
break;
|
break;
|
||||||
|
case "compaction":
|
||||||
|
setState((s) => ({
|
||||||
|
compacting: {
|
||||||
|
...s.compacting,
|
||||||
|
[ev.session_id]: ev.state === "started" ? { auto: ev.auto, tokens: ev.tokens, limit: ev.limit, since: Date.now() } : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
break;
|
||||||
case "mcp_status":
|
case "mcp_status":
|
||||||
setState(() => ({ mcp: ev.servers }));
|
setState(() => ({ mcp: ev.servers }));
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -3603,3 +3603,173 @@ button.danger-solid {
|
|||||||
.plan-pct {
|
.plan-pct {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- chat tabs (chat + terminals) ---------------------------------------------- */
|
||||||
|
.chat-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 10px 0;
|
||||||
|
border-bottom: 1px solid var(--bg3);
|
||||||
|
overflow-x: auto;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.chat-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--fg2);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
span.chat-tab {
|
||||||
|
padding: 0 4px 0 0;
|
||||||
|
}
|
||||||
|
span.chat-tab > button {
|
||||||
|
padding: 6px 4px 6px 12px;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chat-tab .close {
|
||||||
|
padding: 2px 4px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.chat-tab .close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.chat-tab:hover {
|
||||||
|
background: var(--bg1);
|
||||||
|
color: var(--fg0);
|
||||||
|
}
|
||||||
|
.chat-tab.active {
|
||||||
|
color: var(--fg0);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
background: var(--bg1);
|
||||||
|
}
|
||||||
|
.chat-tab.dead {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.chat-tab-add {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.chat-pane {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.chat-pane[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.files-embed-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid var(--bg3);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.files-embed-bar .path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg3);
|
||||||
|
font-family: var(--mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-picker .hint {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.tool-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
.tool-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tool-item.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.mode-select {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compacting-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px dashed var(--bg3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg1);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.compacting-card.auto {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 60%, var(--bg3));
|
||||||
|
}
|
||||||
|
.status-meter .budget-bar {
|
||||||
|
width: 70px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.status-meter .pct {
|
||||||
|
width: 38px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- split view (chats side by side) ------------------------------------------- */
|
||||||
|
.split-view {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.split-pane {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.split-pane + .split-pane {
|
||||||
|
border-left: 1px solid var(--bg3);
|
||||||
|
}
|
||||||
|
.split-pane .topbar {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.split-pane .topbar .grow {
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
.split-pane .topbar h1 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Narrow columns: the context chip moves out (the cost chip opens the usage overlay). */
|
||||||
|
.split-pane .topbar span.chip.hide-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.split-pane + .split-pane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.instructions-modal {
|
||||||
|
width: min(640px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
+7
-2
@@ -19,7 +19,7 @@ export type Part =
|
|||||||
is_error: boolean;
|
is_error: boolean;
|
||||||
meta?: any;
|
meta?: any;
|
||||||
}
|
}
|
||||||
| { type: "summary"; text: string };
|
| { type: "summary"; text: string; auto?: boolean; tokens?: number };
|
||||||
|
|
||||||
export interface Usage {
|
export interface Usage {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
@@ -67,6 +67,8 @@ export interface Session {
|
|||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
folder?: string | null;
|
folder?: string | null;
|
||||||
|
/** Instructions for this chat (added to the system prompt). */
|
||||||
|
instructions?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionOption {
|
export interface QuestionOption {
|
||||||
@@ -118,6 +120,7 @@ export type ServerEvent =
|
|||||||
| { type: "waiting"; session_id: string; reason: string | null }
|
| { type: "waiting"; session_id: string; reason: string | null }
|
||||||
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
||||||
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
||||||
|
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
|
||||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||||
| { type: "resync" };
|
| { type: "resync" };
|
||||||
|
|
||||||
@@ -150,9 +153,11 @@ export interface AgentInfo {
|
|||||||
description: string;
|
description: string;
|
||||||
mode: "primary" | "subagent";
|
mode: "primary" | "subagent";
|
||||||
read_only: boolean;
|
read_only: boolean;
|
||||||
|
/** "coding" or "chat" (plain conversation prompt). */
|
||||||
|
style?: "coding" | "chat";
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tools?: string[] | null;
|
tools?: string[] | null;
|
||||||
source: "builtin" | "config" | "file";
|
source: "builtin" | "config" | "file" | "personal";
|
||||||
max_parallel?: number | null;
|
max_parallel?: number | null;
|
||||||
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
|
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
|
||||||
/** Source file of Markdown agents. */
|
/** Source file of Markdown agents. */
|
||||||
|
|||||||
@@ -3,6 +3,54 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00008": {
|
||||||
|
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
|
||||||
|
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],
|
||||||
|
},
|
||||||
|
"202611-00007": {
|
||||||
|
en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"],
|
||||||
|
de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"],
|
||||||
|
},
|
||||||
|
"202611-00006": {
|
||||||
|
en: ["Docker CLI in the image – with the host's Docker socket mounted, admins' agents and terminals can use Docker"],
|
||||||
|
de: ["Docker-CLI im Image – mit eingehängtem Docker-Socket können Admin-Agents und -Terminals Docker nutzen"],
|
||||||
|
},
|
||||||
|
"202611-00005": {
|
||||||
|
en: ["Agents commit with the git name and e-mail from your settings – also in repositories with their own identity"],
|
||||||
|
de: ["Agents committen mit Git-Name und -E-Mail aus deinen Einstellungen – auch in Repos mit eigener Identität"],
|
||||||
|
},
|
||||||
|
"202611-00004": {
|
||||||
|
en: [
|
||||||
|
"Chats side by side: “Open beside” in a chat's menu in the sidebar",
|
||||||
|
"Settings → My modes: your own modes",
|
||||||
|
"Budget warnings as push notifications, budgets in the status overlay",
|
||||||
|
"File manager tabs are the same on every device",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
"Chats nebeneinander: „Daneben öffnen“ im Menü eines Chats in der Seitenleiste",
|
||||||
|
"Einstellungen → Meine Modi: eigene Modi",
|
||||||
|
"Budget-Warnungen als Push, Budgets im Status-Overlay",
|
||||||
|
"Dateimanager-Tabs sind auf jedem Gerät gleich",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"202611-00003": {
|
||||||
|
en: [
|
||||||
|
"Terminals and file managers as tabs in a chat – terminals keep running, also for another device",
|
||||||
|
"New session → “Just chat” for plain conversation",
|
||||||
|
"Own modes: Settings → Agents & commands → New mode",
|
||||||
|
"The chat shows when the conversation is compacted (automatically or on request)",
|
||||||
|
"File manager: “Open chat here” in a folder's menu",
|
||||||
|
"Terminal client downloadable from the server again",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
"Terminals und Dateimanager als Tabs im Chat – Terminals laufen weiter, auch für ein anderes Gerät",
|
||||||
|
"Neue Sitzung → „Einfach chatten“ für reine Unterhaltung",
|
||||||
|
"Eigene Modi: Einstellungen → Agents & Befehle → Neuer Modus",
|
||||||
|
"Der Chat zeigt an, wenn die Unterhaltung komprimiert wird (automatisch oder auf Wunsch)",
|
||||||
|
"Dateimanager: „Chat hier öffnen“ im Ordner-Menü",
|
||||||
|
"Terminal-Client wieder direkt vom Server herunterladbar",
|
||||||
|
],
|
||||||
|
},
|
||||||
"202611-00002": {
|
"202611-00002": {
|
||||||
en: [
|
en: [
|
||||||
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",
|
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",
|
||||||
|
|||||||
Reference in new issue
Block a user