Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
server/target
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
data
|
||||||
|
workspace
|
||||||
|
.env
|
||||||
|
.git
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Access token for the web UI (required). Generate one with: openssl rand -hex 32
|
||||||
|
CODECRAFT_TOKEN=
|
||||||
|
|
||||||
|
# Directory with your projects, mounted as /workspace.
|
||||||
|
WORKSPACE=/home/zink/Projects
|
||||||
|
|
||||||
|
# Host port.
|
||||||
|
PORT=8787
|
||||||
|
|
||||||
|
# UID/GID that owns the project files (id -u / id -g).
|
||||||
|
UID=1000
|
||||||
|
GID=1000
|
||||||
|
|
||||||
|
# Provider keys – referenced in data/config.toml as ${NAME}.
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
OPENCODE_API_KEY=
|
||||||
|
FAL_KEY=
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/server/target/
|
||||||
|
/web/node_modules/
|
||||||
|
/web/dist/
|
||||||
|
/data/
|
||||||
|
/workspace/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# --- web UI (PWA) -------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS web
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- server -------------------------------------------------------------------
|
||||||
|
FROM rust:1-bookworm AS server
|
||||||
|
WORKDIR /src
|
||||||
|
COPY config.example.toml ./
|
||||||
|
COPY server/Cargo.toml server/Cargo.lock ./server/
|
||||||
|
# Cache dependencies separately from the sources.
|
||||||
|
RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
|
||||||
|
&& cd server && cargo build --release && rm -rf src
|
||||||
|
COPY server/src ./server/src
|
||||||
|
RUN cd server && touch src/main.rs && cargo build --release
|
||||||
|
|
||||||
|
# --- runtime ------------------------------------------------------------------
|
||||||
|
# Node is included so agents can work on JS projects and run npx-based MCP servers.
|
||||||
|
FROM node:22-bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates bash git curl ripgrep jq less procps \
|
||||||
|
python3 python3-venv build-essential \
|
||||||
|
chromium fonts-dejavu-core fonts-noto-color-emoji \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=server /src/server/target/release/codecraft /usr/local/bin/codecraft
|
||||||
|
COPY --from=web /web/dist /app/web
|
||||||
|
ENV CODECRAFT_WEB=/app/web \
|
||||||
|
CODECRAFT_CONFIG=/data/config.toml \
|
||||||
|
HOME=/data/home \
|
||||||
|
RUST_LOG=info
|
||||||
|
RUN mkdir -p /data /workspace && chmod 777 /data /workspace
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 8787
|
||||||
|
# Run as the host user (see docker-compose.yml) so files in /workspace keep their owner.
|
||||||
|
USER 1000:1000
|
||||||
|
ENTRYPOINT ["codecraft"]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# CodeCraft
|
||||||
|
|
||||||
|
A self-hosted, web-based coding harness for AI models – in the spirit of Claude Code and
|
||||||
|
opencode, usable from any browser and installable as a PWA.
|
||||||
|
|
||||||
|
- **Any model**: Anthropic (native Messages API), Google Gemini, opencode Go, fal.ai,
|
||||||
|
OpenRouter and self-hosted models (Ollama, vLLM, llama.cpp, LM Studio) through
|
||||||
|
OpenAI-compatible endpoints.
|
||||||
|
- **Agent loop with tools**: read, write, edit, list, glob, grep, bash, web_fetch,
|
||||||
|
todo_write, ask_user, task (subagents), skill – plus every tool of your MCP servers.
|
||||||
|
- **Modes**: `build` (full access), `plan` (read-only, proposes a plan), `craft`
|
||||||
|
(study a target → build your own version → check visually → refine), and custom
|
||||||
|
agents.
|
||||||
|
- **Human in the loop**: permission requests (with diffs) and questions from agents *and*
|
||||||
|
MCP servers (elicitation) are forwarded to the UI; optional browser notifications.
|
||||||
|
- **MCP**: stdio and Streamable HTTP servers, configured in the UI or the config file.
|
||||||
|
- **Statistics**: every model request is recorded (tokens in/out, cache, time to first
|
||||||
|
token, prefill and output speed, cost per model price) – per chat and in a dashboard
|
||||||
|
with daily charts and a weekday × hour heatmap.
|
||||||
|
- **Claude Code compatible project files**: `AGENTS.md`, `CLAUDE.md`,
|
||||||
|
`.claude/{agents,commands,skills}` (and `.codecraft/…`).
|
||||||
|
- **Configuration** in a commented TOML file that the web UI edits in place (comments
|
||||||
|
are preserved).
|
||||||
|
- **i18n**: English and German; adding a language is one file.
|
||||||
|
|
||||||
|
## Quick start (Docker)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env # set CODECRAFT_TOKEN, WORKSPACE and provider keys
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://<host>:8787`, sign in with the token and pick a project directory. On first
|
||||||
|
start the commented default config is written to `data/config.toml`. Put the service behind
|
||||||
|
a TLS reverse proxy for remote access – the PWA install prompt requires HTTPS.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
See [`config.example.toml`](config.example.toml) – every option is documented there.
|
||||||
|
Highlights:
|
||||||
|
|
||||||
|
- `[providers.<name>]` – `kind = "anthropic" | "openai"`, `base_url`, `api_key`
|
||||||
|
(`${ENV}` references recommended), optional `models` with context size, effort and
|
||||||
|
prices per 1M tokens. Without `models`, the list is fetched from the provider.
|
||||||
|
- `[mcp.<name>]` – `type = "stdio"` (command/args/env) or `"http"` (url/headers).
|
||||||
|
- `[permissions]` – `allow` / `ask` / `deny` per tool (globs such as `mcp__*`), plus
|
||||||
|
`[permissions.bash_rules]` for shell command patterns. Compound commands are only
|
||||||
|
auto-allowed when every part is allowed.
|
||||||
|
- `[agents.<name>]` – custom modes and subagents.
|
||||||
|
- `[craft]` – Chromium binary, screenshot widths, clean room vs. deep mode.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Requirements: Rust (edition 2024), Node ≥ 20.19.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# backend (writes ./data/config.toml on first start)
|
||||||
|
cd server && CODECRAFT_CONFIG=../data/config.toml cargo run
|
||||||
|
|
||||||
|
# frontend with hot reload, proxies /api to 127.0.0.1:8787
|
||||||
|
cd web && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development set `bind = "127.0.0.1:8787"` and `workspace` to your projects
|
||||||
|
directory in `data/config.toml`.
|
||||||
|
|
||||||
|
Tests: `cd server && cargo test`, type check: `cd web && npx tsc --noEmit`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
server/ (Rust, axum + tokio)
|
||||||
|
provider/ Anthropic + OpenAI-compatible streaming clients
|
||||||
|
runner.rs agent loop: streaming, tool execution, permissions, subagents, compaction
|
||||||
|
tools/ built-in tools (fs, bash, web, agent, craft)
|
||||||
|
mcp.rs MCP client (stdio, Streamable HTTP, elicitation)
|
||||||
|
prompts.rs agents/modes, system prompt, AGENTS.md, slash commands, skills
|
||||||
|
permission.rs, config.rs (toml + toml_edit), store.rs (SQLite), api.rs (REST + WebSocket)
|
||||||
|
web/ (React + Vite, PWA)
|
||||||
|
src/components chat, composer, permission/question cards, settings, statistics
|
||||||
|
src/i18n en (reference), de
|
||||||
|
```
|
||||||
|
|
||||||
|
The server streams all events over one WebSocket (`/api/ws`); the UI reconnects and
|
||||||
|
resyncs automatically, so long-running agents keep going when the browser is closed.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- Background shell processes, file checkpoints/undo, `@file` mentions
|
||||||
|
- MCP OAuth, resources and prompts; remote MCP over SSE (legacy)
|
||||||
|
- Hooks (pre/post tool), output styles, per-project config overrides
|
||||||
|
- Image generation tools (fal.ai), OpenAI Responses API
|
||||||
|
- Multi-user accounts
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# CodeCraft – configuration
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# This file is the single source of truth for the server. Everything here can
|
||||||
|
# also be edited in the web UI (Settings). The UI edits this file in place and
|
||||||
|
# keeps your comments and formatting intact.
|
||||||
|
#
|
||||||
|
# Location: $CODECRAFT_CONFIG, otherwise <data_dir>/config.toml (Docker: /data/config.toml)
|
||||||
|
#
|
||||||
|
# Secrets: any string value may reference environment variables with ${NAME},
|
||||||
|
# e.g. api_key = "${ANTHROPIC_API_KEY}". Prefer this over literal keys – keys
|
||||||
|
# entered in the UI are stored here in plain text.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[server]
|
||||||
|
# Address the HTTP server listens on.
|
||||||
|
bind = "0.0.0.0:8787"
|
||||||
|
|
||||||
|
# Root directory the agents may work in. Sessions pick a project folder below it.
|
||||||
|
# Docker: mount your projects here (see docker-compose.yml).
|
||||||
|
workspace = "/workspace"
|
||||||
|
|
||||||
|
# Where sessions (SQLite) and runtime state are stored.
|
||||||
|
data_dir = "/data"
|
||||||
|
|
||||||
|
# Access token for the web UI and API. Leave empty to disable auth (only do
|
||||||
|
# this on localhost!). Recommended: auth_token = "${CODECRAFT_TOKEN}"
|
||||||
|
auth_token = "${CODECRAFT_TOKEN}"
|
||||||
|
|
||||||
|
# Default model for new sessions, as "<provider>/<model>".
|
||||||
|
default_model = "anthropic/claude-sonnet-5-5"
|
||||||
|
|
||||||
|
# Cheap/fast model for titles and history compaction. Falls back to default_model.
|
||||||
|
small_model = "anthropic/claude-haiku-5-5"
|
||||||
|
|
||||||
|
# Compact the conversation automatically when the context is this full (0.0–1.0).
|
||||||
|
auto_compact_threshold = 0.85
|
||||||
|
|
||||||
|
# Currency label for the cost statistics (prices below are in this currency).
|
||||||
|
currency = "USD"
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Providers
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# kind = "anthropic" → Anthropic Messages API (and compatible endpoints)
|
||||||
|
# kind = "openai" → OpenAI-compatible /chat/completions (Gemini, opencode Go,
|
||||||
|
# fal, OpenRouter, Ollama, vLLM, llama.cpp, LM Studio, ...)
|
||||||
|
#
|
||||||
|
# models: optional list. If empty, the model list is fetched from <base_url>/models.
|
||||||
|
# Per-model options: context (tokens), max_output (tokens), reasoning (bool),
|
||||||
|
# vision (bool, can read images), effort ("low" | "medium" | "high" | "xhigh" | "max").
|
||||||
|
#
|
||||||
|
# Prices (per 1M tokens, used for the statistics):
|
||||||
|
# input_price, output_price, cache_read_price, cache_write_price
|
||||||
|
# Cache prices default to input_price when omitted. Costs are stored per request,
|
||||||
|
# so changing a price only affects future requests.
|
||||||
|
#
|
||||||
|
# Authentication: by default "Authorization: Bearer <api_key>" (openai) or
|
||||||
|
# "x-api-key: <api_key>" (anthropic). Override with auth_header / auth_prefix.
|
||||||
|
# Extra static headers: headers = { "X-Foo" = "bar" }
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[providers.anthropic]
|
||||||
|
kind = "anthropic"
|
||||||
|
base_url = "https://api.anthropic.com/v1"
|
||||||
|
api_key = "${ANTHROPIC_API_KEY}"
|
||||||
|
# Current Claude models think adaptively; "effort" controls depth and cost.
|
||||||
|
# Prices: Anthropic list prices in USD per 1M tokens (Nov 2026) – adjust if needed.
|
||||||
|
models = [
|
||||||
|
{ id = "claude-opus-5-5", context = 1000000, max_output = 64000, reasoning = true, vision = true, effort = "high",
|
||||||
|
input_price = 4.0, output_price = 20.0, cache_read_price = 0.20, cache_write_price = 5.0 },
|
||||||
|
{ id = "claude-sonnet-5-5", context = 1000000, max_output = 64000, reasoning = true, vision = true, effort = "medium",
|
||||||
|
input_price = 2.0, output_price = 10.0, cache_read_price = 0.20, cache_write_price = 2.5 },
|
||||||
|
{ id = "claude-haiku-5-5", context = 1000000, max_output = 32000, reasoning = true, vision = true, effort = "low",
|
||||||
|
input_price = 0.10, output_price = 0.50, cache_read_price = 0.01, cache_write_price = 0.125 },
|
||||||
|
]
|
||||||
|
|
||||||
|
# Google Gemini through its OpenAI-compatible endpoint.
|
||||||
|
[providers.gemini]
|
||||||
|
kind = "openai"
|
||||||
|
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||||
|
api_key = "${GEMINI_API_KEY}"
|
||||||
|
models = [] # discovered automatically
|
||||||
|
enabled = false # set to true once GEMINI_API_KEY is available
|
||||||
|
|
||||||
|
# opencode Go subscription (OpenAI-compatible endpoint for most models).
|
||||||
|
# Current model list: https://opencode.ai/inference/go/v1/models
|
||||||
|
[providers.opencode-go]
|
||||||
|
kind = "openai"
|
||||||
|
base_url = "https://opencode.ai/inference/go/openai/v1"
|
||||||
|
api_key = "${OPENCODE_API_KEY}"
|
||||||
|
models = []
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key <FAL_KEY>".
|
||||||
|
# NOTE: verify base_url against the fal docs for your account.
|
||||||
|
[providers.fal]
|
||||||
|
kind = "openai"
|
||||||
|
base_url = "https://fal.run/openrouter/router/openai/v1"
|
||||||
|
api_key = "${FAL_KEY}"
|
||||||
|
auth_prefix = "Key "
|
||||||
|
models = [
|
||||||
|
{ id = "google/gemini-2.5-flash", context = 1000000, max_output = 32000, vision = true },
|
||||||
|
]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
# Self-hosted (Ollama shown; vLLM/llama.cpp/LM Studio work the same way).
|
||||||
|
# From inside Docker, the host is reachable as host.docker.internal.
|
||||||
|
[providers.local]
|
||||||
|
kind = "openai"
|
||||||
|
base_url = "http://host.docker.internal:11434/v1"
|
||||||
|
api_key = ""
|
||||||
|
models = []
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# MCP servers
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# type = "stdio" → command + args (+ env), started as a child process
|
||||||
|
# type = "http" → Streamable HTTP endpoint (url + optional headers)
|
||||||
|
# Tools appear to the model as mcp__<server>__<tool>.
|
||||||
|
# MCP servers may ask you questions (elicitation); they show up in the UI.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# [mcp.trilium]
|
||||||
|
# type = "http"
|
||||||
|
# url = "https://notes.example.org/mcp"
|
||||||
|
# headers = { Authorization = "Bearer ${TRILIUM_TOKEN}" }
|
||||||
|
|
||||||
|
# [mcp.filesystem]
|
||||||
|
# type = "stdio"
|
||||||
|
# command = "npx"
|
||||||
|
# args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
|
||||||
|
# env = {}
|
||||||
|
# enabled = false
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Permissions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# For each tool: "allow" | "ask" | "deny". "ask" forwards the request to the UI.
|
||||||
|
# MCP tools can be addressed as "mcp__<server>__<tool>" or "mcp__<server>__*".
|
||||||
|
# Session permission modes in the UI override this:
|
||||||
|
# default → these rules
|
||||||
|
# accept_edits → file edits allowed without asking
|
||||||
|
# bypass → everything allowed (use with care)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[permissions]
|
||||||
|
read = "allow"
|
||||||
|
glob = "allow"
|
||||||
|
grep = "allow"
|
||||||
|
list = "allow"
|
||||||
|
todo_write = "allow"
|
||||||
|
ask_user = "allow"
|
||||||
|
task = "allow"
|
||||||
|
write = "ask"
|
||||||
|
edit = "ask"
|
||||||
|
bash = "ask"
|
||||||
|
web_fetch = "ask"
|
||||||
|
browser_snapshot = "allow"
|
||||||
|
image_compare = "allow"
|
||||||
|
"mcp__*" = "ask"
|
||||||
|
|
||||||
|
# Shell command patterns (glob, matched against the full command). First match wins.
|
||||||
|
[permissions.bash_rules]
|
||||||
|
"git status*" = "allow"
|
||||||
|
"git diff*" = "allow"
|
||||||
|
"git log*" = "allow"
|
||||||
|
"ls*" = "allow"
|
||||||
|
"pwd" = "allow"
|
||||||
|
"rm -rf /*" = "deny"
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Agents (modes)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Built-in: build (full access), plan (read-only, proposes a plan),
|
||||||
|
# craft (study → build → check → ready), and subagents
|
||||||
|
# general + explore used by the "task" tool.
|
||||||
|
# Define your own or override built-ins here. mode = "primary" shows up in the
|
||||||
|
# mode switcher, mode = "subagent" is only available through the task tool.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# [agents.reviewer]
|
||||||
|
# mode = "subagent"
|
||||||
|
# description = "Reviews code changes for bugs and style issues."
|
||||||
|
# model = "anthropic/claude-opus-5-5" # optional, otherwise session model
|
||||||
|
# tools = ["read", "glob", "grep", "bash"] # optional, default: all tools
|
||||||
|
# prompt = """
|
||||||
|
# You are a meticulous code reviewer. ...
|
||||||
|
# """
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Craft mode
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
[craft]
|
||||||
|
# Headless Chromium binary used to study websites and check builds.
|
||||||
|
chromium = "chromium"
|
||||||
|
# Screen widths used for screenshots and comparisons.
|
||||||
|
widths = [390, 768, 1440]
|
||||||
|
# Clean room: only what a visitor sees (screenshots, visible text, layout).
|
||||||
|
# Set to false for "deep" mode (page source and scripts may be read).
|
||||||
|
clean_room = true
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# CodeCraft – copy .env.example to .env and adjust, then: docker compose up -d --build
|
||||||
|
services:
|
||||||
|
codecraft:
|
||||||
|
build: .
|
||||||
|
image: codecraft:latest
|
||||||
|
container_name: codecraft
|
||||||
|
restart: unless-stopped
|
||||||
|
# Same UID/GID as the owner of your project files.
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8787}:8787"
|
||||||
|
environment:
|
||||||
|
CODECRAFT_TOKEN: ${CODECRAFT_TOKEN:?set CODECRAFT_TOKEN in .env}
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||||
|
OPENCODE_API_KEY: ${OPENCODE_API_KEY:-}
|
||||||
|
FAL_KEY: ${FAL_KEY:-}
|
||||||
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||||
|
volumes:
|
||||||
|
# Config (config.toml), SQLite database, agent home directory.
|
||||||
|
- ./data:/data
|
||||||
|
# The projects the agents may work on.
|
||||||
|
- ${WORKSPACE:-./workspace}:/workspace
|
||||||
|
# Reach self-hosted models (Ollama, vLLM, ...) on the host.
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
shm_size: "1gb" # Chromium (craft mode)
|
||||||
Generated
+2968
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "codecraft"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT"
|
||||||
|
description = "Web-based coding harness for AI models"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
async-trait = "0.1"
|
||||||
|
axum = { version = "0.8", features = ["ws", "macros"] }
|
||||||
|
bytes = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
eventsource-stream = "0.2"
|
||||||
|
futures = "0.3"
|
||||||
|
globset = "0.4"
|
||||||
|
html2text = "0.15"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
shell-words = "1"
|
||||||
|
libc = "0.2"
|
||||||
|
ignore = "0.4"
|
||||||
|
regex = "1"
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] }
|
||||||
|
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
similar = "2"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
|
toml = "1"
|
||||||
|
toml_edit = "0.25"
|
||||||
|
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "v7"] }
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
//! HTTP + WebSocket API.
|
||||||
|
|
||||||
|
use crate::app::{App, PermissionReply};
|
||||||
|
use crate::config::{self, ConfigPatch};
|
||||||
|
use crate::prompts;
|
||||||
|
use crate::runner;
|
||||||
|
use crate::types::{Event, ImageData, Part, Session, new_id, now_ms};
|
||||||
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
|
use axum::extract::{Path, Query, Request, State};
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use axum::middleware::{self, Next};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
type AppState = Arc<App>;
|
||||||
|
|
||||||
|
pub struct ApiError(StatusCode, String);
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(self.0, Json(json!({"error": self.1}))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: Into<anyhow::Error>> From<E> for ApiError {
|
||||||
|
fn from(e: E) -> Self {
|
||||||
|
ApiError(StatusCode::BAD_REQUEST, format!("{:#}", e.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiResult<T = Json<Value>> = Result<T, ApiError>;
|
||||||
|
|
||||||
|
fn not_found(what: &str) -> ApiError {
|
||||||
|
ApiError(StatusCode::NOT_FOUND, format!("{what} not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(app: AppState) -> Router {
|
||||||
|
let api = Router::new()
|
||||||
|
.route("/info", get(info))
|
||||||
|
.route("/config", get(get_config).patch(patch_config))
|
||||||
|
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
||||||
|
.route("/providers/reload", post(reload_providers))
|
||||||
|
.route("/models", get(models))
|
||||||
|
.route("/agents", get(agents))
|
||||||
|
.route("/commands", get(commands))
|
||||||
|
.route("/sessions", get(list_sessions).post(create_session))
|
||||||
|
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||||
|
.route("/sessions/{id}/prompt", post(prompt))
|
||||||
|
.route("/sessions/{id}/abort", post(abort))
|
||||||
|
.route("/sessions/{id}/compact", post(compact))
|
||||||
|
.route("/sessions/{id}/rewind", post(rewind))
|
||||||
|
.route("/sessions/{id}/stats", get(session_stats))
|
||||||
|
.route("/permissions/{id}", post(reply_permission))
|
||||||
|
.route("/questions/{id}", post(answer_question))
|
||||||
|
.route("/pending", get(pending))
|
||||||
|
.route("/mcp", get(mcp_status))
|
||||||
|
.route("/mcp/reload", post(mcp_reload))
|
||||||
|
.route("/fs/dirs", get(list_dirs))
|
||||||
|
.route("/stats", get(stats))
|
||||||
|
.route("/ws", get(ws))
|
||||||
|
.layer(middleware::from_fn_with_state(app.clone(), auth));
|
||||||
|
|
||||||
|
let web_dir = std::env::var("CODECRAFT_WEB").map(PathBuf::from).unwrap_or_else(|_| {
|
||||||
|
["../web/dist", "./web/dist", "/app/web"]
|
||||||
|
.iter()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.find(|p| p.join("index.html").exists())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("./web/dist"))
|
||||||
|
});
|
||||||
|
let index = web_dir.join("index.html");
|
||||||
|
let static_files = tower_http::services::ServeDir::new(&web_dir)
|
||||||
|
.fallback(tower_http::services::ServeFile::new(index));
|
||||||
|
|
||||||
|
Router::new().nest("/api", api).fallback_service(static_files).with_state(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearer token, `token` query parameter (WebSocket) or `codecraft_token` cookie.
|
||||||
|
async fn auth(State(app): State<AppState>, req: Request, next: Next) -> Response {
|
||||||
|
let expected = app.config().auth_token();
|
||||||
|
if expected.is_empty() || req.uri().path().ends_with("/info") {
|
||||||
|
return next.run(req).await;
|
||||||
|
}
|
||||||
|
let header_token = req
|
||||||
|
.headers()
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(String::from);
|
||||||
|
let query_token = req.uri().query().and_then(|q| {
|
||||||
|
q.split('&').find_map(|kv| kv.strip_prefix("token=")).map(String::from)
|
||||||
|
});
|
||||||
|
let cookie_token = req
|
||||||
|
.headers()
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("codecraft_token=")).map(String::from));
|
||||||
|
let ok = [header_token, query_token, cookie_token]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|t| constant_time_eq(t.as_bytes(), expected.as_bytes()));
|
||||||
|
if ok {
|
||||||
|
next.run(req).await
|
||||||
|
} else {
|
||||||
|
ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||||
|
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- info & config ----------------------------------------------------------
|
||||||
|
|
||||||
|
async fn info(State(app): State<AppState>) -> Json<Value> {
|
||||||
|
let cfg = app.config();
|
||||||
|
Json(json!({
|
||||||
|
"name": "CodeCraft",
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"auth_required": !cfg.auth_token().is_empty(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_config(State(app): State<AppState>) -> ApiResult {
|
||||||
|
let cfg = app.config();
|
||||||
|
Ok(Json(json!({
|
||||||
|
"config": config::redacted_json(&cfg),
|
||||||
|
"workspace": app.workspace(),
|
||||||
|
"default_model": if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) },
|
||||||
|
"currency": cfg.server.currency,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PatchBody {
|
||||||
|
patches: Vec<ConfigPatch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_config(State(app): State<AppState>, Json(body): Json<PatchBody>) -> ApiResult {
|
||||||
|
// Masked secrets coming back from the UI must not overwrite real values.
|
||||||
|
let patches: Vec<ConfigPatch> = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect();
|
||||||
|
app.patch_config(&patches).await?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_config_raw(State(app): State<AppState>) -> ApiResult {
|
||||||
|
Ok(Json(json!({"text": app.config_text()?})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawBody {
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_config_raw(State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||||
|
app.save_config(&body.text).await?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reload_providers(State(app): State<AppState>) -> ApiResult {
|
||||||
|
app.reload_providers().await;
|
||||||
|
Ok(Json(app.registry().models_json()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn models(State(app): State<AppState>) -> Json<Value> {
|
||||||
|
Json(app.registry().models_json())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CwdQuery {
|
||||||
|
cwd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cwd_or_workspace(app: &App, cwd: Option<String>) -> PathBuf {
|
||||||
|
cwd.map(PathBuf::from).unwrap_or_else(|| app.workspace())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn agents(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||||
|
let cwd = cwd_or_workspace(&app, q.cwd);
|
||||||
|
Json(json!(prompts::agents(&app, &cwd)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commands(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||||
|
let cwd = cwd_or_workspace(&app, q.cwd);
|
||||||
|
Json(json!({
|
||||||
|
"commands": prompts::commands(&app, &cwd),
|
||||||
|
"skills": prompts::skills(&app, &cwd),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sessions ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_sessions(State(app): State<AppState>) -> ApiResult {
|
||||||
|
let sessions = app.store.list_sessions()?;
|
||||||
|
let list: Vec<Value> = sessions
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| {
|
||||||
|
let busy = app.is_busy(&s.id);
|
||||||
|
let mut v = json!(s);
|
||||||
|
v["busy"] = json!(busy);
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!(list)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateSession {
|
||||||
|
cwd: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
agent: Option<String>,
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_cwd(app: &App, cwd: Option<String>) -> Result<PathBuf, ApiError> {
|
||||||
|
let ws = app.workspace();
|
||||||
|
let p = match cwd.filter(|c| !c.trim().is_empty()) {
|
||||||
|
Some(c) if std::path::Path::new(&c).is_absolute() => PathBuf::from(c),
|
||||||
|
Some(c) => ws.join(c),
|
||||||
|
None => ws.clone(),
|
||||||
|
};
|
||||||
|
let p = std::fs::canonicalize(&p).map_err(|_| ApiError(StatusCode::BAD_REQUEST, format!("directory does not exist: {}", p.display())))?;
|
||||||
|
if !p.starts_with(&ws) || !p.is_dir() {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "directory must be inside the workspace".into()));
|
||||||
|
}
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
|
||||||
|
let cwd = validate_cwd(&app, body.cwd)?;
|
||||||
|
let cfg = app.config();
|
||||||
|
let model = body
|
||||||
|
.model
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
|
||||||
|
.or_else(|| app.registry().first_model())
|
||||||
|
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – add a provider in the settings".into()))?;
|
||||||
|
let now = now_ms();
|
||||||
|
let s = Session {
|
||||||
|
id: new_id(),
|
||||||
|
title: String::new(),
|
||||||
|
cwd: cwd.display().to_string(),
|
||||||
|
model,
|
||||||
|
agent: body.agent.unwrap_or_else(|| "build".into()),
|
||||||
|
permission_mode: body.permission_mode.unwrap_or_else(|| "default".into()),
|
||||||
|
parent_id: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
todos: Value::Null,
|
||||||
|
last_usage: None,
|
||||||
|
};
|
||||||
|
let s = app.save_session(s)?;
|
||||||
|
Ok(Json(json!(s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
|
||||||
|
let messages = app.store.messages(&id)?;
|
||||||
|
let busy = app.is_busy(&id);
|
||||||
|
Ok(Json(json!({"session": s, "messages": messages, "busy": busy})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdateSession {
|
||||||
|
title: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
agent: Option<String>,
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_session(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||||
|
let mut s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
|
||||||
|
if let Some(t) = body.title {
|
||||||
|
s.title = t;
|
||||||
|
}
|
||||||
|
if let Some(m) = body.model {
|
||||||
|
app.registry().resolve(&m)?;
|
||||||
|
s.model = m;
|
||||||
|
}
|
||||||
|
if let Some(a) = body.agent {
|
||||||
|
s.agent = a;
|
||||||
|
}
|
||||||
|
if let Some(p) = body.permission_mode {
|
||||||
|
if !["default", "accept_edits", "bypass"].contains(&p.as_str()) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||||
|
}
|
||||||
|
s.permission_mode = p;
|
||||||
|
}
|
||||||
|
Ok(Json(json!(app.save_session(s)?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
runner::abort(&app, &id);
|
||||||
|
app.store.delete_session(&id)?;
|
||||||
|
app.emit(Event::SessionDeleted { session_id: id });
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PromptBody {
|
||||||
|
text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
images: Vec<ImageData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prompt(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
||||||
|
let session = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
|
||||||
|
let text = body.text.trim().to_string();
|
||||||
|
// Built-in commands.
|
||||||
|
if text == "/compact" {
|
||||||
|
if app.is_busy(&id) {
|
||||||
|
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||||
|
}
|
||||||
|
tokio::spawn(do_compact(app.clone(), id.clone()));
|
||||||
|
return Ok(Json(json!({"ok": true, "command": "compact"})));
|
||||||
|
}
|
||||||
|
let cwd = PathBuf::from(&session.cwd);
|
||||||
|
let mut prompt_text = text.clone();
|
||||||
|
if text.starts_with('/') {
|
||||||
|
if let Some((expanded, agent)) = prompts::expand_command(&app, &cwd, &text) {
|
||||||
|
prompt_text = expanded;
|
||||||
|
if let Some(a) = agent {
|
||||||
|
let mut s = session.clone();
|
||||||
|
s.agent = a;
|
||||||
|
app.save_session(s)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut parts = vec![Part::Text { text: prompt_text }];
|
||||||
|
for image in body.images {
|
||||||
|
parts.push(Part::Image { image });
|
||||||
|
}
|
||||||
|
runner::start_prompt(app.clone(), id, parts)?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_compact(app: AppState, id: String) {
|
||||||
|
let cancel = tokio_util::sync::CancellationToken::new();
|
||||||
|
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
|
||||||
|
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
|
||||||
|
if let Err(e) = runner::compact(app.clone(), &id, cancel).await {
|
||||||
|
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
|
||||||
|
}
|
||||||
|
app.with_runtime(&id, |r| r.cancel = None);
|
||||||
|
app.emit(Event::SessionStatus { session_id: id, busy: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn abort(State(app): State<AppState>, Path(id): Path<String>) -> Json<Value> {
|
||||||
|
runner::abort(&app, &id);
|
||||||
|
Json(json!({"ok": true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compact(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
if app.is_busy(&id) {
|
||||||
|
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||||
|
}
|
||||||
|
tokio::spawn(do_compact(app.clone(), id));
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RewindBody {
|
||||||
|
message_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a message and everything after it (edit & resend). Files are not restored.
|
||||||
|
async fn rewind(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<RewindBody>) -> ApiResult {
|
||||||
|
if app.is_busy(&id) {
|
||||||
|
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||||
|
}
|
||||||
|
app.store.truncate_from(&id, &body.message_id)?;
|
||||||
|
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
|
||||||
|
let mut s = s;
|
||||||
|
s.last_usage = None;
|
||||||
|
app.save_session(s)?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn session_stats(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let mut v = app.store.session_stats(&id)?;
|
||||||
|
v["currency"] = json!(app.config().server.currency);
|
||||||
|
Ok(Json(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- interaction ------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PermissionBody {
|
||||||
|
reply: PermissionReply,
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reply_permission(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
|
||||||
|
app.reply_permission(&id, body.reply, body.message)?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct QuestionBody {
|
||||||
|
/// question text → answer (string or array of strings); null = dismissed
|
||||||
|
answers: Option<Map<String, Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn answer_question(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<QuestionBody>) -> ApiResult {
|
||||||
|
app.answer_question(&id, body.answers)?;
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pending(State(app): State<AppState>) -> Json<Value> {
|
||||||
|
Json(json!(app.pending_events()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mcp_status(State(app): State<AppState>) -> Json<Value> {
|
||||||
|
Json(app.mcp.status_json())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mcp_reload(State(app): State<AppState>) -> Json<Value> {
|
||||||
|
let mcp = app.mcp.clone();
|
||||||
|
let servers = app.config().mcp.clone();
|
||||||
|
let weak = Arc::downgrade(&app);
|
||||||
|
mcp.start_all(weak, servers).await;
|
||||||
|
Json(app.mcp.status_json())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- files ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DirQuery {
|
||||||
|
path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subdirectories for the project picker (inside the workspace only).
|
||||||
|
async fn list_dirs(State(app): State<AppState>, Query(q): Query<DirQuery>) -> ApiResult {
|
||||||
|
let dir = validate_cwd(&app, q.path)?;
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
for e in std::fs::read_dir(&dir)?.flatten() {
|
||||||
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
|
if name.starts_with('.') || !e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dirs.push(json!({"name": name, "path": e.path(), "git": e.path().join(".git").exists()}));
|
||||||
|
}
|
||||||
|
dirs.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||||
|
let ws = app.workspace();
|
||||||
|
Ok(Json(json!({
|
||||||
|
"path": dir,
|
||||||
|
"parent": if dir == ws { Value::Null } else { json!(dir.parent()) },
|
||||||
|
"workspace": ws,
|
||||||
|
"dirs": dirs,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- statistics -------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct StatsQuery {
|
||||||
|
from: Option<i64>,
|
||||||
|
to: Option<i64>,
|
||||||
|
/// Minutes east of UTC (JS: -new Date().getTimezoneOffset()).
|
||||||
|
tz: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stats(State(app): State<AppState>, Query(q): Query<StatsQuery>) -> ApiResult {
|
||||||
|
let to = q.to.unwrap_or_else(|| now_ms() + 1);
|
||||||
|
let from = q.from.unwrap_or(to - 30 * 86_400_000);
|
||||||
|
let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0))?;
|
||||||
|
v["currency"] = json!(app.config().server.currency);
|
||||||
|
v["from"] = json!(from);
|
||||||
|
v["to"] = json!(to);
|
||||||
|
Ok(Json(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- websocket --------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn ws(State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
||||||
|
upgrade.on_upgrade(move |socket| ws_loop(app, socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_loop(app: AppState, mut socket: WebSocket) {
|
||||||
|
let mut rx = app.subscribe();
|
||||||
|
for ev in app.pending_events() {
|
||||||
|
if send_event(&mut socket, &ev).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
ev = rx.recv() => match ev {
|
||||||
|
Ok(ev) => {
|
||||||
|
if send_event(&mut socket, &ev).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
// Client fell behind: tell it to resync.
|
||||||
|
let msg = json!({"type": "resync", "skipped": n}).to_string();
|
||||||
|
if socket.send(WsMessage::Text(msg.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
msg = socket.recv() => match msg {
|
||||||
|
Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ = ping.tick() => {
|
||||||
|
if socket.send(WsMessage::Ping(Vec::new().into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> {
|
||||||
|
let text = serde_json::to_string(ev).unwrap_or_default();
|
||||||
|
socket.send(WsMessage::Text(text.into())).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
//! Shared application state.
|
||||||
|
|
||||||
|
use crate::config::{self, Config};
|
||||||
|
use crate::mcp::McpManager;
|
||||||
|
use crate::provider::Registry;
|
||||||
|
use crate::store::Store;
|
||||||
|
use crate::types::{Event, Question, Session, now_ms};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
use tokio::sync::{broadcast, oneshot};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PermissionReply {
|
||||||
|
/// Allow this one call.
|
||||||
|
Once,
|
||||||
|
/// Allow this tool (or bash command prefix) for the rest of the session.
|
||||||
|
Always,
|
||||||
|
Reject,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingPermission {
|
||||||
|
event: Event,
|
||||||
|
tx: oneshot::Sender<(PermissionReply, Option<String>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingQuestion {
|
||||||
|
event: Event,
|
||||||
|
tx: oneshot::Sender<Option<Map<String, Value>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-session runtime state (not persisted).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Runtime {
|
||||||
|
pub cancel: Option<CancellationToken>,
|
||||||
|
pub read_files: HashSet<PathBuf>,
|
||||||
|
pub approved: HashSet<String>,
|
||||||
|
/// Prompts sent while the agent was busy; injected at the next step.
|
||||||
|
pub queue: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
config_path: PathBuf,
|
||||||
|
config: RwLock<Arc<Config>>,
|
||||||
|
registry: RwLock<Arc<Registry>>,
|
||||||
|
pub store: Store,
|
||||||
|
events: broadcast::Sender<Event>,
|
||||||
|
pub mcp: Arc<McpManager>,
|
||||||
|
runtime: Mutex<HashMap<String, Runtime>>,
|
||||||
|
permissions: Mutex<HashMap<String, PendingPermission>>,
|
||||||
|
questions: Mutex<HashMap<String, PendingQuestion>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub async fn new(config_path: PathBuf, cfg: Config) -> Result<Arc<Self>> {
|
||||||
|
let data = cfg.data_dir();
|
||||||
|
std::fs::create_dir_all(&data)?;
|
||||||
|
let store = Store::open(&data.join("codecraft.db"))?;
|
||||||
|
let registry = Registry::build(&cfg).await;
|
||||||
|
let (events, _) = broadcast::channel(4096);
|
||||||
|
let app = Arc::new(Self {
|
||||||
|
config_path,
|
||||||
|
config: RwLock::new(Arc::new(cfg)),
|
||||||
|
registry: RwLock::new(Arc::new(registry)),
|
||||||
|
store,
|
||||||
|
events,
|
||||||
|
mcp: Arc::new(McpManager::default()),
|
||||||
|
runtime: Mutex::default(),
|
||||||
|
permissions: Mutex::default(),
|
||||||
|
questions: Mutex::default(),
|
||||||
|
});
|
||||||
|
app.clone().start_mcp();
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_mcp(self: Arc<Self>) {
|
||||||
|
let servers = self.config().mcp.clone();
|
||||||
|
let mcp = self.mcp.clone();
|
||||||
|
let weak = Arc::downgrade(&self);
|
||||||
|
tokio::spawn(async move { mcp.start_all(weak, servers).await });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> Arc<Config> {
|
||||||
|
self.config.read().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registry(&self) -> Arc<Registry> {
|
||||||
|
self.registry.read().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace(&self) -> PathBuf {
|
||||||
|
let ws = self.config().workspace();
|
||||||
|
std::fs::canonicalize(&ws).unwrap_or(ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data_dir(&self) -> PathBuf {
|
||||||
|
self.config().data_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_text(&self) -> Result<String> {
|
||||||
|
Ok(std::fs::read_to_string(&self.config_path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate, write and apply a new config text. Providers and MCP servers reload.
|
||||||
|
pub async fn save_config(self: &Arc<Self>, text: &str) -> Result<()> {
|
||||||
|
let cfg = Config::parse(text)?;
|
||||||
|
let tmp = self.config_path.with_extension("toml.tmp");
|
||||||
|
std::fs::write(&tmp, text)?;
|
||||||
|
std::fs::rename(&tmp, &self.config_path)?;
|
||||||
|
let registry = Registry::build(&cfg).await;
|
||||||
|
let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?;
|
||||||
|
*self.config.write().unwrap() = Arc::new(cfg);
|
||||||
|
*self.registry.write().unwrap() = Arc::new(registry);
|
||||||
|
if mcp_changed {
|
||||||
|
self.clone().start_mcp();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn patch_config(self: &Arc<Self>, patches: &[config::ConfigPatch]) -> Result<()> {
|
||||||
|
let text = config::apply_patches(&self.config_text()?, patches)?;
|
||||||
|
self.save_config(&text).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reload_providers(&self) {
|
||||||
|
let registry = Registry::build(&self.config()).await;
|
||||||
|
*self.registry.write().unwrap() = Arc::new(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- events ---------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn emit(&self, ev: Event) {
|
||||||
|
let _ = self.events.send(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||||||
|
self.events.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pending permission and question requests, re-sent to newly connected clients.
|
||||||
|
pub fn pending_events(&self) -> Vec<Event> {
|
||||||
|
let mut out: Vec<Event> = self.permissions.lock().unwrap().values().map(|p| p.event.clone()).collect();
|
||||||
|
out.extend(self.questions.lock().unwrap().values().map(|q| q.event.clone()));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- runtime --------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn with_runtime<R>(&self, session_id: &str, f: impl FnOnce(&mut Runtime) -> R) -> R {
|
||||||
|
let mut rt = self.runtime.lock().unwrap();
|
||||||
|
f(rt.entry(session_id.to_string()).or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_busy(&self, session_id: &str) -> bool {
|
||||||
|
self.with_runtime(session_id, |r| r.cancel.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_read(&self, session_id: &str, path: &Path) {
|
||||||
|
self.with_runtime(session_id, |r| r.read_files.insert(path.to_path_buf()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn was_read(&self, session_id: &str, path: &Path) -> bool {
|
||||||
|
self.with_runtime(session_id, |r| r.read_files.contains(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session(&self, id: &str) -> Result<Session> {
|
||||||
|
self.store.get_session(id)?.ok_or_else(|| anyhow!("session not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_session(&self, mut s: Session) -> Result<Session> {
|
||||||
|
s.updated_at = now_ms();
|
||||||
|
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<()> {
|
||||||
|
let mut s = self.session(session_id)?;
|
||||||
|
s.todos = todos;
|
||||||
|
self.save_session(s)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk up the parent chain to the top-level session.
|
||||||
|
pub fn root_session_id(&self, session_id: &str) -> String {
|
||||||
|
let mut id = session_id.to_string();
|
||||||
|
for _ in 0..16 {
|
||||||
|
match self.store.get_session(&id).ok().flatten().and_then(|s| s.parent_id) {
|
||||||
|
Some(p) => id = p,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- user interaction -------------------------------------------------
|
||||||
|
|
||||||
|
/// Forward questions to the UI and wait. None = dismissed.
|
||||||
|
pub async fn ask_user(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
source: &str,
|
||||||
|
questions: Vec<Question>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
) -> Result<Option<Map<String, Value>>> {
|
||||||
|
let id = crate::types::new_id();
|
||||||
|
let event = Event::QuestionRequest {
|
||||||
|
id: id.clone(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
root_session_id: self.root_session_id(session_id),
|
||||||
|
source: source.to_string(),
|
||||||
|
questions,
|
||||||
|
};
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
self.questions.lock().unwrap().insert(id.clone(), PendingQuestion { event: event.clone(), tx });
|
||||||
|
self.emit(event);
|
||||||
|
let res = tokio::select! {
|
||||||
|
r = rx => r.unwrap_or(None),
|
||||||
|
_ = cancel.cancelled() => None,
|
||||||
|
};
|
||||||
|
self.questions.lock().unwrap().remove(&id);
|
||||||
|
self.emit(Event::QuestionResolved { id, session_id: session_id.to_string() });
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn answer_question(&self, id: &str, answers: Option<Map<String, Value>>) -> Result<()> {
|
||||||
|
let p = self.questions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("question not pending"))?;
|
||||||
|
let _ = p.tx.send(answers);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the user to approve a tool call. Returns the reply and an optional
|
||||||
|
/// message (feedback on rejection).
|
||||||
|
pub async fn request_permission(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
tool: &str,
|
||||||
|
title: String,
|
||||||
|
input: Value,
|
||||||
|
diff: Option<String>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
) -> (PermissionReply, Option<String>) {
|
||||||
|
let id = crate::types::new_id();
|
||||||
|
let event = Event::PermissionRequest {
|
||||||
|
id: id.clone(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
root_session_id: self.root_session_id(session_id),
|
||||||
|
tool: tool.to_string(),
|
||||||
|
title,
|
||||||
|
input,
|
||||||
|
diff,
|
||||||
|
};
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
self.permissions.lock().unwrap().insert(id.clone(), PendingPermission { event: event.clone(), tx });
|
||||||
|
self.emit(event);
|
||||||
|
let res = tokio::select! {
|
||||||
|
r = rx => r.unwrap_or((PermissionReply::Reject, None)),
|
||||||
|
_ = cancel.cancelled() => (PermissionReply::Reject, Some("aborted".into())),
|
||||||
|
};
|
||||||
|
self.permissions.lock().unwrap().remove(&id);
|
||||||
|
self.emit(Event::PermissionResolved { id, session_id: session_id.to_string() });
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reply_permission(&self, id: &str, reply: PermissionReply, message: Option<String>) -> Result<()> {
|
||||||
|
let p = self.permissions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("permission not pending"))?;
|
||||||
|
let _ = p.tx.send((reply, message));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
//! Configuration: a commented TOML file that the web UI edits in place.
|
||||||
|
//!
|
||||||
|
//! Parsing goes through `toml` into typed structs; writes go through
|
||||||
|
//! `toml_edit` so comments and formatting survive UI edits.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml");
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub server: ServerConfig,
|
||||||
|
pub providers: BTreeMap<String, ProviderConfig>,
|
||||||
|
pub mcp: BTreeMap<String, McpConfig>,
|
||||||
|
pub permissions: PermissionConfig,
|
||||||
|
pub agents: BTreeMap<String, AgentConfig>,
|
||||||
|
pub craft: CraftConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub bind: String,
|
||||||
|
pub workspace: String,
|
||||||
|
pub data_dir: String,
|
||||||
|
pub auth_token: String,
|
||||||
|
pub default_model: String,
|
||||||
|
pub small_model: String,
|
||||||
|
pub auto_compact_threshold: f64,
|
||||||
|
/// Currency label for cost statistics (display only).
|
||||||
|
pub currency: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bind: "127.0.0.1:8787".into(),
|
||||||
|
workspace: ".".into(),
|
||||||
|
data_dir: "./data".into(),
|
||||||
|
auth_token: String::new(),
|
||||||
|
default_model: String::new(),
|
||||||
|
small_model: String::new(),
|
||||||
|
auto_compact_threshold: 0.85,
|
||||||
|
currency: "USD".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ProviderKind {
|
||||||
|
Anthropic,
|
||||||
|
Openai,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
pub kind: ProviderKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_header: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_prefix: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub models: Vec<ModelEntry>,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum ModelEntry {
|
||||||
|
Id(String),
|
||||||
|
Full(ModelConfig),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
pub struct ModelConfig {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub context: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_output: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reasoning: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub vision: bool,
|
||||||
|
/// Reasoning effort (Anthropic: low|medium|high|xhigh|max, OpenAI: low|medium|high).
|
||||||
|
#[serde(default)]
|
||||||
|
pub effort: Option<String>,
|
||||||
|
/// Prices in currency units per 1M tokens (used for cost statistics).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub input_price: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub output_price: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cache_read_price: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cache_write_price: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelConfig {
|
||||||
|
/// Cost of one request. Cache prices default to the input price.
|
||||||
|
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
||||||
|
let inp = self.input_price.unwrap_or(0.0);
|
||||||
|
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
|
||||||
|
per(u.input_tokens, inp)
|
||||||
|
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
|
||||||
|
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
|
||||||
|
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelEntry {
|
||||||
|
pub fn resolved(&self) -> ModelConfig {
|
||||||
|
match self {
|
||||||
|
ModelEntry::Id(id) => ModelConfig { id: id.clone(), ..Default::default() },
|
||||||
|
ModelEntry::Full(m) => m.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct McpConfig {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: McpKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum McpKind {
|
||||||
|
Stdio,
|
||||||
|
Http,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
pub struct PermissionConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub bash_rules: BTreeMap<String, String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub tools: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
pub struct AgentConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tools: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct CraftConfig {
|
||||||
|
pub chromium: String,
|
||||||
|
pub widths: Vec<u32>,
|
||||||
|
pub clean_room: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CraftConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { chromium: "chromium".into(), widths: vec![390, 768, 1440], clean_room: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `${NAME}` with the environment variable (empty if unset).
|
||||||
|
pub fn expand_env(s: &str) -> String {
|
||||||
|
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
|
||||||
|
re.replace_all(s, |c: ®ex::Captures| std::env::var(&c[1]).unwrap_or_default())
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn parse(text: &str) -> Result<Self> {
|
||||||
|
let cfg: Config = toml::from_str(text).context("invalid config")?;
|
||||||
|
for (name, p) in &cfg.providers {
|
||||||
|
if name.contains('/') {
|
||||||
|
bail!("provider name must not contain '/': {name}");
|
||||||
|
}
|
||||||
|
if p.base_url.is_empty() && p.kind == ProviderKind::Openai {
|
||||||
|
bail!("provider {name}: base_url is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace(&self) -> PathBuf {
|
||||||
|
PathBuf::from(expand_env(&self.server.workspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data_dir(&self) -> PathBuf {
|
||||||
|
PathBuf::from(expand_env(&self.server.data_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_token(&self) -> String {
|
||||||
|
expand_env(&self.server.auth_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the config file: $CODECRAFT_CONFIG, else ./data/config.toml (or /data in Docker).
|
||||||
|
pub fn config_path() -> PathBuf {
|
||||||
|
if let Ok(p) = std::env::var("CODECRAFT_CONFIG") {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
if Path::new("/data").is_dir() {
|
||||||
|
return PathBuf::from("/data/config.toml");
|
||||||
|
}
|
||||||
|
PathBuf::from("./data/config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the config, writing the commented example on first start.
|
||||||
|
pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
|
||||||
|
if !path.exists() {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, EXAMPLE_CONFIG)?;
|
||||||
|
tracing::info!("wrote default config to {}", path.display());
|
||||||
|
}
|
||||||
|
let text = std::fs::read_to_string(path)?;
|
||||||
|
let cfg = Config::parse(&text)?;
|
||||||
|
Ok((cfg, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ConfigPatch {
|
||||||
|
/// Dotted path into the document, e.g. ["providers", "gemini", "enabled"].
|
||||||
|
pub path: Vec<String>,
|
||||||
|
/// New value as JSON; `null` removes the key.
|
||||||
|
pub value: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_patches(text: &str, patches: &[ConfigPatch]) -> Result<String> {
|
||||||
|
let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?;
|
||||||
|
for patch in patches {
|
||||||
|
if patch.path.is_empty() {
|
||||||
|
bail!("empty patch path");
|
||||||
|
}
|
||||||
|
let (last, parents) = patch.path.split_last().unwrap();
|
||||||
|
let mut table: &mut toml_edit::Table = doc.as_table_mut();
|
||||||
|
for key in parents {
|
||||||
|
let entry = table
|
||||||
|
.entry(key)
|
||||||
|
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()));
|
||||||
|
// Inline tables are converted so we can descend into them.
|
||||||
|
if let Some(inline) = entry.as_inline_table().cloned() {
|
||||||
|
*entry = toml_edit::Item::Table(inline.into_table());
|
||||||
|
}
|
||||||
|
table = entry
|
||||||
|
.as_table_mut()
|
||||||
|
.with_context(|| format!("'{key}' is not a table"))?;
|
||||||
|
}
|
||||||
|
if patch.value.is_null() {
|
||||||
|
table.remove(last);
|
||||||
|
} else if patch.value.is_object() && parents.len() < 2 && table.get(last).is_none_or(|i| i.is_table()) {
|
||||||
|
// New sections (e.g. [providers.foo]) become real tables, not inline ones.
|
||||||
|
let mut t = toml_edit::Table::new();
|
||||||
|
for (k, v) in patch.value.as_object().unwrap() {
|
||||||
|
if let Some(v) = json_to_toml(v) {
|
||||||
|
t.insert(k, toml_edit::Item::Value(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.insert(last, toml_edit::Item::Table(t));
|
||||||
|
} else {
|
||||||
|
let v = json_to_toml(&patch.value).context("unsupported value")?;
|
||||||
|
match table.get_mut(last) {
|
||||||
|
// Keep the existing decoration (trailing comments) of the value.
|
||||||
|
Some(toml_edit::Item::Value(old)) => {
|
||||||
|
let decor = old.decor().clone();
|
||||||
|
*old = v;
|
||||||
|
*old.decor_mut() = decor;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
table.insert(last, toml_edit::Item::Value(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let out = doc.to_string();
|
||||||
|
Config::parse(&out)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_toml(v: &serde_json::Value) -> Option<toml_edit::Value> {
|
||||||
|
use serde_json::Value as J;
|
||||||
|
Some(match v {
|
||||||
|
J::Null => return None,
|
||||||
|
J::Bool(b) => (*b).into(),
|
||||||
|
J::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
i.into()
|
||||||
|
} else {
|
||||||
|
n.as_f64()?.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
J::String(s) => s.as_str().into(),
|
||||||
|
J::Array(a) => {
|
||||||
|
let mut arr = toml_edit::Array::new();
|
||||||
|
for x in a {
|
||||||
|
if let Some(t) = json_to_toml(x) {
|
||||||
|
arr.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.iter().any(|x| x.is_object()) {
|
||||||
|
arr.set_trailing_comma(true);
|
||||||
|
for item in arr.iter_mut() {
|
||||||
|
item.decor_mut().set_prefix("\n ");
|
||||||
|
}
|
||||||
|
arr.set_trailing("\n");
|
||||||
|
}
|
||||||
|
toml_edit::Value::Array(arr)
|
||||||
|
}
|
||||||
|
J::Object(o) => {
|
||||||
|
let mut t = toml_edit::InlineTable::new();
|
||||||
|
for (k, x) in o {
|
||||||
|
if let Some(tv) = json_to_toml(x) {
|
||||||
|
t.insert(k, tv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toml_edit::Value::InlineTable(t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON view of the config for the UI. Secret values are masked unless they are
|
||||||
|
/// env references, so the UI can show "${FOO}" but never a literal key.
|
||||||
|
pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
||||||
|
let mut v = serde_json::to_value(cfg).unwrap_or_default();
|
||||||
|
let mask = |s: &mut serde_json::Value| {
|
||||||
|
if let Some(text) = s.as_str() {
|
||||||
|
if !text.is_empty() && !text.starts_with("${") {
|
||||||
|
*s = serde_json::Value::String("••••••••".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(ps) = v.get_mut("providers").and_then(|p| p.as_object_mut()) {
|
||||||
|
for p in ps.values_mut() {
|
||||||
|
if let Some(k) = p.get_mut("api_key") {
|
||||||
|
mask(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(t) = v.pointer_mut("/server/auth_token") {
|
||||||
|
mask(t);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn example_parses() {
|
||||||
|
let cfg = Config::parse(EXAMPLE_CONFIG).unwrap();
|
||||||
|
assert!(cfg.providers.contains_key("anthropic"));
|
||||||
|
assert_eq!(cfg.permissions.tools.get("read").map(String::as_str), Some("allow"));
|
||||||
|
assert!(cfg.permissions.bash_rules.contains_key("git status*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patch_keeps_comments() {
|
||||||
|
let out = apply_patches(
|
||||||
|
EXAMPLE_CONFIG,
|
||||||
|
&[ConfigPatch {
|
||||||
|
path: vec!["providers".into(), "gemini".into(), "enabled".into()],
|
||||||
|
value: serde_json::json!(true),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint."));
|
||||||
|
assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available"));
|
||||||
|
let cfg = Config::parse(&out).unwrap();
|
||||||
|
assert!(cfg.providers["gemini"].enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patch_adds_provider_section() {
|
||||||
|
let out = apply_patches(
|
||||||
|
EXAMPLE_CONFIG,
|
||||||
|
&[ConfigPatch {
|
||||||
|
path: vec!["providers".into(), "vllm".into()],
|
||||||
|
value: serde_json::json!({"kind": "openai", "base_url": "http://x/v1"}),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.contains("[providers.vllm]"));
|
||||||
|
Config::parse(&out).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//! CodeCraft – web-based coding harness for AI models.
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod mcp;
|
||||||
|
mod permission;
|
||||||
|
mod prompts;
|
||||||
|
mod provider;
|
||||||
|
mod runner;
|
||||||
|
mod store;
|
||||||
|
mod tools;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,codecraft=info")))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// In Docker, HOME lives on the data volume; make sure it exists for git/npm.
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
let _ = std::fs::create_dir_all(home);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = config::config_path();
|
||||||
|
let (cfg, _) = config::load_or_init(&path)?;
|
||||||
|
tracing::info!("config: {}", path.display());
|
||||||
|
tracing::info!("workspace: {}", cfg.workspace().display());
|
||||||
|
if cfg.auth_token().is_empty() {
|
||||||
|
tracing::warn!("auth_token is empty – the API is unprotected. Only use this on localhost.");
|
||||||
|
}
|
||||||
|
let bind = cfg.server.bind.clone();
|
||||||
|
let app = app::App::new(path, cfg).await?;
|
||||||
|
let router = api::router(app);
|
||||||
|
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||||
|
tracing::info!("listening on http://{bind}");
|
||||||
|
axum::serve(listener, router).with_graceful_shutdown(shutdown()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown() {
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
tracing::info!("shutting down");
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
//! Minimal MCP client: JSON-RPC over stdio or Streamable HTTP.
|
||||||
|
//!
|
||||||
|
//! Supports tools (list/call, list_changed), roots and elicitation – server
|
||||||
|
//! questions are forwarded to the user through the same UI as agent questions.
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::config::{McpConfig, McpKind, expand_env};
|
||||||
|
use crate::tools::{Tool, ToolCtx, ToolOutput};
|
||||||
|
use crate::types::{ImageData, Question, QuestionOption};
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use eventsource_stream::Eventsource;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, Weak};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::sync::{Mutex as AsyncMutex, oneshot};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||||
|
const CALL_TIMEOUT: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct McpToolInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub input_schema: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "state")]
|
||||||
|
pub enum McpState {
|
||||||
|
Disabled,
|
||||||
|
Connecting,
|
||||||
|
Connected { tools: usize },
|
||||||
|
Failed { error: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value>>>>>;
|
||||||
|
|
||||||
|
enum Transport {
|
||||||
|
Stdio {
|
||||||
|
stdin: AsyncMutex<tokio::process::ChildStdin>,
|
||||||
|
pending: Pending,
|
||||||
|
_child: AsyncMutex<tokio::process::Child>,
|
||||||
|
},
|
||||||
|
Http {
|
||||||
|
client: reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
headers: BTreeMap<String, String>,
|
||||||
|
session: Mutex<Option<String>>,
|
||||||
|
protocol: Mutex<Option<String>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct McpServer {
|
||||||
|
pub name: String,
|
||||||
|
transport: Transport,
|
||||||
|
next_id: AtomicU64,
|
||||||
|
app: Weak<App>,
|
||||||
|
/// Session that triggered the current tool call (for elicitation routing).
|
||||||
|
current_session: Mutex<Option<String>>,
|
||||||
|
pub tools: Mutex<Vec<McpToolInfo>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rpc(id: u64, method: &str, params: Value) -> Value {
|
||||||
|
json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpServer {
|
||||||
|
pub async fn connect(name: &str, cfg: &McpConfig, app: Weak<App>) -> Result<Arc<Self>> {
|
||||||
|
let transport = match cfg.kind {
|
||||||
|
McpKind::Stdio => {
|
||||||
|
if cfg.command.is_empty() {
|
||||||
|
bail!("command is required for stdio servers");
|
||||||
|
}
|
||||||
|
let mut cmd = tokio::process::Command::new(expand_env(&cfg.command));
|
||||||
|
cmd.args(cfg.args.iter().map(|a| expand_env(a)))
|
||||||
|
.envs(cfg.env.iter().map(|(k, v)| (k.clone(), expand_env(v))))
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
if let Some(app) = app.upgrade() {
|
||||||
|
cmd.current_dir(app.workspace());
|
||||||
|
}
|
||||||
|
let mut child = cmd.spawn().with_context(|| format!("failed to start {}", cfg.command))?;
|
||||||
|
let stdin = child.stdin.take().unwrap();
|
||||||
|
let stdout = child.stdout.take().unwrap();
|
||||||
|
let stderr = child.stderr.take().unwrap();
|
||||||
|
let server_name = name.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stderr).lines();
|
||||||
|
while let Ok(Some(l)) = lines.next_line().await {
|
||||||
|
tracing::debug!("mcp {server_name} stderr: {l}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let pending: Pending = Arc::default();
|
||||||
|
let server = Arc::new(Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
transport: Transport::Stdio {
|
||||||
|
stdin: AsyncMutex::new(stdin),
|
||||||
|
pending: pending.clone(),
|
||||||
|
_child: AsyncMutex::new(child),
|
||||||
|
},
|
||||||
|
next_id: AtomicU64::new(1),
|
||||||
|
app,
|
||||||
|
current_session: Mutex::new(None),
|
||||||
|
tools: Mutex::new(Vec::new()),
|
||||||
|
});
|
||||||
|
let weak = Arc::downgrade(&server);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||||
|
let Some(server) = weak.upgrade() else { break };
|
||||||
|
// Server requests (elicitation) may wait for the user: never block the reader.
|
||||||
|
tokio::spawn(async move { server.handle_incoming(msg).await });
|
||||||
|
}
|
||||||
|
// Process ended: fail all pending requests.
|
||||||
|
for (_, tx) in pending.lock().unwrap().drain() {
|
||||||
|
let _ = tx.send(Err(anyhow!("MCP server exited")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server
|
||||||
|
}
|
||||||
|
McpKind::Http => Arc::new(Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
transport: Transport::Http {
|
||||||
|
client: crate::provider::http_client(),
|
||||||
|
url: expand_env(&cfg.url),
|
||||||
|
headers: cfg.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(),
|
||||||
|
session: Mutex::new(None),
|
||||||
|
protocol: Mutex::new(None),
|
||||||
|
},
|
||||||
|
next_id: AtomicU64::new(1),
|
||||||
|
app,
|
||||||
|
current_session: Mutex::new(None),
|
||||||
|
tools: Mutex::new(Vec::new()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let server = transport;
|
||||||
|
server.initialize().await?;
|
||||||
|
server.refresh_tools().await?;
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initialize(&self) -> Result<()> {
|
||||||
|
let res = self
|
||||||
|
.request(
|
||||||
|
"initialize",
|
||||||
|
json!({
|
||||||
|
"protocolVersion": PROTOCOL_VERSION,
|
||||||
|
"capabilities": {"elicitation": {}, "roots": {"listChanged": false}},
|
||||||
|
"clientInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Transport::Http { protocol, .. } = &self.transport {
|
||||||
|
*protocol.lock().unwrap() =
|
||||||
|
Some(res["protocolVersion"].as_str().unwrap_or(PROTOCOL_VERSION).to_string());
|
||||||
|
}
|
||||||
|
self.notify("notifications/initialized", json!({})).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_tools(&self) -> Result<()> {
|
||||||
|
let mut tools = Vec::new();
|
||||||
|
let mut cursor: Option<String> = None;
|
||||||
|
loop {
|
||||||
|
let params = match &cursor {
|
||||||
|
Some(c) => json!({"cursor": c}),
|
||||||
|
None => json!({}),
|
||||||
|
};
|
||||||
|
let res = self.request("tools/list", params).await?;
|
||||||
|
for t in res["tools"].as_array().cloned().unwrap_or_default() {
|
||||||
|
tools.push(McpToolInfo {
|
||||||
|
name: t["name"].as_str().unwrap_or_default().to_string(),
|
||||||
|
description: t["description"].as_str().unwrap_or_default().to_string(),
|
||||||
|
input_schema: t.get("inputSchema").cloned().unwrap_or(json!({"type": "object"})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursor = res["nextCursor"].as_str().map(String::from);
|
||||||
|
if cursor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*self.tools.lock().unwrap() = tools;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn call_tool(&self, session_id: &str, name: &str, args: Value, cancel: CancellationToken) -> Result<ToolOutput> {
|
||||||
|
*self.current_session.lock().unwrap() = Some(session_id.to_string());
|
||||||
|
let res = tokio::select! {
|
||||||
|
r = tokio::time::timeout(CALL_TIMEOUT, self.request("tools/call", json!({"name": name, "arguments": args}))) => {
|
||||||
|
r.map_err(|_| anyhow!("MCP tool call timed out"))?
|
||||||
|
}
|
||||||
|
_ = cancel.cancelled() => bail!("aborted"),
|
||||||
|
}?;
|
||||||
|
let mut out = ToolOutput { is_error: res["isError"].as_bool().unwrap_or(false), ..Default::default() };
|
||||||
|
let mut texts = Vec::new();
|
||||||
|
for c in res["content"].as_array().cloned().unwrap_or_default() {
|
||||||
|
match c["type"].as_str().unwrap_or_default() {
|
||||||
|
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
|
||||||
|
"image" => out.images.push(ImageData {
|
||||||
|
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
|
||||||
|
data: c["data"].as_str().unwrap_or_default().into(),
|
||||||
|
}),
|
||||||
|
"resource" => {
|
||||||
|
let r = &c["resource"];
|
||||||
|
texts.push(match r["text"].as_str() {
|
||||||
|
Some(t) => format!("[resource {}]\n{t}", r["uri"].as_str().unwrap_or("")),
|
||||||
|
None => format!("[binary resource {}]", r["uri"].as_str().unwrap_or("")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"resource_link" => texts.push(format!("[resource link {}]", c["uri"].as_str().unwrap_or(""))),
|
||||||
|
other => texts.push(format!("[{other} content]")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if texts.is_empty() {
|
||||||
|
if let Some(s) = res.get("structuredContent") {
|
||||||
|
texts.push(serde_json::to_string_pretty(s)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.content = crate::tools::truncate(&texts.join("\n"), 80_000);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- transport -------------------------------------------------------
|
||||||
|
|
||||||
|
async fn request(&self, method: &str, params: Value) -> Result<Value> {
|
||||||
|
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let msg = rpc(id, method, params);
|
||||||
|
match &self.transport {
|
||||||
|
Transport::Stdio { stdin, pending, .. } => {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
pending.lock().unwrap().insert(id, tx);
|
||||||
|
self.write_stdio(stdin, &msg).await?;
|
||||||
|
let res = tokio::time::timeout(CALL_TIMEOUT, rx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("MCP request {method} timed out"))?
|
||||||
|
.map_err(|_| anyhow!("MCP server closed the connection"))?;
|
||||||
|
res
|
||||||
|
}
|
||||||
|
Transport::Http { .. } => self.http_send(&msg, Some(id)).await?.ok_or_else(|| anyhow!("no response to {method}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn notify(&self, method: &str, params: Value) -> Result<()> {
|
||||||
|
let msg = json!({"jsonrpc": "2.0", "method": method, "params": params});
|
||||||
|
self.send_raw(&msg).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_raw(&self, msg: &Value) -> Result<()> {
|
||||||
|
match &self.transport {
|
||||||
|
Transport::Stdio { stdin, .. } => self.write_stdio(stdin, msg).await,
|
||||||
|
Transport::Http { .. } => self.http_send(msg, None).await.map(|_| ()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_stdio(&self, stdin: &AsyncMutex<tokio::process::ChildStdin>, msg: &Value) -> Result<()> {
|
||||||
|
let mut line = serde_json::to_vec(msg)?;
|
||||||
|
line.push(b'\n');
|
||||||
|
let mut w = stdin.lock().await;
|
||||||
|
w.write_all(&line).await?;
|
||||||
|
w.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST a message. For requests, waits for the matching response, handling
|
||||||
|
/// server requests (elicitation) that arrive on the same SSE stream.
|
||||||
|
async fn http_send(&self, msg: &Value, want_id: Option<u64>) -> Result<Option<Value>> {
|
||||||
|
let Transport::Http { client, url, headers, session, protocol } = &self.transport else { unreachable!() };
|
||||||
|
let mut rb = client
|
||||||
|
.post(url)
|
||||||
|
.header("Accept", "application/json, text/event-stream")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(msg);
|
||||||
|
for (k, v) in headers {
|
||||||
|
rb = rb.header(k, v);
|
||||||
|
}
|
||||||
|
if let Some(s) = session.lock().unwrap().clone() {
|
||||||
|
rb = rb.header("Mcp-Session-Id", s);
|
||||||
|
}
|
||||||
|
if let Some(p) = protocol.lock().unwrap().clone() {
|
||||||
|
rb = rb.header("MCP-Protocol-Version", p);
|
||||||
|
}
|
||||||
|
let resp = rb.send().await?;
|
||||||
|
if let Some(s) = resp.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()) {
|
||||||
|
*session.lock().unwrap() = Some(s.to_string());
|
||||||
|
}
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
bail!("MCP HTTP {status}: {}", body.chars().take(400).collect::<String>());
|
||||||
|
}
|
||||||
|
let Some(want) = want_id else { return Ok(None) };
|
||||||
|
let ctype = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
|
||||||
|
if ctype.contains("text/event-stream") {
|
||||||
|
let mut events = resp.bytes_stream().eventsource();
|
||||||
|
while let Some(ev) = events.next().await {
|
||||||
|
let ev = ev.map_err(|e| anyhow!("MCP stream error: {e}"))?;
|
||||||
|
let Ok(v) = serde_json::from_str::<Value>(&ev.data) else { continue };
|
||||||
|
if v.get("id").and_then(|i| i.as_u64()) == Some(want) && v.get("method").is_none() {
|
||||||
|
return Ok(Some(Self::result_of(v)?));
|
||||||
|
}
|
||||||
|
// Server→client requests/notifications on the stream.
|
||||||
|
Box::pin(self.handle_incoming(v)).await;
|
||||||
|
}
|
||||||
|
bail!("MCP stream ended without a response");
|
||||||
|
}
|
||||||
|
let v: Value = resp.json().await?;
|
||||||
|
Ok(Some(Self::result_of(v)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_of(v: Value) -> Result<Value> {
|
||||||
|
if let Some(e) = v.get("error") {
|
||||||
|
bail!("MCP error {}: {}", e["code"], e["message"].as_str().unwrap_or(""));
|
||||||
|
}
|
||||||
|
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_incoming(&self, msg: Value) {
|
||||||
|
let method = msg.get("method").and_then(|m| m.as_str()).map(String::from);
|
||||||
|
let id = msg.get("id").cloned();
|
||||||
|
match (method, id) {
|
||||||
|
// Response to one of our requests (stdio).
|
||||||
|
(None, Some(id)) => {
|
||||||
|
if let (Transport::Stdio { pending, .. }, Some(id)) = (&self.transport, id.as_u64()) {
|
||||||
|
if let Some(tx) = pending.lock().unwrap().remove(&id) {
|
||||||
|
let _ = tx.send(Self::result_of(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Server → client request.
|
||||||
|
(Some(method), Some(id)) => {
|
||||||
|
let result = match method.as_str() {
|
||||||
|
"ping" => Ok(json!({})),
|
||||||
|
"roots/list" => {
|
||||||
|
let root = self.app.upgrade().map(|a| a.workspace()).unwrap_or_default();
|
||||||
|
Ok(json!({"roots": [{"uri": format!("file://{}", root.display()), "name": "workspace"}]}))
|
||||||
|
}
|
||||||
|
"elicitation/create" => self.elicit(&msg["params"]).await,
|
||||||
|
other => Err(anyhow!("method not supported: {other}")),
|
||||||
|
};
|
||||||
|
let reply = match result {
|
||||||
|
Ok(r) => json!({"jsonrpc": "2.0", "id": id, "result": r}),
|
||||||
|
Err(e) => json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": e.to_string()}}),
|
||||||
|
};
|
||||||
|
if let Err(e) = self.send_raw(&reply).await {
|
||||||
|
tracing::warn!("mcp {}: failed to reply: {e:#}", self.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(method), None) => {
|
||||||
|
if method == "notifications/tools/list_changed" {
|
||||||
|
if let Err(e) = self.refresh_tools().await {
|
||||||
|
tracing::warn!("mcp {}: tool refresh failed: {e:#}", self.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn an elicitation form into UI questions and the answers back into content.
|
||||||
|
async fn elicit(&self, params: &Value) -> Result<Value> {
|
||||||
|
let app = self.app.upgrade().ok_or_else(|| anyhow!("app gone"))?;
|
||||||
|
let session = self
|
||||||
|
.current_session
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow!("no active session for elicitation"))?;
|
||||||
|
let message = params["message"].as_str().unwrap_or("").to_string();
|
||||||
|
let props = params["requestedSchema"]["properties"].as_object().cloned().unwrap_or_default();
|
||||||
|
let mut questions = Vec::new();
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for (key, schema) in &props {
|
||||||
|
let title = schema["title"].as_str().unwrap_or(key);
|
||||||
|
let desc = schema["description"].as_str().unwrap_or("");
|
||||||
|
let mut options: Vec<QuestionOption> = Vec::new();
|
||||||
|
if let Some(e) = schema["enum"].as_array() {
|
||||||
|
let names = schema["enumNames"].as_array();
|
||||||
|
for (i, v) in e.iter().enumerate() {
|
||||||
|
let label = v.as_str().map(String::from).unwrap_or_else(|| v.to_string());
|
||||||
|
let description = names.and_then(|n| n.get(i)).and_then(|n| n.as_str()).unwrap_or("").to_string();
|
||||||
|
options.push(QuestionOption { label, description });
|
||||||
|
}
|
||||||
|
} else if schema["type"] == "boolean" {
|
||||||
|
options = vec![
|
||||||
|
QuestionOption { label: "true".into(), description: "Yes".into() },
|
||||||
|
QuestionOption { label: "false".into(), description: "No".into() },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
let question = if props.len() == 1 && !message.is_empty() {
|
||||||
|
format!("{message}\n\n{title}{}", if desc.is_empty() { String::new() } else { format!(" – {desc}") })
|
||||||
|
} else {
|
||||||
|
format!("{title}{}", if desc.is_empty() { String::new() } else { format!(" – {desc}") })
|
||||||
|
};
|
||||||
|
questions.push(Question { question, header: key.chars().take(12).collect(), options, multi_select: false });
|
||||||
|
keys.push((key.clone(), schema.clone()));
|
||||||
|
}
|
||||||
|
if questions.is_empty() {
|
||||||
|
questions.push(Question {
|
||||||
|
question: message.clone(),
|
||||||
|
header: "Confirm".into(),
|
||||||
|
options: vec![
|
||||||
|
QuestionOption { label: "accept".into(), description: String::new() },
|
||||||
|
QuestionOption { label: "decline".into(), description: String::new() },
|
||||||
|
],
|
||||||
|
multi_select: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let source = format!("mcp:{}", self.name);
|
||||||
|
let answers = app.ask_user(&session, &source, questions.clone(), CancellationToken::new()).await?;
|
||||||
|
let Some(answers) = answers else { return Ok(json!({"action": "cancel"})) };
|
||||||
|
if keys.is_empty() {
|
||||||
|
let a = answers.get(&questions[0].question).and_then(|v| v.as_str()).unwrap_or("decline");
|
||||||
|
return Ok(json!({"action": if a == "accept" { "accept" } else { "decline" }}));
|
||||||
|
}
|
||||||
|
let mut content = Map::new();
|
||||||
|
for ((key, schema), q) in keys.iter().zip(&questions) {
|
||||||
|
let Some(raw) = answers.get(&q.question).and_then(|v| v.as_str()) else { continue };
|
||||||
|
let v = match schema["type"].as_str() {
|
||||||
|
Some("boolean") => json!(raw == "true" || raw.eq_ignore_ascii_case("yes")),
|
||||||
|
Some("integer") => raw.trim().parse::<i64>().map(|n| json!(n)).unwrap_or(json!(raw)),
|
||||||
|
Some("number") => raw.trim().parse::<f64>().map(|n| json!(n)).unwrap_or(json!(raw)),
|
||||||
|
_ => json!(raw),
|
||||||
|
};
|
||||||
|
content.insert(key.clone(), v);
|
||||||
|
}
|
||||||
|
Ok(json!({"action": "accept", "content": content}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// All configured MCP servers with their connection state.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct McpManager {
|
||||||
|
servers: Mutex<BTreeMap<String, Arc<McpServer>>>,
|
||||||
|
states: Mutex<BTreeMap<String, McpState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpManager {
|
||||||
|
pub fn status_json(&self) -> Value {
|
||||||
|
let states = self.states.lock().unwrap();
|
||||||
|
let servers = self.servers.lock().unwrap();
|
||||||
|
let mut out = Map::new();
|
||||||
|
for (name, st) in states.iter() {
|
||||||
|
let tools: Vec<Value> = servers
|
||||||
|
.get(name)
|
||||||
|
.map(|s| s.tools.lock().unwrap().iter().map(|t| json!({"name": t.name, "description": t.description})).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
out.insert(name.clone(), json!({"status": st, "tools": tools}));
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (Re)connect all servers from config. Runs connections concurrently.
|
||||||
|
pub async fn start_all(self: &Arc<Self>, app: Weak<App>, configs: BTreeMap<String, McpConfig>) {
|
||||||
|
self.servers.lock().unwrap().clear();
|
||||||
|
self.states.lock().unwrap().clear();
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for (name, cfg) in configs {
|
||||||
|
if !cfg.enabled {
|
||||||
|
self.states.lock().unwrap().insert(name, McpState::Disabled);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.states.lock().unwrap().insert(name.clone(), McpState::Connecting);
|
||||||
|
let me = self.clone();
|
||||||
|
let app = app.clone();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
let res = tokio::time::timeout(Duration::from_secs(60), McpServer::connect(&name, &cfg, app.clone())).await;
|
||||||
|
let state = match res {
|
||||||
|
Ok(Ok(server)) => {
|
||||||
|
let n = server.tools.lock().unwrap().len();
|
||||||
|
me.servers.lock().unwrap().insert(name.clone(), server);
|
||||||
|
McpState::Connected { tools: n }
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => McpState::Failed { error: format!("{e:#}") },
|
||||||
|
Err(_) => McpState::Failed { error: "connection timed out".into() },
|
||||||
|
};
|
||||||
|
tracing::info!("mcp {name}: {state:?}");
|
||||||
|
me.states.lock().unwrap().insert(name, state);
|
||||||
|
if let Some(app) = app.upgrade() {
|
||||||
|
app.emit(crate::types::Event::McpStatus { servers: me.status_json() });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for t in tasks {
|
||||||
|
let _ = t.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tools(&self) -> Vec<Arc<dyn Tool>> {
|
||||||
|
let servers = self.servers.lock().unwrap();
|
||||||
|
let mut out: Vec<Arc<dyn Tool>> = Vec::new();
|
||||||
|
for (sname, server) in servers.iter() {
|
||||||
|
for t in server.tools.lock().unwrap().iter() {
|
||||||
|
out.push(Arc::new(McpTool {
|
||||||
|
full_name: tool_name(sname, &t.name),
|
||||||
|
server: server.clone(),
|
||||||
|
info: t.clone(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tool names must match ^[a-zA-Z0-9_-]{1,64}$ for most providers.
|
||||||
|
pub fn tool_name(server: &str, tool: &str) -> String {
|
||||||
|
let clean = |s: &str| s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect::<String>();
|
||||||
|
let mut n = format!("mcp__{}__{}", clean(server), clean(tool));
|
||||||
|
n.truncate(64);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct McpTool {
|
||||||
|
full_name: String,
|
||||||
|
server: Arc<McpServer>,
|
||||||
|
info: McpToolInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for McpTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.full_name
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
format!("[MCP {}] {}", self.server.name, self.info.description)
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
let mut s = self.info.input_schema.clone();
|
||||||
|
if s.get("type").is_none() {
|
||||||
|
s["type"] = json!("object");
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
fn title(&self, _input: &Value) -> String {
|
||||||
|
format!("{} → {}", self.server.name, self.info.name)
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
self.server.call_tool(&ctx.session_id, &self.info.name, input, ctx.cancel.clone()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
//! Permission evaluation: config rules, session permission modes and
|
||||||
|
//! "always allow" approvals given in the UI.
|
||||||
|
|
||||||
|
use crate::config::PermissionConfig;
|
||||||
|
use crate::tools::Tool;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Allow,
|
||||||
|
Ask,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(s: &str) -> Option<Decision> {
|
||||||
|
match s {
|
||||||
|
"allow" => Some(Decision::Allow),
|
||||||
|
"ask" => Some(Decision::Ask),
|
||||||
|
"deny" => Some(Decision::Deny),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||||
|
globset::GlobBuilder::new(pattern)
|
||||||
|
.literal_separator(false)
|
||||||
|
.build()
|
||||||
|
.map(|g| g.compile_matcher().is_match(text))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a shell command into simple commands on && || ; | and newlines.
|
||||||
|
/// Returns None when the command contains constructs we cannot judge safely
|
||||||
|
/// (command substitution, backticks).
|
||||||
|
pub fn split_command(cmd: &str) -> Option<Vec<String>> {
|
||||||
|
if cmd.contains("$(") || cmd.contains('`') || cmd.contains("<(") || cmd.contains(">(") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut chars = cmd.chars().peekable();
|
||||||
|
let (mut sq, mut dq) = (false, false);
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'\'' if !dq => {
|
||||||
|
sq = !sq;
|
||||||
|
cur.push(c)
|
||||||
|
}
|
||||||
|
'"' if !sq => {
|
||||||
|
dq = !dq;
|
||||||
|
cur.push(c)
|
||||||
|
}
|
||||||
|
'\\' if !sq => {
|
||||||
|
cur.push(c);
|
||||||
|
if let Some(n) = chars.next() {
|
||||||
|
cur.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
';' | '\n' | '|' | '&' if !sq && !dq => {
|
||||||
|
// "&" alone (background) and "&&", "||", "|", ";" all separate commands.
|
||||||
|
if (c == '|' || c == '&') && chars.peek() == Some(&c) {
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
// Redirections like 2>&1 are not separators.
|
||||||
|
if c == '&' && cur.ends_with('>') {
|
||||||
|
cur.push(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parts.push(std::mem::take(&mut cur));
|
||||||
|
}
|
||||||
|
_ => cur.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(cur);
|
||||||
|
Some(parts.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix stored for "always allow" on a bash command: the first two words.
|
||||||
|
pub fn bash_prefix(cmd: &str) -> String {
|
||||||
|
cmd.split_whitespace().take(2).collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Evaluator<'a> {
|
||||||
|
pub cfg: &'a PermissionConfig,
|
||||||
|
pub mode: &'a str,
|
||||||
|
/// Session approvals: tool names, or "bash:<prefix>".
|
||||||
|
pub approved: &'a HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Evaluator<'_> {
|
||||||
|
pub fn evaluate(&self, tool: &dyn Tool, input: &Value) -> Decision {
|
||||||
|
let name = tool.name();
|
||||||
|
let base = self.tool_rule(name).unwrap_or(if tool.read_only() { Decision::Allow } else { Decision::Ask });
|
||||||
|
|
||||||
|
if name == "bash" {
|
||||||
|
let cmd = input["command"].as_str().unwrap_or("");
|
||||||
|
let bash = self.bash_decision(cmd);
|
||||||
|
if bash == Some(Decision::Deny) || base == Decision::Deny {
|
||||||
|
return Decision::Deny;
|
||||||
|
}
|
||||||
|
if self.mode == "bypass" {
|
||||||
|
return Decision::Allow;
|
||||||
|
}
|
||||||
|
return bash.unwrap_or(base);
|
||||||
|
}
|
||||||
|
if base == Decision::Deny {
|
||||||
|
return Decision::Deny;
|
||||||
|
}
|
||||||
|
if self.mode == "bypass" || self.approved.contains(name) {
|
||||||
|
return Decision::Allow;
|
||||||
|
}
|
||||||
|
if self.mode == "accept_edits" && tool.is_edit() {
|
||||||
|
return Decision::Allow;
|
||||||
|
}
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_rule(&self, name: &str) -> Option<Decision> {
|
||||||
|
if let Some(d) = self.cfg.tools.get(name).and_then(|s| parse(s)) {
|
||||||
|
return Some(d);
|
||||||
|
}
|
||||||
|
// Glob rules, most specific (longest) first.
|
||||||
|
let mut globs: Vec<(&String, &String)> =
|
||||||
|
self.cfg.tools.iter().filter(|(k, _)| k.contains('*')).collect();
|
||||||
|
globs.sort_by_key(|(k, _)| std::cmp::Reverse(k.len()));
|
||||||
|
globs.into_iter().find(|(k, _)| glob_match(k, name)).and_then(|(_, v)| parse(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Some(Allow) only if every simple command is allowed; Some(Deny) if any is denied.
|
||||||
|
fn bash_decision(&self, cmd: &str) -> Option<Decision> {
|
||||||
|
// Deny rules are checked against the full command as well as each part.
|
||||||
|
let deny_hit = |text: &str| {
|
||||||
|
self.cfg.bash_rules.iter().any(|(p, d)| d == "deny" && glob_match(p, text))
|
||||||
|
};
|
||||||
|
if deny_hit(cmd.trim()) {
|
||||||
|
return Some(Decision::Deny);
|
||||||
|
}
|
||||||
|
let parts = split_command(cmd)?;
|
||||||
|
if parts.iter().any(|p| deny_hit(p)) {
|
||||||
|
return Some(Decision::Deny);
|
||||||
|
}
|
||||||
|
let all_allowed = !parts.is_empty()
|
||||||
|
&& parts.iter().all(|p| {
|
||||||
|
self.approved.contains(&format!("bash:{}", bash_prefix(p)))
|
||||||
|
|| self.cfg.bash_rules.iter().any(|(pat, d)| d == "allow" && glob_match(pat, p))
|
||||||
|
});
|
||||||
|
if all_allowed { Some(Decision::Allow) } else { None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
struct Fake(&'static str, bool);
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for Fake {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
Value::Null
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
self.1
|
||||||
|
}
|
||||||
|
async fn run(&self, _: Value, _: &crate::tools::ToolCtx) -> anyhow::Result<crate::tools::ToolOutput> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cfg() -> PermissionConfig {
|
||||||
|
let mut bash_rules = BTreeMap::new();
|
||||||
|
bash_rules.insert("git status*".into(), "allow".into());
|
||||||
|
bash_rules.insert("ls*".into(), "allow".into());
|
||||||
|
bash_rules.insert("rm -rf /*".into(), "deny".into());
|
||||||
|
let mut tools = BTreeMap::new();
|
||||||
|
tools.insert("bash".into(), "ask".into());
|
||||||
|
tools.insert("mcp__*".into(), "ask".into());
|
||||||
|
tools.insert("mcp__trilium__*".into(), "allow".into());
|
||||||
|
PermissionConfig { bash_rules, tools }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compound_commands_need_every_part_allowed() {
|
||||||
|
let c = cfg();
|
||||||
|
let approved = HashSet::new();
|
||||||
|
let e = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
||||||
|
let bash = Fake("bash", false);
|
||||||
|
let cmd = |s: &str| serde_json::json!({"command": s});
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("git status")), Decision::Allow);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("git status && ls -la")), Decision::Allow);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("git status; rm -rf build")), Decision::Ask);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("ls $(rm -rf x)")), Decision::Ask);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("git status 2>&1 | ls")), Decision::Allow);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modes_and_globs() {
|
||||||
|
let c = cfg();
|
||||||
|
let approved: HashSet<String> = ["bash:npm test".to_string()].into();
|
||||||
|
let bypass = Evaluator { cfg: &c, mode: "bypass", approved: &approved };
|
||||||
|
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
||||||
|
let bash = Fake("bash", false);
|
||||||
|
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "make"})), Decision::Allow);
|
||||||
|
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "rm -rf /"})), Decision::Deny);
|
||||||
|
assert_eq!(def.evaluate(&bash, &serde_json::json!({"command": "npm test -- --watch"})), Decision::Allow);
|
||||||
|
assert_eq!(def.evaluate(&Fake("mcp__trilium__search", false), &Value::Null), Decision::Allow);
|
||||||
|
assert_eq!(def.evaluate(&Fake("mcp__other__x", false), &Value::Null), Decision::Ask);
|
||||||
|
assert_eq!(def.evaluate(&Fake("read", true), &Value::Null), Decision::Allow);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
//! Agents (modes), system prompts, project instructions, slash commands and skills.
|
||||||
|
//!
|
||||||
|
//! Compatible with Claude Code / opencode layouts: AGENTS.md, CLAUDE.md,
|
||||||
|
//! .claude/{agents,commands,skills}, .codecraft/{agents,commands,skills}.
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Agent {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
/// "primary" (selectable mode) or "subagent" (task tool only).
|
||||||
|
pub mode: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub prompt: String,
|
||||||
|
/// Allowed tools; None = all.
|
||||||
|
pub tools: Option<Vec<String>>,
|
||||||
|
pub read_only: bool,
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
|
||||||
|
|
||||||
|
# Working style
|
||||||
|
- Be direct and concise. Lead with what you did or found; no filler.
|
||||||
|
- Understand before changing: read relevant files, search the codebase, follow existing conventions, libraries and patterns.
|
||||||
|
- Make the change completely: implement, then verify (build, run tests, lint) when possible. Report failures honestly with the relevant output.
|
||||||
|
- Prefer editing existing files over creating new ones. Never create documentation files unless asked.
|
||||||
|
- Use todo_write to plan and track multi-step work; keep exactly one item in progress.
|
||||||
|
- Use the dedicated tools (read, edit, write, glob, grep, list) instead of shell equivalents. Batch independent tool calls in one response.
|
||||||
|
- 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.
|
||||||
|
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
||||||
|
- Reference code as path:line.
|
||||||
|
- Answer in the language the user writes in."#;
|
||||||
|
|
||||||
|
const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify and run things in the project.";
|
||||||
|
|
||||||
|
const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits.
|
||||||
|
Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#;
|
||||||
|
|
||||||
|
const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in four phases. Keep the user informed of the current phase with todo_write (one item per phase).
|
||||||
|
|
||||||
|
1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly.
|
||||||
|
2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code.
|
||||||
|
3. CHECK – Serve or open the build (e.g. `python3 -m http.server PORT` in the background, or file://), snapshot it with browser_snapshot using a 'build-*' label, and compare each width against the original with image_compare. List what does not match yet and the similarity per page/width.
|
||||||
|
4. READY – Refine until the result is close (or improvements stall), then tell the user how to open/run it and summarise remaining differences. Ask for changes.
|
||||||
|
|
||||||
|
Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#;
|
||||||
|
|
||||||
|
const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
|
||||||
|
const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts for the user's private use.";
|
||||||
|
|
||||||
|
const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
|
||||||
|
const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
|
||||||
|
|
||||||
|
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||||
|
let craft = CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF });
|
||||||
|
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 },
|
||||||
|
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 },
|
||||||
|
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 },
|
||||||
|
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 },
|
||||||
|
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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse "---\nkey: value\n---\nbody" frontmatter (flat keys only).
|
||||||
|
pub fn frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
|
||||||
|
let mut meta = BTreeMap::new();
|
||||||
|
let t = text.trim_start_matches('\u{feff}');
|
||||||
|
if let Some(rest) = t.strip_prefix("---") {
|
||||||
|
if let Some(end) = rest.find("\n---") {
|
||||||
|
for line in rest[..end].lines() {
|
||||||
|
if let Some((k, v)) = line.split_once(':') {
|
||||||
|
meta.insert(k.trim().to_string(), v.trim().trim_matches('"').trim_matches('\'').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let body = rest[end + 4..].trim_start_matches(['-', '\n', '\r']).to_string();
|
||||||
|
return (meta, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(meta, t.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resource directories, lowest priority first: data dir, workspace, project.
|
||||||
|
fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
||||||
|
let mut dirs = vec![app.data_dir().join(kind)];
|
||||||
|
let ws = app.workspace();
|
||||||
|
let mut chain = vec![ws.clone()];
|
||||||
|
if cwd != ws {
|
||||||
|
chain.push(cwd.to_path_buf());
|
||||||
|
}
|
||||||
|
for base in chain {
|
||||||
|
dirs.push(base.join(".claude").join(kind));
|
||||||
|
dirs.push(base.join(".codecraft").join(kind));
|
||||||
|
}
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn agents(app: &App, cwd: &Path) -> Vec<Agent> {
|
||||||
|
let cfg = app.config();
|
||||||
|
let mut map: BTreeMap<String, Agent> =
|
||||||
|
builtin_agents(cfg.craft.clean_room).into_iter().map(|a| (a.name.clone(), a)).collect();
|
||||||
|
// Markdown agents (Claude Code format).
|
||||||
|
for dir in resource_dirs(app, cwd, "agents") {
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if p.extension().and_then(|x| x.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(text) = std::fs::read_to_string(&p) else { continue };
|
||||||
|
let (meta, body) = frontmatter(&text);
|
||||||
|
let name = meta.get("name").cloned().unwrap_or_else(|| p.file_stem().unwrap().to_string_lossy().into());
|
||||||
|
let tools = meta.get("tools").map(|t| t.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect());
|
||||||
|
map.insert(name.clone(), Agent {
|
||||||
|
name,
|
||||||
|
description: meta.get("description").cloned().unwrap_or_default(),
|
||||||
|
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
|
||||||
|
prompt: body,
|
||||||
|
tools,
|
||||||
|
read_only: false,
|
||||||
|
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Config agents override everything.
|
||||||
|
for (name, a) in &cfg.agents {
|
||||||
|
let base = map.get(name).cloned();
|
||||||
|
map.insert(name.clone(), Agent {
|
||||||
|
name: name.clone(),
|
||||||
|
description: a.description.clone().or(base.as_ref().map(|b| b.description.clone())).unwrap_or_default(),
|
||||||
|
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(),
|
||||||
|
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),
|
||||||
|
model: a.model.clone().or(base.and_then(|b| b.model)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
map.into_values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_agent(app: &App, cwd: &Path, name: &str) -> Agent {
|
||||||
|
agents(app, cwd)
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.name == name)
|
||||||
|
.unwrap_or_else(|| builtin_agents(true).remove(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AGENTS.md / CLAUDE.md files from the data dir, workspace root down to cwd.
|
||||||
|
fn instruction_files(app: &App, cwd: &Path) -> Vec<(PathBuf, String)> {
|
||||||
|
let mut dirs = vec![app.data_dir()];
|
||||||
|
let ws = app.workspace();
|
||||||
|
let mut d = cwd.to_path_buf();
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
loop {
|
||||||
|
chain.push(d.clone());
|
||||||
|
if d == ws || !d.starts_with(&ws) || !d.pop() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chain.reverse();
|
||||||
|
dirs.extend(chain);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for dir in dirs {
|
||||||
|
for name in ["AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"] {
|
||||||
|
let p = dir.join(name);
|
||||||
|
if let Ok(text) = std::fs::read_to_string(&p) {
|
||||||
|
if !text.trim().is_empty() {
|
||||||
|
out.push((p, text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn system_prompt(app: &App, agent: &Agent, cwd: &Path, model: &str) -> String {
|
||||||
|
let mut s = String::from(BASE_PROMPT);
|
||||||
|
s.push_str("\n\n");
|
||||||
|
s.push_str(&agent.prompt);
|
||||||
|
let git = cwd.join(".git").exists();
|
||||||
|
s.push_str(&format!(
|
||||||
|
"\n\n# Environment\n- Working directory: {}\n- Workspace root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n",
|
||||||
|
cwd.display(),
|
||||||
|
app.workspace().display(),
|
||||||
|
if git { "yes" } else { "no" },
|
||||||
|
std::env::consts::OS,
|
||||||
|
std::env::consts::ARCH,
|
||||||
|
chrono::Local::now().format("%Y-%m-%d"),
|
||||||
|
));
|
||||||
|
let skills = skills(app, cwd);
|
||||||
|
if !skills.is_empty() {
|
||||||
|
s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n");
|
||||||
|
for sk in &skills {
|
||||||
|
s.push_str(&format!("- {}: {}\n", sk.name, sk.description));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let files = instruction_files(app, cwd);
|
||||||
|
if !files.is_empty() {
|
||||||
|
s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n");
|
||||||
|
for (p, text) in files {
|
||||||
|
s.push_str(&format!("\n<instructions path=\"{}\">\n{}\n</instructions>\n", p.display(), text.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slash commands
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Command {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub body: String,
|
||||||
|
pub agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commands(app: &App, cwd: &Path) -> Vec<Command> {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
for dir in resource_dirs(app, cwd, "commands") {
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if p.extension().and_then(|x| x.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(text) = std::fs::read_to_string(&p) else { continue };
|
||||||
|
let (meta, body) = frontmatter(&text);
|
||||||
|
let name = p.file_stem().unwrap().to_string_lossy().to_string();
|
||||||
|
let description = meta
|
||||||
|
.get("description")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(80).collect());
|
||||||
|
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.into_values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand "/name args" into the command's prompt. Returns None if not a known command.
|
||||||
|
pub fn expand_command(app: &App, cwd: &Path, text: &str) -> Option<(String, Option<String>)> {
|
||||||
|
let rest = text.strip_prefix('/')?;
|
||||||
|
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||||
|
let cmd = commands(app, cwd).into_iter().find(|c| c.name == name)?;
|
||||||
|
let args = args.trim();
|
||||||
|
let mut body = cmd.body.replace("$ARGUMENTS", args);
|
||||||
|
for (i, a) in args.split_whitespace().enumerate() {
|
||||||
|
body = body.replace(&format!("${}", i + 1), a);
|
||||||
|
}
|
||||||
|
if !cmd.body.contains("$ARGUMENTS") && !args.is_empty() {
|
||||||
|
body.push_str(&format!("\n\n{args}"));
|
||||||
|
}
|
||||||
|
Some((body, cmd.agent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skills
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Skill {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn skills(app: &App, cwd: &Path) -> Vec<Skill> {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
for dir in resource_dirs(app, cwd, "skills") {
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let p = e.path().join("SKILL.md");
|
||||||
|
let Ok(text) = std::fs::read_to_string(&p) else { continue };
|
||||||
|
let (meta, _) = frontmatter(&text);
|
||||||
|
let name = meta.get("name").cloned().unwrap_or_else(|| e.file_name().to_string_lossy().into());
|
||||||
|
map.insert(name.clone(), Skill { name, description: meta.get("description").cloned().unwrap_or_default(), path: p });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.into_values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SkillTool;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for SkillTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"skill"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Load a skill (packaged instructions for a kind of task) by name. Follow the returned instructions.".into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let name = arg_str(&input, "name")?;
|
||||||
|
let Some(sk) = skills(&ctx.app, &ctx.cwd).into_iter().find(|s| s.name == name) else {
|
||||||
|
return Ok(ToolOutput::error(format!("unknown skill '{name}'")));
|
||||||
|
};
|
||||||
|
let text = std::fs::read_to_string(&sk.path)?;
|
||||||
|
let (_, body) = frontmatter(&text);
|
||||||
|
let dir = sk.path.parent().unwrap_or(Path::new("."));
|
||||||
|
Ok(ToolOutput::text(format!("Skill '{name}' (base directory: {}):\n\n{body}", dir.display())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_frontmatter() {
|
||||||
|
let (m, b) = frontmatter("---\nname: x\ndescription: \"does y\"\n---\nBody here\n");
|
||||||
|
assert_eq!(m["name"], "x");
|
||||||
|
assert_eq!(m["description"], "does y");
|
||||||
|
assert_eq!(b.trim(), "Body here");
|
||||||
|
let (m2, b2) = frontmatter("no frontmatter");
|
||||||
|
assert!(m2.is_empty());
|
||||||
|
assert_eq!(b2, "no frontmatter");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
//! Anthropic Messages API (streaming). Also works with Anthropic-compatible
|
||||||
|
//! third-party endpoints; first-party-only features are gated on the host.
|
||||||
|
|
||||||
|
use super::{ChatRequest, Endpoint, Provider, StreamEvent, http_error};
|
||||||
|
use crate::config::ModelConfig;
|
||||||
|
use crate::types::{Message, Part, Role, Usage};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use eventsource_stream::Eventsource;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
const API_VERSION: &str = "2026-10-21";
|
||||||
|
|
||||||
|
pub struct Anthropic {
|
||||||
|
client: reqwest::Client,
|
||||||
|
ep: Endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Anthropic {
|
||||||
|
pub fn new(client: reqwest::Client, ep: Endpoint) -> Self {
|
||||||
|
Self { client, ep }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_party(&self) -> bool {
|
||||||
|
self.ep.base_url.contains("api.anthropic.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
|
||||||
|
let mut rb = self.client.request(method, format!("{}{}", self.ep.base_url, path));
|
||||||
|
if !self.ep.headers.keys().any(|k| k.eq_ignore_ascii_case("anthropic-version")) {
|
||||||
|
rb = rb.header("anthropic-version", API_VERSION);
|
||||||
|
}
|
||||||
|
self.ep.apply(rb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert our neutral history into Anthropic message params.
|
||||||
|
pub fn to_wire_messages(messages: &[Message]) -> Vec<Value> {
|
||||||
|
let mut out: Vec<Value> = Vec::new();
|
||||||
|
for m in messages {
|
||||||
|
let mut content = Vec::new();
|
||||||
|
for p in &m.parts {
|
||||||
|
match p {
|
||||||
|
Part::Text { text } if !text.is_empty() => {
|
||||||
|
content.push(json!({"type": "text", "text": text}))
|
||||||
|
}
|
||||||
|
Part::Summary { text } => content.push(json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
||||||
|
})),
|
||||||
|
Part::Image { image } => content.push(json!({
|
||||||
|
"type": "image",
|
||||||
|
"source": {"type": "base64", "media_type": image.media_type, "data": image.data}
|
||||||
|
})),
|
||||||
|
// Thinking blocks are replayed unchanged (assistant turns only).
|
||||||
|
Part::Reasoning { text, signature } if m.role == Role::Assistant => {
|
||||||
|
match signature.as_deref() {
|
||||||
|
Some(sig) if sig.starts_with("redacted:") => content.push(json!({
|
||||||
|
"type": "redacted_thinking",
|
||||||
|
"data": &sig["redacted:".len()..]
|
||||||
|
})),
|
||||||
|
Some(sig) => content.push(json!({
|
||||||
|
"type": "thinking", "thinking": text, "signature": sig
|
||||||
|
})),
|
||||||
|
// Reasoning from another provider has no signature – drop it.
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Part::ToolCall { id, name, input, .. } => content.push(json!({
|
||||||
|
"type": "tool_use", "id": id, "name": name, "input": input
|
||||||
|
})),
|
||||||
|
Part::ToolResult { call_id, content: text, images, is_error, .. } => {
|
||||||
|
let mut blocks = vec![json!({"type": "text", "text": if text.is_empty() { "(no output)" } else { text }})];
|
||||||
|
for img in images {
|
||||||
|
blocks.push(json!({
|
||||||
|
"type": "image",
|
||||||
|
"source": {"type": "base64", "media_type": img.media_type, "data": img.data}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
content.push(json!({
|
||||||
|
"type": "tool_result", "tool_use_id": call_id,
|
||||||
|
"content": blocks, "is_error": is_error
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let role = match m.role {
|
||||||
|
Role::User => "user",
|
||||||
|
Role::Assistant => "assistant",
|
||||||
|
};
|
||||||
|
// Merge consecutive messages of the same role (the API requires alternation).
|
||||||
|
if let Some(last) = out.last_mut() {
|
||||||
|
if last["role"] == role {
|
||||||
|
last["content"].as_array_mut().unwrap().extend(content);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(json!({"role": role, "content": content}));
|
||||||
|
}
|
||||||
|
// tool_result blocks must come first in a user message.
|
||||||
|
for msg in out.iter_mut().filter(|m| m["role"] == "user") {
|
||||||
|
let arr = msg["content"].as_array_mut().unwrap();
|
||||||
|
arr.sort_by_key(|b| if b["type"] == "tool_result" { 0 } else { 1 });
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BlockState {
|
||||||
|
kind: String,
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
json: String,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for Anthropic {
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
req: ChatRequest,
|
||||||
|
tx: mpsc::Sender<StreamEvent>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
) -> Result<()> {
|
||||||
|
let first_party = self.first_party();
|
||||||
|
let tools: Vec<Value> = req
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
let mut v = json!({
|
||||||
|
"name": t.name, "description": t.description, "input_schema": t.input_schema
|
||||||
|
});
|
||||||
|
if first_party {
|
||||||
|
v["eager_input_streaming"] = json!(true);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut body = json!({
|
||||||
|
"model": req.model.id,
|
||||||
|
"max_tokens": req.max_tokens,
|
||||||
|
"stream": true,
|
||||||
|
"system": [{"type": "text", "text": req.system, "cache_control": {"type": "ephemeral"}}],
|
||||||
|
"messages": to_wire_messages(&req.messages),
|
||||||
|
});
|
||||||
|
if !tools.is_empty() {
|
||||||
|
body["tools"] = Value::Array(tools);
|
||||||
|
}
|
||||||
|
if first_party {
|
||||||
|
// Automatic breakpoint on the last cacheable block (conversation prefix).
|
||||||
|
body["cache_control"] = json!({"type": "ephemeral"});
|
||||||
|
}
|
||||||
|
if req.reasoning && req.model.reasoning {
|
||||||
|
body["thinking"] = json!({"type": "adaptive", "display": "summarized"});
|
||||||
|
}
|
||||||
|
if let Some(effort) = &req.model.effort {
|
||||||
|
body["output_config"] = json!({"effort": effort});
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(http_error(resp).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut events = resp.bytes_stream().eventsource();
|
||||||
|
let mut blocks: BTreeMap<u64, BlockState> = BTreeMap::new();
|
||||||
|
let mut usage = Usage::default();
|
||||||
|
let mut stop_reason = String::from("end_turn");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let ev = tokio::select! {
|
||||||
|
_ = cancel.cancelled() => return Ok(()),
|
||||||
|
ev = events.next() => ev,
|
||||||
|
};
|
||||||
|
let Some(ev) = ev else { break };
|
||||||
|
let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?;
|
||||||
|
if ev.data.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let data: Value = serde_json::from_str(&ev.data)?;
|
||||||
|
match data["type"].as_str().unwrap_or_default() {
|
||||||
|
"message_start" => {
|
||||||
|
let u = &data["message"]["usage"];
|
||||||
|
usage.input_tokens = u["input_tokens"].as_u64().unwrap_or(0);
|
||||||
|
usage.cache_read_tokens = u["cache_read_input_tokens"].as_u64().unwrap_or(0);
|
||||||
|
usage.cache_write_tokens = u["cache_creation_input_tokens"].as_u64().unwrap_or(0);
|
||||||
|
}
|
||||||
|
"content_block_start" => {
|
||||||
|
let idx = data["index"].as_u64().unwrap_or(0);
|
||||||
|
let cb = &data["content_block"];
|
||||||
|
let mut st = BlockState {
|
||||||
|
kind: cb["type"].as_str().unwrap_or_default().to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match st.kind.as_str() {
|
||||||
|
"tool_use" => {
|
||||||
|
st.id = cb["id"].as_str().unwrap_or_default().into();
|
||||||
|
st.name = cb["name"].as_str().unwrap_or_default().into();
|
||||||
|
}
|
||||||
|
"redacted_thinking" => {
|
||||||
|
st.signature = format!("redacted:{}", cb["data"].as_str().unwrap_or_default());
|
||||||
|
}
|
||||||
|
"text" => {
|
||||||
|
if let Some(t) = cb["text"].as_str().filter(|t| !t.is_empty()) {
|
||||||
|
tx.send(StreamEvent::TextDelta(t.into())).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
blocks.insert(idx, st);
|
||||||
|
}
|
||||||
|
"content_block_delta" => {
|
||||||
|
let idx = data["index"].as_u64().unwrap_or(0);
|
||||||
|
let d = &data["delta"];
|
||||||
|
let st = blocks.entry(idx).or_default();
|
||||||
|
match d["type"].as_str().unwrap_or_default() {
|
||||||
|
"text_delta" => {
|
||||||
|
tx.send(StreamEvent::TextDelta(d["text"].as_str().unwrap_or_default().into())).await.ok();
|
||||||
|
}
|
||||||
|
"thinking_delta" => {
|
||||||
|
tx.send(StreamEvent::ReasoningDelta(d["thinking"].as_str().unwrap_or_default().into())).await.ok();
|
||||||
|
}
|
||||||
|
"signature_delta" => st.signature.push_str(d["signature"].as_str().unwrap_or_default()),
|
||||||
|
"input_json_delta" => st.json.push_str(d["partial_json"].as_str().unwrap_or_default()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"content_block_stop" => {
|
||||||
|
let idx = data["index"].as_u64().unwrap_or(0);
|
||||||
|
if let Some(st) = blocks.remove(&idx) {
|
||||||
|
match st.kind.as_str() {
|
||||||
|
"tool_use" => {
|
||||||
|
let input = if st.json.trim().is_empty() {
|
||||||
|
json!({})
|
||||||
|
} else {
|
||||||
|
// Invalid JSON (e.g. truncated) is passed on as a marker; the
|
||||||
|
// agent loop answers it with an error tool_result.
|
||||||
|
serde_json::from_str(&st.json)
|
||||||
|
.unwrap_or_else(|_| json!({"__invalid_json": st.json}))
|
||||||
|
};
|
||||||
|
tx.send(StreamEvent::ToolCall { id: st.id, name: st.name, input, extra: None }).await.ok();
|
||||||
|
}
|
||||||
|
"thinking" | "redacted_thinking" => {
|
||||||
|
tx.send(StreamEvent::ReasoningSignature(st.signature)).await.ok();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"message_delta" => {
|
||||||
|
if let Some(r) = data["delta"]["stop_reason"].as_str() {
|
||||||
|
stop_reason = r.to_string();
|
||||||
|
}
|
||||||
|
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
|
||||||
|
usage.output_tokens = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"error" => {
|
||||||
|
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
|
||||||
|
return Err(anyhow!("provider error: {msg}"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.send(StreamEvent::Usage(usage)).await.ok();
|
||||||
|
tx.send(StreamEvent::Done { stop_reason }).await.ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelConfig>> {
|
||||||
|
let resp = self.request(reqwest::Method::GET, "/models?limit=100").send().await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(http_error(resp).await);
|
||||||
|
}
|
||||||
|
let v: Value = resp.json().await?;
|
||||||
|
Ok(v["data"]
|
||||||
|
.as_array()
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.map(|m| ModelConfig {
|
||||||
|
id: m["id"].as_str().unwrap_or_default().into(),
|
||||||
|
name: m["display_name"].as_str().map(String::from),
|
||||||
|
context: m["max_input_tokens"].as_u64(),
|
||||||
|
max_output: m["max_tokens"].as_u64(),
|
||||||
|
reasoning: true,
|
||||||
|
vision: true,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
//! Model providers. Two wire protocols cover all targets:
|
||||||
|
//! Anthropic Messages and OpenAI-compatible chat completions.
|
||||||
|
|
||||||
|
pub mod anthropic;
|
||||||
|
pub mod openai;
|
||||||
|
|
||||||
|
use crate::config::{Config, ModelConfig, ProviderConfig, ProviderKind, expand_env};
|
||||||
|
use crate::types::{Message, Usage};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ToolDef {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub input_schema: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
pub model: ModelConfig,
|
||||||
|
pub system: String,
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
pub tools: Vec<ToolDef>,
|
||||||
|
pub max_tokens: u64,
|
||||||
|
/// Enable extended thinking / reasoning when the model supports it.
|
||||||
|
pub reasoning: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum StreamEvent {
|
||||||
|
TextDelta(String),
|
||||||
|
ReasoningDelta(String),
|
||||||
|
ReasoningSignature(String),
|
||||||
|
ToolCall { id: String, name: String, input: Value, extra: Option<Value> },
|
||||||
|
Usage(Usage),
|
||||||
|
Done { stop_reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Provider: Send + Sync {
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
req: ChatRequest,
|
||||||
|
tx: mpsc::Sender<StreamEvent>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelConfig>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connection settings shared by both protocol implementations.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Endpoint {
|
||||||
|
pub base_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub auth_header: String,
|
||||||
|
pub auth_prefix: String,
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Endpoint {
|
||||||
|
fn from_config(p: &ProviderConfig) -> Self {
|
||||||
|
let (default_header, default_prefix) = match p.kind {
|
||||||
|
ProviderKind::Anthropic => ("x-api-key", ""),
|
||||||
|
ProviderKind::Openai => ("Authorization", "Bearer "),
|
||||||
|
};
|
||||||
|
let base = if p.base_url.is_empty() && p.kind == ProviderKind::Anthropic {
|
||||||
|
"https://api.anthropic.com/v1".to_string()
|
||||||
|
} else {
|
||||||
|
expand_env(&p.base_url)
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
base_url: base.trim_end_matches('/').to_string(),
|
||||||
|
api_key: expand_env(&p.api_key),
|
||||||
|
auth_header: p.auth_header.clone().unwrap_or_else(|| default_header.into()),
|
||||||
|
auth_prefix: p.auth_prefix.clone().unwrap_or_else(|| default_prefix.into()),
|
||||||
|
headers: p.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
|
if !self.api_key.is_empty() {
|
||||||
|
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
|
||||||
|
}
|
||||||
|
for (k, v) in &self.headers {
|
||||||
|
rb = rb.header(k, v);
|
||||||
|
}
|
||||||
|
rb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProviderEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub provider: Arc<dyn Provider>,
|
||||||
|
pub models: Vec<ModelConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Registry {
|
||||||
|
pub providers: BTreeMap<String, ProviderEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn http_client() -> reqwest::Client {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.expect("http client")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registry {
|
||||||
|
/// Build providers from config. Providers with an empty model list are queried
|
||||||
|
/// for their models; failures are logged and leave the list empty.
|
||||||
|
pub async fn build(cfg: &Config) -> Self {
|
||||||
|
let client = http_client();
|
||||||
|
let mut providers = BTreeMap::new();
|
||||||
|
for (name, pc) in &cfg.providers {
|
||||||
|
if !pc.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ep = Endpoint::from_config(pc);
|
||||||
|
let provider: Arc<dyn Provider> = match pc.kind {
|
||||||
|
ProviderKind::Anthropic => Arc::new(anthropic::Anthropic::new(client.clone(), ep)),
|
||||||
|
ProviderKind::Openai => Arc::new(openai::OpenAi::new(client.clone(), ep)),
|
||||||
|
};
|
||||||
|
let mut models: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
|
||||||
|
if models.is_empty() {
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(15), provider.list_models()).await {
|
||||||
|
Ok(Ok(m)) => models = m,
|
||||||
|
Ok(Err(e)) => tracing::warn!("provider {name}: model discovery failed: {e:#}"),
|
||||||
|
Err(_) => tracing::warn!("provider {name}: model discovery timed out"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
providers.insert(
|
||||||
|
name.clone(),
|
||||||
|
ProviderEntry { name: name.clone(), provider, models },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Self { providers }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve "provider/model". Unknown models of a known provider are allowed
|
||||||
|
/// with default settings so users can type any model id.
|
||||||
|
pub fn resolve(&self, spec: &str) -> Result<(Arc<dyn Provider>, ModelConfig)> {
|
||||||
|
let (pname, mid) = spec
|
||||||
|
.split_once('/')
|
||||||
|
.ok_or_else(|| anyhow!("model must be 'provider/model', got '{spec}'"))?;
|
||||||
|
let entry = self
|
||||||
|
.providers
|
||||||
|
.get(pname)
|
||||||
|
.ok_or_else(|| anyhow!("unknown or disabled provider '{pname}'"))?;
|
||||||
|
let model = entry
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id == mid)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| ModelConfig { id: mid.to_string(), ..Default::default() });
|
||||||
|
Ok((entry.provider.clone(), model))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn first_model(&self) -> Option<String> {
|
||||||
|
self.providers
|
||||||
|
.values()
|
||||||
|
.find_map(|e| e.models.first().map(|m| format!("{}/{}", e.name, m.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn models_json(&self) -> Value {
|
||||||
|
let list: Vec<Value> = self
|
||||||
|
.providers
|
||||||
|
.values()
|
||||||
|
.map(|e| {
|
||||||
|
serde_json::json!({
|
||||||
|
"provider": e.name,
|
||||||
|
"models": e.models.iter().map(|m| serde_json::json!({
|
||||||
|
"id": m.id,
|
||||||
|
"name": m.name.clone().unwrap_or_else(|| m.id.clone()),
|
||||||
|
"context": m.context,
|
||||||
|
"reasoning": m.reasoning,
|
||||||
|
"vision": m.vision,
|
||||||
|
})).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Value::Array(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn an HTTP error response into a readable error.
|
||||||
|
pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let detail = serde_json::from_str::<Value>(&body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| {
|
||||||
|
v.pointer("/error/message")
|
||||||
|
.or_else(|| v.pointer("/0/error/message"))
|
||||||
|
.or_else(|| v.get("message"))
|
||||||
|
.and_then(|m| m.as_str().map(String::from))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| body.chars().take(500).collect());
|
||||||
|
anyhow!("provider returned HTTP {status}: {detail}")
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
//! OpenAI-compatible chat completions (streaming). Covers Gemini (OpenAI compat),
|
||||||
|
//! opencode Go, fal/OpenRouter, Ollama, vLLM, llama.cpp, LM Studio, ...
|
||||||
|
|
||||||
|
use super::{ChatRequest, Endpoint, Provider, StreamEvent, http_error};
|
||||||
|
use crate::config::ModelConfig;
|
||||||
|
use crate::types::{Message, Part, Role, Usage};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use eventsource_stream::Eventsource;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
pub struct OpenAi {
|
||||||
|
client: reqwest::Client,
|
||||||
|
ep: Endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenAi {
|
||||||
|
pub fn new(client: reqwest::Client, ep: Endpoint) -> Self {
|
||||||
|
Self { client, ep }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_url(img: &crate::types::ImageData) -> String {
|
||||||
|
format!("data:{};base64,{}", img.media_type, img.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
|
||||||
|
let mut out = vec![json!({"role": "system", "content": system})];
|
||||||
|
for m in messages {
|
||||||
|
match m.role {
|
||||||
|
Role::User => {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut tool_images = Vec::new();
|
||||||
|
for p in &m.parts {
|
||||||
|
match p {
|
||||||
|
Part::Text { text } if !text.is_empty() => {
|
||||||
|
parts.push(json!({"type": "text", "text": text}))
|
||||||
|
}
|
||||||
|
Part::Summary { text } => parts.push(json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
|
||||||
|
})),
|
||||||
|
Part::Image { image } => parts.push(json!({
|
||||||
|
"type": "image_url", "image_url": {"url": data_url(image)}
|
||||||
|
})),
|
||||||
|
Part::ToolResult { call_id, name, content, images, is_error, .. } => {
|
||||||
|
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
|
||||||
|
out.push(json!({
|
||||||
|
"role": "tool", "tool_call_id": call_id, "name": name,
|
||||||
|
"content": if text.is_empty() { "(no output)".into() } else { text }
|
||||||
|
}));
|
||||||
|
tool_images.extend(images.iter().cloned());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tool messages cannot carry images: send them as a user message.
|
||||||
|
if !tool_images.is_empty() {
|
||||||
|
let mut c = vec![json!({"type": "text", "text": "Images returned by the tool calls above:"})];
|
||||||
|
c.extend(tool_images.iter().map(|i| json!({"type": "image_url", "image_url": {"url": data_url(i)}})));
|
||||||
|
out.push(json!({"role": "user", "content": c}));
|
||||||
|
}
|
||||||
|
if !parts.is_empty() {
|
||||||
|
out.push(json!({"role": "user", "content": parts}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Role::Assistant => {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut reasoning = String::new();
|
||||||
|
let mut calls = Vec::new();
|
||||||
|
for p in &m.parts {
|
||||||
|
match p {
|
||||||
|
Part::Text { text: t } => text.push_str(t),
|
||||||
|
Part::Reasoning { text: t, .. } => reasoning.push_str(t),
|
||||||
|
Part::ToolCall { id, name, input, extra } => {
|
||||||
|
let mut c = json!({
|
||||||
|
"id": id, "type": "function",
|
||||||
|
"function": {"name": name, "arguments": input.to_string()}
|
||||||
|
});
|
||||||
|
if let Some(e) = extra {
|
||||||
|
c["extra_content"] = e.clone();
|
||||||
|
}
|
||||||
|
calls.push(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut msg = json!({"role": "assistant", "content": if text.is_empty() { Value::Null } else { Value::String(text) }});
|
||||||
|
if !calls.is_empty() {
|
||||||
|
msg["tool_calls"] = Value::Array(calls);
|
||||||
|
}
|
||||||
|
// Some reasoning models (DeepSeek, Kimi) expect their reasoning back during tool loops.
|
||||||
|
if !reasoning.is_empty() {
|
||||||
|
msg["reasoning_content"] = Value::String(reasoning);
|
||||||
|
}
|
||||||
|
if msg["content"].is_null() && msg.get("tool_calls").is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PendingCall {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
args: String,
|
||||||
|
extra: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for OpenAi {
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
req: ChatRequest,
|
||||||
|
tx: mpsc::Sender<StreamEvent>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
) -> Result<()> {
|
||||||
|
let tools: Vec<Value> = req
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|t| json!({
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": t.name, "description": t.description, "parameters": t.input_schema}
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
|
let mut body = json!({
|
||||||
|
"model": req.model.id,
|
||||||
|
"messages": to_wire_messages(&req.system, &req.messages),
|
||||||
|
"stream": true,
|
||||||
|
"stream_options": {"include_usage": true},
|
||||||
|
"max_tokens": req.max_tokens,
|
||||||
|
});
|
||||||
|
if !tools.is_empty() {
|
||||||
|
body["tools"] = Value::Array(tools);
|
||||||
|
}
|
||||||
|
if let Some(effort) = &req.model.effort {
|
||||||
|
body["reasoning_effort"] = json!(effort);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body);
|
||||||
|
let resp = self.ep.apply(rb).send().await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(http_error(resp).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut events = resp.bytes_stream().eventsource();
|
||||||
|
let mut calls: BTreeMap<u64, PendingCall> = BTreeMap::new();
|
||||||
|
let mut usage = Usage::default();
|
||||||
|
let mut stop_reason = String::from("stop");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let ev = tokio::select! {
|
||||||
|
_ = cancel.cancelled() => return Ok(()),
|
||||||
|
ev = events.next() => ev,
|
||||||
|
};
|
||||||
|
let Some(ev) = ev else { break };
|
||||||
|
let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?;
|
||||||
|
if ev.data.trim() == "[DONE]" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ev.data.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let data: Value = serde_json::from_str(&ev.data)?;
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
return Err(anyhow!("provider error: {}", err["message"].as_str().unwrap_or(&err.to_string())));
|
||||||
|
}
|
||||||
|
if let Some(u) = data.get("usage").filter(|u| u.is_object()) {
|
||||||
|
let cached = u["prompt_tokens_details"]["cached_tokens"].as_u64().unwrap_or(0);
|
||||||
|
usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached);
|
||||||
|
usage.cache_read_tokens = cached;
|
||||||
|
usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0);
|
||||||
|
}
|
||||||
|
let Some(choice) = data["choices"].get(0) else { continue };
|
||||||
|
let d = &choice["delta"];
|
||||||
|
if let Some(t) = d["content"].as_str().filter(|t| !t.is_empty()) {
|
||||||
|
tx.send(StreamEvent::TextDelta(t.into())).await.ok();
|
||||||
|
}
|
||||||
|
let r = d["reasoning_content"].as_str().or_else(|| d["reasoning"].as_str());
|
||||||
|
if let Some(r) = r.filter(|r| !r.is_empty()) {
|
||||||
|
tx.send(StreamEvent::ReasoningDelta(r.into())).await.ok();
|
||||||
|
}
|
||||||
|
if let Some(tcs) = d["tool_calls"].as_array() {
|
||||||
|
for (pos, tc) in tcs.iter().enumerate() {
|
||||||
|
let idx = tc["index"].as_u64().unwrap_or(pos as u64);
|
||||||
|
let pc = calls.entry(idx).or_default();
|
||||||
|
if let Some(id) = tc["id"].as_str().filter(|s| !s.is_empty()) {
|
||||||
|
pc.id = id.into();
|
||||||
|
}
|
||||||
|
if let Some(n) = tc["function"]["name"].as_str() {
|
||||||
|
pc.name.push_str(n);
|
||||||
|
}
|
||||||
|
if let Some(a) = tc["function"]["arguments"].as_str() {
|
||||||
|
pc.args.push_str(a);
|
||||||
|
}
|
||||||
|
if let Some(e) = tc.get("extra_content") {
|
||||||
|
pc.extra = Some(e.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(f) = choice["finish_reason"].as_str() {
|
||||||
|
stop_reason = f.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, pc) in calls {
|
||||||
|
let input = if pc.args.trim().is_empty() {
|
||||||
|
json!({})
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(&pc.args).unwrap_or_else(|_| json!({"__invalid_json": pc.args}))
|
||||||
|
};
|
||||||
|
let id = if pc.id.is_empty() { format!("call_{}_{i}", crate::types::new_id()) } else { pc.id };
|
||||||
|
tx.send(StreamEvent::ToolCall { id, name: pc.name, input, extra: pc.extra }).await.ok();
|
||||||
|
}
|
||||||
|
tx.send(StreamEvent::Usage(usage)).await.ok();
|
||||||
|
tx.send(StreamEvent::Done { stop_reason }).await.ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelConfig>> {
|
||||||
|
let rb = self.client.get(format!("{}/models", self.ep.base_url));
|
||||||
|
let resp = self.ep.apply(rb).send().await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(http_error(resp).await);
|
||||||
|
}
|
||||||
|
let v: Value = resp.json().await?;
|
||||||
|
let mut models: Vec<ModelConfig> = v["data"]
|
||||||
|
.as_array()
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|m| m["id"].as_str())
|
||||||
|
// Gemini lists "models/<id>"; the chat endpoint wants the bare id.
|
||||||
|
.map(|id| ModelConfig {
|
||||||
|
id: id.trim_start_matches("models/").to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
//! The agent loop: stream a model response, run tool calls (with permission
|
||||||
|
//! checks), feed results back, repeat until the model stops.
|
||||||
|
|
||||||
|
use crate::app::{App, PermissionReply};
|
||||||
|
use crate::permission::{Decision, Evaluator, bash_prefix, split_command};
|
||||||
|
use crate::prompts::{self, Agent};
|
||||||
|
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
||||||
|
use crate::store::UsageRecord;
|
||||||
|
use crate::tools::{self, Tool, ToolCtx};
|
||||||
|
use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
|
||||||
|
use anyhow::{Result, anyhow, bail};
|
||||||
|
use futures::future::join_all;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
const MAX_STEPS: usize = 250;
|
||||||
|
const MAX_SUBAGENT_DEPTH: usize = 3;
|
||||||
|
|
||||||
|
/// Start processing a user prompt. If the session is busy the prompt is queued
|
||||||
|
/// and injected at the next step.
|
||||||
|
pub fn start_prompt(app: Arc<App>, session_id: String, parts: Vec<Part>) -> Result<()> {
|
||||||
|
let text_only: String = parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None })
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let queued = app.with_runtime(&session_id, |r| {
|
||||||
|
if r.cancel.is_some() && parts.iter().all(|p| matches!(p, Part::Text { .. })) {
|
||||||
|
r.queue.push(text_only.clone());
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if queued {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if app.is_busy(&session_id) {
|
||||||
|
bail!("session is busy");
|
||||||
|
}
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
app.with_runtime(&session_id, |r| r.cancel = Some(cancel.clone()));
|
||||||
|
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = async {
|
||||||
|
let msg = Message::new(Role::User, parts);
|
||||||
|
app.store.add_message(&session_id, &msg)?;
|
||||||
|
app.emit(Event::MessageAdded { session_id: session_id.clone(), message: msg });
|
||||||
|
maybe_generate_title(app.clone(), &session_id, &text_only);
|
||||||
|
run_session(app.clone(), &session_id, cancel.clone(), 0).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::warn!("session {session_id}: {e:#}");
|
||||||
|
app.emit(Event::Error { session_id: session_id.clone(), message: format!("{e:#}") });
|
||||||
|
}
|
||||||
|
// Prompts queued after the last step start a new turn.
|
||||||
|
let leftover = app.with_runtime(&session_id, |r| {
|
||||||
|
r.cancel = None;
|
||||||
|
std::mem::take(&mut r.queue)
|
||||||
|
});
|
||||||
|
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
|
||||||
|
if !leftover.is_empty() && !cancel.is_cancelled() {
|
||||||
|
let _ = start_prompt(app, session_id, vec![Part::Text { text: leftover.join("\n\n") }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn abort(app: &App, session_id: &str) {
|
||||||
|
app.with_runtime(session_id, |r| {
|
||||||
|
if let Some(c) = &r.cancel {
|
||||||
|
c.cancel();
|
||||||
|
}
|
||||||
|
r.queue.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages since the last compaction summary.
|
||||||
|
fn active_history(all: Vec<Message>) -> Vec<Message> {
|
||||||
|
let start = all
|
||||||
|
.iter()
|
||||||
|
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
|
||||||
|
.unwrap_or(0);
|
||||||
|
all.into_iter().skip(start).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_tool_name(s: &str) -> String {
|
||||||
|
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
|
||||||
|
let mut all = tools::builtin_tools();
|
||||||
|
all.extend(app.mcp.tools());
|
||||||
|
all.into_iter()
|
||||||
|
.filter(|t| !agent.read_only || t.read_only())
|
||||||
|
// Craft tools only where they are useful.
|
||||||
|
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare") || agent.tools.is_some())
|
||||||
|
.filter(|t| match &agent.tools {
|
||||||
|
None => true,
|
||||||
|
Some(list) => {
|
||||||
|
let n = normalize_tool_name(t.name());
|
||||||
|
list.iter().any(|a| {
|
||||||
|
let a = normalize_tool_name(a);
|
||||||
|
a == n || (t.name().starts_with("mcp__") && a == "mcp")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Step {
|
||||||
|
message: Message,
|
||||||
|
stop_reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the loop for a session until the model stops. Returns the final text.
|
||||||
|
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
|
||||||
|
let mut final_text = String::new();
|
||||||
|
for _ in 0..MAX_STEPS {
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let session = app.session(session_id)?;
|
||||||
|
let cwd = PathBuf::from(&session.cwd);
|
||||||
|
let agent = prompts::find_agent(&app, &cwd, &session.agent);
|
||||||
|
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
|
||||||
|
let (provider, model) = app.registry().resolve(&model_spec)?;
|
||||||
|
|
||||||
|
// Auto-compaction when the last request used most of the context window.
|
||||||
|
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
|
||||||
|
let threshold = app.config().server.auto_compact_threshold;
|
||||||
|
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
|
||||||
|
compact(app.clone(), session_id, cancel.clone()).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tools = available_tools(&app, &agent);
|
||||||
|
let history = active_history(app.store.messages(session_id)?);
|
||||||
|
let req = ChatRequest {
|
||||||
|
system: prompts::system_prompt(&app, &agent, &cwd, &model_spec),
|
||||||
|
messages: history,
|
||||||
|
tools: tools.iter().map(|t| t.def()).collect(),
|
||||||
|
max_tokens: model.max_output.unwrap_or(16_000),
|
||||||
|
reasoning: true,
|
||||||
|
model: model.clone(),
|
||||||
|
};
|
||||||
|
let step = stream_step(&app, &session, &agent.name, &model_spec, provider, req, &cancel).await?;
|
||||||
|
final_text = step
|
||||||
|
.message
|
||||||
|
.parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None })
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
let calls: Vec<(String, String, Value)> = step
|
||||||
|
.message
|
||||||
|
.parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| match p {
|
||||||
|
Part::ToolCall { id, name, input, .. } => Some((id.clone(), name.clone(), input.clone())),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
// Every tool call needs a result, otherwise the history is invalid.
|
||||||
|
if !calls.is_empty() {
|
||||||
|
let results = calls
|
||||||
|
.iter()
|
||||||
|
.map(|(id, name, _)| tool_result(id, name, "Aborted by user".into(), true, None))
|
||||||
|
.collect();
|
||||||
|
save_user_parts(&app, session_id, results)?;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if calls.is_empty() {
|
||||||
|
match step.stop_reason.as_str() {
|
||||||
|
"max_tokens" | "length" => app.emit(Event::Error {
|
||||||
|
session_id: session_id.into(),
|
||||||
|
message: "The response hit the output token limit (max_output).".into(),
|
||||||
|
}),
|
||||||
|
"refusal" | "content_filter" => app.emit(Event::Error {
|
||||||
|
session_id: session_id.into(),
|
||||||
|
message: "The model declined to respond (refusal).".into(),
|
||||||
|
}),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = ToolCtx {
|
||||||
|
app: app.clone(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
call_id: String::new(),
|
||||||
|
cwd: cwd.clone(),
|
||||||
|
cancel: cancel.clone(),
|
||||||
|
agent: agent.name.clone(),
|
||||||
|
};
|
||||||
|
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
||||||
|
let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only()));
|
||||||
|
let mut results = Vec::new();
|
||||||
|
if all_read_only {
|
||||||
|
let futs = calls.iter().map(|(id, name, input)| {
|
||||||
|
execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth)
|
||||||
|
});
|
||||||
|
results = join_all(futs).await;
|
||||||
|
} else {
|
||||||
|
for (id, name, input) in &calls {
|
||||||
|
results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Inject prompts the user sent while we were working.
|
||||||
|
let queued = app.with_runtime(session_id, |r| std::mem::take(&mut r.queue));
|
||||||
|
for q in queued {
|
||||||
|
results.push(Part::Text { text: q });
|
||||||
|
}
|
||||||
|
save_user_parts(&app, session_id, results)?;
|
||||||
|
}
|
||||||
|
Ok(final_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_result(call_id: &str, name: &str, content: String, is_error: bool, meta: Option<Value>) -> Part {
|
||||||
|
Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_user_parts(app: &App, session_id: &str, parts: Vec<Part>) -> Result<()> {
|
||||||
|
let msg = Message::new(Role::User, parts);
|
||||||
|
app.store.add_message(session_id, &msg)?;
|
||||||
|
app.emit(Event::MessageAdded { session_id: session_id.into(), message: msg });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn execute_call(
|
||||||
|
app: &Arc<App>,
|
||||||
|
session: &Session,
|
||||||
|
base: &ToolCtx,
|
||||||
|
tool: Option<Arc<dyn Tool>>,
|
||||||
|
call_id: &str,
|
||||||
|
name: &str,
|
||||||
|
input: Value,
|
||||||
|
depth: usize,
|
||||||
|
) -> Part {
|
||||||
|
let Some(tool) = tool else {
|
||||||
|
return tool_result(call_id, name, format!("Unknown or unavailable tool '{name}' in this mode."), true, None);
|
||||||
|
};
|
||||||
|
if let Some(raw) = input.get("__invalid_json") {
|
||||||
|
return tool_result(
|
||||||
|
call_id,
|
||||||
|
name,
|
||||||
|
format!("Tool input was not valid JSON (possibly truncated). Retry with a smaller input.\n{}", raw.as_str().unwrap_or("").chars().take(300).collect::<String>()),
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if name == "task" && depth >= MAX_SUBAGENT_DEPTH {
|
||||||
|
return tool_result(call_id, name, "Maximum subagent depth reached.".into(), true, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission check. Approvals are tracked on the top-level session.
|
||||||
|
let root = app.root_session_id(&session.id);
|
||||||
|
let cfg = app.config();
|
||||||
|
let decision = app.with_runtime(&root, |r| {
|
||||||
|
Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input)
|
||||||
|
});
|
||||||
|
let ctx = ToolCtx {
|
||||||
|
app: app.clone(),
|
||||||
|
session_id: base.session_id.clone(),
|
||||||
|
call_id: call_id.into(),
|
||||||
|
cwd: base.cwd.clone(),
|
||||||
|
cancel: base.cancel.clone(),
|
||||||
|
agent: base.agent.clone(),
|
||||||
|
};
|
||||||
|
match decision {
|
||||||
|
Decision::Deny => {
|
||||||
|
return tool_result(call_id, name, "Denied by the permission rules in the configuration.".into(), true, None);
|
||||||
|
}
|
||||||
|
Decision::Ask => {
|
||||||
|
let diff = tool.preview(&input, &ctx).await;
|
||||||
|
let (reply, message) = app
|
||||||
|
.request_permission(&session.id, name, tool.title(&input), input.clone(), diff, base.cancel.clone())
|
||||||
|
.await;
|
||||||
|
match reply {
|
||||||
|
PermissionReply::Reject => {
|
||||||
|
let mut text = "The user rejected this tool call.".to_string();
|
||||||
|
if let Some(m) = message.filter(|m| !m.trim().is_empty()) {
|
||||||
|
text.push_str(&format!(" User feedback: {m}"));
|
||||||
|
}
|
||||||
|
return tool_result(call_id, name, text, true, None);
|
||||||
|
}
|
||||||
|
PermissionReply::Always => app.with_runtime(&root, |r| {
|
||||||
|
if name == "bash" {
|
||||||
|
let cmd = input["command"].as_str().unwrap_or("");
|
||||||
|
for part in split_command(cmd).unwrap_or_default() {
|
||||||
|
r.approved.insert(format!("bash:{}", bash_prefix(&part)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.approved.insert(name.to_string());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
PermissionReply::Once => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Decision::Allow => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = if name == "task" {
|
||||||
|
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||||
|
run_task(app, &ctx, input, depth).await
|
||||||
|
} else {
|
||||||
|
tool.run(input, &ctx).await
|
||||||
|
};
|
||||||
|
match res {
|
||||||
|
Ok(out) => Part::ToolResult {
|
||||||
|
call_id: call_id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
content: out.content,
|
||||||
|
images: out.images,
|
||||||
|
is_error: out.is_error,
|
||||||
|
meta: out.meta,
|
||||||
|
},
|
||||||
|
Err(e) => tool_result(call_id, name, format!("Error: {e:#}"), true, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_task(app: &Arc<App>, ctx: &ToolCtx, input: Value, depth: usize) -> Result<tools::ToolOutput> {
|
||||||
|
let prompt = tools::arg_str(&input, "prompt")?.to_string();
|
||||||
|
let description = input["description"].as_str().unwrap_or("subagent").to_string();
|
||||||
|
let agent = input["subagent_type"].as_str().unwrap_or("general").to_string();
|
||||||
|
let (child, text) = spawn_subagent(app.clone(), ctx, &agent, &description, prompt, depth + 1).await?;
|
||||||
|
Ok(tools::ToolOutput::text(text).with_meta(serde_json::json!({"session_id": child, "agent": agent})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point used by the task tool when called outside the runner.
|
||||||
|
pub async fn run_subagent(app: Arc<App>, ctx: &ToolCtx, agent: &str, description: &str, prompt: String) -> Result<(String, String)> {
|
||||||
|
spawn_subagent(app, ctx, agent, description, prompt, 1).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_subagent(
|
||||||
|
app: Arc<App>,
|
||||||
|
ctx: &ToolCtx,
|
||||||
|
agent_name: &str,
|
||||||
|
description: &str,
|
||||||
|
prompt: String,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<(String, String)> {
|
||||||
|
let parent = app.session(&ctx.session_id)?;
|
||||||
|
let parent_agent = prompts::find_agent(&app, &ctx.cwd, &parent.agent);
|
||||||
|
let agents = prompts::agents(&app, &ctx.cwd);
|
||||||
|
let mut agent = agents
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == agent_name && a.mode != "primary")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let names: Vec<_> = agents.iter().filter(|a| a.mode != "primary").map(|a| a.name.as_str()).collect();
|
||||||
|
anyhow!("unknown subagent '{agent_name}'. Available: {}", names.join(", "))
|
||||||
|
})?;
|
||||||
|
// Read-only parents may only spawn read-only subagents.
|
||||||
|
if parent_agent.read_only && !agent.read_only {
|
||||||
|
agent = agents.into_iter().find(|a| a.name == "explore").unwrap_or(agent);
|
||||||
|
}
|
||||||
|
let now = now_ms();
|
||||||
|
let child = Session {
|
||||||
|
id: new_id(),
|
||||||
|
title: description.to_string(),
|
||||||
|
cwd: parent.cwd.clone(),
|
||||||
|
model: agent.model.clone().unwrap_or_else(|| parent.model.clone()),
|
||||||
|
agent: agent.name.clone(),
|
||||||
|
permission_mode: parent.permission_mode.clone(),
|
||||||
|
parent_id: Some(parent.id.clone()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
todos: Value::Null,
|
||||||
|
last_usage: None,
|
||||||
|
};
|
||||||
|
let child = app.save_session(child)?;
|
||||||
|
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||||
|
app.store.add_message(&child.id, &msg)?;
|
||||||
|
app.emit(Event::MessageAdded { session_id: child.id.clone(), message: msg });
|
||||||
|
app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: true });
|
||||||
|
let res = Box::pin(run_session(app.clone(), &child.id, ctx.cancel.child_token(), depth)).await;
|
||||||
|
app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: false });
|
||||||
|
let text = res?;
|
||||||
|
Ok((child.id, if text.trim().is_empty() { "(subagent returned no text)".into() } else { text }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_retryable(e: &anyhow::Error) -> bool {
|
||||||
|
let s = format!("{e:#}");
|
||||||
|
s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One model request with streaming to the UI, usage recording and retries.
|
||||||
|
async fn stream_step(
|
||||||
|
app: &Arc<App>,
|
||||||
|
session: &Session,
|
||||||
|
agent: &str,
|
||||||
|
model_spec: &str,
|
||||||
|
provider: Arc<dyn Provider>,
|
||||||
|
req: ChatRequest,
|
||||||
|
cancel: &CancellationToken,
|
||||||
|
) -> Result<Step> {
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let msg_id = new_id();
|
||||||
|
let (tx, mut rx) = mpsc::channel::<StreamEvent>(256);
|
||||||
|
let p = provider.clone();
|
||||||
|
let r = req.clone();
|
||||||
|
let c = cancel.clone();
|
||||||
|
let handle = tokio::spawn(async move { p.stream(r, tx, c).await });
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut ttft: Option<u128> = None;
|
||||||
|
let mut parts: Vec<Part> = Vec::new();
|
||||||
|
let mut usage = Usage::default();
|
||||||
|
let mut stop_reason = String::new();
|
||||||
|
while let Some(ev) = rx.recv().await {
|
||||||
|
if ttft.is_none() && !matches!(ev, StreamEvent::Usage(_) | StreamEvent::Done { .. }) {
|
||||||
|
ttft = Some(started.elapsed().as_millis());
|
||||||
|
}
|
||||||
|
match ev {
|
||||||
|
StreamEvent::TextDelta(t) => {
|
||||||
|
match parts.last_mut() {
|
||||||
|
Some(Part::Text { text }) => text.push_str(&t),
|
||||||
|
_ => parts.push(Part::Text { text: t.clone() }),
|
||||||
|
}
|
||||||
|
app.emit(Event::TextDelta { session_id: session.id.clone(), message_id: msg_id.clone(), text: t });
|
||||||
|
}
|
||||||
|
StreamEvent::ReasoningDelta(t) => {
|
||||||
|
match parts.last_mut() {
|
||||||
|
Some(Part::Reasoning { text, signature: None }) => text.push_str(&t),
|
||||||
|
_ => parts.push(Part::Reasoning { text: t.clone(), signature: None }),
|
||||||
|
}
|
||||||
|
app.emit(Event::ReasoningDelta { session_id: session.id.clone(), message_id: msg_id.clone(), text: t });
|
||||||
|
}
|
||||||
|
StreamEvent::ReasoningSignature(sig) => match parts.last_mut() {
|
||||||
|
Some(Part::Reasoning { signature, .. }) if signature.is_none() => *signature = Some(sig),
|
||||||
|
// Thinking without visible text (display omitted / redacted).
|
||||||
|
_ => parts.push(Part::Reasoning { text: String::new(), signature: Some(sig) }),
|
||||||
|
},
|
||||||
|
StreamEvent::ToolCall { id, name, input, extra } => {
|
||||||
|
app.emit(Event::ToolStarted {
|
||||||
|
session_id: session.id.clone(),
|
||||||
|
message_id: msg_id.clone(),
|
||||||
|
call_id: id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
input: input.clone(),
|
||||||
|
});
|
||||||
|
parts.push(Part::ToolCall { id, name, input, extra });
|
||||||
|
}
|
||||||
|
StreamEvent::Usage(u) => usage = u,
|
||||||
|
StreamEvent::Done { stop_reason: s } => stop_reason = s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = handle.await.map_err(|e| anyhow!("provider task failed: {e}"))?;
|
||||||
|
let duration = started.elapsed().as_millis() as u64;
|
||||||
|
let (provider_name, model_id) = model_spec.split_once('/').unwrap_or(("", model_spec));
|
||||||
|
let record = |error: bool, usage: &Usage| UsageRecord {
|
||||||
|
ts: now_ms(),
|
||||||
|
session_id: session.id.clone(),
|
||||||
|
root_session_id: app.root_session_id(&session.id),
|
||||||
|
provider: provider_name.into(),
|
||||||
|
model: model_id.into(),
|
||||||
|
agent: agent.into(),
|
||||||
|
input_tokens: usage.input_tokens,
|
||||||
|
output_tokens: usage.output_tokens,
|
||||||
|
cache_read_tokens: usage.cache_read_tokens,
|
||||||
|
cache_write_tokens: usage.cache_write_tokens,
|
||||||
|
ttft_ms: ttft.unwrap_or(duration as u128) as u64,
|
||||||
|
duration_ms: duration,
|
||||||
|
cost: req.model.cost(usage),
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = app.store.add_usage(&record(true, &usage));
|
||||||
|
if parts.is_empty() && attempt < 4 && is_retryable(&e) && !cancel.is_cancelled() {
|
||||||
|
let wait = std::time::Duration::from_secs(2u64.pow(attempt));
|
||||||
|
app.emit(Event::Error {
|
||||||
|
session_id: session.id.clone(),
|
||||||
|
message: format!("{e:#} – retrying in {}s", wait.as_secs()),
|
||||||
|
});
|
||||||
|
tokio::time::sleep(wait).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
// Keep what was streamed, but drop unfinished tool calls and unsigned thinking.
|
||||||
|
parts.retain(|p| matches!(p, Part::Text { .. }));
|
||||||
|
}
|
||||||
|
if usage.input_tokens + usage.output_tokens > 0 || !cancel.is_cancelled() {
|
||||||
|
let _ = app.store.add_usage(&record(false, &usage));
|
||||||
|
}
|
||||||
|
let mut message = Message::new(Role::Assistant, parts);
|
||||||
|
message.id = msg_id;
|
||||||
|
message.model = Some(model_spec.to_string());
|
||||||
|
message.usage = Some(usage.clone());
|
||||||
|
if !message.parts.is_empty() {
|
||||||
|
app.store.add_message(&session.id, &message)?;
|
||||||
|
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||||
|
}
|
||||||
|
let mut s = app.session(&session.id)?;
|
||||||
|
s.last_usage = Some(usage);
|
||||||
|
app.save_session(s)?;
|
||||||
|
return Ok(Step { message, stop_reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect a complete, tool-free answer (titles, summaries).
|
||||||
|
async fn complete_text(provider: Arc<dyn Provider>, req: ChatRequest, cancel: CancellationToken) -> Result<(String, Usage)> {
|
||||||
|
let (tx, mut rx) = mpsc::channel(256);
|
||||||
|
let handle = tokio::spawn(async move { provider.stream(req, tx, cancel).await });
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut usage = Usage::default();
|
||||||
|
while let Some(ev) = rx.recv().await {
|
||||||
|
match ev {
|
||||||
|
StreamEvent::TextDelta(t) => text.push_str(&t),
|
||||||
|
StreamEvent::Usage(u) => usage = u,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle.await??;
|
||||||
|
Ok((text, usage))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn small_model(app: &App, session: &Session) -> String {
|
||||||
|
let cfg = app.config();
|
||||||
|
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
|
||||||
|
cfg.server.small_model.clone()
|
||||||
|
} else {
|
||||||
|
session.model.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::config::ModelConfig, usage: &Usage, agent: &str) {
|
||||||
|
let (p, m) = spec.split_once('/').unwrap_or(("", spec));
|
||||||
|
let _ = app.store.add_usage(&UsageRecord {
|
||||||
|
ts: now_ms(),
|
||||||
|
session_id: session.id.clone(),
|
||||||
|
root_session_id: app.root_session_id(&session.id),
|
||||||
|
provider: p.into(),
|
||||||
|
model: m.into(),
|
||||||
|
agent: agent.into(),
|
||||||
|
input_tokens: usage.input_tokens,
|
||||||
|
output_tokens: usage.output_tokens,
|
||||||
|
cache_read_tokens: usage.cache_read_tokens,
|
||||||
|
cache_write_tokens: usage.cache_write_tokens,
|
||||||
|
ttft_ms: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
cost: model.cost(usage),
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||||
|
let Ok(session) = app.session(session_id) else { return };
|
||||||
|
if session.parent_id.is_some() || !session.title.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text: String = text.chars().take(2000).collect();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let spec = small_model(&app, &session);
|
||||||
|
let Ok((provider, mut model)) = app.registry().resolve(&spec) else { return };
|
||||||
|
model.effort = Some("low".into());
|
||||||
|
let req = ChatRequest {
|
||||||
|
system: "Write a short title (max 6 words) for a coding session that starts with the user's message below. Use the user's language. Reply with the title only, no quotes or punctuation at the end.".into(),
|
||||||
|
messages: vec![Message::new(Role::User, vec![Part::Text { text }])],
|
||||||
|
tools: vec![],
|
||||||
|
max_tokens: 2000,
|
||||||
|
reasoning: false,
|
||||||
|
model: model.clone(),
|
||||||
|
};
|
||||||
|
match complete_text(provider, req, CancellationToken::new()).await {
|
||||||
|
Ok((title, usage)) => {
|
||||||
|
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();
|
||||||
|
if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) {
|
||||||
|
if s.title.is_empty() {
|
||||||
|
s.title = title.chars().take(80).collect();
|
||||||
|
let _ = app.save_session(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::debug!("title generation failed: {e:#}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points.";
|
||||||
|
|
||||||
|
/// 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<()> {
|
||||||
|
let session = app.session(session_id)?;
|
||||||
|
let history = active_history(app.store.messages(session_id)?);
|
||||||
|
if history.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let spec = session.model.clone();
|
||||||
|
let (provider, model) = app.registry().resolve(&spec)?;
|
||||||
|
let mut messages = history;
|
||||||
|
// Strip images to keep the summary request small.
|
||||||
|
for m in &mut messages {
|
||||||
|
m.parts.retain(|p| !matches!(p, Part::Image { .. }));
|
||||||
|
for p in &mut m.parts {
|
||||||
|
if let Part::ToolResult { images, .. } = p {
|
||||||
|
images.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.push(Message::new(Role::User, vec![Part::Text { text: COMPACT_PROMPT.into() }]));
|
||||||
|
let req = ChatRequest {
|
||||||
|
system: "You are a precise summarizer of software engineering sessions.".into(),
|
||||||
|
messages,
|
||||||
|
tools: vec![],
|
||||||
|
max_tokens: 16_000,
|
||||||
|
reasoning: false,
|
||||||
|
model: model.clone(),
|
||||||
|
};
|
||||||
|
let (summary, usage) = complete_text(provider, req, cancel).await?;
|
||||||
|
record_side_usage(&app, &session, &spec, &model, &usage, "compaction");
|
||||||
|
if summary.trim().is_empty() {
|
||||||
|
bail!("compaction produced an empty summary");
|
||||||
|
}
|
||||||
|
save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?;
|
||||||
|
let mut s = app.session(session_id)?;
|
||||||
|
s.last_usage = None;
|
||||||
|
// Files must be re-read after compaction before editing.
|
||||||
|
app.with_runtime(session_id, |r| r.read_files.clear());
|
||||||
|
app.save_session(s)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
//! SQLite persistence for sessions and messages.
|
||||||
|
|
||||||
|
use crate::types::{Message, Session, now_ms};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use anyhow::Result;
|
||||||
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
pub struct Store {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"PRAGMA journal_mode=WAL;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
parent_id TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS messages_session ON messages(session_id, seq);
|
||||||
|
-- One row per model request. Kept when sessions are deleted (statistics).
|
||||||
|
CREATE TABLE IF NOT EXISTS usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
root_session_id TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
agent TEXT NOT NULL,
|
||||||
|
input_tokens INTEGER NOT NULL,
|
||||||
|
output_tokens INTEGER NOT NULL,
|
||||||
|
cache_read_tokens INTEGER NOT NULL,
|
||||||
|
cache_write_tokens INTEGER NOT NULL,
|
||||||
|
ttft_ms INTEGER NOT NULL,
|
||||||
|
duration_ms INTEGER NOT NULL,
|
||||||
|
cost REAL NOT NULL,
|
||||||
|
error INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS usage_ts ON usage(ts);
|
||||||
|
CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);",
|
||||||
|
)?;
|
||||||
|
Ok(Self { conn: Mutex::new(conn) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_session(&self, s: &Session) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (id, data, parent_id, updated_at) VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at",
|
||||||
|
params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let data: Option<String> = conn
|
||||||
|
.query_row("SELECT data FROM sessions WHERE id = ?1", [id], |r| r.get(0))
|
||||||
|
.optional()?;
|
||||||
|
Ok(data.map(|d| serde_json::from_str(&d)).transpose()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level sessions (subagent sessions are reachable through their parent).
|
||||||
|
pub fn list_sessions(&self) -> Result<Vec<Session>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT data FROM sessions WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 500",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
out.push(serde_json::from_str(&row?)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_session(&self, id: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
// Child sessions (subagents) go with their parent.
|
||||||
|
conn.execute("DELETE FROM sessions WHERE parent_id = ?1", [id])?;
|
||||||
|
conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
|
||||||
|
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET data = excluded.data",
|
||||||
|
params![m.id, session_id, serde_json::to_string(m)?],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
|
||||||
|
params![session_id, now_ms()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn messages(&self, session_id: &str) -> Result<Vec<Message>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT data FROM messages WHERE session_id = ?1 ORDER BY seq")?;
|
||||||
|
let rows = stmt.query_map([session_id], |r| r.get::<_, String>(0))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
out.push(serde_json::from_str(&row?)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all messages after (and including) the given one – used for "edit & resend".
|
||||||
|
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM messages WHERE session_id = ?1 AND seq >=
|
||||||
|
(SELECT seq FROM messages WHERE id = ?2 AND session_id = ?1)",
|
||||||
|
params![session_id, message_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One model request, as recorded for statistics.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct UsageRecord {
|
||||||
|
pub ts: i64,
|
||||||
|
pub session_id: String,
|
||||||
|
pub root_session_id: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
pub agent: String,
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
pub cache_read_tokens: u64,
|
||||||
|
pub cache_write_tokens: u64,
|
||||||
|
/// Time to first token (prefill + queueing), milliseconds.
|
||||||
|
pub ttft_ms: u64,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
pub cost: f64,
|
||||||
|
pub error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregation SQL shared by all statistics queries.
|
||||||
|
const AGG: &str = "COUNT(*) AS requests,
|
||||||
|
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||||
|
COALESCE(SUM(cache_read_tokens),0), COALESCE(SUM(cache_write_tokens),0),
|
||||||
|
COALESCE(SUM(cost),0.0), COALESCE(SUM(ttft_ms),0), COALESCE(SUM(duration_ms),0),
|
||||||
|
COALESCE(SUM(error),0)";
|
||||||
|
|
||||||
|
fn agg_row(r: &rusqlite::Row, offset: usize) -> rusqlite::Result<Value> {
|
||||||
|
let requests: i64 = r.get(offset)?;
|
||||||
|
let input: i64 = r.get(offset + 1)?;
|
||||||
|
let output: i64 = r.get(offset + 2)?;
|
||||||
|
let cache_read: i64 = r.get(offset + 3)?;
|
||||||
|
let cache_write: i64 = r.get(offset + 4)?;
|
||||||
|
let cost: f64 = r.get(offset + 5)?;
|
||||||
|
let ttft: i64 = r.get(offset + 6)?;
|
||||||
|
let duration: i64 = r.get(offset + 7)?;
|
||||||
|
let errors: i64 = r.get(offset + 8)?;
|
||||||
|
let gen_ms = (duration - ttft).max(0);
|
||||||
|
let prompt = input + cache_read + cache_write;
|
||||||
|
Ok(json!({
|
||||||
|
"requests": requests,
|
||||||
|
"input_tokens": input,
|
||||||
|
"output_tokens": output,
|
||||||
|
"cache_read_tokens": cache_read,
|
||||||
|
"cache_write_tokens": cache_write,
|
||||||
|
"cost": cost,
|
||||||
|
"errors": errors,
|
||||||
|
"avg_ttft_ms": if requests > 0 { ttft / requests } else { 0 },
|
||||||
|
// Output speed: tokens generated per second after the first token.
|
||||||
|
"output_tps": if gen_ms > 0 { output as f64 * 1000.0 / gen_ms as f64 } else { 0.0 },
|
||||||
|
// Prefill speed: prompt tokens processed per second until the first token.
|
||||||
|
"prefill_tps": if ttft > 0 { prompt as f64 * 1000.0 / ttft as f64 } else { 0.0 },
|
||||||
|
"cache_hit_rate": if prompt > 0 { cache_read as f64 / prompt as f64 } else { 0.0 },
|
||||||
|
"duration_ms": duration,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn add_usage(&self, u: &UsageRecord) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
|
||||||
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||||
|
ttft_ms, duration_ms, cost, error)
|
||||||
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
|
||||||
|
params![
|
||||||
|
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
||||||
|
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
|
||||||
|
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics for one chat (including its subagents), totals and per model.
|
||||||
|
pub fn session_stats(&self, root_session_id: &str) -> Result<Value> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let total = conn.query_row(
|
||||||
|
&format!("SELECT {AGG} FROM usage WHERE root_session_id = ?1"),
|
||||||
|
[root_session_id],
|
||||||
|
|r| agg_row(r, 0),
|
||||||
|
)?;
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT provider || '/' || model, {AGG} FROM usage WHERE root_session_id = ?1
|
||||||
|
GROUP BY 1 ORDER BY 1"
|
||||||
|
))?;
|
||||||
|
let by_model = stmt
|
||||||
|
.query_map([root_session_id], |r| {
|
||||||
|
let mut v = agg_row(r, 1)?;
|
||||||
|
v["model"] = json!(r.get::<_, String>(0)?);
|
||||||
|
Ok(v)
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(json!({"total": total, "by_model": by_model}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||||
|
/// into the viewer's local time.
|
||||||
|
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64) -> Result<Value> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let shift = tz_offset_min * 60_000;
|
||||||
|
let total = conn.query_row(
|
||||||
|
&format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2"),
|
||||||
|
params![from, to],
|
||||||
|
|r| agg_row(r, 0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let grouped = |key_sql: &str, key_name: &str| -> Result<Vec<Value>> {
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3 GROUP BY 1 ORDER BY 1"
|
||||||
|
))?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![from, to, shift], |r| {
|
||||||
|
let mut v = agg_row(r, 1)?;
|
||||||
|
v[key_name] = match r.get_ref(0)? {
|
||||||
|
rusqlite::types::ValueRef::Integer(i) => json!(i),
|
||||||
|
rusqlite::types::ValueRef::Text(t) => json!(String::from_utf8_lossy(t)),
|
||||||
|
_ => Value::Null,
|
||||||
|
};
|
||||||
|
Ok(v)
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
};
|
||||||
|
|
||||||
|
let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
|
||||||
|
let by_day = grouped(day_sql, "day")?;
|
||||||
|
let by_model = grouped("provider || '/' || model", "model")?;
|
||||||
|
let by_provider = grouped("provider", "provider")?;
|
||||||
|
let by_agent = grouped("agent", "agent")?;
|
||||||
|
// Heatmap: weekday (0 = Sunday) x hour of day.
|
||||||
|
let heatmap = grouped(
|
||||||
|
"CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \
|
||||||
|
CAST(strftime('%H', (ts + ?3) / 1000, 'unixepoch') AS INTEGER)",
|
||||||
|
"slot",
|
||||||
|
)?;
|
||||||
|
let by_day_model = {
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT {day_sql}, provider || '/' || model, SUM(cost),
|
||||||
|
SUM(input_tokens + cache_read_tokens + cache_write_tokens), SUM(output_tokens)
|
||||||
|
FROM usage WHERE ts >= ?1 AND ts < ?2 GROUP BY 1, 2 ORDER BY 1"
|
||||||
|
))?;
|
||||||
|
stmt.query_map(params![from, to, shift], |r| {
|
||||||
|
Ok(json!({
|
||||||
|
"day": r.get::<_, String>(0)?,
|
||||||
|
"model": r.get::<_, String>(1)?,
|
||||||
|
"cost": r.get::<_, f64>(2)?,
|
||||||
|
"input_tokens": r.get::<_, i64>(3)?,
|
||||||
|
"output_tokens": r.get::<_, i64>(4)?,
|
||||||
|
}))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||||
|
};
|
||||||
|
// Most expensive chats in the range.
|
||||||
|
let top_sessions = {
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG}
|
||||||
|
FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id
|
||||||
|
WHERE u.ts >= ?1 AND u.ts < ?2
|
||||||
|
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
|
||||||
|
))?;
|
||||||
|
stmt.query_map(params![from, to], |r| {
|
||||||
|
let mut v = agg_row(r, 2)?;
|
||||||
|
v["session_id"] = json!(r.get::<_, String>(0)?);
|
||||||
|
v["title"] = json!(r.get::<_, String>(1)?);
|
||||||
|
Ok(v)
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||||
|
};
|
||||||
|
Ok(json!({
|
||||||
|
"total": total,
|
||||||
|
"by_day": by_day,
|
||||||
|
"by_day_model": by_day_model,
|
||||||
|
"by_model": by_model,
|
||||||
|
"by_provider": by_provider,
|
||||||
|
"by_agent": by_agent,
|
||||||
|
"heatmap": heatmap,
|
||||||
|
"top_sessions": top_sessions,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! Tools that interact with the harness itself: todo list, questions to the
|
||||||
|
//! user and subagents.
|
||||||
|
|
||||||
|
use super::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||||
|
use crate::types::Question;
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
pub struct TodoWrite;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TodoWrite {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"todo_write"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Maintain a task list for the current work, shown live to the user. Send the complete \
|
||||||
|
list each time. Use it for multi-step tasks: exactly one item in_progress at a time, \
|
||||||
|
mark items completed as soon as they are done."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"todos": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {"type": "string"},
|
||||||
|
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
|
||||||
|
},
|
||||||
|
"required": ["content", "status"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["todos"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let todos = input.get("todos").cloned().unwrap_or(json!([]));
|
||||||
|
if !todos.is_array() {
|
||||||
|
bail!("todos must be an array");
|
||||||
|
}
|
||||||
|
ctx.app.set_todos(&ctx.session_id, todos.clone())?;
|
||||||
|
let open = todos.as_array().unwrap().iter().filter(|t| t["status"] != "completed").count();
|
||||||
|
Ok(ToolOutput::text(format!("Todo list updated ({open} open).")).with_meta(json!({"todos": todos})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct AskUser;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for AskUser {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"ask_user"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Ask the user one or more questions and wait for the answers. Use this only when you are \
|
||||||
|
blocked on a decision that is genuinely the user's (requirements, preferences, choices \
|
||||||
|
between valid approaches) – not for things you can find out yourself. Offer 2-4 concrete \
|
||||||
|
options per question; the user can always answer with free text instead."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"questions": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 4,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"question": {"type": "string"},
|
||||||
|
"header": {"type": "string", "description": "Very short label (max 12 chars)"},
|
||||||
|
"multi_select": {"type": "boolean"},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {"type": "string"},
|
||||||
|
"description": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["label"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["question"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["questions"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let questions: Vec<Question> = serde_json::from_value(input["questions"].clone())?;
|
||||||
|
if questions.is_empty() {
|
||||||
|
bail!("at least one question is required");
|
||||||
|
}
|
||||||
|
let answers = ctx
|
||||||
|
.app
|
||||||
|
.ask_user(&ctx.session_id, "agent", questions.clone(), ctx.cancel.clone())
|
||||||
|
.await?;
|
||||||
|
let Some(answers) = answers else {
|
||||||
|
return Ok(ToolOutput::error("The user dismissed the questions without answering."));
|
||||||
|
};
|
||||||
|
let mut out = String::from("The user answered:\n");
|
||||||
|
for q in &questions {
|
||||||
|
let a = answers.get(&q.question).map(|v| match v {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Array(a) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "),
|
||||||
|
other => other.to_string(),
|
||||||
|
});
|
||||||
|
out.push_str(&format!("- {}\n → {}\n", q.question, a.unwrap_or_else(|| "(no answer)".into())));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput::text(out).with_meta(json!({"answers": answers})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Task;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Task {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"task"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Launch a subagent to handle an independent, multi-step task in its own context (e.g. a \
|
||||||
|
broad codebase search, a focused review). The subagent only knows what you put in the \
|
||||||
|
prompt – write a complete, self-contained brief. Its final message is returned to you. \
|
||||||
|
Several task calls in one response run in parallel."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"description": {"type": "string", "description": "Short (3-5 words) task description"},
|
||||||
|
"prompt": {"type": "string", "description": "The full brief for the subagent"},
|
||||||
|
"subagent_type": {"type": "string", "description": "Subagent to use (default: general)"}
|
||||||
|
},
|
||||||
|
"required": ["description", "prompt"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
// Read-only agents (plan, explore) can spawn read-only subagents; the runner enforces it.
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
format!("Subagent: {}", input["description"].as_str().unwrap_or("task"))
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let prompt = arg_str(&input, "prompt")?.to_string();
|
||||||
|
let description = input["description"].as_str().unwrap_or("subagent").to_string();
|
||||||
|
let agent = input["subagent_type"].as_str().unwrap_or("general").to_string();
|
||||||
|
let (child_id, result) =
|
||||||
|
crate::runner::run_subagent(ctx.app.clone(), ctx, &agent, &description, prompt).await?;
|
||||||
|
Ok(ToolOutput::text(result).with_meta(json!({"session_id": child_id, "agent": agent})))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! Shell command execution with timeout, cancellation and live output.
|
||||||
|
|
||||||
|
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
|
||||||
|
const MAX_TIMEOUT_MS: u64 = 600_000;
|
||||||
|
const MAX_OUTPUT: usize = 30_000;
|
||||||
|
|
||||||
|
pub struct Bash;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Bash {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"bash"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Run a shell command (bash) in the session working directory. stdout and stderr are \
|
||||||
|
combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \
|
||||||
|
does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \
|
||||||
|
dedicated read/edit/glob/grep tools over cat/sed/find/grep."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {"type": "string"},
|
||||||
|
"description": {"type": "string", "description": "5-10 word description of what the command does"},
|
||||||
|
"timeout_ms": {"type": "integer"}
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
input["description"]
|
||||||
|
.as_str()
|
||||||
|
.map(String::from)
|
||||||
|
.unwrap_or_else(|| format!("Run: {}", input["command"].as_str().unwrap_or("?")))
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let command = arg_str(&input, "command")?;
|
||||||
|
let timeout = Duration::from_millis(
|
||||||
|
input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS),
|
||||||
|
);
|
||||||
|
let mut cmd = Command::new("bash");
|
||||||
|
cmd.arg("-c")
|
||||||
|
.arg(format!("{command} 2>&1"))
|
||||||
|
.current_dir(&ctx.cwd)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.env("CODECRAFT", "1")
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.env("PAGER", "cat")
|
||||||
|
.kill_on_drop(true);
|
||||||
|
// Own process group so the whole tree can be killed on abort/timeout.
|
||||||
|
cmd.process_group(0);
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
let pid = child.id();
|
||||||
|
let stdout = child.stdout.take().unwrap();
|
||||||
|
// split() instead of lines(): non-UTF-8 output must not end the read loop.
|
||||||
|
let mut lines = BufReader::new(stdout).split(b'\n');
|
||||||
|
let mut output = String::new();
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut last_progress = Instant::now();
|
||||||
|
let deadline = tokio::time::sleep(timeout);
|
||||||
|
tokio::pin!(deadline);
|
||||||
|
|
||||||
|
let kill_group = |pid: Option<u32>| {
|
||||||
|
if let Some(pid) = pid {
|
||||||
|
// SAFETY: plain syscall; negative pid targets the process group.
|
||||||
|
unsafe {
|
||||||
|
libc::kill(-(pid as i32), libc::SIGKILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = loop {
|
||||||
|
tokio::select! {
|
||||||
|
line = lines.next_segment() => match line {
|
||||||
|
Ok(Some(l)) => {
|
||||||
|
if output.len() < MAX_OUTPUT * 4 {
|
||||||
|
output.push_str(&String::from_utf8_lossy(&l));
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
if last_progress.elapsed() > Duration::from_millis(300) {
|
||||||
|
ctx.progress(tail(&output, 4000));
|
||||||
|
last_progress = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) | Err(_) => break child.wait().await?,
|
||||||
|
},
|
||||||
|
_ = &mut deadline => {
|
||||||
|
kill_group(pid);
|
||||||
|
let _ = child.wait().await;
|
||||||
|
output.push_str(&format!("\n[command timed out after {}s and was killed]", timeout.as_secs()));
|
||||||
|
return Ok(ToolOutput::error(truncate(&output, MAX_OUTPUT)));
|
||||||
|
}
|
||||||
|
_ = ctx.cancel.cancelled() => {
|
||||||
|
kill_group(pid);
|
||||||
|
let _ = child.wait().await;
|
||||||
|
return Ok(ToolOutput::error("[command aborted by user]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let code = status.code().unwrap_or(-1);
|
||||||
|
let mut text = truncate(output.trim_end(), MAX_OUTPUT);
|
||||||
|
if text.is_empty() {
|
||||||
|
text = "(no output)".into();
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
text.push_str(&format!("\n[exit code {code}]"));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput {
|
||||||
|
content: text,
|
||||||
|
is_error: code != 0,
|
||||||
|
meta: Some(json!({"exit_code": code, "duration_ms": started.elapsed().as_millis() as u64})),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tail(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let mut start = s.len() - max;
|
||||||
|
while !s.is_char_boundary(start) {
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
s[start..].to_string()
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! Craft mode tools: study a target in a headless browser and compare a build
|
||||||
|
//! with the original visually.
|
||||||
|
|
||||||
|
use super::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||||
|
use crate::types::ImageData;
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use base64::Engine;
|
||||||
|
use image::{GenericImageView, ImageFormat, Rgba, RgbaImage};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
const SNAPSHOT_HEIGHT: u32 = 2000;
|
||||||
|
/// Long-side limit for images sent to the model.
|
||||||
|
const MAX_IMAGE_SIDE: u32 = 1568;
|
||||||
|
|
||||||
|
fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let img = if w.max(h) > MAX_IMAGE_SIDE {
|
||||||
|
img.resize(MAX_IMAGE_SIDE, MAX_IMAGE_SIDE, image::imageops::FilterType::Triangle)
|
||||||
|
} else {
|
||||||
|
img.clone()
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
|
||||||
|
Ok(ImageData {
|
||||||
|
media_type: "image/png".into(),
|
||||||
|
data: base64::engine::general_purpose::STANDARD.encode(&buf),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slug(s: &str) -> String {
|
||||||
|
let mut out: String = s
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
|
||||||
|
.collect();
|
||||||
|
while out.contains("--") {
|
||||||
|
out = out.replace("--", "-");
|
||||||
|
}
|
||||||
|
out.trim_matches('-').chars().take(60).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chromium(ctx: &ToolCtx, args: &[String]) -> Result<std::process::Output> {
|
||||||
|
let bin = ctx.app.config().craft.chromium.clone();
|
||||||
|
let fut = Command::new(&bin)
|
||||||
|
.args([
|
||||||
|
"--headless=new",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--hide-scrollbars",
|
||||||
|
"--mute-audio",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--virtual-time-budget=8000",
|
||||||
|
])
|
||||||
|
.args(args)
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output();
|
||||||
|
tokio::select! {
|
||||||
|
r = tokio::time::timeout(Duration::from_secs(60), fut) => {
|
||||||
|
r.context("chromium timed out")?.with_context(|| format!("could not start '{bin}' (set craft.chromium in the config)"))
|
||||||
|
}
|
||||||
|
_ = ctx.cancel.cancelled() => bail!("aborted"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BrowserSnapshot;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for BrowserSnapshot {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"browser_snapshot"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Open a URL in a headless browser and capture what a visitor sees: screenshots at the \
|
||||||
|
configured screen widths (or the given ones) plus the visible text. Works for public \
|
||||||
|
sites and for local builds (http://localhost:PORT or file:///path). Screenshots are saved \
|
||||||
|
under .craft/snapshots/<label>-<width>.png in the working directory for image_compare."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {"type": "string"},
|
||||||
|
"label": {"type": "string", "description": "Name for the saved files, e.g. 'original-home' or 'build-home'"},
|
||||||
|
"widths": {"type": "array", "items": {"type": "integer"}},
|
||||||
|
"text": {"type": "boolean", "description": "Include visible text (default true)"}
|
||||||
|
},
|
||||||
|
"required": ["url"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
format!("Snapshot {}", input["url"].as_str().unwrap_or("?"))
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let url = arg_str(&input, "url")?.to_string();
|
||||||
|
let label = slug(input["label"].as_str().unwrap_or(&url));
|
||||||
|
let widths: Vec<u32> = input["widths"]
|
||||||
|
.as_array()
|
||||||
|
.map(|a| a.iter().filter_map(|w| w.as_u64().map(|w| w as u32)).collect())
|
||||||
|
.filter(|v: &Vec<u32>| !v.is_empty())
|
||||||
|
.unwrap_or_else(|| ctx.app.config().craft.widths.clone());
|
||||||
|
let dir = ctx.cwd.join(".craft/snapshots");
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
|
||||||
|
let mut images = Vec::new();
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for w in widths.iter().take(4) {
|
||||||
|
let path = dir.join(format!("{label}-{w}.png"));
|
||||||
|
let out = chromium(
|
||||||
|
ctx,
|
||||||
|
&[
|
||||||
|
format!("--window-size={w},{SNAPSHOT_HEIGHT}"),
|
||||||
|
format!("--screenshot={}", path.display()),
|
||||||
|
url.clone(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !path.exists() {
|
||||||
|
bail!("screenshot failed: {}", String::from_utf8_lossy(&out.stderr).chars().take(800).collect::<String>());
|
||||||
|
}
|
||||||
|
let img = image::open(&path)?;
|
||||||
|
images.push(png_for_model(&img)?);
|
||||||
|
files.push(path.strip_prefix(&ctx.cwd).unwrap_or(&path).display().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut text = format!("Snapshots of {url}:\n");
|
||||||
|
for (f, w) in files.iter().zip(&widths) {
|
||||||
|
text.push_str(&format!("- {w}px → {f}\n"));
|
||||||
|
}
|
||||||
|
if input["text"].as_bool().unwrap_or(true) {
|
||||||
|
let out = chromium(ctx, &["--dump-dom".into(), url.clone()]).await?;
|
||||||
|
let dom = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let visible = html2text::from_read(dom.as_bytes(), 120).unwrap_or_default();
|
||||||
|
text.push_str("\nVisible text:\n");
|
||||||
|
text.push_str(&super::truncate(&visible, 20_000));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput { content: text, images, meta: Some(json!({"files": files})), ..Default::default() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct ImageCompare;
|
||||||
|
|
||||||
|
/// Similarity in [0, 1] and a diff image (differences in red over a faded original).
|
||||||
|
pub fn compare(a: &image::DynamicImage, b: &image::DynamicImage) -> (f64, RgbaImage) {
|
||||||
|
let (w, h) = a.dimensions();
|
||||||
|
let b = if b.dimensions() != (w, h) {
|
||||||
|
b.resize_exact(w, h, image::imageops::FilterType::Triangle)
|
||||||
|
} else {
|
||||||
|
b.clone()
|
||||||
|
};
|
||||||
|
let a = a.to_rgba8();
|
||||||
|
let b = b.to_rgba8();
|
||||||
|
let mut diff = RgbaImage::new(w, h);
|
||||||
|
let mut total = 0f64;
|
||||||
|
for (x, y, pa) in a.enumerate_pixels() {
|
||||||
|
let pb = b.get_pixel(x, y);
|
||||||
|
let d = (0..3).map(|i| (pa[i] as f64 - pb[i] as f64).abs()).sum::<f64>() / (3.0 * 255.0);
|
||||||
|
total += d;
|
||||||
|
let gray = ((pa[0] as u32 + pa[1] as u32 + pa[2] as u32) / 3) as u8;
|
||||||
|
let faded = 200 + gray / 5;
|
||||||
|
diff.put_pixel(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
if d > 0.1 { Rgba([255, (255.0 * (1.0 - d)) as u8 / 2, 0, 255]) } else { Rgba([faded, faded, faded, 255]) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let px = (w as f64 * h as f64).max(1.0);
|
||||||
|
(1.0 - total / px, diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ImageCompare {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"image_compare"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Compare two images (e.g. the original's and the build's snapshot at the same width). \
|
||||||
|
Returns a similarity score (0-100 %) and a diff image where differences are red."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"original": {"type": "string", "description": "Path of the reference image"},
|
||||||
|
"candidate": {"type": "string", "description": "Path of the image to check"}
|
||||||
|
},
|
||||||
|
"required": ["original", "candidate"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let pa: PathBuf = ctx.resolve(arg_str(&input, "original")?)?;
|
||||||
|
let pb: PathBuf = ctx.resolve(arg_str(&input, "candidate")?)?;
|
||||||
|
let (score, diff) = tokio::task::spawn_blocking(move || -> Result<_> {
|
||||||
|
let a = image::open(&pa).with_context(|| format!("cannot open {}", pa.display()))?;
|
||||||
|
let b = image::open(&pb).with_context(|| format!("cannot open {}", pb.display()))?;
|
||||||
|
Ok(compare(&a, &b))
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
let diff_path = ctx.cwd.join(".craft/snapshots").join(format!(
|
||||||
|
"diff-{}.png",
|
||||||
|
Path::new(input["candidate"].as_str().unwrap_or("x")).file_stem().and_then(|s| s.to_str()).unwrap_or("x")
|
||||||
|
));
|
||||||
|
if let Some(d) = diff_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(d).await?;
|
||||||
|
}
|
||||||
|
diff.save(&diff_path)?;
|
||||||
|
let img = image::DynamicImage::ImageRgba8(diff);
|
||||||
|
let pct = (score * 1000.0).round() / 10.0;
|
||||||
|
Ok(ToolOutput {
|
||||||
|
content: format!("Similarity: {pct} %\nDiff image: {}", diff_path.display()),
|
||||||
|
images: vec![png_for_model(&img)?],
|
||||||
|
meta: Some(json!({"similarity": pct})),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_images_are_fully_similar() {
|
||||||
|
let img = image::DynamicImage::ImageRgba8(RgbaImage::from_pixel(10, 10, Rgba([10, 20, 30, 255])));
|
||||||
|
let (s, _) = compare(&img, &img);
|
||||||
|
assert!((s - 1.0).abs() < 1e-9);
|
||||||
|
let other = image::DynamicImage::ImageRgba8(RgbaImage::from_pixel(10, 10, Rgba([255, 255, 255, 255])));
|
||||||
|
let (s2, _) = compare(&img, &other);
|
||||||
|
assert!(s2 < 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
//! File system tools: read, write, edit, list, glob, grep.
|
||||||
|
|
||||||
|
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
|
||||||
|
use crate::types::ImageData;
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use base64::Engine;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const MAX_LINES: usize = 2000;
|
||||||
|
const MAX_LINE_LEN: usize = 2000;
|
||||||
|
const MAX_OUTPUT: usize = 60_000;
|
||||||
|
|
||||||
|
fn image_type(path: &Path) -> Option<&'static str> {
|
||||||
|
match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
|
||||||
|
"png" => Some("image/png"),
|
||||||
|
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||||
|
"gif" => Some("image/gif"),
|
||||||
|
"webp" => Some("image/webp"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unified_diff(path: &str, old: &str, new: &str) -> String {
|
||||||
|
similar::TextDiff::from_lines(old, new)
|
||||||
|
.unified_diff()
|
||||||
|
.context_radius(3)
|
||||||
|
.header(&format!("a/{path}"), &format!("b/{path}"))
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rel(ctx: &ToolCtx, p: &Path) -> String {
|
||||||
|
p.strip_prefix(&ctx.cwd).unwrap_or(p).display().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Read;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Read {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"read"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Read a file. Returns numbered lines (cat -n style). Use offset/limit for large files \
|
||||||
|
(default: first 2000 lines). Images (png, jpg, gif, webp) are returned as images. \
|
||||||
|
Always read a file before editing or overwriting it."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "File path, absolute or relative to the working directory"},
|
||||||
|
"offset": {"type": "integer", "description": "1-based line to start from"},
|
||||||
|
"limit": {"type": "integer", "description": "Number of lines to read"}
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
format!("Read {}", input["path"].as_str().unwrap_or("?"))
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||||
|
if path.is_dir() {
|
||||||
|
bail!("{} is a directory – use the list tool", path.display());
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(&path).await?;
|
||||||
|
ctx.app.mark_read(&ctx.session_id, &path);
|
||||||
|
if let Some(mt) = image_type(&path) {
|
||||||
|
return Ok(ToolOutput {
|
||||||
|
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
|
||||||
|
images: vec![ImageData {
|
||||||
|
media_type: mt.into(),
|
||||||
|
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if bytes.iter().take(8000).any(|b| *b == 0) {
|
||||||
|
return Ok(ToolOutput::text(format!("{} is a binary file ({} bytes)", path.display(), bytes.len())));
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
if text.is_empty() {
|
||||||
|
return Ok(ToolOutput::text("(file is empty)"));
|
||||||
|
}
|
||||||
|
let offset = input["offset"].as_u64().unwrap_or(1).max(1) as usize;
|
||||||
|
let limit = input["limit"].as_u64().map(|l| l as usize).unwrap_or(MAX_LINES);
|
||||||
|
let total = text.lines().count();
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, line) in text.lines().enumerate().skip(offset - 1).take(limit) {
|
||||||
|
let line = if line.len() > MAX_LINE_LEN {
|
||||||
|
let mut cut = MAX_LINE_LEN;
|
||||||
|
while !line.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
format!("{}…", &line[..cut])
|
||||||
|
} else {
|
||||||
|
line.to_string()
|
||||||
|
};
|
||||||
|
out.push_str(&format!("{:>6}\t{}\n", i + 1, line));
|
||||||
|
}
|
||||||
|
let shown_end = (offset - 1 + limit).min(total);
|
||||||
|
if shown_end < total {
|
||||||
|
out.push_str(&format!("\n[showing lines {offset}-{shown_end} of {total}; use offset to read more]\n"));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput::text(truncate(&out, MAX_OUTPUT)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Write;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Write {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"write"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Create or overwrite a file with the given content. Existing files must be read first. \
|
||||||
|
Prefer the edit tool for changes to existing files."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"content": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["path", "content"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn is_edit(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
format!("Write {}", input["path"].as_str().unwrap_or("?"))
|
||||||
|
}
|
||||||
|
async fn preview(&self, input: &Value, ctx: &ToolCtx) -> Option<String> {
|
||||||
|
let path = ctx.resolve(input["path"].as_str()?).ok()?;
|
||||||
|
let old = tokio::fs::read_to_string(&path).await.unwrap_or_default();
|
||||||
|
Some(unified_diff(&rel(ctx, &path), &old, input["content"].as_str()?))
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||||
|
let content = arg_str(&input, "content")?;
|
||||||
|
let old = if path.exists() {
|
||||||
|
if !ctx.app.was_read(&ctx.session_id, &path) {
|
||||||
|
return Ok(ToolOutput::error("File exists but has not been read in this session. Read it first."));
|
||||||
|
}
|
||||||
|
tokio::fs::read_to_string(&path).await.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
tokio::fs::create_dir_all(dir).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(&path, content).await?;
|
||||||
|
ctx.app.mark_read(&ctx.session_id, &path);
|
||||||
|
let diff = unified_diff(&rel(ctx, &path), &old, content);
|
||||||
|
Ok(ToolOutput::text(format!("Wrote {} ({} lines)", path.display(), content.lines().count()))
|
||||||
|
.with_meta(json!({"diff": diff, "path": path})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Edit;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Edit {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"edit"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Replace text in a file. old_string must match the file exactly (including indentation) \
|
||||||
|
and be unique unless replace_all is true. The file must have been read first."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"old_string": {"type": "string", "description": "Exact text to replace"},
|
||||||
|
"new_string": {"type": "string", "description": "Replacement text"},
|
||||||
|
"replace_all": {"type": "boolean", "description": "Replace every occurrence"}
|
||||||
|
},
|
||||||
|
"required": ["path", "old_string", "new_string"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn is_edit(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
format!("Edit {}", input["path"].as_str().unwrap_or("?"))
|
||||||
|
}
|
||||||
|
async fn preview(&self, input: &Value, ctx: &ToolCtx) -> Option<String> {
|
||||||
|
let path = ctx.resolve(input["path"].as_str()?).ok()?;
|
||||||
|
let old = tokio::fs::read_to_string(&path).await.ok()?;
|
||||||
|
let new = apply_edit(&old, input).ok()?;
|
||||||
|
Some(unified_diff(&rel(ctx, &path), &old, &new))
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||||
|
if !ctx.app.was_read(&ctx.session_id, &path) {
|
||||||
|
return Ok(ToolOutput::error("File has not been read in this session. Read it first."));
|
||||||
|
}
|
||||||
|
let old = tokio::fs::read_to_string(&path).await?;
|
||||||
|
let new = match apply_edit(&old, &input) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => return Ok(ToolOutput::error(e.to_string())),
|
||||||
|
};
|
||||||
|
tokio::fs::write(&path, &new).await?;
|
||||||
|
let diff = unified_diff(&rel(ctx, &path), &old, &new);
|
||||||
|
Ok(ToolOutput::text(format!("Edited {}", path.display()))
|
||||||
|
.with_meta(json!({"diff": diff, "path": path})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_edit(old: &str, input: &Value) -> Result<String> {
|
||||||
|
let from = arg_str(input, "old_string")?;
|
||||||
|
let to = arg_str(input, "new_string")?;
|
||||||
|
if from.is_empty() {
|
||||||
|
bail!("old_string must not be empty (use write to create files)");
|
||||||
|
}
|
||||||
|
if from == to {
|
||||||
|
bail!("old_string and new_string are identical");
|
||||||
|
}
|
||||||
|
let count = old.matches(from).count();
|
||||||
|
let all = input["replace_all"].as_bool().unwrap_or(false);
|
||||||
|
match count {
|
||||||
|
0 => bail!("old_string not found in file. Re-read the file and match it exactly."),
|
||||||
|
1 => Ok(old.replacen(from, to, 1)),
|
||||||
|
n if all => {
|
||||||
|
let _ = n;
|
||||||
|
Ok(old.replace(from, to))
|
||||||
|
}
|
||||||
|
n => bail!("old_string occurs {n} times. Add surrounding context to make it unique or set replace_all."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct List;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for List {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"list"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"List a directory (respects .gitignore). Directories end with '/'.".into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Directory, default: working directory"},
|
||||||
|
"depth": {"type": "integer", "description": "Recursion depth (default 1, max 5)"}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let dir = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
|
||||||
|
let depth = input["depth"].as_u64().unwrap_or(1).clamp(1, 5) as usize;
|
||||||
|
let cwd = ctx.cwd.clone();
|
||||||
|
let out = tokio::task::spawn_blocking(move || {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for e in ignore::WalkBuilder::new(&dir).max_depth(Some(depth)).hidden(false).build().flatten() {
|
||||||
|
if e.depth() == 0 || e.path().components().any(|c| c.as_os_str() == ".git") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p = e.path().strip_prefix(&dir).unwrap_or(e.path());
|
||||||
|
let is_dir = e.file_type().is_some_and(|t| t.is_dir());
|
||||||
|
lines.push(format!("{}{}", p.display(), if is_dir { "/" } else { "" }));
|
||||||
|
if lines.len() >= 1000 {
|
||||||
|
lines.push("[... truncated at 1000 entries ...]".into());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.sort();
|
||||||
|
let _ = cwd;
|
||||||
|
lines.join("\n")
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(ToolOutput::text(if out.is_empty() { "(empty directory)".into() } else { out }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Glob;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Glob {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"glob"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Find files by glob pattern (e.g. \"**/*.rs\", \"src/**/test_*.py\"). Respects .gitignore. \
|
||||||
|
Results are sorted by modification time, newest first."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string"},
|
||||||
|
"path": {"type": "string", "description": "Directory to search, default: working directory"}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let pattern = arg_str(&input, "pattern")?.to_string();
|
||||||
|
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
|
||||||
|
let out = tokio::task::spawn_blocking(move || -> Result<String> {
|
||||||
|
let matcher = globset::GlobBuilder::new(&pattern).literal_separator(true).build()?.compile_matcher();
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
for e in ignore::WalkBuilder::new(&root).hidden(false).build().flatten() {
|
||||||
|
if !e.file_type().is_some_and(|t| t.is_file()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let relp = e.path().strip_prefix(&root).unwrap_or(e.path());
|
||||||
|
if relp.components().any(|c| c.as_os_str() == ".git") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matcher.is_match(relp) {
|
||||||
|
let mtime = e.metadata().ok().and_then(|m| m.modified().ok());
|
||||||
|
hits.push((mtime, e.path().display().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hits.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
let n = hits.len();
|
||||||
|
let mut s: Vec<String> = hits.into_iter().take(500).map(|h| h.1).collect();
|
||||||
|
if n > 500 {
|
||||||
|
s.push(format!("[... {} more files ...]", n - 500));
|
||||||
|
}
|
||||||
|
Ok(if s.is_empty() { "No files found".into() } else { s.join("\n") })
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
Ok(ToolOutput::text(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Grep;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Grep {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"grep"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Search file contents with a regular expression (Rust regex syntax). Respects .gitignore. \
|
||||||
|
output_mode: \"files\" (default, matching file paths), \"content\" (matching lines with \
|
||||||
|
line numbers), \"count\"."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string"},
|
||||||
|
"path": {"type": "string", "description": "File or directory, default: working directory"},
|
||||||
|
"glob": {"type": "string", "description": "Only search files matching this glob, e.g. \"*.ts\""},
|
||||||
|
"output_mode": {"type": "string", "enum": ["files", "content", "count"]},
|
||||||
|
"case_insensitive": {"type": "boolean"},
|
||||||
|
"context": {"type": "integer", "description": "Lines of context around matches (content mode)"}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let re = regex::RegexBuilder::new(arg_str(&input, "pattern")?)
|
||||||
|
.case_insensitive(input["case_insensitive"].as_bool().unwrap_or(false))
|
||||||
|
.build()?;
|
||||||
|
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
|
||||||
|
let glob = input["glob"]
|
||||||
|
.as_str()
|
||||||
|
.map(|g| globset::Glob::new(g).map(|g| g.compile_matcher()))
|
||||||
|
.transpose()?;
|
||||||
|
let mode = input["output_mode"].as_str().unwrap_or("files").to_string();
|
||||||
|
let context = input["context"].as_u64().unwrap_or(0) as usize;
|
||||||
|
let cancel = ctx.cancel.clone();
|
||||||
|
let out = tokio::task::spawn_blocking(move || {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut files = 0usize;
|
||||||
|
for e in ignore::WalkBuilder::new(&root).hidden(false).build().flatten() {
|
||||||
|
if cancel.is_cancelled() || out.len() > MAX_OUTPUT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !e.file_type().is_some_and(|t| t.is_file()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p = e.path();
|
||||||
|
if p.components().any(|c| c.as_os_str() == ".git") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(g) = &glob {
|
||||||
|
let name = p.file_name().map(Path::new).unwrap_or(p);
|
||||||
|
if !g.is_match(name) && !g.is_match(p.strip_prefix(&root).unwrap_or(p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Ok(bytes) = std::fs::read(p) else { continue };
|
||||||
|
if bytes.iter().take(8000).any(|b| *b == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
let hits: Vec<usize> = lines.iter().enumerate().filter(|(_, l)| re.is_match(l)).map(|(i, _)| i).collect();
|
||||||
|
if hits.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files += 1;
|
||||||
|
match mode.as_str() {
|
||||||
|
"count" => out.push_str(&format!("{}:{}\n", p.display(), hits.len())),
|
||||||
|
"content" => {
|
||||||
|
let mut last_printed: Option<usize> = None;
|
||||||
|
for &h in &hits {
|
||||||
|
let start = h.saturating_sub(context);
|
||||||
|
let end = (h + context).min(lines.len().saturating_sub(1));
|
||||||
|
for i in start..=end {
|
||||||
|
if last_printed.is_some_and(|l| i <= l) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sep = if i == h { ':' } else { '-' };
|
||||||
|
let line: String = lines[i].chars().take(MAX_LINE_LEN).collect();
|
||||||
|
out.push_str(&format!("{}{sep}{}{sep}{}\n", p.display(), i + 1, line));
|
||||||
|
last_printed = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("{}\n", p.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files == 0 { "No matches found".to_string() } else { out }
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(ToolOutput::text(truncate(&out, MAX_OUTPUT)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_requires_unique_match() {
|
||||||
|
let input = json!({"old_string": "a", "new_string": "b"});
|
||||||
|
assert!(apply_edit("a a", &input).is_err());
|
||||||
|
assert_eq!(apply_edit("a c", &input).unwrap(), "b c");
|
||||||
|
let all = json!({"old_string": "a", "new_string": "b", "replace_all": true});
|
||||||
|
assert_eq!(apply_edit("a a", &all).unwrap(), "b b");
|
||||||
|
assert!(apply_edit("xyz", &input).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
//! Built-in tools. Each tool declares a JSON schema, whether it is read-only
|
||||||
|
//! (allowed in plan mode) and how to run it.
|
||||||
|
|
||||||
|
pub mod agent;
|
||||||
|
pub mod bash;
|
||||||
|
pub mod craft;
|
||||||
|
pub mod fs;
|
||||||
|
pub mod web;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::provider::ToolDef;
|
||||||
|
use crate::types::ImageData;
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ToolOutput {
|
||||||
|
pub content: String,
|
||||||
|
pub images: Vec<ImageData>,
|
||||||
|
pub meta: Option<Value>,
|
||||||
|
pub is_error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolOutput {
|
||||||
|
pub fn text(s: impl Into<String>) -> Self {
|
||||||
|
Self { content: s.into(), ..Default::default() }
|
||||||
|
}
|
||||||
|
pub fn error(s: impl Into<String>) -> Self {
|
||||||
|
Self { content: s.into(), is_error: true, ..Default::default() }
|
||||||
|
}
|
||||||
|
pub fn with_meta(mut self, meta: Value) -> Self {
|
||||||
|
self.meta = Some(meta);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ToolCtx {
|
||||||
|
pub app: Arc<App>,
|
||||||
|
pub session_id: String,
|
||||||
|
pub call_id: String,
|
||||||
|
pub cwd: PathBuf,
|
||||||
|
pub cancel: CancellationToken,
|
||||||
|
/// Agent mode running this tool (subagents inherit restrictions).
|
||||||
|
pub agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolCtx {
|
||||||
|
/// Resolve a user/model supplied path against the session cwd and make sure
|
||||||
|
/// it stays inside the workspace.
|
||||||
|
pub fn resolve(&self, p: &str) -> Result<PathBuf> {
|
||||||
|
let p = p.trim();
|
||||||
|
let raw = if Path::new(p).is_absolute() { PathBuf::from(p) } else { self.cwd.join(p) };
|
||||||
|
let norm = normalize(&raw);
|
||||||
|
let ws = normalize(&self.app.workspace());
|
||||||
|
if !norm.starts_with(&ws) && !norm.starts_with("/tmp") {
|
||||||
|
bail!("path {} is outside the workspace {}", norm.display(), ws.display());
|
||||||
|
}
|
||||||
|
Ok(norm)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self, output: impl Into<String>) {
|
||||||
|
self.app.emit(crate::types::Event::ToolProgress {
|
||||||
|
session_id: self.session_id.clone(),
|
||||||
|
call_id: self.call_id.clone(),
|
||||||
|
output: output.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lexical normalization (no symlink resolution, works for non-existing files).
|
||||||
|
pub fn normalize(p: &Path) -> PathBuf {
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for c in p.components() {
|
||||||
|
match c {
|
||||||
|
Component::ParentDir => {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
Component::CurDir => {}
|
||||||
|
other => out.push(other.as_os_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Tool: Send + Sync {
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
fn description(&self) -> String;
|
||||||
|
fn schema(&self) -> Value;
|
||||||
|
/// Read-only tools are available in plan mode and never modify state.
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
/// Category used by the "accept_edits" permission mode.
|
||||||
|
fn is_edit(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
/// Short human title for permission prompts.
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
let _ = input;
|
||||||
|
self.name().to_string()
|
||||||
|
}
|
||||||
|
/// Optional unified diff shown in permission prompts.
|
||||||
|
async fn preview(&self, _input: &Value, _ctx: &ToolCtx) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput>;
|
||||||
|
|
||||||
|
fn def(&self) -> ToolDef {
|
||||||
|
ToolDef { name: self.name().into(), description: self.description(), input_schema: self.schema() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||||
|
vec![
|
||||||
|
Arc::new(fs::Read),
|
||||||
|
Arc::new(fs::Write),
|
||||||
|
Arc::new(fs::Edit),
|
||||||
|
Arc::new(fs::List),
|
||||||
|
Arc::new(fs::Glob),
|
||||||
|
Arc::new(fs::Grep),
|
||||||
|
Arc::new(bash::Bash),
|
||||||
|
Arc::new(web::WebFetch),
|
||||||
|
Arc::new(agent::TodoWrite),
|
||||||
|
Arc::new(agent::AskUser),
|
||||||
|
Arc::new(agent::Task),
|
||||||
|
Arc::new(craft::BrowserSnapshot),
|
||||||
|
Arc::new(craft::ImageCompare),
|
||||||
|
Arc::new(crate::prompts::SkillTool),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper for reading typed arguments.
|
||||||
|
pub fn arg_str<'a>(input: &'a Value, key: &str) -> Result<&'a str> {
|
||||||
|
input
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing required string argument '{key}'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let mut cut = max;
|
||||||
|
while !s.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
format!("{}\n\n[... output truncated: {} of {} bytes shown ...]", &s[..cut], cut, s.len())
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
//! Fetch a URL and return readable text.
|
||||||
|
|
||||||
|
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const MAX_BYTES: usize = 5 * 1024 * 1024;
|
||||||
|
const MAX_OUTPUT: usize = 80_000;
|
||||||
|
|
||||||
|
pub struct WebFetch;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for WebFetch {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"web_fetch"
|
||||||
|
}
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Fetch a URL (http/https). HTML is converted to readable text; JSON and plain text are \
|
||||||
|
returned as-is. Set raw=true to get the unconverted body (e.g. HTML source)."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {"type": "string"},
|
||||||
|
"raw": {"type": "boolean"}
|
||||||
|
},
|
||||||
|
"required": ["url"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn title(&self, input: &Value) -> String {
|
||||||
|
format!("Fetch {}", input["url"].as_str().unwrap_or("?"))
|
||||||
|
}
|
||||||
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
|
let url = arg_str(&input, "url")?;
|
||||||
|
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||||
|
bail!("only http(s) URLs are supported");
|
||||||
|
}
|
||||||
|
let raw = input["raw"].as_bool().unwrap_or(false);
|
||||||
|
if raw && ctx.agent == "craft" && ctx.app.config().craft.clean_room {
|
||||||
|
return Ok(ToolOutput::error(
|
||||||
|
"Clean room mode: page source may not be read. Use browser_snapshot, or switch \
|
||||||
|
craft.clean_room to false (deep mode) in the settings.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let resp = crate::provider::http_client()
|
||||||
|
.get(url)
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let ctype = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
let bytes = &bytes[..bytes.len().min(MAX_BYTES)];
|
||||||
|
let body = String::from_utf8_lossy(bytes);
|
||||||
|
let text = if !raw && ctype.contains("html") {
|
||||||
|
html2text::from_read(body.as_bytes(), 120).unwrap_or_else(|_| body.to_string())
|
||||||
|
} else {
|
||||||
|
body.to_string()
|
||||||
|
};
|
||||||
|
let out = format!("HTTP {status} ({ctype})\n\n{}", truncate(&text, MAX_OUTPUT));
|
||||||
|
Ok(if status.is_success() { ToolOutput::text(out) } else { ToolOutput::error(out) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
//! Provider-neutral message model and the event stream sent to clients.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Role {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ImageData {
|
||||||
|
pub media_type: String,
|
||||||
|
/// Base64-encoded bytes.
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum Part {
|
||||||
|
Text {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Image {
|
||||||
|
image: ImageData,
|
||||||
|
},
|
||||||
|
Reasoning {
|
||||||
|
text: String,
|
||||||
|
/// Provider-specific signature needed to replay thinking blocks (Anthropic).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
|
ToolCall {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: Value,
|
||||||
|
/// Opaque provider data that must be echoed back (e.g. Gemini thought signatures).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
extra: Option<Value>,
|
||||||
|
},
|
||||||
|
ToolResult {
|
||||||
|
call_id: String,
|
||||||
|
/// Tool name, kept for providers that need it (Gemini via OpenAI compat) and the UI.
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
content: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
images: Vec<ImageData>,
|
||||||
|
#[serde(default)]
|
||||||
|
is_error: bool,
|
||||||
|
/// Structured data for the UI (diffs, todo lists, ...). Never sent to the model.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
meta: Option<Value>,
|
||||||
|
},
|
||||||
|
/// Marks the start of a compacted history; the text is the summary.
|
||||||
|
Summary {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: String,
|
||||||
|
pub role: Role,
|
||||||
|
pub parts: Vec<Part>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage: Option<Usage>,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
pub fn new(role: Role, parts: Vec<Part>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: new_id(),
|
||||||
|
role,
|
||||||
|
parts,
|
||||||
|
model: None,
|
||||||
|
usage: None,
|
||||||
|
created_at: now_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Usage {
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_read_tokens: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_write_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Usage {
|
||||||
|
/// Total tokens of context the last request consumed.
|
||||||
|
pub fn context_tokens(&self) -> u64 {
|
||||||
|
self.input_tokens + self.cache_read_tokens + self.cache_write_tokens + self.output_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub cwd: String,
|
||||||
|
/// "provider/model"
|
||||||
|
pub model: String,
|
||||||
|
/// Active agent mode (build, plan, craft, ...).
|
||||||
|
pub agent: String,
|
||||||
|
/// Permission mode: default | accept_edits | bypass
|
||||||
|
pub permission_mode: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub todos: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_usage: Option<Usage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Question forwarded from an agent (or an MCP server) to the user.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Question {
|
||||||
|
pub question: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub header: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub options: Vec<QuestionOption>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub multi_select: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuestionOption {
|
||||||
|
pub label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events broadcast to all connected clients. Clients filter by session id.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum Event {
|
||||||
|
SessionUpdated { session: Session },
|
||||||
|
SessionDeleted { session_id: String },
|
||||||
|
SessionStatus { session_id: String, busy: bool },
|
||||||
|
MessageAdded { session_id: String, message: Message },
|
||||||
|
/// Streaming deltas for the assistant message currently being generated.
|
||||||
|
TextDelta { session_id: String, message_id: String, text: String },
|
||||||
|
ReasoningDelta { session_id: String, message_id: String, text: String },
|
||||||
|
ToolStarted { session_id: String, message_id: String, call_id: String, name: String, input: Value },
|
||||||
|
ToolProgress { session_id: String, call_id: String, output: String },
|
||||||
|
PermissionRequest { id: String, session_id: String, root_session_id: String, tool: String, title: String, input: Value, #[serde(skip_serializing_if = "Option::is_none")] diff: Option<String> },
|
||||||
|
PermissionResolved { id: String, session_id: String },
|
||||||
|
QuestionRequest { id: String, session_id: String, root_session_id: String, source: String, questions: Vec<Question> },
|
||||||
|
QuestionResolved { id: String, session_id: String },
|
||||||
|
Error { session_id: String, message: String },
|
||||||
|
McpStatus { servers: Value },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_id() -> String {
|
||||||
|
uuid::Uuid::now_v7().simple().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_ms() -> i64 {
|
||||||
|
chrono::Utc::now().timestamp_millis()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#282828" />
|
||||||
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<title>CodeCraft</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+8069
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "codecraft-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.2.1",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.6",
|
||||||
|
"vite-plugin-pwa": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#282828"/>
|
||||||
|
<path d="M196 160 100 256l96 96" fill="none" stroke="#fabd2f" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="m316 160 96 96-96 96" fill="none" stroke="#fabd2f" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M286 120 226 392" stroke="#fe8019" stroke-width="40" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 477 B |
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ApiError, connectEvents, get, getToken } from "./api";
|
||||||
|
import { useT } from "./i18n";
|
||||||
|
import { navigate, useRoute } from "./router";
|
||||||
|
import { applyEvent, loadSessions, resync, setState } from "./store";
|
||||||
|
import { Sidebar } from "./components/Sidebar";
|
||||||
|
import { Login } from "./components/Login";
|
||||||
|
import { ChatView } from "./components/ChatView";
|
||||||
|
import { NewSession } from "./components/NewSession";
|
||||||
|
import { Settings } from "./components/Settings";
|
||||||
|
import { Stats } from "./components/Stats";
|
||||||
|
|
||||||
|
type AuthState = "checking" | "login" | "ok";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const t = useT();
|
||||||
|
const route = useRoute();
|
||||||
|
const [auth, setAuth] = useState<AuthState>("checking");
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// Check whether a token is needed and whether ours works.
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const info = await get<{ auth_required: boolean }>("/info");
|
||||||
|
if (info.auth_required && !getToken()) return setAuth("login");
|
||||||
|
await loadSessions();
|
||||||
|
setAuth("ok");
|
||||||
|
} catch (e) {
|
||||||
|
setAuth(e instanceof ApiError && e.status === 401 ? "login" : "ok");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth !== "ok") return;
|
||||||
|
return connectEvents({
|
||||||
|
onEvent: applyEvent,
|
||||||
|
onOpen: () => {
|
||||||
|
setState(() => ({ connected: true }));
|
||||||
|
void resync().catch(() => undefined);
|
||||||
|
},
|
||||||
|
onClose: () => setState(() => ({ connected: false })),
|
||||||
|
});
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
|
// Close the mobile menu on navigation.
|
||||||
|
useEffect(() => setMenuOpen(false), [route.join("/")]);
|
||||||
|
|
||||||
|
if (auth === "checking") return <div className="empty">{t("common.loading")}</div>;
|
||||||
|
if (auth === "login") return <Login onSuccess={() => setAuth("ok")} />;
|
||||||
|
|
||||||
|
const [section, arg] = route;
|
||||||
|
let content;
|
||||||
|
if (section === "s" && arg) content = <ChatView key={arg} sessionId={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 content = <NewSession onMenu={() => setMenuOpen(true)} onCreated={(id) => navigate(`/s/${id}`)} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
||||||
|
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
|
||||||
|
<main className="main">{content}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ServerEvent } from "./types";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "codecraft.token";
|
||||||
|
|
||||||
|
export function getToken(): string {
|
||||||
|
return localStorage.getItem(TOKEN_KEY) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T = any>(path: string, init: RequestInit & { json?: unknown } = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { ...(init.headers as Record<string, string>) };
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
let body = init.body;
|
||||||
|
if (init.json !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
body = JSON.stringify(init.json);
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api${path}`, { ...init, headers, body });
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
if (!res.ok) throw new ApiError(res.status, data?.error ?? res.statusText);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const get = <T = any>(path: string) => api<T>(path);
|
||||||
|
export const post = <T = any>(path: string, json?: unknown) => api<T>(path, { method: "POST", json: json ?? {} });
|
||||||
|
export const patch = <T = any>(path: string, json: unknown) => api<T>(path, { method: "PATCH", json });
|
||||||
|
export const put = <T = any>(path: string, json: unknown) => api<T>(path, { method: "PUT", json });
|
||||||
|
export const del = <T = any>(path: string) => api<T>(path, { method: "DELETE" });
|
||||||
|
|
||||||
|
/** WebSocket with automatic reconnect. `onOpen` runs after every (re)connect so
|
||||||
|
* the caller can resync state that may have been missed. */
|
||||||
|
export function connectEvents(handlers: {
|
||||||
|
onEvent: (ev: ServerEvent) => void;
|
||||||
|
onOpen: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}): () => void {
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let stopped = false;
|
||||||
|
let retry = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const token = encodeURIComponent(getToken());
|
||||||
|
ws = new WebSocket(`${proto}://${location.host}/api/ws?token=${token}`);
|
||||||
|
ws.onopen = () => {
|
||||||
|
retry = 0;
|
||||||
|
handlers.onOpen();
|
||||||
|
};
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
try {
|
||||||
|
handlers.onEvent(JSON.parse(m.data));
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
handlers.onClose();
|
||||||
|
if (stopped) return;
|
||||||
|
retry = Math.min(retry + 1, 6);
|
||||||
|
timer = setTimeout(open, 500 * 2 ** retry);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
open();
|
||||||
|
// Reconnect immediately when the app comes back to the foreground (mobile PWA).
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === "visible" && ws && ws.readyState > WebSocket.OPEN) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisible);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener("visibilitychange", onVisible);
|
||||||
|
ws?.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Hand-written SVG charts following the dataviz method: categorical colors in
|
||||||
|
// fixed slot order, thin marks with 2px gaps, recessive axes, hover tooltips,
|
||||||
|
// legends, and a table view for every chart.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
|
export const SERIES = Array.from({ length: 8 }, (_, i) => `var(--series-${i + 1})`);
|
||||||
|
const SEQ = Array.from({ length: 7 }, (_, i) => `var(--seq-${i})`);
|
||||||
|
|
||||||
|
function useWidth<T extends HTMLElement>() {
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
const [width, setWidth] = useState(600);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
|
||||||
|
ro.observe(ref.current);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
return [ref, width] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round up to 1/2/2.5/5 × 10^n for clean axis ticks. */
|
||||||
|
function niceMax(v: number): number {
|
||||||
|
if (v <= 0) return 1;
|
||||||
|
const exp = Math.pow(10, Math.floor(Math.log10(v)));
|
||||||
|
for (const m of [1, 2, 2.5, 5, 10]) if (v <= m * exp) return m * exp;
|
||||||
|
return 10 * exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Series {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Legend({ series }: { series: Series[] }) {
|
||||||
|
return (
|
||||||
|
<div className="legend">
|
||||||
|
{series.map((s) => (
|
||||||
|
<span key={s.key}>
|
||||||
|
<i style={{ background: s.color }} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tip {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stacked bar chart over categories (days). values[i][seriesKey]. */
|
||||||
|
export function StackedBars({
|
||||||
|
categories,
|
||||||
|
categoryLabel,
|
||||||
|
series,
|
||||||
|
values,
|
||||||
|
format,
|
||||||
|
height = 220,
|
||||||
|
}: {
|
||||||
|
categories: string[];
|
||||||
|
categoryLabel: (c: string) => string;
|
||||||
|
series: Series[];
|
||||||
|
values: Record<string, number>[];
|
||||||
|
format: (v: number) => string;
|
||||||
|
height?: number;
|
||||||
|
}) {
|
||||||
|
const [ref, width] = useWidth<HTMLDivElement>();
|
||||||
|
const [tip, setTip] = useState<Tip>();
|
||||||
|
const [hover, setHover] = useState<number>();
|
||||||
|
const left = 56;
|
||||||
|
const bottom = 22;
|
||||||
|
const top = 8;
|
||||||
|
const plotW = Math.max(width - left - 4, 50);
|
||||||
|
const plotH = height - top - bottom;
|
||||||
|
const totals = values.map((v) => series.reduce((a, s) => a + (v[s.key] ?? 0), 0));
|
||||||
|
const max = niceMax(Math.max(...totals, 0));
|
||||||
|
const n = Math.max(categories.length, 1);
|
||||||
|
const slot = plotW / n;
|
||||||
|
const barW = Math.max(Math.min(slot * 0.72, 28), 2);
|
||||||
|
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => f * max);
|
||||||
|
const labelEvery = Math.ceil(n / Math.max(Math.floor(plotW / 70), 1));
|
||||||
|
const y = (v: number) => top + plotH - (v / max) * plotH;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart" ref={ref} onMouseLeave={() => (setTip(undefined), setHover(undefined))}>
|
||||||
|
<svg height={height} role="img">
|
||||||
|
<g className="axis">
|
||||||
|
{ticks.map((tk) => (
|
||||||
|
<g key={tk}>
|
||||||
|
<line className="grid-line" x1={left} x2={left + plotW} y1={y(tk)} y2={y(tk)} />
|
||||||
|
<text x={left - 6} y={y(tk) + 4} textAnchor="end">
|
||||||
|
{format(tk)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{categories.map((c, i) =>
|
||||||
|
i % labelEvery === 0 ? (
|
||||||
|
<text key={c} x={left + slot * i + slot / 2} y={height - 6} textAnchor="middle">
|
||||||
|
{categoryLabel(c)}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
{categories.map((c, i) => {
|
||||||
|
let acc = 0;
|
||||||
|
const x = left + slot * i + (slot - barW) / 2;
|
||||||
|
const segs = series.filter((s) => (values[i][s.key] ?? 0) > 0);
|
||||||
|
return (
|
||||||
|
<g key={c} opacity={hover === undefined || hover === i ? 1 : 0.55}>
|
||||||
|
{segs.map((s, si) => {
|
||||||
|
const v = values[i][s.key] ?? 0;
|
||||||
|
const y0 = y(acc);
|
||||||
|
acc += v;
|
||||||
|
const y1 = y(acc);
|
||||||
|
// 2px surface gap between stacked segments.
|
||||||
|
const h = Math.max(y0 - y1 - (si > 0 ? 2 : 0), 1);
|
||||||
|
const yTop = y1;
|
||||||
|
const isTop = si === segs.length - 1;
|
||||||
|
const r = isTop ? Math.min(4, barW / 2, h) : 0;
|
||||||
|
const d = `M${x},${yTop + h} V${yTop + r} Q${x},${yTop} ${x + r},${yTop} H${x + barW - r} Q${x + barW},${yTop} ${x + barW},${yTop + r} V${yTop + h} Z`;
|
||||||
|
return <path key={s.key} d={d} fill={s.color} />;
|
||||||
|
})}
|
||||||
|
{/* Hit target: full slot height, wider than the bar. */}
|
||||||
|
<rect
|
||||||
|
x={left + slot * i}
|
||||||
|
y={top}
|
||||||
|
width={slot}
|
||||||
|
height={plotH}
|
||||||
|
fill="transparent"
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
setHover(i);
|
||||||
|
const box = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
||||||
|
setTip({
|
||||||
|
x: Math.min(e.clientX - box.left + 12, width - 180),
|
||||||
|
y: e.clientY - box.top + 12,
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<b>{categoryLabel(c)}</b>
|
||||||
|
{[...series].reverse().map((s) =>
|
||||||
|
values[i][s.key] ? (
|
||||||
|
<div key={s.key} className="tt-row">
|
||||||
|
<span>
|
||||||
|
<i style={{ background: s.color }} /> {s.label}
|
||||||
|
</span>
|
||||||
|
<span>{format(values[i][s.key])}</span>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
<div className="tt-row" style={{ borderTop: "1px solid var(--bg3)", marginTop: 3 }}>
|
||||||
|
<span>Σ</span>
|
||||||
|
<b>{format(totals[i])}</b>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{tip && (
|
||||||
|
<div className="tooltip" style={{ left: tip.x, top: tip.y }}>
|
||||||
|
{tip.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weekday × hour heatmap with a 7-step sequential ramp. cells[weekday*24+hour]. */
|
||||||
|
export function Heatmap({
|
||||||
|
cells,
|
||||||
|
weekdays,
|
||||||
|
format,
|
||||||
|
firstDay = 1,
|
||||||
|
}: {
|
||||||
|
cells: number[];
|
||||||
|
weekdays: string[];
|
||||||
|
format: (v: number) => string;
|
||||||
|
firstDay?: number;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const [ref, width] = useWidth<HTMLDivElement>();
|
||||||
|
const [tip, setTip] = useState<Tip>();
|
||||||
|
const left = 40;
|
||||||
|
const top = 18;
|
||||||
|
const cell = Math.max(Math.floor((width - left) / 24), 8);
|
||||||
|
const max = Math.max(...cells, 0);
|
||||||
|
const order = Array.from({ length: 7 }, (_, i) => (firstDay + i) % 7);
|
||||||
|
const bin = (v: number) => (v <= 0 || max === 0 ? 0 : Math.min(6, 1 + Math.floor((v / max) * 5.999)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
|
||||||
|
<svg height={top + cell * 7 + 4} role="img" aria-label={t("stats.heatmap")}>
|
||||||
|
<g className="axis">
|
||||||
|
{[0, 3, 6, 9, 12, 15, 18, 21].map((h) => (
|
||||||
|
<text key={h} x={left + h * cell + cell / 2} y={12} textAnchor="middle">
|
||||||
|
{h}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{order.map((d, row) => (
|
||||||
|
<text key={d} x={left - 6} y={top + row * cell + cell / 2 + 4} textAnchor="end">
|
||||||
|
{weekdays[d]}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
{order.map((d, row) =>
|
||||||
|
Array.from({ length: 24 }, (_, h) => {
|
||||||
|
const v = cells[d * 24 + h] ?? 0;
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={`${d}-${h}`}
|
||||||
|
x={left + h * cell + 1}
|
||||||
|
y={top + row * cell + 1}
|
||||||
|
width={cell - 2}
|
||||||
|
height={cell - 2}
|
||||||
|
rx={3}
|
||||||
|
fill={SEQ[bin(v)]}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
const box = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
||||||
|
setTip({
|
||||||
|
x: Math.min(e.clientX - box.left + 12, width - 140),
|
||||||
|
y: e.clientY - box.top + 12,
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<b>
|
||||||
|
{weekdays[d]} {String(h).padStart(2, "0")}:00–{String(h).padStart(2, "0")}:59
|
||||||
|
</b>
|
||||||
|
<div>{format(v)}</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div className="legend" style={{ marginTop: 6 }}>
|
||||||
|
<span>{format(0)}</span>
|
||||||
|
{SEQ.slice(1).map((c, i) => (
|
||||||
|
<i key={i} style={{ background: c, width: 18 }} />
|
||||||
|
))}
|
||||||
|
<span>{format(max)}</span>
|
||||||
|
</div>
|
||||||
|
{tip && (
|
||||||
|
<div className="tooltip" style={{ left: tip.x, top: tip.y }}>
|
||||||
|
{tip.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { del, get, patch, post } from "../api";
|
||||||
|
import { useModels } from "../hooks";
|
||||||
|
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { dismissError, loadSession, useStore } from "../store";
|
||||||
|
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
||||||
|
import { Composer } from "./Composer";
|
||||||
|
import { PermissionCard, QuestionCard } from "./Interaction";
|
||||||
|
import { Markdown } from "./Markdown";
|
||||||
|
import { TodoList, ToolCard } from "./ToolCard";
|
||||||
|
|
||||||
|
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||||
|
|
||||||
|
const EMPTY: Message[] = [];
|
||||||
|
|
||||||
|
interface SessionStats {
|
||||||
|
total: Aggregate;
|
||||||
|
by_model: (Aggregate & { model: string })[];
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reasoning({ text, live }: { text: string; live: boolean }) {
|
||||||
|
const t = useT();
|
||||||
|
if (!text.trim()) return null;
|
||||||
|
return (
|
||||||
|
<details className="reasoning" open={live}>
|
||||||
|
<summary>{t("chat.reasoning")}</summary>
|
||||||
|
<div className="body">{text}</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantParts({ parts, results, running, live }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((p, i) => {
|
||||||
|
switch (p.type) {
|
||||||
|
case "text":
|
||||||
|
return <Markdown key={i} text={p.text} />;
|
||||||
|
case "reasoning":
|
||||||
|
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
|
||||||
|
case "tool_call":
|
||||||
|
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsPanel({ stats }: { stats: SessionStats }) {
|
||||||
|
const t = useT();
|
||||||
|
const a = stats.total;
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ margin: "8px 0" }}>
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("stats.model")}</th>
|
||||||
|
<th className="num">{t("stats.requests")}</th>
|
||||||
|
<th className="num">{t("stats.input")}</th>
|
||||||
|
<th className="num">{t("stats.cacheRead")}</th>
|
||||||
|
<th className="num">{t("stats.output")}</th>
|
||||||
|
<th className="num">{t("stats.ttft")}</th>
|
||||||
|
<th className="num">{t("stats.outputSpeed")}</th>
|
||||||
|
<th className="num">{t("stats.cost")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...stats.by_model, { ...a, model: t("stats.total") }].map((r) => (
|
||||||
|
<tr key={r.model}>
|
||||||
|
<td>{r.model}</td>
|
||||||
|
<td className="num">{fmtNumber(r.requests)}</td>
|
||||||
|
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
|
||||||
|
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
|
||||||
|
<td className="num">{fmtCompact(r.output_tokens)}</td>
|
||||||
|
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
|
||||||
|
<td className="num">{fmtNumber(r.output_tps, 1)} tok/s</td>
|
||||||
|
<td className="num">{fmtCost(r.cost, stats.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>
|
||||||
|
{t("stats.prefillSpeed")}: {fmtNumber(a.prefill_tps)} tok/s · {t("stats.cacheHit")}: {fmtPercent(a.cache_hit_rate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const session = useStore((s) => s.sessionById[sessionId]);
|
||||||
|
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||||
|
const draft = useStore((s) => s.drafts[sessionId]);
|
||||||
|
const busy = useStore((s) => !!s.busy[sessionId]);
|
||||||
|
const errors = useStore((s) => s.errors[sessionId]);
|
||||||
|
const allPermissions = useStore((s) => s.permissions);
|
||||||
|
const allQuestions = useStore((s) => s.questions);
|
||||||
|
const models = useModels();
|
||||||
|
const [loadError, setLoadError] = useState<string>();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [stats, setStats] = useState<SessionStats>();
|
||||||
|
const [showStats, setShowStats] = useState(false);
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const stick = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSession(sessionId).catch((e) => setLoadError(e.message));
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
|
||||||
|
const questions = allQuestions.filter((q) => q.session_id === sessionId || q.root_session_id === sessionId);
|
||||||
|
|
||||||
|
// Session cost/tokens; refreshed when messages arrive.
|
||||||
|
useEffect(() => {
|
||||||
|
get<SessionStats>(`/sessions/${sessionId}/stats`).then(setStats).catch(() => undefined);
|
||||||
|
}, [sessionId, messages.length]);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const m = new Map<string, ToolResult>();
|
||||||
|
for (const msg of messages) for (const p of msg.parts) if (p.type === "tool_result") m.set(p.call_id, p);
|
||||||
|
return m;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Keep scrolled to the bottom while the user hasn't scrolled up.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loadError) return <div className="empty">{loadError}</div>;
|
||||||
|
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
||||||
|
|
||||||
|
const update = async (p: Partial<Session>) => {
|
||||||
|
try {
|
||||||
|
await patch(`/sessions/${sessionId}`, p);
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async (raw: string, images: ImageData[]) => {
|
||||||
|
const txt = raw.trim();
|
||||||
|
if (txt === "/new") {
|
||||||
|
setText("");
|
||||||
|
return navigate("/new");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await post(`/sessions/${sessionId}/prompt`, { text: txt, images });
|
||||||
|
setText("");
|
||||||
|
stick.current = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewind = async (m: Message) => {
|
||||||
|
if (!confirm(t("chat.rewindConfirm"))) return;
|
||||||
|
const txt = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("");
|
||||||
|
await post(`/sessions/${sessionId}/rewind`, { message_id: m.id });
|
||||||
|
await loadSession(sessionId);
|
||||||
|
setText(txt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!confirm(t("chat.deleteConfirm"))) return;
|
||||||
|
await del(`/sessions/${sessionId}`);
|
||||||
|
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Context usage of the last request relative to the model's window.
|
||||||
|
const [prov, ...rest] = session.model.split("/");
|
||||||
|
const modelInfo = models.find((p) => p.provider === prov)?.models.find((m) => m.id === rest.join("/"));
|
||||||
|
const u = session.last_usage;
|
||||||
|
const ctxTokens = u ? u.input_tokens + u.cache_read_tokens + u.cache_write_tokens + u.output_tokens : 0;
|
||||||
|
const ctxShare = modelInfo?.context && ctxTokens ? ctxTokens / modelInfo.context : undefined;
|
||||||
|
const todos = Array.isArray(session.todos) ? session.todos : [];
|
||||||
|
const openTodos = todos.filter((x) => x.status !== "completed");
|
||||||
|
const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<div className="grow">
|
||||||
|
{editingTitle ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
defaultValue={session.title}
|
||||||
|
onBlur={(e) => (setEditingTitle(false), update({ title: e.target.value }))}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h1 onDoubleClick={() => setEditingTitle(true)} title={t("chat.renameHint")}>
|
||||||
|
{session.parent_id && (
|
||||||
|
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
|
||||||
|
↩
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{session.title || t("session.untitled")}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
<div className="path">{session.cwd}</div>
|
||||||
|
</div>
|
||||||
|
{ctxShare !== undefined && (
|
||||||
|
<span className={`chip ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
|
||||||
|
{t("chat.context")} {fmtPercent(ctxShare)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{stats && (
|
||||||
|
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
|
||||||
|
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "}
|
||||||
|
{fmtCompact(stats.total.output_tokens)}↓ · {fmtCost(stats.total.cost, stats.currency)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
||||||
|
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
|
||||||
|
⇊
|
||||||
|
</button>
|
||||||
|
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="chat-scroll"
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="chat">
|
||||||
|
{showStats && stats && <StatsPanel stats={stats} />}
|
||||||
|
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
||||||
|
{messages.map((m) => {
|
||||||
|
if (m.role === "assistant") {
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="msg">
|
||||||
|
<AssistantParts parts={m.parts} results={results} running={busy} live={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const summary = m.parts.find((p) => p.type === "summary");
|
||||||
|
if (summary && summary.type === "summary") {
|
||||||
|
return (
|
||||||
|
<details key={m.id} className="summary-divider">
|
||||||
|
<summary>{t("chat.compacted")}</summary>
|
||||||
|
<Markdown text={summary.text} />
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const texts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||||
|
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||||
|
if (texts.length === 0 && imgs.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="msg">
|
||||||
|
<div className="msg-user">
|
||||||
|
{!busy && (
|
||||||
|
<div className="msg-actions">
|
||||||
|
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{texts.map((p) => p.text).join("\n\n")}
|
||||||
|
{imgs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{imgs.map((p, i) => (
|
||||||
|
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{draft && (
|
||||||
|
<div className="msg">
|
||||||
|
<AssistantParts parts={draft.parts} results={results} running={busy} live />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{busy && !draft && <div className="msg-meta">{t("chat.thinking")}</div>}
|
||||||
|
{openTodos.length > 0 && (
|
||||||
|
<div className="todos">
|
||||||
|
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||||
|
<TodoList todos={todos} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{permissions.map((p) => (
|
||||||
|
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
||||||
|
))}
|
||||||
|
{questions.map((q) => (
|
||||||
|
<QuestionCard key={q.id} req={q} currentId={sessionId} />
|
||||||
|
))}
|
||||||
|
{errors?.map((e) => (
|
||||||
|
<div key={e.id} className="error-box" role="alert">
|
||||||
|
<span>{e.message}</span>
|
||||||
|
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{session.agent === "plan" && !busy && lastIsAssistant && (
|
||||||
|
<div className="row" style={{ justifyContent: "center", margin: "12px 0" }}>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
onClick={async () => {
|
||||||
|
await update({ agent: "build" });
|
||||||
|
await send(t("plan.implementPrompt"), []);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("plan.implement")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Composer
|
||||||
|
session={session}
|
||||||
|
busy={busy}
|
||||||
|
text={text}
|
||||||
|
setText={setText}
|
||||||
|
onSend={send}
|
||||||
|
onAbort={() => post(`/sessions/${sessionId}/abort`)}
|
||||||
|
onUpdate={update}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useAgents, useFetch } from "../hooks";
|
||||||
|
import { useT, type Key } from "../i18n";
|
||||||
|
import type { ImageData, Session } from "../types";
|
||||||
|
import { ModelSelect } from "./ModelSelect";
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN: { name: string; key: Key }[] = [
|
||||||
|
{ name: "compact", key: "command.compact" },
|
||||||
|
{ name: "new", key: "command.new" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function readImage(file: File): Promise<ImageData> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => {
|
||||||
|
const url = String(r.result);
|
||||||
|
resolve({ media_type: file.type || "image/png", data: url.slice(url.indexOf(",") + 1) });
|
||||||
|
};
|
||||||
|
r.onerror = reject;
|
||||||
|
r.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
session,
|
||||||
|
busy,
|
||||||
|
text,
|
||||||
|
setText,
|
||||||
|
onSend,
|
||||||
|
onAbort,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
session: Session;
|
||||||
|
busy: boolean;
|
||||||
|
text: string;
|
||||||
|
setText: (s: string) => void;
|
||||||
|
onSend: (text: string, images: ImageData[]) => void;
|
||||||
|
onAbort: () => void;
|
||||||
|
onUpdate: (patch: Partial<Session>) => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [images, setImages] = useState<ImageData[]>([]);
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
|
||||||
|
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
|
||||||
|
|
||||||
|
// Auto-grow.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.4)}px`;
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
const m = text.match(/^\/(\S*)$/);
|
||||||
|
if (!m) return [];
|
||||||
|
const all: Suggestion[] = [
|
||||||
|
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
|
||||||
|
...(cmdData?.commands ?? []),
|
||||||
|
];
|
||||||
|
return all.filter((c) => c.name.startsWith(m[1])).slice(0, 12);
|
||||||
|
}, [text, cmdData, t]);
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
if (!text.trim() && images.length === 0) return;
|
||||||
|
onSend(text, images);
|
||||||
|
setImages([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((a) => (a + (e.key === "ArrowDown" ? 1 : suggestions.length - 1)) % suggestions.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setText(`/${suggestions[Math.min(active, suggestions.length - 1)].name} `);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Enter sends on desktop; on touch devices Enter inserts a newline.
|
||||||
|
const touch = matchMedia("(pointer: coarse)").matches;
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !touch && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && busy) onAbort();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFiles = async (files: FileList | File[]) => {
|
||||||
|
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
|
||||||
|
setImages((cur) => [...cur, ...imgs]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="composer-wrap">
|
||||||
|
<div
|
||||||
|
className="composer"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void addFiles(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="suggest" role="listbox">
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.name}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === active}
|
||||||
|
className={i === active ? "active" : ""}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setText(`/${s.name} `);
|
||||||
|
ref.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="name">/{s.name}</span>
|
||||||
|
<span className="desc">{s.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="attachments">
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<div className="att" key={i}>
|
||||||
|
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||||
|
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
rows={1}
|
||||||
|
value={text}
|
||||||
|
placeholder={busy ? t("composer.placeholderBusy") : t("composer.placeholder")}
|
||||||
|
onChange={(e) => {
|
||||||
|
setText(e.target.value);
|
||||||
|
setActive(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const files = [...e.clipboardData.files];
|
||||||
|
if (files.some((f) => f.type.startsWith("image/"))) {
|
||||||
|
e.preventDefault();
|
||||||
|
void addFiles(files);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={t("composer.placeholder")}
|
||||||
|
/>
|
||||||
|
<div className="composer-bar">
|
||||||
|
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
|
||||||
|
{agents.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.name}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={session.agent === a.name}
|
||||||
|
className={session.agent === a.name ? "active" : ""}
|
||||||
|
title={a.description}
|
||||||
|
onClick={() => onUpdate({ agent: a.name })}
|
||||||
|
>
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={session.permission_mode}
|
||||||
|
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
|
||||||
|
title={t("composer.permissionMode")}
|
||||||
|
>
|
||||||
|
<option value="default">{t("permissionMode.default")}</option>
|
||||||
|
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
||||||
|
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||||
|
</select>
|
||||||
|
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
|
||||||
|
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
||||||
|
📎
|
||||||
|
<input type="file" accept="image/*" multiple hidden onChange={(e) => e.target.files && addFiles(e.target.files)} />
|
||||||
|
</label>
|
||||||
|
<span className="grow" />
|
||||||
|
{busy && (
|
||||||
|
<button className="danger" onClick={onAbort} title="Esc">
|
||||||
|
■ {t("composer.stop")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="primary" onClick={send} disabled={!text.trim() && images.length === 0}>
|
||||||
|
{busy ? t("composer.queue") : t("composer.send")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export function Diff({ diff }: { diff: string }) {
|
||||||
|
const lines = diff.split("\n");
|
||||||
|
return (
|
||||||
|
<div className="diff">
|
||||||
|
{lines.map((l, i) => {
|
||||||
|
let cls = "";
|
||||||
|
if (l.startsWith("+++") || l.startsWith("---")) cls = "meta";
|
||||||
|
else if (l.startsWith("@@")) cls = "hunk";
|
||||||
|
else if (l.startsWith("+")) cls = "add";
|
||||||
|
else if (l.startsWith("-")) cls = "del";
|
||||||
|
return (
|
||||||
|
<div key={i} className={cls}>
|
||||||
|
{l || " "}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { post } from "../api";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import type { PermissionRequest, QuestionRequest } from "../types";
|
||||||
|
import { Diff } from "./Diff";
|
||||||
|
|
||||||
|
function SubagentHint({ sessionId, currentId }: { sessionId: string; currentId: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const title = useStore((s) => s.sessionById[sessionId]?.title);
|
||||||
|
if (sessionId === currentId) return null;
|
||||||
|
return (
|
||||||
|
<div className="from">
|
||||||
|
{t("interaction.fromSubagent")}{" "}
|
||||||
|
<a href={`#/s/${sessionId}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${sessionId}`))}>
|
||||||
|
{title || sessionId.slice(-6)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionCard({ req, currentId }: { req: PermissionRequest; currentId: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const [feedback, setFeedback] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const reply = async (r: "once" | "always" | "reject") => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await post(`/permissions/${req.id}`, { reply: r, message: r === "reject" ? feedback : undefined });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const command: string | undefined = req.tool === "bash" ? req.input?.command : undefined;
|
||||||
|
const alwaysLabel =
|
||||||
|
req.tool === "bash"
|
||||||
|
? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
|
||||||
|
: t("permission.alwaysTool", { tool: req.tool });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
|
||||||
|
<h3>
|
||||||
|
🔐 {t("permission.title")} <span className="chip mono">{req.tool}</span>
|
||||||
|
</h3>
|
||||||
|
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
||||||
|
<div style={{ margin: "6px 0" }}>{req.title}</div>
|
||||||
|
{command && <pre className="mono" style={{ whiteSpace: "pre-wrap", margin: "6px 0" }}>$ {command}</pre>}
|
||||||
|
{req.diff ? (
|
||||||
|
<Diff diff={req.diff} />
|
||||||
|
) : (
|
||||||
|
!command && (
|
||||||
|
<details>
|
||||||
|
<summary style={{ cursor: "pointer", color: "var(--muted)" }}>{t("tool.input")}</summary>
|
||||||
|
<pre className="mono" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{JSON.stringify(req.input, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
style={{ width: "100%", marginTop: 8 }}
|
||||||
|
placeholder={t("permission.feedback")}
|
||||||
|
value={feedback}
|
||||||
|
onChange={(e) => setFeedback(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && reply("reject")}
|
||||||
|
/>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="primary" disabled={busy} onClick={() => reply("once")} autoFocus>
|
||||||
|
{t("permission.allowOnce")}
|
||||||
|
</button>
|
||||||
|
<button disabled={busy} onClick={() => reply("always")}>
|
||||||
|
{alwaysLabel}
|
||||||
|
</button>
|
||||||
|
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
|
||||||
|
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OTHER = "__other__";
|
||||||
|
|
||||||
|
export function QuestionCard({ req, currentId }: { req: QuestionRequest; currentId: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const [selected, setSelected] = useState<Record<number, string[]>>({});
|
||||||
|
const [other, setOther] = useState<Record<number, string>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const toggle = (qi: number, label: string, multi: boolean) => {
|
||||||
|
setSelected((s) => {
|
||||||
|
const cur = s[qi] ?? [];
|
||||||
|
if (!multi) return { ...s, [qi]: [label] };
|
||||||
|
return { ...s, [qi]: cur.includes(label) ? cur.filter((x) => x !== label) : [...cur, label] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const answerFor = (qi: number): string | string[] | undefined => {
|
||||||
|
const q = req.questions[qi];
|
||||||
|
const sel = (selected[qi] ?? []).map((l) => (l === OTHER ? other[qi]?.trim() ?? "" : l)).filter(Boolean);
|
||||||
|
if (q.options.length === 0) return other[qi]?.trim() || undefined;
|
||||||
|
if (sel.length === 0) return undefined;
|
||||||
|
return q.multi_select ? sel : sel[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const complete = req.questions.every((_, i) => answerFor(i) !== undefined);
|
||||||
|
|
||||||
|
const submit = async (dismiss = false) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const answers = dismiss ? null : Object.fromEntries(req.questions.map((q, i) => [q.question, answerFor(i)]));
|
||||||
|
await post(`/questions/${req.id}`, { answers });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const source = req.source.startsWith("mcp:") ? t("question.fromMcp", { server: req.source.slice(4) }) : t("question.fromAgent");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" role="dialog" aria-label={t("question.title")}>
|
||||||
|
<h3>❓ {t("question.title")}</h3>
|
||||||
|
<div className="from">{source}</div>
|
||||||
|
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
||||||
|
{req.questions.map((q, qi) => (
|
||||||
|
<div key={qi} style={{ marginTop: 10 }}>
|
||||||
|
{q.header && <span className="q-header">{q.header}</span>}
|
||||||
|
<div style={{ whiteSpace: "pre-wrap", color: "var(--fg0)" }}>{q.question}</div>
|
||||||
|
{q.options.length > 0 ? (
|
||||||
|
<div className="q-options" role={q.multi_select ? "group" : "radiogroup"}>
|
||||||
|
{[...q.options, { label: OTHER, description: "" }].map((o) => {
|
||||||
|
const isSel = (selected[qi] ?? []).includes(o.label);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={o.label}
|
||||||
|
className={`q-option ${isSel ? "selected" : ""}`}
|
||||||
|
role={q.multi_select ? "checkbox" : "radio"}
|
||||||
|
aria-checked={isSel}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => toggle(qi, o.label, !!q.multi_select)}
|
||||||
|
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)}
|
||||||
|
>
|
||||||
|
<span>{q.multi_select ? (isSel ? "☑" : "☐") : isSel ? "◉" : "○"}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{o.label === OTHER ? (
|
||||||
|
<input
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
placeholder={t("question.other")}
|
||||||
|
value={other[qi] ?? ""}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onFocus={() => !isSel && toggle(qi, OTHER, !!q.multi_select)}
|
||||||
|
onChange={(e) => setOther((s) => ({ ...s, [qi]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>{o.label}</div>
|
||||||
|
{o.description && <div className="desc">{o.description}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
style={{ width: "100%", marginTop: 6 }}
|
||||||
|
placeholder={t("question.answer")}
|
||||||
|
value={other[qi] ?? ""}
|
||||||
|
onChange={(e) => setOther((s) => ({ ...s, [qi]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="actions">
|
||||||
|
<button className="primary" disabled={busy || !complete} onClick={() => submit()}>
|
||||||
|
{t("question.submit")}
|
||||||
|
</button>
|
||||||
|
<button disabled={busy} onClick={() => submit(true)}>
|
||||||
|
{t("question.dismiss")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { get, setToken } from "../api";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
|
export function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [token, setValue] = useState("");
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setToken(token.trim());
|
||||||
|
try {
|
||||||
|
await get("/sessions");
|
||||||
|
onSuccess();
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<img src="/icon.svg" alt="" />
|
||||||
|
<h2 style={{ margin: 0 }}>CodeCraft</h2>
|
||||||
|
<div style={{ color: "var(--fg3)" }}>{t("login.prompt")}</div>
|
||||||
|
<input type="password" autoFocus value={token} onChange={(e) => setValue(e.target.value)} placeholder={t("login.token")} />
|
||||||
|
{error && <div style={{ color: "var(--red)" }}>{t("login.invalid")}</div>}
|
||||||
|
<button className="primary" type="submit" disabled={!token.trim()}>
|
||||||
|
{t("login.submit")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeHighlight from "rehype-highlight";
|
||||||
|
|
||||||
|
export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="markdown">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
||||||
|
components={{ a: (props) => <a {...props} target="_blank" rel="noreferrer" /> }}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useModels, modelOptions } from "../hooks";
|
||||||
|
|
||||||
|
/** Model picker; keeps unknown current values selectable. */
|
||||||
|
export function ModelSelect({ value, onChange, title }: { value: string; onChange: (v: string) => void; title?: string }) {
|
||||||
|
const options = modelOptions(useModels());
|
||||||
|
const known = options.some((o) => o.value === value);
|
||||||
|
return (
|
||||||
|
<select value={value} onChange={(e) => onChange(e.target.value)} title={title}>
|
||||||
|
{!known && value && <option value={value}>{value}</option>}
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { post } from "../api";
|
||||||
|
import { useAgents, useFetch } from "../hooks";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { ModelSelect } from "./ModelSelect";
|
||||||
|
|
||||||
|
interface DirListing {
|
||||||
|
path: string;
|
||||||
|
parent: string | null;
|
||||||
|
workspace: string;
|
||||||
|
dirs: { name: string; path: string; git: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAST_DIR = "codecraft.lastDir";
|
||||||
|
|
||||||
|
export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreated: (id: string) => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [path, setPath] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
|
||||||
|
const { data: listing, error: dirError } = useFetch<DirListing>(`/fs/dirs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
|
||||||
|
const config = useFetch<{ default_model?: string }>("/config").data;
|
||||||
|
const [model, setModel] = useState("");
|
||||||
|
const [agent, setAgent] = useState("build");
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const agents = useAgents(listing?.path).filter((a) => a.mode === "primary");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model && config?.default_model) setModel(config.default_model);
|
||||||
|
}, [config, model]);
|
||||||
|
|
||||||
|
// A remembered directory that vanished: fall back to the workspace root.
|
||||||
|
useEffect(() => {
|
||||||
|
if (dirError && path) setPath("");
|
||||||
|
}, [dirError, path]);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!listing) return;
|
||||||
|
setCreating(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_DIR, listing.path);
|
||||||
|
const s = await post<{ id: string }>("/sessions", { cwd: listing.path, model: model || undefined, agent });
|
||||||
|
if (prompt.trim()) await post(`/sessions/${s.id}/prompt`, { text: prompt.trim() });
|
||||||
|
onCreated(s.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<div className="grow">
|
||||||
|
<h1>{t("new.title")}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-inner" style={{ maxWidth: 760 }}>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("new.directory")}</h3>
|
||||||
|
<div className="row" style={{ marginBottom: 8 }}>
|
||||||
|
<code className="grow" style={{ flex: 1 }}>
|
||||||
|
{rel}
|
||||||
|
</code>
|
||||||
|
{listing?.parent && (
|
||||||
|
<button className="small" onClick={() => setPath(listing.parent!)}>
|
||||||
|
↑ {t("new.up")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="dir-list">
|
||||||
|
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||||||
|
{listing?.dirs.map((d) => (
|
||||||
|
<button key={d.path} onClick={() => setPath(d.path)}>
|
||||||
|
<span>📁</span>
|
||||||
|
<span style={{ flex: 1 }}>{d.name}</span>
|
||||||
|
{d.git && <span className="chip">git</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<div className="form">
|
||||||
|
<label>{t("new.model")}</label>
|
||||||
|
<ModelSelect value={model} onChange={setModel} />
|
||||||
|
<label>{t("new.mode")}</label>
|
||||||
|
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
|
||||||
|
{agents.map((a) => (
|
||||||
|
<option key={a.name} value={a.name}>
|
||||||
|
{a.name} – {a.description}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("new.firstPrompt")}</h3>
|
||||||
|
<textarea
|
||||||
|
style={{ width: "100%", minHeight: 110 }}
|
||||||
|
value={prompt}
|
||||||
|
placeholder={t("composer.placeholder")}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) void create();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
<button className="primary" disabled={!listing || creating} onClick={create}>
|
||||||
|
{t("new.start", { dir: rel })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { get, patch as apiPatch, post, put } from "../api";
|
||||||
|
import { useFetch, useModels } from "../hooks";
|
||||||
|
import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import { applyTheme, getTheme, type Theme } from "../theme";
|
||||||
|
import { ModelSelect } from "./ModelSelect";
|
||||||
|
|
||||||
|
type Json = any;
|
||||||
|
interface ConfigResponse {
|
||||||
|
config: Json;
|
||||||
|
workspace: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
type Patch = { path: string[]; value: unknown };
|
||||||
|
|
||||||
|
const MASK = "••••••••";
|
||||||
|
|
||||||
|
async function savePatches(patches: Patch[]) {
|
||||||
|
await apiPatch("/config", { patches });
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSaver(reload: () => void) {
|
||||||
|
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||||
|
const t = useT();
|
||||||
|
const run = async (fn: () => Promise<unknown>) => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
setNotice({ ok: true, text: t("settings.saved") });
|
||||||
|
reload();
|
||||||
|
} catch (e: any) {
|
||||||
|
setNotice({ ok: false, text: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const view = notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>;
|
||||||
|
return { run, view };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- general ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const s = cfg.config.server;
|
||||||
|
const c = cfg.config.craft;
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
default_model: s.default_model ?? "",
|
||||||
|
small_model: s.small_model ?? "",
|
||||||
|
currency: s.currency ?? "USD",
|
||||||
|
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
||||||
|
clean_room: c.clean_room ?? true,
|
||||||
|
chromium: c.chromium ?? "chromium",
|
||||||
|
widths: (c.widths ?? []).join(", "),
|
||||||
|
});
|
||||||
|
const [theme, setThemeState] = useState<Theme>(getTheme());
|
||||||
|
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
|
||||||
|
const { run, view } = useSaver(reload);
|
||||||
|
|
||||||
|
const save = () =>
|
||||||
|
run(() =>
|
||||||
|
savePatches([
|
||||||
|
{ path: ["server", "default_model"], value: form.default_model },
|
||||||
|
{ path: ["server", "small_model"], value: form.small_model },
|
||||||
|
{ path: ["server", "currency"], value: form.currency },
|
||||||
|
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
||||||
|
{ path: ["craft", "clean_room"], value: form.clean_room },
|
||||||
|
{ path: ["craft", "chromium"], value: form.chromium },
|
||||||
|
{ path: ["craft", "widths"], value: String(form.widths).split(/[\s,]+/).filter(Boolean).map(Number) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("settings.interface")}</h3>
|
||||||
|
<div className="form">
|
||||||
|
<label>{t("settings.language")}</label>
|
||||||
|
<select value={getLang()} onChange={(e) => setLang(e.target.value)}>
|
||||||
|
{Object.entries(languages).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label>{t("settings.theme")}</label>
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value as Theme;
|
||||||
|
setThemeState(v);
|
||||||
|
applyTheme(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="system">{t("settings.themeSystem")}</option>
|
||||||
|
<option value="dark">{t("settings.themeDark")}</option>
|
||||||
|
<option value="light">{t("settings.themeLight")}</option>
|
||||||
|
</select>
|
||||||
|
<label>{t("settings.notifications")}</label>
|
||||||
|
<div>
|
||||||
|
{notif === "granted" ? (
|
||||||
|
<span className="chip">{t("settings.notificationsOn")}</span>
|
||||||
|
) : (
|
||||||
|
<button className="small" onClick={() => Notification.requestPermission().then(setNotif)} disabled={notif === "denied"}>
|
||||||
|
{t("settings.notificationsEnable")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hint">{t("settings.notificationsHint")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("settings.server")}</h3>
|
||||||
|
<div className="form">
|
||||||
|
<label>{t("settings.workspace")}</label>
|
||||||
|
<code>{cfg.workspace}</code>
|
||||||
|
<label>{t("settings.defaultModel")}</label>
|
||||||
|
<ModelSelect value={form.default_model} onChange={(v) => setForm({ ...form, default_model: v })} />
|
||||||
|
<label>{t("settings.smallModel")}</label>
|
||||||
|
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
|
||||||
|
<div className="hint">{t("settings.smallModelHint")}</div>
|
||||||
|
<label>{t("settings.currency")}</label>
|
||||||
|
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
|
||||||
|
<label>{t("settings.autoCompact")}</label>
|
||||||
|
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
||||||
|
<div className="hint">{t("settings.autoCompactHint")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("settings.craft")}</h3>
|
||||||
|
<div className="form">
|
||||||
|
<label>{t("settings.cleanRoom")}</label>
|
||||||
|
<input type="checkbox" checked={form.clean_room} onChange={(e) => setForm({ ...form, clean_room: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||||
|
<div className="hint">{t("settings.cleanRoomHint")}</div>
|
||||||
|
<label>{t("settings.chromium")}</label>
|
||||||
|
<input value={form.chromium} onChange={(e) => setForm({ ...form, chromium: e.target.value })} />
|
||||||
|
<label>{t("settings.widths")}</label>
|
||||||
|
<input value={form.widths} onChange={(e) => setForm({ ...form, widths: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{view}
|
||||||
|
<button className="primary" onClick={save}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- providers --------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ModelRow {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
context?: number;
|
||||||
|
max_output?: number;
|
||||||
|
reasoning?: boolean;
|
||||||
|
vision?: boolean;
|
||||||
|
effort?: string;
|
||||||
|
input_price?: number;
|
||||||
|
output_price?: number;
|
||||||
|
cache_read_price?: number;
|
||||||
|
cache_write_price?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
|
||||||
|
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "${ANTHROPIC_API_KEY}" },
|
||||||
|
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "${GEMINI_API_KEY}" },
|
||||||
|
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "${OPENCODE_API_KEY}" },
|
||||||
|
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "${FAL_KEY}", auth_prefix: "Key " },
|
||||||
|
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "${OPENROUTER_API_KEY}" },
|
||||||
|
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
|
||||||
|
custom: { kind: "openai", base_url: "http://", api_key: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeModels(models: Json[]): ModelRow[] {
|
||||||
|
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanModel(m: ModelRow) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(m)) {
|
||||||
|
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
|
||||||
|
if (v === false && (k === "reasoning" || k === "vision")) continue;
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [form, setForm] = useState({ ...p, api_key: p.api_key === MASK ? "" : p.api_key });
|
||||||
|
const [models, setModels] = useState<ModelRow[]>(normalizeModels(p.models));
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const discovered = useModels().find((x) => x.provider === name)?.models ?? [];
|
||||||
|
const { run, view } = useSaver(reload);
|
||||||
|
|
||||||
|
const num = (v: string) => (v === "" ? undefined : Number(v));
|
||||||
|
const setModel = (i: number, patch: Partial<ModelRow>) => setModels((ms) => ms.map((m, j) => (j === i ? { ...m, ...patch } : m)));
|
||||||
|
|
||||||
|
const save = () =>
|
||||||
|
run(() => {
|
||||||
|
const patches: Patch[] = [
|
||||||
|
{ path: ["providers", name, "kind"], value: form.kind },
|
||||||
|
{ path: ["providers", name, "base_url"], value: form.base_url },
|
||||||
|
{ path: ["providers", name, "enabled"], value: !!form.enabled },
|
||||||
|
{ path: ["providers", name, "models"], value: models.filter((m) => m.id.trim()).map(cleanModel) },
|
||||||
|
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
|
||||||
|
];
|
||||||
|
// Empty key field = keep the stored (masked) key.
|
||||||
|
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
|
||||||
|
return savePatches(patches);
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
if (!confirm(t("settings.deleteProviderConfirm", { name }))) return;
|
||||||
|
void run(() => savePatches([{ path: ["providers", name], value: null }]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDiscovered = () => {
|
||||||
|
const have = new Set(models.map((m) => m.id));
|
||||||
|
setModels([...models, ...discovered.filter((d) => !have.has(d.id)).map((d) => ({ id: d.id, context: d.context }))]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
<input type="checkbox" checked={!!form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} title={t("settings.enabled")} />
|
||||||
|
{name}
|
||||||
|
<span className="chip">{form.kind}</span>
|
||||||
|
<span className="chip">{t("settings.modelCount", { n: models.length || discovered.length })}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="small" onClick={() => setOpen(!open)}>
|
||||||
|
{open ? t("common.collapse") : t("common.edit")}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="form">
|
||||||
|
<label>{t("settings.kind")}</label>
|
||||||
|
<select value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
|
||||||
|
<option value="anthropic">Anthropic Messages API</option>
|
||||||
|
<option value="openai">OpenAI-compatible</option>
|
||||||
|
</select>
|
||||||
|
<label>{t("settings.baseUrl")}</label>
|
||||||
|
<input value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} />
|
||||||
|
<label>{t("settings.apiKey")}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.api_key}
|
||||||
|
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : "${ENV_VAR}"}
|
||||||
|
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t("settings.apiKeyHint")}</div>
|
||||||
|
<label>{t("settings.authPrefix")}</label>
|
||||||
|
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ marginTop: 16 }}>
|
||||||
|
{t("settings.models")}
|
||||||
|
<span className="spacer" />
|
||||||
|
{discovered.length > 0 && (
|
||||||
|
<button className="small" onClick={addDiscovered}>
|
||||||
|
{t("settings.addDiscovered", { n: discovered.length })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="small" onClick={() => setModels([...models, { id: "" }])}>
|
||||||
|
+ {t("settings.addModel")}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 6, fontSize: 12, color: "var(--muted)" }}>
|
||||||
|
{t("settings.pricesHint")}
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>{t("settings.context")}</th>
|
||||||
|
<th>{t("settings.maxOutput")}</th>
|
||||||
|
<th title={t("settings.reasoning")}>🧠</th>
|
||||||
|
<th title={t("settings.vision")}>👁</th>
|
||||||
|
<th>{t("settings.effort")}</th>
|
||||||
|
<th>{t("settings.priceIn")}</th>
|
||||||
|
<th>{t("settings.priceOut")}</th>
|
||||||
|
<th>{t("settings.priceCacheRead")}</th>
|
||||||
|
<th>{t("settings.priceCacheWrite")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{models.map((m, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<input value={m.id} onChange={(e) => setModel(i, { id: e.target.value })} style={{ minWidth: 160 }} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" value={m.context ?? ""} onChange={(e) => setModel(i, { context: num(e.target.value) })} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" checked={!!m.vision} onChange={(e) => setModel(i, { vision: e.target.checked })} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select value={m.effort ?? ""} onChange={(e) => setModel(i, { effort: e.target.value || undefined })}>
|
||||||
|
<option value="">–</option>
|
||||||
|
{["low", "medium", "high", "xhigh", "max"].map((x) => (
|
||||||
|
<option key={x}>{x}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
{(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => (
|
||||||
|
<td key={k}>
|
||||||
|
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td>
|
||||||
|
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
|
||||||
|
{view}
|
||||||
|
<div className="row" style={{ marginTop: 12 }}>
|
||||||
|
<button className="primary" onClick={save}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="danger" onClick={remove}>
|
||||||
|
{t("common.delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [preset, setPreset] = useState("anthropic");
|
||||||
|
const { run, view } = useSaver(reload);
|
||||||
|
const providers = cfg.config.providers ?? {};
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
const n = name.trim() || preset;
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||||
|
const p = PRESETS[preset];
|
||||||
|
const value: Record<string, unknown> = { kind: p.kind, base_url: p.base_url, api_key: p.api_key, enabled: true, models: [] };
|
||||||
|
if (p.auth_prefix) value.auth_prefix = p.auth_prefix;
|
||||||
|
void run(() => savePatches([{ path: ["providers", n], value }]));
|
||||||
|
setName("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
|
<button onClick={() => post("/providers/reload").then(reload)}>{t("settings.reloadProviders")}</button>
|
||||||
|
</div>
|
||||||
|
{Object.entries(providers).map(([n, p]) => (
|
||||||
|
<ProviderEditor key={n + JSON.stringify(p)} name={n} p={p} reload={reload} />
|
||||||
|
))}
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("settings.addProvider")}</h3>
|
||||||
|
<div className="row">
|
||||||
|
<select value={preset} onChange={(e) => setPreset(e.target.value)}>
|
||||||
|
{Object.keys(PRESETS).map((k) => (
|
||||||
|
<option key={k}>{k}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input placeholder={t("settings.providerName")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<button className="primary" onClick={add}>
|
||||||
|
+ {t("common.add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{view}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MCP --------------------------------------------------------------------
|
||||||
|
|
||||||
|
function linesToMap(text: string, sep: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const i = line.indexOf(sep);
|
||||||
|
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + sep.length).trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToLines(m: Record<string, string> | undefined, sep: string) {
|
||||||
|
return Object.entries(m ?? {})
|
||||||
|
.map(([k, v]) => `${k}${sep}${v}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload: () => void; isNew?: boolean }) {
|
||||||
|
const t = useT();
|
||||||
|
const status = useStore((s) => s.mcp[name]);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name,
|
||||||
|
type: m.type ?? "stdio",
|
||||||
|
command: m.command ?? "",
|
||||||
|
args: (m.args ?? []).join("\n"),
|
||||||
|
env: mapToLines(m.env, "="),
|
||||||
|
url: m.url ?? "",
|
||||||
|
headers: mapToLines(m.headers, ": "),
|
||||||
|
enabled: m.enabled ?? true,
|
||||||
|
});
|
||||||
|
const [open, setOpen] = useState(!!isNew);
|
||||||
|
const { run, view } = useSaver(reload);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const n = form.name.trim();
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||||
|
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
|
||||||
|
if (form.type === "stdio") {
|
||||||
|
value.command = form.command;
|
||||||
|
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
|
||||||
|
value.env = linesToMap(form.env, "=");
|
||||||
|
} else {
|
||||||
|
value.url = form.url;
|
||||||
|
value.headers = linesToMap(form.headers, ":");
|
||||||
|
}
|
||||||
|
return run(async () => {
|
||||||
|
const patches: Patch[] = [];
|
||||||
|
if (!isNew && n !== name) patches.push({ path: ["mcp", name], value: null });
|
||||||
|
patches.push({ path: ["mcp", n], value });
|
||||||
|
await savePatches(patches);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const st = status?.status;
|
||||||
|
const stateLabel = st ? t(`mcp.state.${st.state}` as Key) : isNew ? "" : t("mcp.state.unknown");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
{isNew ? t("settings.addMcp") : name}
|
||||||
|
{!isNew && <span className={`chip ${st?.state === "failed" ? "warn" : ""}`}>{stateLabel}</span>}
|
||||||
|
{st?.state === "connected" && <span className="chip">{t("mcp.tools", { n: st.tools ?? 0 })}</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
{!isNew && (
|
||||||
|
<button className="small" onClick={() => setOpen(!open)}>
|
||||||
|
{open ? t("common.collapse") : t("common.edit")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{st?.state === "failed" && <div className="notice err">{st.error}</div>}
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="form">
|
||||||
|
<label>{t("settings.name")}</label>
|
||||||
|
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
<label>{t("settings.kind")}</label>
|
||||||
|
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
||||||
|
<option value="stdio">stdio</option>
|
||||||
|
<option value="http">Streamable HTTP</option>
|
||||||
|
</select>
|
||||||
|
{form.type === "stdio" ? (
|
||||||
|
<>
|
||||||
|
<label>{t("mcp.command")}</label>
|
||||||
|
<input value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} placeholder="npx" />
|
||||||
|
<label>{t("mcp.args")}</label>
|
||||||
|
<textarea rows={3} value={form.args} onChange={(e) => setForm({ ...form, args: e.target.value })} className="mono" />
|
||||||
|
<label>{t("mcp.env")}</label>
|
||||||
|
<textarea rows={2} value={form.env} onChange={(e) => setForm({ ...form, env: e.target.value })} className="mono" placeholder="KEY=${SECRET}" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label>URL</label>
|
||||||
|
<input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://…/mcp" />
|
||||||
|
<label>{t("mcp.headers")}</label>
|
||||||
|
<textarea rows={2} value={form.headers} onChange={(e) => setForm({ ...form, headers: e.target.value })} className="mono" placeholder="Authorization: Bearer ${TOKEN}" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label>{t("settings.enabled")}</label>
|
||||||
|
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||||
|
</div>
|
||||||
|
{status?.tools?.length ? (
|
||||||
|
<details style={{ marginTop: 10 }}>
|
||||||
|
<summary style={{ cursor: "pointer" }}>{t("mcp.tools", { n: status.tools.length })}</summary>
|
||||||
|
<ul style={{ fontSize: 12.5 }}>
|
||||||
|
{status.tools.map((tool) => (
|
||||||
|
<li key={tool.name}>
|
||||||
|
<code>{tool.name}</code> – {tool.description}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{view}
|
||||||
|
<div className="row" style={{ marginTop: 12 }}>
|
||||||
|
<button className="primary" onClick={save}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
{!isNew && (
|
||||||
|
<button className="danger" onClick={() => confirm(t("settings.deleteMcpConfirm", { name })) && run(() => savePatches([{ path: ["mcp", name], value: null }]))}>
|
||||||
|
{t("common.delete")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mcp({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const servers = cfg.config.mcp ?? {};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
|
<button onClick={() => post("/mcp/reload")}>{t("mcp.reconnect")}</button>
|
||||||
|
</div>
|
||||||
|
{Object.keys(servers).length === 0 && <div className="notice">{t("mcp.none")}</div>}
|
||||||
|
{Object.entries(servers).map(([n, m]) => (
|
||||||
|
<McpEditor key={n + JSON.stringify(m)} name={n} m={m} reload={reload} />
|
||||||
|
))}
|
||||||
|
<McpEditor key={`new-${Object.keys(servers).length}`} name="" m={{}} reload={reload} isNew />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- permissions ------------------------------------------------------------
|
||||||
|
|
||||||
|
function RuleTable({ rules, setRules, choices, keyLabel }: { rules: [string, string][]; setRules: (r: [string, string][]) => void; choices: string[]; keyLabel: string }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{keyLabel}</th>
|
||||||
|
<th>{t("permissions.rule")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.map(([k, v], i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<input className="mono" value={k} onChange={(e) => setRules(rules.map((r, j) => (j === i ? [e.target.value, r[1]] : r)))} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select value={v} onChange={(e) => setRules(rules.map((r, j) => (j === i ? [r[0], e.target.value] : r)))}>
|
||||||
|
{choices.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{t(`permissions.${c}` as Key)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3}>
|
||||||
|
<button className="small" onClick={() => setRules([...rules, ["", choices[0]]])}>
|
||||||
|
+ {t("common.add")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const { bash_rules = {}, ...tools } = cfg.config.permissions ?? {};
|
||||||
|
const [toolRules, setToolRules] = useState<[string, string][]>(Object.entries(tools) as [string, string][]);
|
||||||
|
const [bashRules, setBashRules] = useState<[string, string][]>(Object.entries(bash_rules) as [string, string][]);
|
||||||
|
const { run, view } = useSaver(reload);
|
||||||
|
|
||||||
|
const save = () =>
|
||||||
|
run(() => {
|
||||||
|
const patches: Patch[] = [];
|
||||||
|
for (const k of Object.keys(tools)) if (!toolRules.some(([n]) => n === k)) patches.push({ path: ["permissions", k], value: null });
|
||||||
|
for (const [k, v] of toolRules) if (k.trim()) patches.push({ path: ["permissions", k.trim()], value: v });
|
||||||
|
for (const k of Object.keys(bash_rules)) if (!bashRules.some(([n]) => n === k)) patches.push({ path: ["permissions", "bash_rules", k], value: null });
|
||||||
|
for (const [k, v] of bashRules) if (k.trim()) patches.push({ path: ["permissions", "bash_rules", k.trim()], value: v });
|
||||||
|
return savePatches(patches);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="notice">{t("permissions.intro")}</div>
|
||||||
|
<div className="two-col">
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("permissions.tools")}</h3>
|
||||||
|
<RuleTable rules={toolRules} setRules={setToolRules} choices={["allow", "ask", "deny"]} keyLabel={t("permissions.tool")} />
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("permissions.bash")}</h3>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 6 }}>{t("permissions.bashHint")}</div>
|
||||||
|
<RuleTable rules={bashRules} setRules={setBashRules} choices={["allow", "deny"]} keyLabel={t("permissions.pattern")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{view}
|
||||||
|
<button className="primary" onClick={save}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- agents (read-only overview) ----------------------------------------------
|
||||||
|
|
||||||
|
function Agents() {
|
||||||
|
const t = useT();
|
||||||
|
const agents = useFetch<Json[]>("/agents").data ?? [];
|
||||||
|
const cmds = useFetch<{ commands: Json[]; skills: Json[] }>("/commands").data;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="notice">{t("agents.intro")}</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("agents.title")}</h3>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{agents.map((a) => (
|
||||||
|
<tr key={a.name}>
|
||||||
|
<td>
|
||||||
|
<code>{a.name}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="chip">{a.mode}</span> {a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||||
|
</td>
|
||||||
|
<td>{a.description}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="two-col">
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("agents.commands")}</h3>
|
||||||
|
{cmds?.commands.length ? cmds.commands.map((c) => <div key={c.name}><code>/{c.name}</code> – {c.description}</div>) : <div className="empty">–</div>}
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("agents.skills")}</h3>
|
||||||
|
{cmds?.skills.length ? cmds.skills.map((s) => <div key={s.name}><code>{s.name}</code> – {s.description}</div>) : <div className="empty">–</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- raw ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Raw({ reload }: { reload: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [text, setText] = useState<string>();
|
||||||
|
const { run, view } = useSaver(reload);
|
||||||
|
useEffect(() => {
|
||||||
|
get<{ text: string }>("/config/raw").then((r) => setText(r.text));
|
||||||
|
}, []);
|
||||||
|
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="notice">{t("settings.rawHint")}</div>
|
||||||
|
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
|
{view}
|
||||||
|
<div className="row" style={{ marginTop: 8 }}>
|
||||||
|
<button className="primary" onClick={() => run(() => put("/config/raw", { text }))}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))}>{t("settings.revert")}</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- page -------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TABS = ["general", "providers", "mcp", "permissions", "agents", "raw"] as const;
|
||||||
|
|
||||||
|
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const active = (TABS as readonly string[]).includes(tab ?? "") ? tab! : "general";
|
||||||
|
const { data: cfg, error, reload } = useFetch<ConfigResponse>("/config");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<div className="grow">
|
||||||
|
<h1>{t("nav.settings")}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-inner">
|
||||||
|
<div className="tabs" role="tablist">
|
||||||
|
{TABS.map((x) => (
|
||||||
|
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
|
||||||
|
{t(`settings.tab.${x}` as Key)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
{!cfg && !error && <div className="empty">{t("common.loading")}</div>}
|
||||||
|
{cfg && active === "general" && <General key={JSON.stringify(cfg.config.server)} cfg={cfg} reload={reload} />}
|
||||||
|
{cfg && active === "providers" && <Providers cfg={cfg} reload={reload} />}
|
||||||
|
{cfg && active === "mcp" && <Mcp cfg={cfg} reload={reload} />}
|
||||||
|
{cfg && active === "permissions" && <Permissions key={JSON.stringify(cfg.config.permissions)} cfg={cfg} reload={reload} />}
|
||||||
|
{active === "agents" && <Agents />}
|
||||||
|
{active === "raw" && <Raw reload={reload} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { fmtRelative, useT, type Key } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import type { Session } from "../types";
|
||||||
|
|
||||||
|
function basename(p: string) {
|
||||||
|
return p.split("/").filter(Boolean).pop() ?? p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupKey(ms: number): Key {
|
||||||
|
const day = 86400000;
|
||||||
|
const startOfToday = new Date().setHours(0, 0, 0, 0);
|
||||||
|
if (ms >= startOfToday) return "sidebar.today";
|
||||||
|
if (ms >= startOfToday - day) return "sidebar.yesterday";
|
||||||
|
if (ms >= startOfToday - 7 * day) return "sidebar.week";
|
||||||
|
return "sidebar.older";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const sessions = useStore((s) => s.sessions);
|
||||||
|
const busy = useStore((s) => s.busy);
|
||||||
|
const connected = useStore((s) => s.connected);
|
||||||
|
const permissions = useStore((s) => s.permissions);
|
||||||
|
const questions = useStore((s) => s.questions);
|
||||||
|
|
||||||
|
const attention = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const p of permissions) ids.add(p.root_session_id ?? p.session_id);
|
||||||
|
for (const q of questions) ids.add(q.root_session_id ?? q.session_id);
|
||||||
|
return ids;
|
||||||
|
}, [permissions, questions]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const out: { key: Key; items: Session[] }[] = [];
|
||||||
|
for (const s of sessions) {
|
||||||
|
const key = groupKey(s.updated_at);
|
||||||
|
const g = out.find((x) => x.key === key);
|
||||||
|
if (g) g.items.push(s);
|
||||||
|
else out.push({ key, items: [s] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`sidebar ${open ? "open" : ""}`}>
|
||||||
|
<div className="sidebar-head">
|
||||||
|
<div className="logo">
|
||||||
|
<img src="/icon.svg" alt="" />
|
||||||
|
CodeCraft
|
||||||
|
</div>
|
||||||
|
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "0 12px 8px" }}>
|
||||||
|
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
|
||||||
|
+ {t("sidebar.newSession")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav className="session-list">
|
||||||
|
{sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g.key}>
|
||||||
|
<div className="session-group">{t(g.key)}</div>
|
||||||
|
{g.items.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
className={`session-item ${s.id === activeId ? "active" : ""}`}
|
||||||
|
onClick={() => navigate(`/s/${s.id}`)}
|
||||||
|
>
|
||||||
|
<div className="title">
|
||||||
|
{attention.has(s.id) ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy[s.id] && <i className="busy-dot" />}
|
||||||
|
<span>{s.title || t("session.untitled")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub">
|
||||||
|
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-foot">
|
||||||
|
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
||||||
|
{t("nav.stats")}
|
||||||
|
</button>
|
||||||
|
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
|
||||||
|
{t("nav.settings")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useFetch } from "../hooks";
|
||||||
|
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, type Key } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import type { Aggregate } from "../types";
|
||||||
|
import { Heatmap, Legend, SERIES, StackedBars, type Series } from "./Charts";
|
||||||
|
|
||||||
|
type Row<K extends string> = Aggregate & Record<K, string>;
|
||||||
|
|
||||||
|
interface StatsData {
|
||||||
|
total: Aggregate;
|
||||||
|
by_day: Row<"day">[];
|
||||||
|
by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[];
|
||||||
|
by_model: Row<"model">[];
|
||||||
|
by_provider: Row<"provider">[];
|
||||||
|
by_agent: Row<"agent">[];
|
||||||
|
heatmap: (Aggregate & { slot: number })[];
|
||||||
|
top_sessions: (Aggregate & { session_id: string; title: string })[];
|
||||||
|
currency: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGES: { key: string; days: number; label: Key }[] = [
|
||||||
|
{ key: "7", days: 7, label: "stats.range7" },
|
||||||
|
{ key: "30", days: 30, label: "stats.range30" },
|
||||||
|
{ key: "90", days: 90, label: "stats.range90" },
|
||||||
|
{ key: "365", days: 365, label: "stats.range365" },
|
||||||
|
{ key: "all", days: 0, label: "stats.rangeAll" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type HeatMetric = "requests" | "tokens" | "cost";
|
||||||
|
|
||||||
|
function dayKey(ms: number) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All days between from and to (local time), so empty days show as gaps. */
|
||||||
|
function dayRange(fromMs: number, toMs: number, firstDataDay?: string): string[] {
|
||||||
|
const start = new Date(firstDataDay && fromMs === 0 ? `${firstDataDay}T00:00:00` : fromMs);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let d = new Date(start); d.getTime() <= toMs && out.length < 800; d.setDate(d.getDate() + 1)) out.push(dayKey(d.getTime()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="k-label">{label}</div>
|
||||||
|
<div className="k-value">{value}</div>
|
||||||
|
{sub && <div className="k-sub">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & Record<string, any>)[]; keyName: string; label: string; currency: string; link?: (r: any) => void }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{label}</th>
|
||||||
|
<th className="num">{t("stats.requests")}</th>
|
||||||
|
<th className="num">{t("stats.input")}</th>
|
||||||
|
<th className="num">{t("stats.cacheRead")}</th>
|
||||||
|
<th className="num">{t("stats.output")}</th>
|
||||||
|
<th className="num">{t("stats.ttft")}</th>
|
||||||
|
<th className="num">{t("stats.outputSpeed")}</th>
|
||||||
|
<th className="num">{t("stats.prefillSpeed")}</th>
|
||||||
|
<th className="num">{t("stats.cost")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r[keyName]}>
|
||||||
|
<td>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
|
||||||
|
<td className="num">{fmtNumber(r.requests)}</td>
|
||||||
|
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
|
||||||
|
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
|
||||||
|
<td className="num">{fmtCompact(r.output_tokens)}</td>
|
||||||
|
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
|
||||||
|
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
|
||||||
|
<td className="num">{fmtNumber(r.prefill_tps)}</td>
|
||||||
|
<td className="num">{fmtCost(r.cost, currency)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [range, setRange] = useState("30");
|
||||||
|
const [heat, setHeat] = useState<HeatMetric>("requests");
|
||||||
|
const [tableView, setTableView] = useState(false);
|
||||||
|
const days = RANGES.find((r) => r.key === range)!.days;
|
||||||
|
const tz = -new Date().getTimezoneOffset();
|
||||||
|
// Stable per range: rounded to the minute so the URL doesn't change on every render.
|
||||||
|
const now = Math.floor(Date.now() / 60000) * 60000 + 60000;
|
||||||
|
const from = days ? now - days * 86400000 : 0;
|
||||||
|
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}`);
|
||||||
|
|
||||||
|
const view = useMemo(() => {
|
||||||
|
if (!data) return undefined;
|
||||||
|
const cats = dayRange(data.from, data.to, data.by_day[0]?.day);
|
||||||
|
// Up to 7 models by cost get their own color; the rest fold into "Other".
|
||||||
|
const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests);
|
||||||
|
const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model);
|
||||||
|
const other = t("stats.other");
|
||||||
|
const modelSeries: Series[] = [...named, ...(ranked.length > 8 ? [other] : [])].map((m, i) => ({ key: m, label: m, color: SERIES[i] }));
|
||||||
|
const costByDay = cats.map(() => ({}) as Record<string, number>);
|
||||||
|
const idx = new Map(cats.map((c, i) => [c, i]));
|
||||||
|
for (const r of data.by_day_model) {
|
||||||
|
const i = idx.get(r.day);
|
||||||
|
if (i === undefined) continue;
|
||||||
|
const key = named.includes(r.model) ? r.model : other;
|
||||||
|
costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost;
|
||||||
|
}
|
||||||
|
const tokenSeries: Series[] = [
|
||||||
|
{ key: "input", label: t("stats.input"), color: SERIES[0] },
|
||||||
|
{ key: "cache", label: t("stats.cacheRead"), color: SERIES[1] },
|
||||||
|
{ key: "output", label: t("stats.output"), color: SERIES[2] },
|
||||||
|
];
|
||||||
|
const tokensByDay = cats.map((c): Record<string, number> => {
|
||||||
|
const d = data.by_day.find((x) => x.day === c);
|
||||||
|
return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {};
|
||||||
|
});
|
||||||
|
const cells = Array<number>(168).fill(0);
|
||||||
|
for (const h of data.heatmap) {
|
||||||
|
cells[h.slot] =
|
||||||
|
heat === "requests" ? h.requests : heat === "cost" ? h.cost : h.input_tokens + h.cache_read_tokens + h.cache_write_tokens + h.output_tokens;
|
||||||
|
}
|
||||||
|
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells };
|
||||||
|
}, [data, heat, t]);
|
||||||
|
|
||||||
|
const dayLabel = (d: string) =>
|
||||||
|
new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`));
|
||||||
|
const cur = data?.currency ?? "USD";
|
||||||
|
const tot = data?.total;
|
||||||
|
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<div className="grow">
|
||||||
|
<h1>{t("nav.stats")}</h1>
|
||||||
|
{data && (
|
||||||
|
<div className="path">
|
||||||
|
{data.from ? fmtDate(data.from, false) : t("stats.rangeAll")} – {fmtDate(data.to, false)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mode-tabs" role="tablist">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button key={r.key} className={range === r.key ? "active" : ""} onClick={() => setRange(r.key)}>
|
||||||
|
{t(r.label)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="small" onClick={() => setTableView(!tableView)}>
|
||||||
|
{tableView ? t("stats.chartView") : t("stats.tableView")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-inner">
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
{!data && !error && <div className="empty">{t("common.loading")}</div>}
|
||||||
|
{data && tot && view && (
|
||||||
|
<>
|
||||||
|
<div className="kpis">
|
||||||
|
<Kpi label={t("stats.cost")} value={fmtCost(tot.cost, cur)} sub={t("stats.perRequest", { v: fmtCost(tot.requests ? tot.cost / tot.requests : 0, cur) })} />
|
||||||
|
<Kpi label={t("stats.requests")} value={fmtNumber(tot.requests)} sub={tot.errors ? t("stats.errors", { n: tot.errors }) : undefined} />
|
||||||
|
<Kpi label={t("stats.input")} value={fmtCompact(tot.input_tokens + tot.cache_read_tokens + tot.cache_write_tokens)} sub={`${t("stats.cacheRead")}: ${fmtCompact(tot.cache_read_tokens)}`} />
|
||||||
|
<Kpi label={t("stats.output")} value={fmtCompact(tot.output_tokens)} />
|
||||||
|
<Kpi label={t("stats.cacheHit")} value={fmtPercent(tot.cache_hit_rate)} />
|
||||||
|
<Kpi label={t("stats.ttft")} value={`${fmtNumber(tot.avg_ttft_ms)} ms`} sub={t("stats.ttftHint")} />
|
||||||
|
<Kpi label={t("stats.outputSpeed")} value={`${fmtNumber(tot.output_tps, 1)} tok/s`} />
|
||||||
|
<Kpi label={t("stats.prefillSpeed")} value={`${fmtNumber(tot.prefill_tps)} tok/s`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tot.requests === 0 ? (
|
||||||
|
<div className="empty">{t("stats.noData")}</div>
|
||||||
|
) : tableView ? (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("stats.perDay")}</h3>
|
||||||
|
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("stats.costPerDay")}</h3>
|
||||||
|
<Legend series={view.modelSeries} />
|
||||||
|
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("stats.tokensPerDay")}</h3>
|
||||||
|
<Legend series={view.tokenSeries} />
|
||||||
|
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
{t("stats.heatmap")}
|
||||||
|
<span className="spacer" />
|
||||||
|
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)}>
|
||||||
|
<option value="requests">{t("stats.requests")}</option>
|
||||||
|
<option value="tokens">{t("stats.tokens")}</option>
|
||||||
|
<option value="cost">{t("stats.cost")}</option>
|
||||||
|
</select>
|
||||||
|
</h3>
|
||||||
|
<Heatmap cells={view.cells} weekdays={weekdayNames()} format={heatFormat} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tot.requests > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("stats.byModel")}</h3>
|
||||||
|
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
|
||||||
|
</div>
|
||||||
|
<div className="two-col">
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("stats.byProvider")}</h3>
|
||||||
|
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("stats.byAgent")}</h3>
|
||||||
|
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<h3>{t("stats.topSessions")}</h3>
|
||||||
|
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => navigate(`/s/${r.session_id}`)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useT } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import type { Part, Todo } from "../types";
|
||||||
|
import { Diff } from "./Diff";
|
||||||
|
|
||||||
|
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||||
|
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||||
|
|
||||||
|
function short(s: unknown, n = 90): string {
|
||||||
|
const str = typeof s === "string" ? s : JSON.stringify(s);
|
||||||
|
return str.length > n ? `${str.slice(0, n)}…` : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-line summary of a tool call. */
|
||||||
|
export function toolTitle(name: string, input: any): string {
|
||||||
|
switch (name) {
|
||||||
|
case "read":
|
||||||
|
case "write":
|
||||||
|
case "edit":
|
||||||
|
return input?.path ?? "";
|
||||||
|
case "list":
|
||||||
|
return input?.path ?? ".";
|
||||||
|
case "bash":
|
||||||
|
return input?.description || input?.command || "";
|
||||||
|
case "glob":
|
||||||
|
case "grep":
|
||||||
|
return `${input?.pattern ?? ""}${input?.path ? ` in ${input.path}` : ""}`;
|
||||||
|
case "web_fetch":
|
||||||
|
case "browser_snapshot":
|
||||||
|
return input?.url ?? "";
|
||||||
|
case "task":
|
||||||
|
return `${input?.subagent_type ?? "general"}: ${input?.description ?? ""}`;
|
||||||
|
case "image_compare":
|
||||||
|
return `${input?.original ?? ""} ↔ ${input?.candidate ?? ""}`;
|
||||||
|
case "skill":
|
||||||
|
return input?.name ?? "";
|
||||||
|
case "ask_user":
|
||||||
|
return input?.questions?.[0]?.question ?? "";
|
||||||
|
default:
|
||||||
|
return short(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodoList({ todos }: { todos: Todo[] }) {
|
||||||
|
const icon = { completed: "☑", in_progress: "▶", pending: "☐" } as const;
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{todos.map((td, i) => (
|
||||||
|
<li key={i} className={td.status}>
|
||||||
|
<span>{icon[td.status] ?? "☐"}</span>
|
||||||
|
<span>{td.content}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCard({ call, result, running }: { call: ToolCall; result?: ToolResult; running: boolean }) {
|
||||||
|
const t = useT();
|
||||||
|
const progress = useStore((s) => s.progress[call.id]);
|
||||||
|
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||||||
|
const statusText = result ? (result.is_error ? "✗" : "✓") : running ? "…" : "–";
|
||||||
|
const meta = result?.meta;
|
||||||
|
|
||||||
|
// The todo list is shown in its own panel; keep the card compact.
|
||||||
|
if (call.name === "todo_write" && !result?.is_error) {
|
||||||
|
return (
|
||||||
|
<div className="todos">
|
||||||
|
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||||
|
<TodoList todos={call.input?.todos ?? []} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="tool" open={status === "err" && !!result}>
|
||||||
|
<summary>
|
||||||
|
<span className="tool-name">{call.name}</span>
|
||||||
|
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
|
||||||
|
{meta?.similarity !== undefined && <span className="chip">{meta.similarity} %</span>}
|
||||||
|
<span className={`status ${status}`}>{statusText}</span>
|
||||||
|
</summary>
|
||||||
|
<div className="tool-body">
|
||||||
|
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||||||
|
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
||||||
|
{call.name === "task" && meta?.session_id && (
|
||||||
|
<button className="small" onClick={() => navigate(`/s/${meta.session_id}`)}>
|
||||||
|
{t("tool.openSubagent")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!["bash", "edit", "write", "task"].includes(call.name) && (
|
||||||
|
<>
|
||||||
|
<div className="label">{t("tool.input")}</div>
|
||||||
|
<pre className="mono">{JSON.stringify(call.input, null, 2)}</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!result && progress && (
|
||||||
|
<>
|
||||||
|
<div className="label">{t("tool.output")}</div>
|
||||||
|
<pre className="mono">{progress}</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result && !((call.name === "edit" || call.name === "write") && meta?.diff && !result.is_error) && (
|
||||||
|
<>
|
||||||
|
<div className="label">{t("tool.output")}</div>
|
||||||
|
<pre className="mono">{result.content}</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result?.images?.map((img, i) => (
|
||||||
|
<img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { get } from "./api";
|
||||||
|
import type { AgentInfo, ProviderModels } from "./types";
|
||||||
|
|
||||||
|
/** Fetch JSON and refetch on demand. */
|
||||||
|
export function useFetch<T>(path: string | null) {
|
||||||
|
const [data, setData] = useState<T | undefined>();
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!path) return;
|
||||||
|
let alive = true;
|
||||||
|
get<T>(path)
|
||||||
|
.then((d) => alive && (setData(d), setError(undefined)))
|
||||||
|
.catch((e) => alive && setError(String(e.message ?? e)));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [path, tick]);
|
||||||
|
const reload = useCallback(() => setTick((x) => x + 1), []);
|
||||||
|
return { data, error, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModels() {
|
||||||
|
return useFetch<ProviderModels[]>("/models").data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgents(cwd?: string) {
|
||||||
|
const q = cwd ? `?cwd=${encodeURIComponent(cwd)}` : "";
|
||||||
|
return useFetch<AgentInfo[]>(`/agents${q}`).data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelOptions(models: ProviderModels[]): { value: string; label: string }[] {
|
||||||
|
return models.flatMap((p) => p.models.map((m) => ({ value: `${p.provider}/${m.id}`, label: `${p.provider} · ${m.name}` })));
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { Messages } from "./en";
|
||||||
|
|
||||||
|
export const de: Messages = {
|
||||||
|
"common.loading": "Lädt…",
|
||||||
|
"common.save": "Speichern",
|
||||||
|
"common.add": "Hinzufügen",
|
||||||
|
"common.edit": "Bearbeiten",
|
||||||
|
"common.delete": "Löschen",
|
||||||
|
"common.remove": "Entfernen",
|
||||||
|
"common.close": "Schließen",
|
||||||
|
"common.collapse": "Einklappen",
|
||||||
|
|
||||||
|
"nav.menu": "Menü",
|
||||||
|
"nav.settings": "Einstellungen",
|
||||||
|
"nav.stats": "Statistik",
|
||||||
|
|
||||||
|
"login.prompt": "Gib das auf dem Server konfigurierte Zugriffstoken ein.",
|
||||||
|
"login.token": "Zugriffstoken",
|
||||||
|
"login.invalid": "Ungültiges Token.",
|
||||||
|
"login.submit": "Anmelden",
|
||||||
|
|
||||||
|
"sidebar.newSession": "Neue Session",
|
||||||
|
"sidebar.noSessions": "Noch keine Sessions",
|
||||||
|
"sidebar.today": "Heute",
|
||||||
|
"sidebar.yesterday": "Gestern",
|
||||||
|
"sidebar.week": "Letzte 7 Tage",
|
||||||
|
"sidebar.older": "Älter",
|
||||||
|
"sidebar.connected": "Verbunden",
|
||||||
|
"sidebar.disconnected": "Getrennt – verbinde neu…",
|
||||||
|
"sidebar.needsInput": "Wartet auf deine Eingabe",
|
||||||
|
|
||||||
|
"session.untitled": "Neue Session",
|
||||||
|
|
||||||
|
"new.title": "Neue Session",
|
||||||
|
"new.directory": "Projektverzeichnis",
|
||||||
|
"new.up": "Hoch",
|
||||||
|
"new.noSubdirs": "Keine Unterverzeichnisse",
|
||||||
|
"new.model": "Modell",
|
||||||
|
"new.mode": "Modus",
|
||||||
|
"new.firstPrompt": "Was soll erledigt werden? (optional)",
|
||||||
|
"new.start": "Starten in {dir}",
|
||||||
|
|
||||||
|
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
||||||
|
"chat.reasoning": "Überlegungen",
|
||||||
|
"chat.thinking": "Arbeitet…",
|
||||||
|
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||||
|
"chat.context": "Kontext",
|
||||||
|
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
||||||
|
"chat.statsHint": "Tokens und Kosten dieser Session (Klick für Details)",
|
||||||
|
"chat.renameHint": "Doppelklick zum Umbenennen",
|
||||||
|
"chat.rewind": "Ab hier bearbeiten und neu senden",
|
||||||
|
"chat.rewindConfirm": "Diese Nachricht und alles danach entfernen? Dateiänderungen werden nicht rückgängig gemacht.",
|
||||||
|
"chat.delete": "Session löschen",
|
||||||
|
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
||||||
|
|
||||||
|
"composer.placeholder": "Nachricht… (/ für Befehle, Bilder einfügen)",
|
||||||
|
"composer.placeholderBusy": "Nachricht wird eingereiht und beim nächsten Schritt gesendet…",
|
||||||
|
"composer.send": "Senden",
|
||||||
|
"composer.queue": "Einreihen",
|
||||||
|
"composer.stop": "Stopp",
|
||||||
|
"composer.mode": "Modus",
|
||||||
|
"composer.model": "Modell",
|
||||||
|
"composer.permissionMode": "Berechtigungsmodus",
|
||||||
|
"composer.attach": "Bilder anhängen",
|
||||||
|
|
||||||
|
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||||
|
"command.new": "Neue Session starten",
|
||||||
|
|
||||||
|
"permissionMode.default": "Vor Änderungen fragen",
|
||||||
|
"permissionMode.accept_edits": "Änderungen automatisch annehmen",
|
||||||
|
"permissionMode.bypass": "Alles erlauben",
|
||||||
|
|
||||||
|
"plan.implement": "Zu Build wechseln und Plan umsetzen",
|
||||||
|
"plan.implementPrompt": "Setze den Plan um.",
|
||||||
|
|
||||||
|
"tool.input": "Eingabe",
|
||||||
|
"tool.output": "Ausgabe",
|
||||||
|
"tool.todos": "Aufgaben",
|
||||||
|
"tool.openSubagent": "Subagent-Session öffnen",
|
||||||
|
|
||||||
|
"interaction.fromSubagent": "Vom Subagenten:",
|
||||||
|
|
||||||
|
"permission.title": "Freigabe erforderlich",
|
||||||
|
"permission.allowOnce": "Einmal erlauben",
|
||||||
|
"permission.alwaysTool": "{tool} in dieser Session immer erlauben",
|
||||||
|
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
|
||||||
|
"permission.reject": "Ablehnen",
|
||||||
|
"permission.rejectWithFeedback": "Mit Rückmeldung ablehnen",
|
||||||
|
"permission.feedback": "Optional: Sag dem Agenten, was er stattdessen tun soll",
|
||||||
|
|
||||||
|
"question.title": "Frage an dich",
|
||||||
|
"question.fromAgent": "Der Agent braucht eine Entscheidung",
|
||||||
|
"question.fromMcp": "MCP-Server „{server}“ fragt",
|
||||||
|
"question.other": "Andere Antwort (Freitext)",
|
||||||
|
"question.answer": "Deine Antwort",
|
||||||
|
"question.submit": "Antworten",
|
||||||
|
"question.dismiss": "Verwerfen",
|
||||||
|
|
||||||
|
"settings.tab.general": "Allgemein",
|
||||||
|
"settings.tab.providers": "Provider & Modelle",
|
||||||
|
"settings.tab.mcp": "MCP",
|
||||||
|
"settings.tab.permissions": "Berechtigungen",
|
||||||
|
"settings.tab.agents": "Agenten & Befehle",
|
||||||
|
"settings.tab.raw": "Config-Datei",
|
||||||
|
"settings.saved": "Gespeichert – Änderungen sind aktiv.",
|
||||||
|
"settings.interface": "Oberfläche (dieses Gerät)",
|
||||||
|
"settings.language": "Sprache",
|
||||||
|
"settings.theme": "Design",
|
||||||
|
"settings.themeSystem": "System",
|
||||||
|
"settings.themeDark": "Dunkel",
|
||||||
|
"settings.themeLight": "Hell",
|
||||||
|
"settings.notifications": "Benachrichtigungen",
|
||||||
|
"settings.notificationsEnable": "Aktivieren",
|
||||||
|
"settings.notificationsOn": "Aktiv",
|
||||||
|
"settings.notificationsHint": "Benachrichtigen, wenn ein Agent eine Frage stellt, während die App im Hintergrund ist.",
|
||||||
|
"settings.server": "Server",
|
||||||
|
"settings.workspace": "Workspace",
|
||||||
|
"settings.defaultModel": "Standardmodell",
|
||||||
|
"settings.smallModel": "Kleines Modell",
|
||||||
|
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
||||||
|
"settings.currency": "Währung",
|
||||||
|
"settings.autoCompact": "Auto-Komprimierung bei",
|
||||||
|
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
||||||
|
"settings.craft": "Craft-Modus",
|
||||||
|
"settings.cleanRoom": "Clean Room",
|
||||||
|
"settings.cleanRoomHint": "Nur nutzen, was ein Besucher sieht. Aus = Deep-Modus (Quelltext und Skripte dürfen gelesen werden).",
|
||||||
|
"settings.chromium": "Chromium-Programm",
|
||||||
|
"settings.widths": "Bildschirmbreiten (px)",
|
||||||
|
"settings.kind": "Typ",
|
||||||
|
"settings.baseUrl": "Basis-URL",
|
||||||
|
"settings.apiKey": "API-Key",
|
||||||
|
"settings.apiKeyStored": "Gespeichert – leer lassen zum Beibehalten",
|
||||||
|
"settings.apiKeyHint": "Am besten ${ENV_VAR}-Verweise nutzen; direkte Keys stehen im Klartext in der Config-Datei.",
|
||||||
|
"settings.authPrefix": "Auth-Präfix",
|
||||||
|
"settings.enabled": "Aktiv",
|
||||||
|
"settings.modelCount": "{n} Modelle",
|
||||||
|
"settings.models": "Modelle & Preise",
|
||||||
|
"settings.addModel": "Modell",
|
||||||
|
"settings.addDiscovered": "{n} gefundene hinzufügen",
|
||||||
|
"settings.pricesHint": "Preise pro 1 Mio. Tokens in der eingestellten Währung. Cache-Preise fallen auf den Input-Preis zurück.",
|
||||||
|
"settings.modelsEmptyHint": "Keine Modelle eingetragen: Sie werden automatisch beim Provider abgefragt.",
|
||||||
|
"settings.context": "Kontext",
|
||||||
|
"settings.maxOutput": "Max. Ausgabe",
|
||||||
|
"settings.reasoning": "Reasoning",
|
||||||
|
"settings.vision": "Bilder",
|
||||||
|
"settings.effort": "Effort",
|
||||||
|
"settings.priceIn": "Input",
|
||||||
|
"settings.priceOut": "Output",
|
||||||
|
"settings.priceCacheRead": "Cache lesen",
|
||||||
|
"settings.priceCacheWrite": "Cache schreiben",
|
||||||
|
"settings.addProvider": "Provider hinzufügen",
|
||||||
|
"settings.providerName": "Name (optional)",
|
||||||
|
"settings.reloadProviders": "Provider & Modelle neu laden",
|
||||||
|
"settings.deleteProviderConfirm": "Provider „{name}“ löschen?",
|
||||||
|
"settings.invalidName": "Namen dürfen nur Buchstaben, Ziffern, - und _ enthalten.",
|
||||||
|
"settings.name": "Name",
|
||||||
|
"settings.addMcp": "MCP-Server hinzufügen",
|
||||||
|
"settings.deleteMcpConfirm": "MCP-Server „{name}“ löschen?",
|
||||||
|
"settings.rawHint": "Die komplette Konfigurationsdatei inklusive Kommentaren. Sie wird vor dem Speichern geprüft.",
|
||||||
|
"settings.revert": "Zurücksetzen",
|
||||||
|
|
||||||
|
"mcp.reconnect": "Alle neu verbinden",
|
||||||
|
"mcp.none": "Keine MCP-Server konfiguriert.",
|
||||||
|
"mcp.tools": "{n} Tools",
|
||||||
|
"mcp.command": "Befehl",
|
||||||
|
"mcp.args": "Argumente (eins pro Zeile)",
|
||||||
|
"mcp.env": "Umgebung (KEY=Wert)",
|
||||||
|
"mcp.headers": "Header (Name: Wert)",
|
||||||
|
"mcp.state.disabled": "deaktiviert",
|
||||||
|
"mcp.state.connecting": "verbinde…",
|
||||||
|
"mcp.state.connected": "verbunden",
|
||||||
|
"mcp.state.failed": "Fehler",
|
||||||
|
"mcp.state.unknown": "nicht gestartet",
|
||||||
|
|
||||||
|
"permissions.intro": "Regeln für die Tool-Nutzung. „Fragen“ zeigt eine Freigabekarte. MCP-Tools: mcp__<server>__<tool>, Platzhalter erlaubt (mcp__*).",
|
||||||
|
"permissions.tools": "Tools",
|
||||||
|
"permissions.bash": "Shell-Befehle",
|
||||||
|
"permissions.bashHint": "Glob-Muster auf den ganzen Befehl. Zusammengesetzte Befehle (&&, ;, |) sind nur erlaubt, wenn jeder Teil erlaubt ist.",
|
||||||
|
"permissions.tool": "Tool",
|
||||||
|
"permissions.pattern": "Muster",
|
||||||
|
"permissions.rule": "Regel",
|
||||||
|
"permissions.allow": "Erlauben",
|
||||||
|
"permissions.ask": "Fragen",
|
||||||
|
"permissions.deny": "Verbieten",
|
||||||
|
|
||||||
|
"agents.intro": "Modi und Subagenten. Eigene in der Config-Datei ([agents.*]) oder als Markdown in .codecraft/agents bzw. .claude/agents anlegen.",
|
||||||
|
"agents.title": "Agenten",
|
||||||
|
"agents.readOnly": "nur lesen",
|
||||||
|
"agents.commands": "Slash-Befehle",
|
||||||
|
"agents.skills": "Skills",
|
||||||
|
|
||||||
|
"stats.range7": "7 Tage",
|
||||||
|
"stats.range30": "30 Tage",
|
||||||
|
"stats.range90": "90 Tage",
|
||||||
|
"stats.range365": "1 Jahr",
|
||||||
|
"stats.rangeAll": "Alles",
|
||||||
|
"stats.tableView": "Tabellenansicht",
|
||||||
|
"stats.chartView": "Diagrammansicht",
|
||||||
|
"stats.cost": "Kosten",
|
||||||
|
"stats.perRequest": "{v} pro Anfrage",
|
||||||
|
"stats.requests": "Anfragen",
|
||||||
|
"stats.errors": "{n} fehlgeschlagen",
|
||||||
|
"stats.input": "Input",
|
||||||
|
"stats.output": "Output",
|
||||||
|
"stats.cacheRead": "Cache gelesen",
|
||||||
|
"stats.cacheHit": "Cache-Trefferquote",
|
||||||
|
"stats.tokens": "Tokens",
|
||||||
|
"stats.ttft": "Zeit bis zum ersten Token",
|
||||||
|
"stats.ttftHint": "Durchschnittliche Prefill-Wartezeit",
|
||||||
|
"stats.outputSpeed": "Ausgabegeschwindigkeit",
|
||||||
|
"stats.prefillSpeed": "Prefill-Geschwindigkeit",
|
||||||
|
"stats.costPerDay": "Kosten pro Tag nach Modell",
|
||||||
|
"stats.tokensPerDay": "Tokens pro Tag",
|
||||||
|
"stats.heatmap": "Aktivität nach Wochentag und Uhrzeit",
|
||||||
|
"stats.byModel": "Nach Modell",
|
||||||
|
"stats.byProvider": "Nach Provider",
|
||||||
|
"stats.byAgent": "Nach Modus",
|
||||||
|
"stats.topSessions": "Teuerste Sessions",
|
||||||
|
"stats.perDay": "Pro Tag",
|
||||||
|
"stats.day": "Tag",
|
||||||
|
"stats.model": "Modell",
|
||||||
|
"stats.provider": "Provider",
|
||||||
|
"stats.agent": "Modus",
|
||||||
|
"stats.session": "Session",
|
||||||
|
"stats.total": "Gesamt",
|
||||||
|
"stats.other": "Sonstige",
|
||||||
|
"stats.noData": "Keine Anfragen in diesem Zeitraum.",
|
||||||
|
};
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Reference language. Every other language must define the same keys.
|
||||||
|
export const en = {
|
||||||
|
"common.loading": "Loading…",
|
||||||
|
"common.save": "Save",
|
||||||
|
"common.add": "Add",
|
||||||
|
"common.edit": "Edit",
|
||||||
|
"common.delete": "Delete",
|
||||||
|
"common.remove": "Remove",
|
||||||
|
"common.close": "Close",
|
||||||
|
"common.collapse": "Collapse",
|
||||||
|
|
||||||
|
"nav.menu": "Menu",
|
||||||
|
"nav.settings": "Settings",
|
||||||
|
"nav.stats": "Statistics",
|
||||||
|
|
||||||
|
"login.prompt": "Enter the access token configured on the server.",
|
||||||
|
"login.token": "Access token",
|
||||||
|
"login.invalid": "Invalid token.",
|
||||||
|
"login.submit": "Sign in",
|
||||||
|
|
||||||
|
"sidebar.newSession": "New session",
|
||||||
|
"sidebar.noSessions": "No sessions yet",
|
||||||
|
"sidebar.today": "Today",
|
||||||
|
"sidebar.yesterday": "Yesterday",
|
||||||
|
"sidebar.week": "Last 7 days",
|
||||||
|
"sidebar.older": "Older",
|
||||||
|
"sidebar.connected": "Connected",
|
||||||
|
"sidebar.disconnected": "Disconnected – reconnecting…",
|
||||||
|
"sidebar.needsInput": "Waiting for your input",
|
||||||
|
|
||||||
|
"session.untitled": "New session",
|
||||||
|
|
||||||
|
"new.title": "New session",
|
||||||
|
"new.directory": "Project directory",
|
||||||
|
"new.up": "Up",
|
||||||
|
"new.noSubdirs": "No subdirectories",
|
||||||
|
"new.model": "Model",
|
||||||
|
"new.mode": "Mode",
|
||||||
|
"new.firstPrompt": "What should be done? (optional)",
|
||||||
|
"new.start": "Start in {dir}",
|
||||||
|
|
||||||
|
"chat.empty": "Send a message to start.",
|
||||||
|
"chat.reasoning": "Reasoning",
|
||||||
|
"chat.thinking": "Working…",
|
||||||
|
"chat.compacted": "Conversation compacted – summary",
|
||||||
|
"chat.context": "Context",
|
||||||
|
"chat.contextHint": "{tokens} tokens in the last request",
|
||||||
|
"chat.statsHint": "Tokens and cost of this session (click for details)",
|
||||||
|
"chat.renameHint": "Double-click to rename",
|
||||||
|
"chat.rewind": "Edit and resend from here",
|
||||||
|
"chat.rewindConfirm": "Remove this message and everything after it? File changes are not undone.",
|
||||||
|
"chat.delete": "Delete session",
|
||||||
|
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
||||||
|
|
||||||
|
"composer.placeholder": "Message… (/ for commands, paste images)",
|
||||||
|
"composer.placeholderBusy": "Message is queued and sent at the next step…",
|
||||||
|
"composer.send": "Send",
|
||||||
|
"composer.queue": "Queue",
|
||||||
|
"composer.stop": "Stop",
|
||||||
|
"composer.mode": "Mode",
|
||||||
|
"composer.model": "Model",
|
||||||
|
"composer.permissionMode": "Permission mode",
|
||||||
|
"composer.attach": "Attach images",
|
||||||
|
|
||||||
|
"command.compact": "Summarize the conversation to free up context",
|
||||||
|
"command.new": "Start a new session",
|
||||||
|
|
||||||
|
"permissionMode.default": "Ask before changes",
|
||||||
|
"permissionMode.accept_edits": "Auto-accept edits",
|
||||||
|
"permissionMode.bypass": "Allow everything",
|
||||||
|
|
||||||
|
"plan.implement": "Switch to build and implement the plan",
|
||||||
|
"plan.implementPrompt": "Implement the plan.",
|
||||||
|
|
||||||
|
"tool.input": "Input",
|
||||||
|
"tool.output": "Output",
|
||||||
|
"tool.todos": "Tasks",
|
||||||
|
"tool.openSubagent": "Open subagent session",
|
||||||
|
|
||||||
|
"interaction.fromSubagent": "From subagent:",
|
||||||
|
|
||||||
|
"permission.title": "Permission required",
|
||||||
|
"permission.allowOnce": "Allow once",
|
||||||
|
"permission.alwaysTool": "Always allow {tool} in this session",
|
||||||
|
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
|
||||||
|
"permission.reject": "Reject",
|
||||||
|
"permission.rejectWithFeedback": "Reject with feedback",
|
||||||
|
"permission.feedback": "Optional: tell the agent what to do instead",
|
||||||
|
|
||||||
|
"question.title": "Question for you",
|
||||||
|
"question.fromAgent": "The agent needs a decision",
|
||||||
|
"question.fromMcp": "MCP server \"{server}\" asks",
|
||||||
|
"question.other": "Other (free text)",
|
||||||
|
"question.answer": "Your answer",
|
||||||
|
"question.submit": "Answer",
|
||||||
|
"question.dismiss": "Dismiss",
|
||||||
|
|
||||||
|
"settings.tab.general": "General",
|
||||||
|
"settings.tab.providers": "Providers & models",
|
||||||
|
"settings.tab.mcp": "MCP",
|
||||||
|
"settings.tab.permissions": "Permissions",
|
||||||
|
"settings.tab.agents": "Agents & commands",
|
||||||
|
"settings.tab.raw": "Config file",
|
||||||
|
"settings.saved": "Saved – changes are active.",
|
||||||
|
"settings.interface": "Interface (this device)",
|
||||||
|
"settings.language": "Language",
|
||||||
|
"settings.theme": "Theme",
|
||||||
|
"settings.themeSystem": "System",
|
||||||
|
"settings.themeDark": "Dark",
|
||||||
|
"settings.themeLight": "Light",
|
||||||
|
"settings.notifications": "Notifications",
|
||||||
|
"settings.notificationsEnable": "Enable",
|
||||||
|
"settings.notificationsOn": "Enabled",
|
||||||
|
"settings.notificationsHint": "Notify when an agent asks a question while the app is in the background.",
|
||||||
|
"settings.server": "Server",
|
||||||
|
"settings.workspace": "Workspace",
|
||||||
|
"settings.defaultModel": "Default model",
|
||||||
|
"settings.smallModel": "Small model",
|
||||||
|
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
||||||
|
"settings.currency": "Currency",
|
||||||
|
"settings.autoCompact": "Auto-compact at",
|
||||||
|
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
||||||
|
"settings.craft": "Craft mode",
|
||||||
|
"settings.cleanRoom": "Clean room",
|
||||||
|
"settings.cleanRoomHint": "Only use what a visitor sees. Off = deep mode (page source and scripts may be read).",
|
||||||
|
"settings.chromium": "Chromium binary",
|
||||||
|
"settings.widths": "Screen widths (px)",
|
||||||
|
"settings.kind": "Type",
|
||||||
|
"settings.baseUrl": "Base URL",
|
||||||
|
"settings.apiKey": "API key",
|
||||||
|
"settings.apiKeyStored": "Stored – leave empty to keep",
|
||||||
|
"settings.apiKeyHint": "Prefer ${ENV_VAR} references; literal keys are stored in plain text in the config file.",
|
||||||
|
"settings.authPrefix": "Auth prefix",
|
||||||
|
"settings.enabled": "Enabled",
|
||||||
|
"settings.modelCount": "{n} models",
|
||||||
|
"settings.models": "Models & prices",
|
||||||
|
"settings.addModel": "Model",
|
||||||
|
"settings.addDiscovered": "Add {n} discovered",
|
||||||
|
"settings.pricesHint": "Prices per 1M tokens in the configured currency. Cache prices default to the input price.",
|
||||||
|
"settings.modelsEmptyHint": "No models listed: they are discovered automatically from the provider.",
|
||||||
|
"settings.context": "Context",
|
||||||
|
"settings.maxOutput": "Max output",
|
||||||
|
"settings.reasoning": "Reasoning",
|
||||||
|
"settings.vision": "Vision",
|
||||||
|
"settings.effort": "Effort",
|
||||||
|
"settings.priceIn": "Input",
|
||||||
|
"settings.priceOut": "Output",
|
||||||
|
"settings.priceCacheRead": "Cache read",
|
||||||
|
"settings.priceCacheWrite": "Cache write",
|
||||||
|
"settings.addProvider": "Add provider",
|
||||||
|
"settings.providerName": "Name (optional)",
|
||||||
|
"settings.reloadProviders": "Reload providers & models",
|
||||||
|
"settings.deleteProviderConfirm": "Delete provider \"{name}\"?",
|
||||||
|
"settings.invalidName": "Names may only contain letters, digits, - and _.",
|
||||||
|
"settings.name": "Name",
|
||||||
|
"settings.addMcp": "Add MCP server",
|
||||||
|
"settings.deleteMcpConfirm": "Delete MCP server \"{name}\"?",
|
||||||
|
"settings.rawHint": "The complete configuration file including comments. It is validated before saving.",
|
||||||
|
"settings.revert": "Revert",
|
||||||
|
|
||||||
|
"mcp.reconnect": "Reconnect all",
|
||||||
|
"mcp.none": "No MCP servers configured.",
|
||||||
|
"mcp.tools": "{n} tools",
|
||||||
|
"mcp.command": "Command",
|
||||||
|
"mcp.args": "Arguments (one per line)",
|
||||||
|
"mcp.env": "Environment (KEY=value)",
|
||||||
|
"mcp.headers": "Headers (Name: value)",
|
||||||
|
"mcp.state.disabled": "disabled",
|
||||||
|
"mcp.state.connecting": "connecting…",
|
||||||
|
"mcp.state.connected": "connected",
|
||||||
|
"mcp.state.failed": "failed",
|
||||||
|
"mcp.state.unknown": "not started",
|
||||||
|
|
||||||
|
"permissions.intro": "Rules for tool use. \"Ask\" shows an approval card. MCP tools: mcp__<server>__<tool>, wildcards allowed (mcp__*).",
|
||||||
|
"permissions.tools": "Tools",
|
||||||
|
"permissions.bash": "Shell commands",
|
||||||
|
"permissions.bashHint": "Glob patterns on the full command. Compound commands (&&, ;, |) are only allowed if every part is.",
|
||||||
|
"permissions.tool": "Tool",
|
||||||
|
"permissions.pattern": "Pattern",
|
||||||
|
"permissions.rule": "Rule",
|
||||||
|
"permissions.allow": "Allow",
|
||||||
|
"permissions.ask": "Ask",
|
||||||
|
"permissions.deny": "Deny",
|
||||||
|
|
||||||
|
"agents.intro": "Modes and subagents. Add your own in the config file ([agents.*]) or as Markdown in .codecraft/agents or .claude/agents.",
|
||||||
|
"agents.title": "Agents",
|
||||||
|
"agents.readOnly": "read-only",
|
||||||
|
"agents.commands": "Slash commands",
|
||||||
|
"agents.skills": "Skills",
|
||||||
|
|
||||||
|
"stats.range7": "7 days",
|
||||||
|
"stats.range30": "30 days",
|
||||||
|
"stats.range90": "90 days",
|
||||||
|
"stats.range365": "1 year",
|
||||||
|
"stats.rangeAll": "All",
|
||||||
|
"stats.tableView": "Table view",
|
||||||
|
"stats.chartView": "Chart view",
|
||||||
|
"stats.cost": "Cost",
|
||||||
|
"stats.perRequest": "{v} per request",
|
||||||
|
"stats.requests": "Requests",
|
||||||
|
"stats.errors": "{n} failed",
|
||||||
|
"stats.input": "Input",
|
||||||
|
"stats.output": "Output",
|
||||||
|
"stats.cacheRead": "Cache read",
|
||||||
|
"stats.cacheHit": "Cache hit rate",
|
||||||
|
"stats.tokens": "Tokens",
|
||||||
|
"stats.ttft": "Time to first token",
|
||||||
|
"stats.ttftHint": "Average prefill wait",
|
||||||
|
"stats.outputSpeed": "Output speed",
|
||||||
|
"stats.prefillSpeed": "Prefill speed",
|
||||||
|
"stats.costPerDay": "Cost per day by model",
|
||||||
|
"stats.tokensPerDay": "Tokens per day",
|
||||||
|
"stats.heatmap": "Activity by weekday and hour",
|
||||||
|
"stats.byModel": "By model",
|
||||||
|
"stats.byProvider": "By provider",
|
||||||
|
"stats.byAgent": "By mode",
|
||||||
|
"stats.topSessions": "Most expensive sessions",
|
||||||
|
"stats.perDay": "Per day",
|
||||||
|
"stats.day": "Day",
|
||||||
|
"stats.model": "Model",
|
||||||
|
"stats.provider": "Provider",
|
||||||
|
"stats.agent": "Mode",
|
||||||
|
"stats.session": "Session",
|
||||||
|
"stats.total": "Total",
|
||||||
|
"stats.other": "Other",
|
||||||
|
"stats.noData": "No requests in this period.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Messages = Record<keyof typeof en, string>;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Minimal i18n: dictionaries per language, `{name}` placeholders, persisted choice.
|
||||||
|
// To add a language: copy en.ts, translate the values, register it in `languages`.
|
||||||
|
// TypeScript enforces that every language defines every key of `en`.
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { en, type Messages } from "./en";
|
||||||
|
import { de } from "./de";
|
||||||
|
|
||||||
|
export type Key = keyof typeof en;
|
||||||
|
|
||||||
|
export const languages: Record<string, { name: string; messages: Messages }> = {
|
||||||
|
en: { name: "English", messages: en },
|
||||||
|
de: { name: "Deutsch", messages: de },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "codecraft.lang";
|
||||||
|
|
||||||
|
function detect(): string {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved && languages[saved]) return saved;
|
||||||
|
const nav = navigator.language.slice(0, 2);
|
||||||
|
return languages[nav] ? nav : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = detect();
|
||||||
|
document.documentElement.lang = current;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getLang() {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLang(lang: string) {
|
||||||
|
if (!languages[lang]) return;
|
||||||
|
current = lang;
|
||||||
|
localStorage.setItem(STORAGE_KEY, lang);
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key: Key, vars?: Record<string, string | number>): string {
|
||||||
|
let s: string = languages[current].messages[key] ?? en[key] ?? key;
|
||||||
|
if (vars) {
|
||||||
|
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-render on language change. Returns `t` for convenience. */
|
||||||
|
export function useT() {
|
||||||
|
useSyncExternalStore(
|
||||||
|
(l) => {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => listeners.delete(l);
|
||||||
|
},
|
||||||
|
() => current,
|
||||||
|
);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- locale-aware formatting -------------------------------------------------
|
||||||
|
|
||||||
|
export function fmtNumber(n: number, digits = 0) {
|
||||||
|
return new Intl.NumberFormat(current, { maximumFractionDigits: digits }).format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtCompact(n: number) {
|
||||||
|
return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtCost(n: number, currency: string) {
|
||||||
|
const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2;
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(current, { style: "currency", currency, maximumFractionDigits: digits }).format(n);
|
||||||
|
} catch {
|
||||||
|
return `${n.toFixed(digits)} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtPercent(n: number) {
|
||||||
|
return new Intl.NumberFormat(current, { style: "percent", maximumFractionDigits: 1 }).format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDate(ms: number, withTime = true) {
|
||||||
|
return new Intl.DateTimeFormat(current, withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" }).format(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtRelative(ms: number) {
|
||||||
|
const diff = (ms - Date.now()) / 1000;
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(current, { numeric: "auto" });
|
||||||
|
const abs = Math.abs(diff);
|
||||||
|
if (abs < 60) return rtf.format(Math.round(diff), "second");
|
||||||
|
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
|
||||||
|
if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour");
|
||||||
|
return rtf.format(Math.round(diff / 86400), "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weekdayNames(): string[] {
|
||||||
|
// Index 0 = Sunday, matching SQLite's %w.
|
||||||
|
const base = new Date(Date.UTC(2024, 0, 7)); // a Sunday
|
||||||
|
return Array.from({ length: 7 }, (_, i) =>
|
||||||
|
new Intl.DateTimeFormat(current, { weekday: "short", timeZone: "UTC" }).format(new Date(base.getTime() + i * 86400000)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { registerSW } from "virtual:pwa-register";
|
||||||
|
import "./styles.css";
|
||||||
|
import { App } from "./App";
|
||||||
|
import { applyTheme } from "./theme";
|
||||||
|
|
||||||
|
applyTheme();
|
||||||
|
registerSW({ immediate: true });
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/** Hash routes: #/ , #/s/<id>, #/new, #/settings[/tab], #/stats */
|
||||||
|
export function useRoute(): string[] {
|
||||||
|
const hash = useSyncExternalStore(
|
||||||
|
(l) => {
|
||||||
|
window.addEventListener("hashchange", l);
|
||||||
|
return () => window.removeEventListener("hashchange", l);
|
||||||
|
},
|
||||||
|
() => location.hash,
|
||||||
|
);
|
||||||
|
return hash.replace(/^#\/?/, "").split("/").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigate(path: string) {
|
||||||
|
location.hash = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { get } from "./api";
|
||||||
|
import type {
|
||||||
|
McpServerStatus,
|
||||||
|
Message,
|
||||||
|
Part,
|
||||||
|
PermissionRequest,
|
||||||
|
QuestionRequest,
|
||||||
|
ServerEvent,
|
||||||
|
Session,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** The assistant message currently being streamed. */
|
||||||
|
export interface Draft {
|
||||||
|
id: string;
|
||||||
|
parts: Part[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionError {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
connected: boolean;
|
||||||
|
sessions: Session[];
|
||||||
|
/** Includes subagent sessions loaded on demand. */
|
||||||
|
sessionById: Record<string, Session>;
|
||||||
|
messages: Record<string, Message[]>;
|
||||||
|
drafts: Record<string, Draft | undefined>;
|
||||||
|
busy: Record<string, boolean>;
|
||||||
|
progress: Record<string, string>;
|
||||||
|
permissions: PermissionRequest[];
|
||||||
|
questions: QuestionRequest[];
|
||||||
|
errors: Record<string, SessionError[]>;
|
||||||
|
mcp: Record<string, McpServerStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: State = {
|
||||||
|
connected: false,
|
||||||
|
sessions: [],
|
||||||
|
sessionById: {},
|
||||||
|
messages: {},
|
||||||
|
drafts: {},
|
||||||
|
busy: {},
|
||||||
|
progress: {},
|
||||||
|
permissions: [],
|
||||||
|
questions: [],
|
||||||
|
errors: {},
|
||||||
|
mcp: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function setState(fn: (s: State) => Partial<State>) {
|
||||||
|
state = { ...state, ...fn(state) };
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStore<T>(selector: (s: State) => T): T {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(l) => {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => listeners.delete(l);
|
||||||
|
},
|
||||||
|
() => selector(state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertSession(s: State, session: Session): Partial<State> {
|
||||||
|
const sessionById = { ...s.sessionById, [session.id]: session };
|
||||||
|
if (session.parent_id) return { sessionById };
|
||||||
|
const others = s.sessions.filter((x) => x.id !== session.id);
|
||||||
|
const sessions = [session, ...others].sort((a, b) => b.updated_at - a.updated_at);
|
||||||
|
return { sessions, sessionById };
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorSeq = 0;
|
||||||
|
|
||||||
|
function draftFor(s: State, sid: string, mid: string): Draft {
|
||||||
|
const d = s.drafts[sid];
|
||||||
|
return d && d.id === mid ? { ...d, parts: [...d.parts] } : { id: mid, parts: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyEvent(ev: ServerEvent) {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "session_updated":
|
||||||
|
setState((s) => upsertSession(s, ev.session));
|
||||||
|
break;
|
||||||
|
case "session_deleted":
|
||||||
|
setState((s) => {
|
||||||
|
const { [ev.session_id]: _, ...rest } = s.sessionById;
|
||||||
|
return { sessions: s.sessions.filter((x) => x.id !== ev.session_id), sessionById: rest };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "session_status":
|
||||||
|
setState((s) => ({
|
||||||
|
busy: { ...s.busy, [ev.session_id]: ev.busy },
|
||||||
|
drafts: ev.busy ? s.drafts : { ...s.drafts, [ev.session_id]: undefined },
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
case "message_added":
|
||||||
|
setState((s) => {
|
||||||
|
const list = s.messages[ev.session_id];
|
||||||
|
const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts;
|
||||||
|
if (!list) return { drafts };
|
||||||
|
if (list.some((m) => m.id === ev.message.id)) return { drafts };
|
||||||
|
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "text_delta":
|
||||||
|
case "reasoning_delta":
|
||||||
|
setState((s) => {
|
||||||
|
const d = draftFor(s, ev.session_id, ev.message_id);
|
||||||
|
const kind = ev.type === "text_delta" ? "text" : "reasoning";
|
||||||
|
const last = d.parts[d.parts.length - 1];
|
||||||
|
if (last && last.type === kind) {
|
||||||
|
d.parts[d.parts.length - 1] = { ...last, text: last.text + ev.text } as Part;
|
||||||
|
} else {
|
||||||
|
d.parts.push({ type: kind, text: ev.text } as Part);
|
||||||
|
}
|
||||||
|
return { drafts: { ...s.drafts, [ev.session_id]: d } };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "tool_started":
|
||||||
|
setState((s) => {
|
||||||
|
const d = draftFor(s, ev.session_id, ev.message_id);
|
||||||
|
d.parts.push({ type: "tool_call", id: ev.call_id, name: ev.name, input: ev.input });
|
||||||
|
return { drafts: { ...s.drafts, [ev.session_id]: d } };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "tool_progress":
|
||||||
|
setState((s) => ({ progress: { ...s.progress, [ev.call_id]: ev.output } }));
|
||||||
|
break;
|
||||||
|
case "permission_request":
|
||||||
|
setState((s) => ({ permissions: [...s.permissions.filter((p) => p.id !== ev.id), ev] }));
|
||||||
|
break;
|
||||||
|
case "permission_resolved":
|
||||||
|
setState((s) => ({ permissions: s.permissions.filter((p) => p.id !== ev.id) }));
|
||||||
|
break;
|
||||||
|
case "question_request":
|
||||||
|
setState((s) => ({ questions: [...s.questions.filter((q) => q.id !== ev.id), ev] }));
|
||||||
|
notify(ev.questions[0]?.question ?? "");
|
||||||
|
break;
|
||||||
|
case "question_resolved":
|
||||||
|
setState((s) => ({ questions: s.questions.filter((q) => q.id !== ev.id) }));
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
setState((s) => ({
|
||||||
|
errors: {
|
||||||
|
...s.errors,
|
||||||
|
[ev.session_id]: [...(s.errors[ev.session_id] ?? []), { id: ++errorSeq, message: ev.message }].slice(-5),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
case "mcp_status":
|
||||||
|
setState(() => ({ mcp: ev.servers }));
|
||||||
|
break;
|
||||||
|
case "resync":
|
||||||
|
void resync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notify when the app is in the background and an agent needs input. */
|
||||||
|
function notify(body: string) {
|
||||||
|
if (document.visibilityState === "visible" || !("Notification" in window)) return;
|
||||||
|
if (Notification.permission === "granted") {
|
||||||
|
new Notification("CodeCraft", { body, icon: "/icon-192.png" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissError(sessionId: string, id: number) {
|
||||||
|
setState((s) => ({ errors: { ...s.errors, [sessionId]: (s.errors[sessionId] ?? []).filter((e) => e.id !== id) } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSessions() {
|
||||||
|
const list = await get<Session[]>("/sessions");
|
||||||
|
setState((s) => ({
|
||||||
|
sessions: list,
|
||||||
|
sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) },
|
||||||
|
busy: { ...s.busy, ...Object.fromEntries(list.map((x) => [x.id, !!x.busy])) },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSession(id: string) {
|
||||||
|
const data = await get<{ session: Session; messages: Message[]; busy: boolean }>(`/sessions/${id}`);
|
||||||
|
setState((s) => ({
|
||||||
|
...upsertSession(s, data.session),
|
||||||
|
sessionById: { ...s.sessionById, [id]: data.session },
|
||||||
|
messages: { ...s.messages, [id]: data.messages },
|
||||||
|
busy: { ...s.busy, [id]: data.busy },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After reconnects: reload everything that could have changed. */
|
||||||
|
export async function resync() {
|
||||||
|
const [pending, mcp] = await Promise.all([get<any[]>("/pending"), get("/mcp")]);
|
||||||
|
setState(() => ({
|
||||||
|
permissions: pending.filter((e) => e.type === "permission_request"),
|
||||||
|
questions: pending.filter((e) => e.type === "question_request"),
|
||||||
|
mcp,
|
||||||
|
}));
|
||||||
|
await loadSessions();
|
||||||
|
const loaded = Object.keys(getState().messages);
|
||||||
|
await Promise.all(loaded.map((id) => loadSession(id).catch(() => undefined)));
|
||||||
|
}
|
||||||
+1172
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
|||||||
|
const KEY = "codecraft.theme";
|
||||||
|
|
||||||
|
export type Theme = "system" | "dark" | "light";
|
||||||
|
|
||||||
|
export function getTheme(): Theme {
|
||||||
|
return (localStorage.getItem(KEY) as Theme) ?? "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme = getTheme()) {
|
||||||
|
localStorage.setItem(KEY, theme);
|
||||||
|
if (theme === "system") delete document.documentElement.dataset.theme;
|
||||||
|
else document.documentElement.dataset.theme = theme;
|
||||||
|
const dark = theme === "dark" || (theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", dark ? "#282828" : "#fbf1c7");
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Mirrors server/src/types.rs
|
||||||
|
|
||||||
|
export interface ImageData {
|
||||||
|
media_type: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Part =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| { type: "image"; image: ImageData }
|
||||||
|
| { type: "reasoning"; text: string; signature?: string }
|
||||||
|
| { type: "tool_call"; id: string; name: string; input: any }
|
||||||
|
| {
|
||||||
|
type: "tool_result";
|
||||||
|
call_id: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
images?: ImageData[];
|
||||||
|
is_error: boolean;
|
||||||
|
meta?: any;
|
||||||
|
}
|
||||||
|
| { type: "summary"; text: string };
|
||||||
|
|
||||||
|
export interface Usage {
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cache_read_tokens: number;
|
||||||
|
cache_write_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
parts: Part[];
|
||||||
|
model?: string;
|
||||||
|
usage?: Usage;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Todo {
|
||||||
|
content: string;
|
||||||
|
status: "pending" | "in_progress" | "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
cwd: string;
|
||||||
|
model: string;
|
||||||
|
agent: string;
|
||||||
|
permission_mode: "default" | "accept_edits" | "bypass";
|
||||||
|
parent_id?: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
todos: Todo[] | null;
|
||||||
|
last_usage?: Usage | null;
|
||||||
|
busy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionOption {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
question: string;
|
||||||
|
header?: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
multi_select?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionRequest {
|
||||||
|
type: "permission_request";
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
root_session_id?: string;
|
||||||
|
tool: string;
|
||||||
|
title: string;
|
||||||
|
input: any;
|
||||||
|
diff?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionRequest {
|
||||||
|
type: "question_request";
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
root_session_id?: string;
|
||||||
|
source: string;
|
||||||
|
questions: Question[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerEvent =
|
||||||
|
| { type: "session_updated"; session: Session }
|
||||||
|
| { type: "session_deleted"; session_id: string }
|
||||||
|
| { type: "session_status"; session_id: string; busy: boolean }
|
||||||
|
| { type: "message_added"; session_id: string; message: Message }
|
||||||
|
| { type: "text_delta"; session_id: string; message_id: string; text: string }
|
||||||
|
| { type: "reasoning_delta"; session_id: string; message_id: string; text: string }
|
||||||
|
| { type: "tool_started"; session_id: string; message_id: string; call_id: string; name: string; input: any }
|
||||||
|
| { type: "tool_progress"; session_id: string; call_id: string; output: string }
|
||||||
|
| PermissionRequest
|
||||||
|
| { type: "permission_resolved"; id: string; session_id: string }
|
||||||
|
| QuestionRequest
|
||||||
|
| { type: "question_resolved"; id: string; session_id: string }
|
||||||
|
| { type: "error"; session_id: string; message: string }
|
||||||
|
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||||
|
| { type: "resync" };
|
||||||
|
|
||||||
|
export interface McpServerStatus {
|
||||||
|
status: { state: "disabled" | "connecting" | "connected" | "failed"; tools?: number; error?: string };
|
||||||
|
tools: { name: string; description: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
context?: number;
|
||||||
|
reasoning: boolean;
|
||||||
|
vision: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderModels {
|
||||||
|
provider: string;
|
||||||
|
models: ModelInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentInfo {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
mode: "primary" | "subagent";
|
||||||
|
read_only: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Aggregate {
|
||||||
|
requests: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cache_read_tokens: number;
|
||||||
|
cache_write_tokens: number;
|
||||||
|
cost: number;
|
||||||
|
errors: number;
|
||||||
|
avg_ttft_ms: number;
|
||||||
|
output_tps: number;
|
||||||
|
prefill_tps: number;
|
||||||
|
cache_hit_rate: number;
|
||||||
|
duration_ms: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["vite/client", "vite-plugin-pwa/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
const backend = process.env.CODECRAFT_BACKEND ?? "http://127.0.0.1:8787";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["icon.svg", "apple-touch-icon.png"],
|
||||||
|
manifest: {
|
||||||
|
name: "CodeCraft",
|
||||||
|
short_name: "CodeCraft",
|
||||||
|
description: "Web-based coding harness for AI models",
|
||||||
|
theme_color: "#282828",
|
||||||
|
background_color: "#282828",
|
||||||
|
display: "standalone",
|
||||||
|
start_url: "/",
|
||||||
|
icons: [
|
||||||
|
{ src: "icon-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ src: "icon-512.png", sizes: "512x512", type: "image/png" },
|
||||||
|
{ src: "icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "icon.svg", sizes: "any", type: "image/svg+xml" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
// The API is always live data; only the app shell is cached.
|
||||||
|
navigateFallbackDenylist: [/^\/api/],
|
||||||
|
runtimeCaching: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": { target: backend, ws: true, changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user