Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8474
-693
No files matched your search
+10
-4
@@ -1,11 +1,17 @@
|
||||
# Access token for the web UI (required). Generate one with: openssl rand -hex 32
|
||||
CODECRAFT_TOKEN=
|
||||
# Optional: create the first admin on first start. Otherwise the server logs a
|
||||
# one-time setup code and you create the admin in the web UI.
|
||||
CODECRAFT_ADMIN_USER=
|
||||
CODECRAFT_ADMIN_PASSWORD=
|
||||
|
||||
# Single sign-on client secret (see [auth.oidc] in config.toml).
|
||||
OIDC_CLIENT_SECRET=
|
||||
|
||||
# Directory with your projects, mounted as /workspace.
|
||||
WORKSPACE=/home/zink/Projects
|
||||
WORKSPACE=/path/to/your/projects
|
||||
|
||||
# Host port.
|
||||
# Host port and address (127.0.0.1 = only reachable through a local reverse proxy).
|
||||
PORT=8787
|
||||
BIND_ADDR=127.0.0.1
|
||||
|
||||
# UID/GID that owns the project files (id -u / id -g).
|
||||
UID=1000
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
docker-compose.override.yml
|
||||
@@ -1,93 +1,226 @@
|
||||
# CodeCraft
|
||||
<p align="center">
|
||||
<img src="web/public/icon.svg" width="112" height="112" alt="CodeCraft icon: angle brackets with a slash on a dark tile">
|
||||
</p>
|
||||
|
||||
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.
|
||||
<h1 align="center">CodeCraft</h1>
|
||||
|
||||
- **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.
|
||||
<p align="center">
|
||||
<b>A self-hosted coding agent for your browser: any model, your tools, your rules.</b><br>
|
||||
Run Claude, Gemini, opencode Go, fal.ai or your own models as coding agents with files, shell, MCP and subagents,<br>
|
||||
approve what they do from any device, and see exactly what every request costs.
|
||||
</p>
|
||||
|
||||
## Quick start (Docker)
|
||||
<p align="center">
|
||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
<img alt="Docker" src="https://img.shields.io/badge/deploy-Docker-1d63ed?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#how-it-works">How it works</a> ·
|
||||
<a href="#modes">Modes</a> ·
|
||||
<a href="#models">Models</a> ·
|
||||
<a href="#users">Users</a> ·
|
||||
<a href="#statistics">Statistics</a> ·
|
||||
<a href="#get-started">Get started</a> ·
|
||||
<a href="#configuration">Configuration</a> ·
|
||||
<a href="#themes">Themes</a> ·
|
||||
<a href="#development">Development</a> ·
|
||||
<a href="#status">Status</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/chat.png" alt="A CodeCraft session: the agent edits a file, a permission card shows the diff and asks before running a command, the task list shows what is left" width="100%">
|
||||
<br>
|
||||
<sub>A session: the agent works through its task list, shows diffs, and asks before anything outside the rules.</sub>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🧠 Any model</h3>
|
||||
Anthropic natively, plus everything that speaks the OpenAI API: Gemini, opencode Go, fal.ai, OpenRouter, Ollama, vLLM, llama.cpp. Mix them in one chat.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🛠️ Real tools</h3>
|
||||
Read, write, edit, glob, grep, shell, web fetch, task lists, subagents, skills – and every tool of your MCP servers.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🙋 You decide</h3>
|
||||
Permission requests with diffs, questions from agents and MCP servers, per-tool rules. On your phone, with notifications.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>📊 Every token counted</h3>
|
||||
Tokens, cache hits, prefill and output speed, and cost per request – per chat and in a dashboard.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## How it works
|
||||
|
||||
CodeCraft runs on your server, next to your projects. You talk to it from a browser or the
|
||||
installed app; the agent keeps working when you close the tab.
|
||||
|
||||
1. **Pick a folder and a mode.** Every user has their own folder; admins can share more.
|
||||
2. **Say what you want.** The agent plans with a task list, reads the code and makes changes.
|
||||
3. **Approve or answer.** Anything outside your rules shows up as a card: allow once, allow
|
||||
for the session, or reject with feedback. Questions from agents and MCP servers too.
|
||||
4. **Delegate.** The agent hands work to subagents – in parallel or in the background, on
|
||||
other models and hosts – and continues while they run.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/mobile.png" alt="CodeCraft on a phone: a session with a question card offering three options" width="100%"><br>
|
||||
<sub><b>Mobile:</b> answer questions and approve changes from your phone.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/settings.png" alt="Settings: providers with models, context sizes and prices per million tokens" width="100%"><br>
|
||||
<sub><b>Settings:</b> providers, models, prices, MCP, permissions and users – all in the UI.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | What it does |
|
||||
|---|---|
|
||||
| **build** | Full access: implement, edit, run. |
|
||||
| **plan** | Read-only: investigate, ask, and propose a step-by-step plan. One click switches to build. |
|
||||
| **craft** | Study a target, build your own version, check it visually, refine. Websites are captured at several screen widths in a headless browser and compared with the build (similarity per page and width). *Clean room* uses only what a visitor sees; *deep mode* may read the page source. |
|
||||
| *custom* | Your own modes and subagents – in the UI, the config file or as Markdown in `.codecraft/agents` / `.claude/agents`. |
|
||||
|
||||
Projects keep working the way they do in Claude Code and opencode: `AGENTS.md`, `CLAUDE.md`,
|
||||
slash commands in `.claude/commands` and skills in `.claude/skills` are picked up automatically.
|
||||
|
||||
## Models
|
||||
|
||||
Every provider is an endpoint with a list of models. Each model can have a context size,
|
||||
output limit, reasoning effort, extra request options and prices per million tokens.
|
||||
|
||||
- **Several models per chat:** switch the model at any time, give modes a fixed model, and
|
||||
run subagents on other models – e.g. a coding model on one GPU host and a small model that
|
||||
writes documentation and translations in the background on another.
|
||||
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
|
||||
can only process one at a time; `max_parallel` limits instances of a subagent. Waiting time
|
||||
is shown in the chat and never counted as model latency.
|
||||
- **Background subagents:** the agent starts them with `background = true`, keeps working,
|
||||
and receives their result as a message when they finish.
|
||||
|
||||
## Users
|
||||
|
||||
- Local accounts (Argon2 passwords) and single sign-on via OpenID Connect (Authentik, Authelia,
|
||||
Keycloak, …) with optional admin and access groups.
|
||||
- **Admins** manage providers, MCP, permissions, users and shared folders and see all statistics.
|
||||
**Users** see their own sessions and statistics.
|
||||
- Every user works in `<workspace>/<username>`; admins can share additional folders with
|
||||
selected users. Sessions are private and can be shared read-only.
|
||||
|
||||
> [!NOTE]
|
||||
> File tools are confined to a user's folders. Shell commands run in the shared container and
|
||||
> are **not** sandboxed per user – only give accounts to people you trust with that container.
|
||||
|
||||
## Statistics
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/stats.png" alt="The statistics dashboard: cost and token tiles, cost per day stacked by model, tokens per day, and an activity heatmap by weekday and hour" width="100%">
|
||||
</p>
|
||||
|
||||
Every model request is stored with input, output and cache tokens, time to first token,
|
||||
prefill and output speed, and its cost at the price configured at that moment. The dashboard
|
||||
shows totals, cost per day by model, tokens per day, a weekday × hour heatmap, and tables per
|
||||
model, provider, mode, user and session – each chart also as a table.
|
||||
|
||||
## Get started
|
||||
|
||||
```sh
|
||||
cp .env.example .env # set CODECRAFT_TOKEN, WORKSPACE and provider keys
|
||||
git clone <this repository> codecraft && cd codecraft
|
||||
cp .env.example .env # workspace path, provider keys, optional first admin
|
||||
docker compose up -d --build
|
||||
docker compose logs codecraft # shows the one-time setup code for the first admin
|
||||
```
|
||||
|
||||
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.
|
||||
All state – configuration, database, the agent's home directory – lives in the Docker volume
|
||||
`codecraft-data`; rebuilding or recreating the container keeps it. Your projects are mounted
|
||||
from `WORKSPACE`.
|
||||
|
||||
The port is bound to `127.0.0.1` by default. Put a TLS reverse proxy in front of it (enable
|
||||
WebSocket support) and set `auth.public_url` – installing the PWA requires HTTPS.
|
||||
|
||||
## Configuration
|
||||
|
||||
See [`config.example.toml`](config.example.toml) – every option is documented there.
|
||||
Highlights:
|
||||
One commented TOML file, `/data/config.toml` in the volume, created from
|
||||
[`config.example.toml`](config.example.toml) on first start. The settings page edits it in
|
||||
place and keeps your comments. Secrets belong in the environment and are referenced as
|
||||
`${NAME}`.
|
||||
|
||||
- `[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.
|
||||
| Section | Purpose |
|
||||
|---|---|
|
||||
| `[server]` | workspace, default and small model, currency, auto-compaction, subagent limit |
|
||||
| `[providers.<name>]` | `kind = "anthropic" \| "openai"`, `base_url`, `api_key`, `max_concurrency`, `models` |
|
||||
| `[mcp.<name>]` | `type = "stdio"` (command, args, env) or `"http"` (url, headers) |
|
||||
| `[permissions]` | `allow` / `ask` / `deny` per tool (globs like `mcp__*`); `bash_rules` for commands |
|
||||
| `[agents.<name>]` | modes and subagents: model, tools, `max_parallel`, prompt |
|
||||
| `[auth]`, `[auth.oidc]` | sign-in, public URL, single sign-on |
|
||||
| `[craft]` | headless browser, screen widths, clean room |
|
||||
|
||||
Compound shell commands (`&&`, `;`, `|`) are only auto-allowed when every part is allowed.
|
||||
|
||||
## Themes
|
||||
|
||||
Colours, syntax highlighting and chart palettes come from CSS variables. Each theme is one
|
||||
file in [`web/src/themes`](web/src/themes) (Gruvbox Dark and Light ship by default):
|
||||
|
||||
1. Copy `gruvbox-dark.css` to `<id>.css`, change the values on `:root[data-theme="<id>"]`.
|
||||
2. Import it in `themes/index.ts` and add it to `THEMES` in `web/src/theme.ts`.
|
||||
|
||||
The variables cover surfaces (`--bg0`–`--bg3`), text (`--fg0`–`--fg3`, `--muted`), accents,
|
||||
diffs, syntax (`--syntax-*`), charts (`--series-1`–`8` categorical, `--seq-0`–`6` sequential)
|
||||
and the browser bar colour (`--theme-color`). The chart colours are validated for colour-vision
|
||||
deficiencies against the background – re-check them when the background changes a lot.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Rust (edition 2024), Node ≥ 20.19.
|
||||
Requirements: Rust (edition 2024) and Node ≥ 20.19.
|
||||
|
||||
```sh
|
||||
# backend (writes ./data/config.toml on first start)
|
||||
# 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
|
||||
Tests: `cd server && cargo test` · type check: `cd web && npx tsc --noEmit`.
|
||||
|
||||
```
|
||||
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)
|
||||
server/ Rust (axum, tokio)
|
||||
runner.rs agent loop: streaming, tools, permissions, subagents, compaction
|
||||
provider/ Anthropic and OpenAI-compatible streaming clients, concurrency slots
|
||||
tools/ built-in tools (files, shell, 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
|
||||
auth.rs users, sessions, OpenID Connect
|
||||
prompts.rs modes, system prompt, AGENTS.md, commands, skills
|
||||
web/ React + Vite PWA
|
||||
src/components chat, composer, cards, settings, statistics
|
||||
src/themes CSS themes
|
||||
src/i18n English (reference) and German
|
||||
```
|
||||
|
||||
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.
|
||||
Translations: copy `web/src/i18n/en.ts`, translate the values and register the language in
|
||||
`web/src/i18n/index.ts` – the type checker reports missing keys.
|
||||
|
||||
## Roadmap
|
||||
## Status
|
||||
|
||||
- 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
|
||||
Early alpha. Planned next:
|
||||
|
||||
- Per-user sandboxes for shell commands
|
||||
- File checkpoints and undo, background shells, `@file` mentions
|
||||
- MCP OAuth, resources and prompts
|
||||
- Hooks before/after tool calls
|
||||
- Image generation tools (fal.ai) and the OpenAI Responses API
|
||||
+99
-11
@@ -24,10 +24,6 @@ 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"
|
||||
|
||||
@@ -37,6 +33,14 @@ 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
|
||||
|
||||
# Max. subagents running at the same time (all sessions). Additionally, each
|
||||
# subagent can be limited with max_parallel, and each provider/model with
|
||||
# max_concurrency (see below).
|
||||
max_parallel_subagents = 4
|
||||
|
||||
# MCP servers with more tools than this are loaded on demand (see [mcp.*] load).
|
||||
mcp_direct_limit = 15
|
||||
|
||||
# Currency label for the cost statistics (prices below are in this currency).
|
||||
currency = "USD"
|
||||
|
||||
@@ -57,6 +61,13 @@ currency = "USD"
|
||||
# Cache prices default to input_price when omitted. Costs are stored per request,
|
||||
# so changing a price only affects future requests.
|
||||
#
|
||||
# Concurrency: max_concurrency limits simultaneous requests – on the provider
|
||||
# (= the host) and/or per model. Requests beyond the limit wait in a queue,
|
||||
# e.g. for self-hosted models that can only process one request at a time.
|
||||
#
|
||||
# options: extra request body fields sent as-is, e.g.
|
||||
# options = { temperature = 0.5, reasoning_budget_tokens = 5000 }
|
||||
#
|
||||
# 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" }
|
||||
@@ -112,6 +123,7 @@ enabled = false
|
||||
kind = "openai"
|
||||
base_url = "http://host.docker.internal:11434/v1"
|
||||
api_key = ""
|
||||
max_concurrency = 1 # one request at a time on this host
|
||||
models = []
|
||||
enabled = false
|
||||
|
||||
@@ -122,6 +134,10 @@ enabled = false
|
||||
# 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>.
|
||||
# load = "auto" (default) | "always" | "on_demand": servers with many tools
|
||||
# (more than server.mcp_direct_limit) are not sent with every request; the
|
||||
# model finds them with mcp_search and calls them with mcp_call. This keeps
|
||||
# requests small – important for self-hosted models.
|
||||
# MCP servers may ask you questions (elicitation); they show up in the UI.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -163,6 +179,7 @@ bash = "ask"
|
||||
web_fetch = "ask"
|
||||
browser_snapshot = "allow"
|
||||
image_compare = "allow"
|
||||
generate_image = "ask" # costs money per image
|
||||
"mcp__*" = "ask"
|
||||
|
||||
# Shell command patterns (glob, matched against the full command). First match wins.
|
||||
@@ -185,14 +202,85 @@ image_compare = "allow"
|
||||
# mode switcher, mode = "subagent" is only available through the task tool.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# [agents.reviewer]
|
||||
# Subagents can run on a different model (and host) than the chat, and in the
|
||||
# background: the main agent calls task(background = true), keeps working and
|
||||
# receives the result as a message when the subagent is done.
|
||||
|
||||
# [agents.writer]
|
||||
# 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. ...
|
||||
# """
|
||||
# description = "Writes documentation, summaries, guides and translations."
|
||||
# model = "local/qwen3-4b" # optional, otherwise the session model
|
||||
# tools = ["read", "glob", "grep"] # optional, default: all tools
|
||||
# max_parallel = 1 # optional, instances at the same time
|
||||
# prompt = '''
|
||||
# You are a technical writer. Write clear, well-structured text.
|
||||
# '''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Image generation (fal.ai)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Agents get the tool generate_image; the "Images" page creates images manually.
|
||||
# Images are saved in the project (output_dir) and listed in a gallery.
|
||||
# Model ids: https://fal.ai/models – price is per image (for the statistics).
|
||||
# size_param: "image_size" (FLUX-style presets, default) or "aspect_ratio" ("16:9").
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[images]
|
||||
enabled = false
|
||||
base_url = "https://fal.run"
|
||||
api_key = "${FAL_KEY}"
|
||||
default_model = "fal-ai/flux/schnell"
|
||||
output_dir = "generated"
|
||||
models = [
|
||||
{ id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast", price = 0.003 },
|
||||
{ id = "fal-ai/flux-2-pro", name = "FLUX.2 pro" },
|
||||
{ id = "fal-ai/ideogram/v3", name = "Ideogram 3 – text in images", size_param = "aspect_ratio" },
|
||||
{ id = "fal-ai/recraft/v4/pro/text-to-image", name = "Recraft V4 pro – design" },
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Users & sign-in
|
||||
# -----------------------------------------------------------------------------
|
||||
# Users are managed in the web UI (Settings → Users). On first start without
|
||||
# users, the server prints a one-time setup code to its log; use it in the UI
|
||||
# to create the first admin – or set CODECRAFT_ADMIN_USER and
|
||||
# CODECRAFT_ADMIN_PASSWORD in the environment.
|
||||
#
|
||||
# Every user works in their own directory <workspace>/<username>; admins can
|
||||
# use the whole workspace. Note: file tools are confined to that directory,
|
||||
# but shell commands run in the shared container and are not sandboxed.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[auth]
|
||||
# Lifetime of a login in days.
|
||||
session_days = 30
|
||||
# External URL of this server – required for single sign-on (OIDC redirects).
|
||||
public_url = ""
|
||||
# Secure cookies: "auto" (when served via https), "true" or "false".
|
||||
cookie_secure = "auto"
|
||||
|
||||
# Single sign-on via OpenID Connect (Authentik, Authelia, Keycloak, ...).
|
||||
# Redirect URI to register at the IdP: <public_url>/api/auth/oidc/callback
|
||||
[auth.oidc]
|
||||
enabled = false
|
||||
issuer = "https://auth.example.org/application/o/codecraft/"
|
||||
client_id = "codecraft"
|
||||
client_secret = "${OIDC_CLIENT_SECRET}"
|
||||
scopes = ["openid", "profile", "email", "groups"]
|
||||
username_claim = "preferred_username"
|
||||
groups_claim = "groups"
|
||||
# Members of this group are admins; empty = manage roles in CodeCraft.
|
||||
admin_group = ""
|
||||
# Only members of this group may sign in; empty = no restriction.
|
||||
allowed_group = ""
|
||||
# Create users automatically on their first sign-in.
|
||||
auto_create = true
|
||||
# Link a sign-in to an existing local user with the same username.
|
||||
# Only enable this if you trust the usernames of your IdP.
|
||||
link_by_username = false
|
||||
button_label = "Single sign-on"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+19
-10
@@ -1,4 +1,10 @@
|
||||
# CodeCraft – copy .env.example to .env and adjust, then: docker compose up -d --build
|
||||
#
|
||||
# Persistent state lives in the named volume "codecraft-data" (mounted at /data):
|
||||
# /data/config.toml configuration (also editable in the web UI)
|
||||
# /data/codecraft.db sessions, messages, statistics
|
||||
# /data/home home directory of the agent (git config, caches, ...)
|
||||
# Rebuilding or recreating the container keeps all of it.
|
||||
services:
|
||||
codecraft:
|
||||
build: .
|
||||
@@ -7,21 +13,24 @@ services:
|
||||
restart: unless-stopped
|
||||
# Same UID/GID as the owner of your project files.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
# Bound to localhost by default: expose it through a TLS reverse proxy.
|
||||
# Set BIND_ADDR=0.0.0.0 to publish it on all interfaces.
|
||||
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:-}
|
||||
- "${BIND_ADDR:-127.0.0.1}:${PORT:-8787}:8787"
|
||||
# All variables from .env are available to the config as ${NAME}
|
||||
# (provider keys, MCP tokens, OIDC secret, initial admin).
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
# Config (config.toml), SQLite database, agent home directory.
|
||||
- ./data:/data
|
||||
- codecraft-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)
|
||||
|
||||
volumes:
|
||||
codecraft-data:
|
||||
name: codecraft-data
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Generated
+126
-3
@@ -32,6 +32,18 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.3.1",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -157,12 +169,27 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -172,6 +199,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
@@ -264,11 +300,18 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "codecraft"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -281,11 +324,13 @@ dependencies = [
|
||||
"ignore",
|
||||
"image",
|
||||
"libc",
|
||||
"rand 0.10.3",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shell-words",
|
||||
"similar",
|
||||
"tokio",
|
||||
@@ -308,6 +353,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -386,6 +437,24 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
@@ -398,8 +467,20 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -757,6 +838,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.12.0"
|
||||
@@ -1309,12 +1399,33 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"phc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phc"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"ctutils",
|
||||
"getrandom 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
@@ -1635,6 +1746,7 @@ dependencies = [
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -1925,7 +2037,18 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-2
@@ -2,11 +2,13 @@
|
||||
name = "codecraft"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "Web-based coding harness for AI models"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
argon2 = "0.6"
|
||||
sha2 = "0.11"
|
||||
rand = "0.10"
|
||||
async-trait = "0.1"
|
||||
axum = { version = "0.8", features = ["ws", "macros"] }
|
||||
bytes = "1"
|
||||
@@ -21,7 +23,7 @@ shell-words = "1"
|
||||
libc = "0.2"
|
||||
ignore = "0.4"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] }
|
||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
+405
-122
@@ -1,17 +1,20 @@
|
||||
//! HTTP + WebSocket API.
|
||||
//! HTTP + WebSocket API. Every route except the public auth endpoints requires a
|
||||
//! signed-in user; admin-only routes check the role.
|
||||
|
||||
use crate::app::{App, PermissionReply};
|
||||
use crate::app::{Access, App, PermissionReply};
|
||||
use crate::auth::{self, require_admin};
|
||||
use crate::config::{self, ConfigPatch};
|
||||
use crate::prompts;
|
||||
use crate::runner;
|
||||
use crate::store::User;
|
||||
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::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::path::PathBuf;
|
||||
@@ -19,7 +22,7 @@ use std::sync::Arc;
|
||||
|
||||
type AppState = Arc<App>;
|
||||
|
||||
pub struct ApiError(StatusCode, String);
|
||||
pub struct ApiError(pub StatusCode, pub String);
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
@@ -33,15 +36,33 @@ impl<E: Into<anyhow::Error>> From<E> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
type ApiResult<T = Json<Value>> = Result<T, ApiError>;
|
||||
pub type ApiResult<T = Json<Value>> = Result<T, ApiError>;
|
||||
|
||||
fn not_found(what: &str) -> ApiError {
|
||||
ApiError(StatusCode::NOT_FOUND, format!("{what} not found"))
|
||||
}
|
||||
|
||||
fn forbidden() -> ApiError {
|
||||
ApiError(StatusCode::FORBIDDEN, "no access to this session".into())
|
||||
}
|
||||
|
||||
pub fn router(app: AppState) -> Router {
|
||||
let api = Router::new()
|
||||
.route("/info", get(info))
|
||||
// public
|
||||
.route("/info", get(auth::info))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/setup", post(auth::setup))
|
||||
.route("/auth/oidc/start", get(auth::oidc_start))
|
||||
.route("/auth/oidc/callback", get(auth::oidc_callback))
|
||||
// signed in
|
||||
.route("/auth/password", post(auth::change_password))
|
||||
.route("/users", get(auth::list_users).post(auth::create_user))
|
||||
.route("/users/directory", get(auth::directory))
|
||||
.route("/users/{id}", axum::routing::patch(auth::update_user).delete(auth::delete_user))
|
||||
.route("/folders", get(list_folders).post(create_folder))
|
||||
.route("/folders/{id}", delete(delete_folder))
|
||||
.route("/folders/{id}/access", put(set_folder_access))
|
||||
.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))
|
||||
@@ -55,15 +76,38 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/compact", post(compact))
|
||||
.route("/sessions/{id}/rewind", post(rewind))
|
||||
.route("/sessions/{id}/stats", get(session_stats))
|
||||
.route("/sessions/{id}/shares", get(get_shares).put(put_shares))
|
||||
.route("/sessions/{id}/queue", get(get_queue))
|
||||
.route("/sessions/{id}/queue/flush", post(flush_queue))
|
||||
.route("/sessions/{id}/queue/{item}", delete(delete_queue_item))
|
||||
.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/roots", get(list_roots))
|
||||
.route("/files/list", get(crate::files::list))
|
||||
.route("/files/read", get(crate::files::read))
|
||||
.route("/files/raw", get(crate::files::raw))
|
||||
.route("/files/write", put(crate::files::write))
|
||||
.route("/files/create", post(crate::files::create))
|
||||
.route("/files/move", post(crate::files::move_or_copy))
|
||||
.route("/files/delete", delete(crate::files::remove))
|
||||
.route("/files/upload", put(crate::files::upload))
|
||||
.route("/files/search", get(crate::files::search))
|
||||
.route("/git", post(crate::git::handle))
|
||||
.route("/fs/dirs", get(list_dirs))
|
||||
.route("/stats", get(stats))
|
||||
.route("/budget", get(budget))
|
||||
.route("/providers/{name}/prices", get(provider_prices))
|
||||
.route("/images", get(list_images).post(create_images))
|
||||
.route("/images/config", get(images_config))
|
||||
.route("/images/{id}", delete(delete_image))
|
||||
.route("/images/{id}/file", get(image_file))
|
||||
.route("/ws", get(ws))
|
||||
.layer(middleware::from_fn_with_state(app.clone(), auth));
|
||||
.layer(middleware::from_fn_with_state(app.clone(), auth::require_user))
|
||||
// Prompts may carry images (base64); the default limit is 2 MB.
|
||||
.layer(axum::extract::DefaultBodyLimit::max(50 * 1024 * 1024));
|
||||
|
||||
let web_dir = std::env::var("CODECRAFT_WEB").map(PathBuf::from).unwrap_or_else(|_| {
|
||||
["../web/dist", "./web/dist", "/app/web"]
|
||||
@@ -73,64 +117,42 @@ pub fn router(app: AppState) -> Router {
|
||||
.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));
|
||||
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;
|
||||
/// Load a session the user may at least read.
|
||||
fn readable(app: &App, user: &User, id: &str) -> Result<Session, ApiError> {
|
||||
let s = app.store.get_session(id)?.ok_or_else(|| not_found("session"))?;
|
||||
if app.access(user, id) == Access::None {
|
||||
return Err(forbidden());
|
||||
}
|
||||
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()
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Load a session the user may control (owner or admin).
|
||||
fn writable(app: &App, user: &User, id: &str) -> Result<Session, ApiError> {
|
||||
let s = readable(app, user, id)?;
|
||||
if app.access(user, id) != Access::Full {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "this session is shared with you read-only".into()));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// --- config (admin) -----------------------------------------------------------
|
||||
|
||||
// --- 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 {
|
||||
async fn get_config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let cfg = app.config();
|
||||
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
|
||||
if !user.is_admin() {
|
||||
// Users only need defaults, not the configuration.
|
||||
return Ok(Json(json!({"default_model": default_model, "currency": cfg.server.currency})));
|
||||
}
|
||||
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()) },
|
||||
"default_model": default_model,
|
||||
"currency": cfg.server.currency,
|
||||
})))
|
||||
}
|
||||
@@ -140,14 +162,16 @@ struct PatchBody {
|
||||
patches: Vec<ConfigPatch>,
|
||||
}
|
||||
|
||||
async fn patch_config(State(app): State<AppState>, Json(body): Json<PatchBody>) -> ApiResult {
|
||||
async fn patch_config(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<PatchBody>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
// 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 {
|
||||
async fn get_config_raw(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
Ok(Json(json!({"text": app.config_text()?})))
|
||||
}
|
||||
|
||||
@@ -156,12 +180,14 @@ struct RawBody {
|
||||
text: String,
|
||||
}
|
||||
|
||||
async fn put_config_raw(State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
app.save_config(&body.text).await?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn reload_providers(State(app): State<AppState>) -> ApiResult {
|
||||
async fn reload_providers(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
app.reload_providers().await;
|
||||
Ok(Json(app.registry().models_json()))
|
||||
}
|
||||
@@ -175,33 +201,92 @@ struct CwdQuery {
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
fn cwd_or_workspace(app: &App, cwd: Option<String>) -> PathBuf {
|
||||
cwd.map(PathBuf::from).unwrap_or_else(|| app.workspace())
|
||||
/// Root and cwd for listing agents/commands; falls back to the user's first root.
|
||||
fn scope(app: &App, user: &User, cwd: Option<String>) -> (PathBuf, PathBuf) {
|
||||
let owner = Some(user.id.as_str());
|
||||
let roots = app.user_roots(owner);
|
||||
let cwd = cwd.map(PathBuf::from).filter(|c| roots.iter().any(|r| c.starts_with(r))).unwrap_or_else(|| roots[0].clone());
|
||||
(app.root_for(owner, &cwd), cwd)
|
||||
}
|
||||
|
||||
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 agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||
Json(json!(prompts::agents(&app, &root, &cwd)))
|
||||
}
|
||||
|
||||
async fn commands(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||
let cwd = cwd_or_workspace(&app, q.cwd);
|
||||
async fn commands(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||
Json(json!({
|
||||
"commands": prompts::commands(&app, &cwd),
|
||||
"skills": prompts::skills(&app, &cwd),
|
||||
"commands": prompts::commands(&app, &root, &cwd),
|
||||
"skills": prompts::skills(&app, &root, &cwd),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- shared folders (admin) ---------------------------------------------------
|
||||
|
||||
async fn list_folders(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
Ok(Json(json!({"workspace": app.workspace(), "folders": app.store.folders()?})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FolderBody {
|
||||
/// Path relative to the workspace (or absolute inside it). Created if missing.
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
users: Vec<String>,
|
||||
}
|
||||
|
||||
async fn create_folder(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<FolderBody>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
let ws = app.workspace();
|
||||
let rel = body.path.trim().trim_start_matches(ws.to_str().unwrap_or("")).trim_matches('/');
|
||||
if rel.is_empty() || rel.split('/').any(|c| c.is_empty() || c == "." || c == ".." || c.starts_with('.')) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid folder path".into()));
|
||||
}
|
||||
let path = ws.join(rel);
|
||||
std::fs::create_dir_all(&path)?;
|
||||
let path = std::fs::canonicalize(&path)?;
|
||||
if !path.starts_with(&ws) || path == ws {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "folder must be inside the workspace".into()));
|
||||
}
|
||||
let id = app.store.add_folder(&path.display().to_string())?;
|
||||
app.store.set_folder_access(&id, &body.users)?;
|
||||
Ok(Json(json!({"id": id, "path": path})))
|
||||
}
|
||||
|
||||
async fn delete_folder(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
// Only the share is removed; the directory and its files stay.
|
||||
app.store.delete_folder(&id)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AccessBody {
|
||||
users: Vec<String>,
|
||||
}
|
||||
|
||||
async fn set_folder_access(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<AccessBody>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
app.store.set_folder_access(&id, &body.users)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
// --- sessions ---------------------------------------------------------------
|
||||
|
||||
async fn list_sessions(State(app): State<AppState>) -> ApiResult {
|
||||
let sessions = app.store.list_sessions()?;
|
||||
let list: Vec<Value> = sessions
|
||||
async fn list_sessions(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
|
||||
let list: Vec<Value> = app
|
||||
.store
|
||||
.list_sessions(filter)?
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let busy = app.is_busy(&s.id);
|
||||
let shared = s.owner_id.as_deref() != Some(user.id.as_str());
|
||||
let mut v = json!(s);
|
||||
v["busy"] = json!(busy);
|
||||
v["shared"] = json!(shared);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
@@ -216,29 +301,34 @@ struct CreateSession {
|
||||
permission_mode: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_cwd(app: &App, cwd: Option<String>) -> Result<PathBuf, ApiError> {
|
||||
let ws = app.workspace();
|
||||
/// Resolve a directory and check it lies inside one of the user's roots.
|
||||
fn validate_cwd(app: &App, user: &User, cwd: Option<String>) -> Result<PathBuf, ApiError> {
|
||||
let roots = app.user_roots(Some(&user.id));
|
||||
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(),
|
||||
Some(c) => roots[0].join(c),
|
||||
None => roots[0].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()));
|
||||
if !p.is_dir() || !roots.iter().any(|r| p.starts_with(r)) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "directory is outside your folders".into()));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
|
||||
let cwd = validate_cwd(&app, body.cwd)?;
|
||||
async fn create_session(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
|
||||
let cwd = validate_cwd(&app, &user, 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()))?;
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
|
||||
let mode = body.permission_mode.unwrap_or_else(|| "auto".into());
|
||||
if !crate::permission::MODES.contains(&mode.as_str()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
}
|
||||
let now = now_ms();
|
||||
let s = Session {
|
||||
id: new_id(),
|
||||
@@ -246,8 +336,9 @@ async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSess
|
||||
cwd: cwd.display().to_string(),
|
||||
model,
|
||||
agent: body.agent.unwrap_or_else(|| "build".into()),
|
||||
permission_mode: body.permission_mode.unwrap_or_else(|| "default".into()),
|
||||
permission_mode: mode,
|
||||
parent_id: None,
|
||||
owner_id: Some(user.id.clone()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
todos: Value::Null,
|
||||
@@ -257,11 +348,13 @@ async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSess
|
||||
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"))?;
|
||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let s = readable(&app, &user, &id)?;
|
||||
let messages = app.store.messages(&id)?;
|
||||
let busy = app.is_busy(&id);
|
||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy})))
|
||||
let can_write = app.access(&user, &id) == Access::Full;
|
||||
let queue = app.store.queue_list(&id)?;
|
||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -272,8 +365,8 @@ struct UpdateSession {
|
||||
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"))?;
|
||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||
let mut s = writable(&app, &user, &id)?;
|
||||
if let Some(t) = body.title {
|
||||
s.title = t;
|
||||
}
|
||||
@@ -285,7 +378,7 @@ async fn update_session(State(app): State<AppState>, Path(id): Path<String>, Jso
|
||||
s.agent = a;
|
||||
}
|
||||
if let Some(p) = body.permission_mode {
|
||||
if !["default", "accept_edits", "bypass"].contains(&p.as_str()) {
|
||||
if !crate::permission::MODES.contains(&p.as_str()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
}
|
||||
s.permission_mode = p;
|
||||
@@ -293,10 +386,12 @@ async fn update_session(State(app): State<AppState>, Path(id): Path<String>, Jso
|
||||
Ok(Json(json!(app.save_session(s)?)))
|
||||
}
|
||||
|
||||
async fn delete_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
// Notify before deleting: visibility is checked against the stored session.
|
||||
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
||||
runner::abort(&app, &id);
|
||||
app.store.delete_session(&id)?;
|
||||
app.emit(Event::SessionDeleted { session_id: id });
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -307,8 +402,8 @@ struct PromptBody {
|
||||
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"))?;
|
||||
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
||||
let session = writable(&app, &user, &id)?;
|
||||
let text = body.text.trim().to_string();
|
||||
// Built-in commands.
|
||||
if text == "/compact" {
|
||||
@@ -319,9 +414,10 @@ async fn prompt(State(app): State<AppState>, Path(id): Path<String>, Json(body):
|
||||
return Ok(Json(json!({"ok": true, "command": "compact"})));
|
||||
}
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let mut prompt_text = text.clone();
|
||||
if text.starts_with('/') {
|
||||
if let Some((expanded, agent)) = prompts::expand_command(&app, &cwd, &text) {
|
||||
if let Some((expanded, agent)) = prompts::expand_command(&app, &root, &cwd, &text) {
|
||||
prompt_text = expanded;
|
||||
if let Some(a) = agent {
|
||||
let mut s = session.clone();
|
||||
@@ -334,10 +430,32 @@ async fn prompt(State(app): State<AppState>, Path(id): Path<String>, Json(body):
|
||||
for image in body.images {
|
||||
parts.push(Part::Image { image });
|
||||
}
|
||||
runner::start_prompt(app.clone(), id, parts)?;
|
||||
match runner::submit(app.clone(), id, parts, "user")? {
|
||||
runner::Submitted::Started => Ok(Json(json!({"ok": true}))),
|
||||
runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_queue(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
Ok(Json(json!(app.store.queue_list(&id)?)))
|
||||
}
|
||||
|
||||
async fn delete_queue_item(Extension(user): Extension<User>, State(app): State<AppState>, Path((id, item)): Path<(String, String)>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
if !app.store.queue_remove(&id, &item)? {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "already delivered to the agent".into()));
|
||||
}
|
||||
let items = app.store.queue_list(&id)?;
|
||||
app.emit(Event::QueueUpdated { session_id: id, items: json!(items) });
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn flush_queue(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
|
||||
}
|
||||
|
||||
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()));
|
||||
@@ -349,12 +467,14 @@ async fn do_compact(app: AppState, id: String) {
|
||||
app.emit(Event::SessionStatus { session_id: id, busy: false });
|
||||
}
|
||||
|
||||
async fn abort(State(app): State<AppState>, Path(id): Path<String>) -> Json<Value> {
|
||||
async fn abort(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
runner::abort(&app, &id);
|
||||
Json(json!({"ok": true}))
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn compact(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
async fn compact(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
@@ -368,33 +488,58 @@ struct RewindBody {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
async fn rewind(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<RewindBody>) -> ApiResult {
|
||||
let mut s = writable(&app, &user, &id)?;
|
||||
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 {
|
||||
async fn session_stats(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let mut v = app.store.session_stats(&id)?;
|
||||
v["currency"] = json!(app.config().server.currency);
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
async fn get_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
Ok(Json(json!(app.store.shares(&id)?)))
|
||||
}
|
||||
|
||||
async fn put_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<AccessBody>) -> ApiResult {
|
||||
let s = writable(&app, &user, &id)?;
|
||||
if s.parent_id.is_some() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "share the parent session instead".into()));
|
||||
}
|
||||
let users: Vec<String> = body.users.into_iter().filter(|u| Some(u) != s.owner_id.as_ref()).collect();
|
||||
app.set_shares(&id, &users)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
// --- interaction ------------------------------------------------------------
|
||||
|
||||
/// Only users who control the session may answer its permission requests and questions.
|
||||
fn check_pending(app: &App, user: &User, id: &str) -> Result<(), ApiError> {
|
||||
let sid = app.pending_session(id).ok_or_else(|| not_found("request"))?;
|
||||
if app.access(user, &sid) != Access::Full {
|
||||
return Err(forbidden());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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 {
|
||||
async fn reply_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
|
||||
check_pending(&app, &user, &id)?;
|
||||
app.reply_permission(&id, body.reply, body.message)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
@@ -405,37 +550,55 @@ struct QuestionBody {
|
||||
answers: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
async fn answer_question(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<QuestionBody>) -> ApiResult {
|
||||
async fn answer_question(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<QuestionBody>) -> ApiResult {
|
||||
check_pending(&app, &user, &id)?;
|
||||
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 pending(Extension(user): Extension<User>, State(app): State<AppState>) -> Json<Value> {
|
||||
let list: Vec<Event> = app.pending_events().into_iter().filter(|e| app.event_visible(&user, e)).collect();
|
||||
Json(json!(list))
|
||||
}
|
||||
|
||||
async fn mcp_status(State(app): State<AppState>) -> Json<Value> {
|
||||
Json(app.mcp.status_json())
|
||||
async fn mcp_status(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
Ok(Json(app.mcp.status_json()))
|
||||
}
|
||||
|
||||
async fn mcp_reload(State(app): State<AppState>) -> Json<Value> {
|
||||
async fn mcp_reload(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
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())
|
||||
Ok(Json(app.mcp.status_json()))
|
||||
}
|
||||
|
||||
// --- files ------------------------------------------------------------------
|
||||
|
||||
/// The directories the user may work in (own folder, shared folders).
|
||||
async fn list_roots(Extension(user): Extension<User>, State(app): State<AppState>) -> Json<Value> {
|
||||
let ws = app.workspace();
|
||||
let roots: Vec<Value> = app
|
||||
.user_roots(Some(&user.id))
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let rel = r.strip_prefix(&ws).map(|p| p.display().to_string()).unwrap_or_default();
|
||||
json!({"path": r, "name": if rel.is_empty() { "/".to_string() } else { rel }})
|
||||
})
|
||||
.collect();
|
||||
Json(json!(roots))
|
||||
}
|
||||
|
||||
#[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)?;
|
||||
/// Subdirectories for the project picker (inside the user's roots only).
|
||||
async fn list_dirs(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<DirQuery>) -> ApiResult {
|
||||
let dir = validate_cwd(&app, &user, 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();
|
||||
@@ -445,15 +608,125 @@ async fn list_dirs(State(app): State<AppState>, Query(q): Query<DirQuery>) -> Ap
|
||||
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();
|
||||
let root = app.root_for(Some(&user.id), &dir);
|
||||
Ok(Json(json!({
|
||||
"path": dir,
|
||||
"parent": if dir == ws { Value::Null } else { json!(dir.parent()) },
|
||||
"workspace": ws,
|
||||
"parent": if dir == root { Value::Null } else { json!(dir.parent()) },
|
||||
"root": root,
|
||||
"workspace": app.workspace(),
|
||||
"dirs": dirs,
|
||||
})))
|
||||
}
|
||||
|
||||
// --- budget -------------------------------------------------------------------
|
||||
|
||||
/// Budgets concern the whole installation: admins only.
|
||||
async fn budget(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
Ok(Json(crate::budget::overview(&app).await?))
|
||||
}
|
||||
|
||||
async fn provider_prices(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
Ok(Json(crate::budget::discover_prices(&app, &name).await?))
|
||||
}
|
||||
|
||||
// --- images -------------------------------------------------------------------
|
||||
|
||||
async fn images_config(State(app): State<AppState>) -> Json<Value> {
|
||||
let cfg = app.config();
|
||||
let ic = &cfg.images;
|
||||
Json(json!({
|
||||
"enabled": ic.enabled && !crate::config::expand_env(&ic.api_key).is_empty(),
|
||||
"default_model": ic.default_model,
|
||||
"output_dir": ic.output_dir,
|
||||
"sizes": crate::images::SIZES,
|
||||
"models": ic.models.iter().map(|m| json!({"id": m.id, "name": m.name.clone().unwrap_or_else(|| m.id.clone()), "price": m.price})).collect::<Vec<_>>(),
|
||||
"currency": cfg.server.currency,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_images(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!(app.store.images(Some(&user.id), 200)?)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImageBody {
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
size: Option<String>,
|
||||
#[serde(default = "one")]
|
||||
count: u32,
|
||||
/// Target directory (absolute inside the user's folders); default: first folder/output_dir.
|
||||
directory: Option<String>,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
async fn create_images(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<ImageBody>) -> ApiResult {
|
||||
if body.prompt.trim().is_empty() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "prompt is empty".into()));
|
||||
}
|
||||
let roots = app.user_roots(Some(&user.id));
|
||||
let dir = match body.directory.filter(|d| !d.trim().is_empty()) {
|
||||
Some(d) => {
|
||||
let p = crate::tools::normalize(&PathBuf::from(d));
|
||||
if !roots.iter().any(|r| p.starts_with(r)) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "directory is outside your folders".into()));
|
||||
}
|
||||
p
|
||||
}
|
||||
None => roots[0].join(&app.config().images.output_dir),
|
||||
};
|
||||
let records = crate::images::generate(
|
||||
&app,
|
||||
crate::images::GenerateRequest {
|
||||
prompt: body.prompt.trim().to_string(),
|
||||
model: body.model,
|
||||
size: body.size,
|
||||
count: body.count,
|
||||
dir,
|
||||
user_id: Some(user.id.clone()),
|
||||
session_id: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(records)))
|
||||
}
|
||||
|
||||
fn own_image(app: &App, user: &User, id: &str) -> Result<crate::store::ImageRecord, ApiError> {
|
||||
let img = app.store.image(id)?.ok_or_else(|| not_found("image"))?;
|
||||
if !user.is_admin() && img.user_id.as_deref() != Some(user.id.as_str()) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "not your image".into()));
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
async fn image_file(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> Result<Response, ApiError> {
|
||||
let img = own_image(&app, &user, &id)?;
|
||||
let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?;
|
||||
let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) {
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
Ok(([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400")], bytes).into_response())
|
||||
}
|
||||
|
||||
async fn delete_image(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let img = own_image(&app, &user, &id)?;
|
||||
// Only delete files inside the owner's folders.
|
||||
let roots = app.user_roots(img.user_id.as_deref());
|
||||
let p = PathBuf::from(&img.path);
|
||||
if roots.iter().any(|r| p.starts_with(r)) {
|
||||
let _ = tokio::fs::remove_file(&p).await;
|
||||
}
|
||||
app.store.delete_image(&id)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
// --- statistics -------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -462,12 +735,15 @@ struct StatsQuery {
|
||||
to: Option<i64>,
|
||||
/// Minutes east of UTC (JS: -new Date().getTimezoneOffset()).
|
||||
tz: Option<i64>,
|
||||
/// Admins: user id to filter by (omitted = all users).
|
||||
user: Option<String>,
|
||||
}
|
||||
|
||||
async fn stats(State(app): State<AppState>, Query(q): Query<StatsQuery>) -> ApiResult {
|
||||
async fn stats(Extension(user): Extension<User>, 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))?;
|
||||
let filter = if user.is_admin() { q.user.filter(|u| !u.is_empty()) } else { Some(user.id.clone()) };
|
||||
let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0), filter.as_deref())?;
|
||||
v["currency"] = json!(app.config().server.currency);
|
||||
v["from"] = json!(from);
|
||||
v["to"] = json!(to);
|
||||
@@ -476,23 +752,25 @@ async fn stats(State(app): State<AppState>, Query(q): Query<StatsQuery>) -> ApiR
|
||||
|
||||
// --- websocket --------------------------------------------------------------
|
||||
|
||||
async fn ws(State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
||||
upgrade.on_upgrade(move |socket| ws_loop(app, socket))
|
||||
async fn ws(Extension(user): Extension<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
||||
upgrade.on_upgrade(move |socket| ws_loop(app, user, socket))
|
||||
}
|
||||
|
||||
async fn ws_loop(app: AppState, mut socket: WebSocket) {
|
||||
async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
|
||||
let mut rx = app.subscribe();
|
||||
for ev in app.pending_events() {
|
||||
if send_event(&mut socket, &ev).await.is_err() {
|
||||
if app.event_visible(&user, &ev) && send_event(&mut socket, &ev).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||
// Re-check the login regularly so disabled users lose their live connection.
|
||||
let mut recheck = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(ev) => {
|
||||
if send_event(&mut socket, &ev).await.is_err() {
|
||||
if app.event_visible(&user, &ev) && send_event(&mut socket, &ev).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -514,6 +792,11 @@ async fn ws_loop(app: AppState, mut socket: WebSocket) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = recheck.tick() => {
|
||||
if !matches!(app.store.user(&user.id), Ok(Some(u)) if !u.disabled) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+155
-3
@@ -10,7 +10,7 @@ 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::sync::{OwnedSemaphorePermit, Semaphore, broadcast, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
@@ -39,8 +39,6 @@ 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 {
|
||||
@@ -53,6 +51,24 @@ pub struct App {
|
||||
runtime: Mutex<HashMap<String, Runtime>>,
|
||||
permissions: Mutex<HashMap<String, PendingPermission>>,
|
||||
questions: Mutex<HashMap<String, PendingQuestion>>,
|
||||
/// Subagent concurrency limits: key → (size, semaphore). Recreated when the size changes.
|
||||
subagent_limits: Mutex<HashMap<String, (usize, Arc<Semaphore>)>>,
|
||||
/// One-time code for creating the first admin (None once users exist).
|
||||
pub setup_code: Mutex<Option<String>>,
|
||||
pub oidc_pending: Mutex<HashMap<String, crate::auth::OidcPending>>,
|
||||
pub oidc_discovery: Mutex<Option<(String, Value)>>,
|
||||
/// session id → (root session id, owner) – both never change.
|
||||
owners: Mutex<HashMap<String, (String, Option<String>)>>,
|
||||
/// root session id → users it is shared with.
|
||||
shares: Mutex<HashMap<String, Vec<String>>>,
|
||||
}
|
||||
|
||||
/// What a user may do with a session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
None,
|
||||
Read,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -72,7 +88,14 @@ impl App {
|
||||
runtime: Mutex::default(),
|
||||
permissions: Mutex::default(),
|
||||
questions: Mutex::default(),
|
||||
subagent_limits: Mutex::default(),
|
||||
setup_code: Mutex::default(),
|
||||
oidc_pending: Mutex::default(),
|
||||
oidc_discovery: Mutex::default(),
|
||||
owners: Mutex::default(),
|
||||
shares: Mutex::default(),
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
Ok(app)
|
||||
}
|
||||
@@ -97,6 +120,81 @@ impl App {
|
||||
std::fs::canonicalize(&ws).unwrap_or(ws)
|
||||
}
|
||||
|
||||
/// Directories a session owner may work in: admins (and legacy sessions
|
||||
/// without owner) the whole workspace, users their own folder plus shared folders.
|
||||
pub fn user_roots(&self, owner_id: Option<&str>) -> Vec<PathBuf> {
|
||||
let ws = self.workspace();
|
||||
let Some(user) = owner_id.and_then(|id| self.store.user(id).ok().flatten()) else {
|
||||
return vec![ws];
|
||||
};
|
||||
if user.is_admin() {
|
||||
return vec![ws];
|
||||
}
|
||||
let own = ws.join(&user.username);
|
||||
let _ = std::fs::create_dir_all(&own);
|
||||
let mut roots = vec![own];
|
||||
for p in self.store.folders_for_user(&user.id).unwrap_or_default() {
|
||||
let p = PathBuf::from(p);
|
||||
if p.starts_with(&ws) && p.is_dir() {
|
||||
roots.push(p);
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// The root that contains `cwd` (most specific first).
|
||||
pub fn root_for(&self, owner_id: Option<&str>, cwd: &Path) -> PathBuf {
|
||||
let mut roots = self.user_roots(owner_id);
|
||||
roots.sort_by_key(|r| std::cmp::Reverse(r.as_os_str().len()));
|
||||
roots.iter().find(|r| cwd.starts_with(r)).cloned().unwrap_or_else(|| roots.pop().unwrap_or_else(|| self.workspace()))
|
||||
}
|
||||
|
||||
fn owner_of(&self, session_id: &str) -> Option<(String, Option<String>)> {
|
||||
if let Some(v) = self.owners.lock().unwrap().get(session_id) {
|
||||
return Some(v.clone());
|
||||
}
|
||||
let root = self.root_session_id(session_id);
|
||||
let owner = self.store.get_session(&root).ok().flatten()?.owner_id;
|
||||
let v = (root, owner);
|
||||
self.owners.lock().unwrap().insert(session_id.to_string(), v.clone());
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn shared_with(&self, root: &str) -> Vec<String> {
|
||||
if let Some(v) = self.shares.lock().unwrap().get(root) {
|
||||
return v.clone();
|
||||
}
|
||||
let v = self.store.shares(root).unwrap_or_default();
|
||||
self.shares.lock().unwrap().insert(root.to_string(), v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
pub fn set_shares(&self, root: &str, users: &[String]) -> Result<()> {
|
||||
self.store.set_shares(root, users)?;
|
||||
self.shares.lock().unwrap().insert(root.to_string(), users.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn access(&self, user: &crate::store::User, session_id: &str) -> Access {
|
||||
let Some((root, owner)) = self.owner_of(session_id) else { return Access::None };
|
||||
if user.is_admin() || owner.as_deref() == Some(user.id.as_str()) {
|
||||
Access::Full
|
||||
} else if self.shared_with(&root).contains(&user.id) {
|
||||
Access::Read
|
||||
} else {
|
||||
Access::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an event may be sent to this user's connections.
|
||||
pub fn event_visible(&self, user: &crate::store::User, ev: &Event) -> bool {
|
||||
match ev.session_id() {
|
||||
None => user.is_admin(),
|
||||
Some(_) if user.is_admin() => true,
|
||||
Some(id) => self.access(user, id) != Access::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> PathBuf {
|
||||
self.config().data_dir()
|
||||
}
|
||||
@@ -197,6 +295,54 @@ impl App {
|
||||
id
|
||||
}
|
||||
|
||||
fn limit(&self, key: &str, size: usize) -> Arc<Semaphore> {
|
||||
let mut m = self.subagent_limits.lock().unwrap();
|
||||
match m.get(key) {
|
||||
Some((n, sem)) if *n == size => sem.clone(),
|
||||
_ => {
|
||||
let sem = Arc::new(Semaphore::new(size));
|
||||
m.insert(key.to_string(), (size, sem.clone()));
|
||||
sem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until a subagent of this type may run (per-agent limit, then the global
|
||||
/// limit – always in this order). Returns None if cancelled while waiting, and
|
||||
/// whether the call had to wait.
|
||||
pub async fn subagent_slot(
|
||||
&self,
|
||||
agent: &str,
|
||||
max_parallel: Option<usize>,
|
||||
session_id: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> (Option<Vec<OwnedSemaphorePermit>>, bool) {
|
||||
let mut sems = Vec::new();
|
||||
if let Some(n) = max_parallel.filter(|n| *n > 0) {
|
||||
sems.push((format!("subagent:{agent}"), self.limit(&format!("agent:{agent}"), n)));
|
||||
}
|
||||
let global = self.config().server.max_parallel_subagents;
|
||||
if global > 0 {
|
||||
sems.push(("subagent:*".to_string(), self.limit("*", global)));
|
||||
}
|
||||
let mut waited = false;
|
||||
let mut permits = Vec::new();
|
||||
for (reason, sem) in sems {
|
||||
if sem.available_permits() == 0 {
|
||||
waited = true;
|
||||
self.emit(Event::Waiting { session_id: session_id.to_string(), reason: Some(reason) });
|
||||
}
|
||||
tokio::select! {
|
||||
p = sem.acquire_owned() => match p {
|
||||
Ok(p) => permits.push(p),
|
||||
Err(_) => return (None, waited),
|
||||
},
|
||||
_ = cancel.cancelled() => return (None, waited),
|
||||
}
|
||||
}
|
||||
(Some(permits), waited)
|
||||
}
|
||||
|
||||
// --- user interaction -------------------------------------------------
|
||||
|
||||
/// Forward questions to the UI and wait. None = dismissed.
|
||||
@@ -227,6 +373,12 @@ impl App {
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Session of a pending permission request or question.
|
||||
pub fn pending_session(&self, id: &str) -> Option<String> {
|
||||
let from_perm = self.permissions.lock().unwrap().get(id).and_then(|p| p.event.session_id().map(String::from));
|
||||
from_perm.or_else(|| self.questions.lock().unwrap().get(id).and_then(|q| q.event.session_id().map(String::from)))
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
//! Users, sign-in (local passwords + OpenID Connect) and access control.
|
||||
|
||||
use crate::api::{ApiError, ApiResult};
|
||||
use crate::app::App;
|
||||
use crate::config::expand_env;
|
||||
use crate::store::User;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
|
||||
use axum::extract::{Path, Query, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::{Extension, Json};
|
||||
use base64::Engine;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const COOKIE: &str = "codecraft_session";
|
||||
const MIN_PASSWORD: usize = 8;
|
||||
|
||||
// --- primitives ---------------------------------------------------------------
|
||||
|
||||
pub fn random_token(bytes: usize) -> String {
|
||||
let mut buf = vec![0u8; bytes];
|
||||
rand::fill(&mut buf[..]);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
|
||||
}
|
||||
|
||||
pub fn sha256_hex(s: &str) -> String {
|
||||
Sha256::digest(s.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
let mut salt = [0u8; 16];
|
||||
rand::fill(&mut salt);
|
||||
let hash = Argon2::default()
|
||||
.hash_password_with_salt(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow!("hashing failed: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||
Argon2::default().verify_password(password.as_bytes(), hash).is_ok()
|
||||
}
|
||||
|
||||
fn valid_username(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
&& !name.starts_with('.')
|
||||
}
|
||||
|
||||
/// Usernames become directory names; derive a safe one from an IdP claim.
|
||||
fn sanitize_username(raw: &str) -> String {
|
||||
let local = raw.split('@').next().unwrap_or(raw);
|
||||
let s: String = local
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c.to_ascii_lowercase() } else { '_' })
|
||||
.collect();
|
||||
s.trim_start_matches('.').chars().take(64).collect()
|
||||
}
|
||||
|
||||
// --- cookies & middleware -------------------------------------------------------
|
||||
|
||||
fn cookie_secure(app: &App, headers: &HeaderMap) -> bool {
|
||||
match app.config().auth.cookie_secure.as_str() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => {
|
||||
app.config().auth.public_url.starts_with("https://")
|
||||
|| headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()) == Some("https")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn session_cookie(app: &App, headers: &HeaderMap, token: &str, max_age_secs: i64) -> HeaderValue {
|
||||
let secure = if cookie_secure(app, headers) { "; Secure" } else { "" };
|
||||
HeaderValue::from_str(&format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}{secure}"))
|
||||
.expect("valid cookie")
|
||||
}
|
||||
|
||||
pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
if let Some(t) = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
{
|
||||
return Some(t.trim().to_string());
|
||||
}
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|c| c.split(';'))
|
||||
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from))
|
||||
}
|
||||
|
||||
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
||||
let token = token_from_headers(headers)?;
|
||||
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
|
||||
}
|
||||
|
||||
/// Paths reachable without a login.
|
||||
fn is_public(path: &str) -> bool {
|
||||
["/info", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
||||
.iter()
|
||||
.any(|p| path.ends_with(p))
|
||||
}
|
||||
|
||||
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
|
||||
if is_public(req.uri().path()) {
|
||||
return next.run(req).await;
|
||||
}
|
||||
match current_user(&app, req.headers()) {
|
||||
Some(user) => {
|
||||
req.extensions_mut().insert(user);
|
||||
next.run(req).await
|
||||
}
|
||||
None => ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_admin(user: &User) -> Result<(), ApiError> {
|
||||
if user.is_admin() { Ok(()) } else { Err(ApiError(StatusCode::FORBIDDEN, "admin only".into())) }
|
||||
}
|
||||
|
||||
fn issue_login(app: &App, headers: &HeaderMap, user: &User) -> Result<HeaderValue> {
|
||||
let token = random_token(32);
|
||||
let days = app.config().auth.session_days.max(1) as i64;
|
||||
let ua = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("");
|
||||
app.store.add_token(&sha256_hex(&token), &user.id, crate::types::now_ms() + days * 86_400_000, &ua.chars().take(200).collect::<String>())?;
|
||||
Ok(session_cookie(app, headers, &token, days * 86_400))
|
||||
}
|
||||
|
||||
// --- bootstrap ------------------------------------------------------------------
|
||||
|
||||
/// Create the first admin from the environment, or generate a setup code.
|
||||
pub fn bootstrap(app: &App) -> Result<()> {
|
||||
if app.store.user_count()? > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if let (Ok(user), Ok(pass)) = (std::env::var("CODECRAFT_ADMIN_USER"), std::env::var("CODECRAFT_ADMIN_PASSWORD")) {
|
||||
if valid_username(&user) && pass.len() >= MIN_PASSWORD {
|
||||
let u = app.store.create_user(&user, &user, "admin", Some(&hash_password(&pass)?), None)?;
|
||||
let n = app.store.adopt_orphans(&u.id)?;
|
||||
tracing::info!("created admin '{user}' from the environment ({n} existing sessions assigned)");
|
||||
return Ok(());
|
||||
}
|
||||
tracing::warn!("CODECRAFT_ADMIN_USER/PASSWORD ignored: invalid username or password shorter than {MIN_PASSWORD}");
|
||||
}
|
||||
let code = random_token(9);
|
||||
tracing::warn!("no users yet – open the web UI and create the first admin with setup code: {code}");
|
||||
*app.setup_code.lock().unwrap() = Some(code);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- endpoints: session -------------------------------------------------------
|
||||
|
||||
pub async fn info(State(app): State<Arc<App>>, headers: HeaderMap) -> Json<Value> {
|
||||
let cfg = app.config();
|
||||
let user = current_user(&app, &headers);
|
||||
Json(json!({
|
||||
"name": "CodeCraft",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"setup_required": app.setup_code.lock().unwrap().is_some(),
|
||||
"oidc": if cfg.auth.oidc.enabled { json!({"label": cfg.auth.oidc.button_label}) } else { Value::Null },
|
||||
"user": user,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginBody {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<LoginBody>) -> ApiResult<Response> {
|
||||
let found = app.store.user_by_name(body.username.trim())?;
|
||||
// Verify even for unknown users so timing does not reveal which names exist.
|
||||
let hash = found.as_ref().and_then(|(_, h)| h.clone());
|
||||
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
|
||||
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
|
||||
};
|
||||
let cookie = issue_login(&app, &headers, &user)?;
|
||||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||||
}
|
||||
|
||||
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
|
||||
if let Some(t) = token_from_headers(&headers) {
|
||||
let _ = app.store.delete_token(&sha256_hex(&t));
|
||||
}
|
||||
([(header::SET_COOKIE, session_cookie(&app, &headers, "", 0))], Json(json!({"ok": true}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetupBody {
|
||||
code: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<SetupBody>) -> ApiResult<Response> {
|
||||
let expected = app.setup_code.lock().unwrap().clone();
|
||||
let Some(expected) = expected else {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into()));
|
||||
};
|
||||
if body.code.trim() != expected {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into()));
|
||||
}
|
||||
let name = body.username.trim();
|
||||
if !valid_username(name) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username".into()));
|
||||
}
|
||||
if body.password.len() < MIN_PASSWORD {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
|
||||
}
|
||||
let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?;
|
||||
app.store.adopt_orphans(&user.id)?;
|
||||
*app.setup_code.lock().unwrap() = None;
|
||||
let cookie = issue_login(&app, &headers, &user)?;
|
||||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PasswordBody {
|
||||
current: Option<String>,
|
||||
new: String,
|
||||
}
|
||||
|
||||
/// Change the own password (OIDC-only users may set one without `current`).
|
||||
pub async fn change_password(State(app): State<Arc<App>>, Extension(user): Extension<User>, headers: HeaderMap, Json(body): Json<PasswordBody>) -> ApiResult {
|
||||
if body.new.len() < MIN_PASSWORD {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
|
||||
}
|
||||
if let Some((_, Some(hash))) = app.store.user_by_name(&user.username)? {
|
||||
if !verify_password(body.current.as_deref().unwrap_or(""), &hash) {
|
||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "current password is wrong".into()));
|
||||
}
|
||||
}
|
||||
app.store.update_user(&user.id, None, None, None, Some(&hash_password(&body.new)?), None)?;
|
||||
// Sign out all other devices.
|
||||
app.store.delete_user_tokens(&user.id)?;
|
||||
let _ = issue_login(&app, &headers, &user)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
// --- endpoints: user management (admin) ------------------------------------------
|
||||
|
||||
pub async fn list_users(Extension(me): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
|
||||
require_admin(&me)?;
|
||||
Ok(Json(json!(app.store.users()?)))
|
||||
}
|
||||
|
||||
/// Minimal user list for sharing dialogs (all signed-in users).
|
||||
pub async fn directory(State(app): State<Arc<App>>) -> ApiResult {
|
||||
let list: Vec<Value> = app
|
||||
.store
|
||||
.users()?
|
||||
.into_iter()
|
||||
.filter(|u| !u.disabled)
|
||||
.map(|u| json!({"id": u.id, "username": u.username, "display_name": u.display_name}))
|
||||
.collect();
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUserBody {
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
display_name: String,
|
||||
password: Option<String>,
|
||||
#[serde(default = "default_role")]
|
||||
role: String,
|
||||
}
|
||||
|
||||
fn default_role() -> String {
|
||||
"user".into()
|
||||
}
|
||||
|
||||
fn check_role(role: &str) -> Result<(), ApiError> {
|
||||
if role == "admin" || role == "user" { Ok(()) } else { Err(ApiError(StatusCode::BAD_REQUEST, "role must be admin or user".into())) }
|
||||
}
|
||||
|
||||
pub async fn create_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Json(body): Json<CreateUserBody>) -> ApiResult {
|
||||
require_admin(&me)?;
|
||||
let name = body.username.trim();
|
||||
if !valid_username(name) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username (letters, digits, - _ .)".into()));
|
||||
}
|
||||
check_role(&body.role)?;
|
||||
let hash = match body.password.as_deref().filter(|p| !p.is_empty()) {
|
||||
Some(p) if p.len() < MIN_PASSWORD => {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
|
||||
}
|
||||
Some(p) => Some(hash_password(p)?),
|
||||
None => None,
|
||||
};
|
||||
if app.store.user_by_name(name)?.is_some() {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "username already exists".into()));
|
||||
}
|
||||
let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() };
|
||||
let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?;
|
||||
Ok(Json(json!(user)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserBody {
|
||||
display_name: Option<String>,
|
||||
role: Option<String>,
|
||||
disabled: Option<bool>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>, Json(body): Json<UpdateUserBody>) -> ApiResult {
|
||||
require_admin(&me)?;
|
||||
let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?;
|
||||
if let Some(r) = &body.role {
|
||||
check_role(r)?;
|
||||
}
|
||||
let loses_admin = target.is_admin() && !target.disabled && (body.role.as_deref().is_some_and(|r| r != "admin") || body.disabled == Some(true));
|
||||
if loses_admin && app.store.admin_count()? <= 1 {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be demoted or disabled".into()));
|
||||
}
|
||||
let hash = match body.password.as_deref().filter(|p| !p.is_empty()) {
|
||||
Some(p) if p.len() < MIN_PASSWORD => {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
|
||||
}
|
||||
Some(p) => Some(hash_password(p)?),
|
||||
None => None,
|
||||
};
|
||||
app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?;
|
||||
if hash.is_some() {
|
||||
app.store.delete_user_tokens(&id)?;
|
||||
}
|
||||
Ok(Json(json!(app.store.user(&id)?)))
|
||||
}
|
||||
|
||||
pub async fn delete_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>) -> ApiResult {
|
||||
require_admin(&me)?;
|
||||
let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?;
|
||||
if target.id == me.id {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "you cannot delete yourself".into()));
|
||||
}
|
||||
if target.is_admin() && app.store.admin_count()? <= 1 {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be deleted".into()));
|
||||
}
|
||||
// Sessions stay (statistics); they become visible to admins only.
|
||||
app.store.delete_user(&id)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
// --- OpenID Connect -----------------------------------------------------------------
|
||||
|
||||
pub struct OidcPending {
|
||||
nonce: String,
|
||||
verifier: String,
|
||||
created: std::time::Instant,
|
||||
}
|
||||
|
||||
async fn discovery(app: &App) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let issuer = expand_env(&cfg.auth.oidc.issuer);
|
||||
if let Some((iss, doc)) = app.oidc_discovery.lock().unwrap().clone() {
|
||||
if iss == issuer {
|
||||
return Ok(doc);
|
||||
}
|
||||
}
|
||||
let url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/'));
|
||||
let doc: Value = crate::provider::http_client().get(&url).send().await?.error_for_status()?.json().await?;
|
||||
*app.oidc_discovery.lock().unwrap() = Some((issuer, doc.clone()));
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn redirect_uri(app: &App) -> Result<String> {
|
||||
let base = app.config().auth.public_url.trim_end_matches('/').to_string();
|
||||
if base.is_empty() {
|
||||
bail!("auth.public_url must be set for single sign-on");
|
||||
}
|
||||
Ok(format!("{base}/api/auth/oidc/callback"))
|
||||
}
|
||||
|
||||
fn error_page(message: &str) -> Response {
|
||||
let msg = message.replace('&', "&").replace('<', "<").replace('>', ">");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
format!("<!doctype html><meta charset=utf-8><title>CodeCraft</title><body style=\"font-family:sans-serif;padding:2em\"><h2>Sign-in failed</h2><p>{msg}</p><p><a href=\"/\">Back</a></p>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn oidc_start(State(app): State<Arc<App>>) -> Response {
|
||||
match oidc_start_inner(&app).await {
|
||||
Ok(url) => Redirect::to(&url).into_response(),
|
||||
Err(e) => error_page(&format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn oidc_start_inner(app: &App) -> Result<String> {
|
||||
let cfg = app.config();
|
||||
if !cfg.auth.oidc.enabled {
|
||||
bail!("single sign-on is not enabled");
|
||||
}
|
||||
let doc = discovery(app).await?;
|
||||
let endpoint = doc["authorization_endpoint"].as_str().context("no authorization_endpoint")?;
|
||||
let state = random_token(24);
|
||||
let nonce = random_token(24);
|
||||
let verifier = random_token(48);
|
||||
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
|
||||
{
|
||||
let mut pending = app.oidc_pending.lock().unwrap();
|
||||
pending.retain(|_, p| p.created.elapsed().as_secs() < 600);
|
||||
pending.insert(state.clone(), OidcPending { nonce: nonce.clone(), verifier, created: std::time::Instant::now() });
|
||||
}
|
||||
let mut url = reqwest::Url::parse(endpoint)?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &expand_env(&cfg.auth.oidc.client_id))
|
||||
.append_pair("redirect_uri", &redirect_uri(app)?)
|
||||
.append_pair("scope", &cfg.auth.oidc.scopes.join(" "))
|
||||
.append_pair("state", &state)
|
||||
.append_pair("nonce", &nonce)
|
||||
.append_pair("code_challenge", &challenge)
|
||||
.append_pair("code_challenge_method", "S256");
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CallbackQuery {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn oidc_callback(State(app): State<Arc<App>>, headers: HeaderMap, Query(q): Query<CallbackQuery>) -> Response {
|
||||
if let Some(e) = q.error {
|
||||
return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default()));
|
||||
}
|
||||
match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await {
|
||||
Ok(user) => match issue_login(&app, &headers, &user) {
|
||||
Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(),
|
||||
Err(e) => error_page(&format!("{e:#}")),
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("oidc sign-in failed: {e:#}");
|
||||
error_page(&format!("{e:#}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_jwt_claims(jwt: &str) -> Result<Value> {
|
||||
let payload = jwt.split('.').nth(1).context("malformed id_token")?;
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload.trim_end_matches('='))
|
||||
.context("malformed id_token payload")?;
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
fn claim_list(v: &Value) -> Vec<String> {
|
||||
match v {
|
||||
Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(String::from)).collect(),
|
||||
Value::String(s) => s.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
async fn oidc_callback_inner(app: &App, code: String, state: String) -> Result<User> {
|
||||
let cfg = app.config();
|
||||
let oc = &cfg.auth.oidc;
|
||||
let pending = app.oidc_pending.lock().unwrap().remove(&state).context("unknown or expired sign-in attempt – please try again")?;
|
||||
let doc = discovery(app).await?;
|
||||
let token_endpoint = doc["token_endpoint"].as_str().context("no token_endpoint")?;
|
||||
let client_id = expand_env(&oc.client_id);
|
||||
let resp = crate::provider::http_client()
|
||||
.post(token_endpoint)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code.as_str()),
|
||||
("redirect_uri", redirect_uri(app)?.as_str()),
|
||||
("client_id", client_id.as_str()),
|
||||
("client_secret", expand_env(&oc.client_secret).as_str()),
|
||||
("code_verifier", pending.verifier.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("token exchange failed: HTTP {} {}", resp.status(), resp.text().await.unwrap_or_default().chars().take(300).collect::<String>());
|
||||
}
|
||||
let tokens: Value = resp.json().await?;
|
||||
// The id_token comes straight from the token endpoint over TLS, which
|
||||
// authenticates the issuer (OIDC Core 3.1.3.7); we still check its claims.
|
||||
let mut claims = decode_jwt_claims(tokens["id_token"].as_str().context("no id_token in response")?)?;
|
||||
let issuer = expand_env(&oc.issuer);
|
||||
if claims["iss"].as_str().map(|s| s.trim_end_matches('/')) != Some(issuer.trim_end_matches('/')) {
|
||||
bail!("id_token issuer mismatch");
|
||||
}
|
||||
let aud_ok = match &claims["aud"] {
|
||||
Value::String(a) => *a == client_id,
|
||||
Value::Array(a) => a.iter().any(|x| x.as_str() == Some(&client_id)),
|
||||
_ => false,
|
||||
};
|
||||
if !aud_ok {
|
||||
bail!("id_token audience mismatch");
|
||||
}
|
||||
if claims["nonce"].as_str() != Some(pending.nonce.as_str()) {
|
||||
bail!("id_token nonce mismatch");
|
||||
}
|
||||
if claims["exp"].as_i64().unwrap_or(0) < chrono::Utc::now().timestamp() {
|
||||
bail!("id_token expired");
|
||||
}
|
||||
// Groups and names are often only in the userinfo response.
|
||||
if let (Some(ep), Some(at)) = (doc["userinfo_endpoint"].as_str(), tokens["access_token"].as_str()) {
|
||||
if let Ok(r) = crate::provider::http_client().get(ep).bearer_auth(at).send().await {
|
||||
if let Ok(info) = r.json::<Value>().await {
|
||||
if info["sub"] == claims["sub"] {
|
||||
for (k, v) in info.as_object().cloned().unwrap_or_default() {
|
||||
if claims.get(&k).is_none() {
|
||||
claims[&k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let sub = claims["sub"].as_str().context("no sub claim")?.to_string();
|
||||
let groups = claim_list(&claims[&oc.groups_claim]);
|
||||
if !oc.allowed_group.is_empty() && !groups.contains(&oc.allowed_group) {
|
||||
bail!("your account is not allowed to use CodeCraft");
|
||||
}
|
||||
let is_admin = !oc.admin_group.is_empty() && groups.contains(&oc.admin_group);
|
||||
let display = claims["name"].as_str().unwrap_or("").to_string();
|
||||
|
||||
let user = if let Some(u) = app.store.user_by_oidc(&sub)? {
|
||||
u
|
||||
} else {
|
||||
let raw = claims[&oc.username_claim]
|
||||
.as_str()
|
||||
.or_else(|| claims["preferred_username"].as_str())
|
||||
.or_else(|| claims["email"].as_str())
|
||||
.unwrap_or(&sub);
|
||||
let name = sanitize_username(raw);
|
||||
if !valid_username(&name) {
|
||||
bail!("cannot derive a valid username from the '{}' claim", oc.username_claim);
|
||||
}
|
||||
match app.store.user_by_name(&name)? {
|
||||
Some((existing, _)) if oc.link_by_username && existing.oidc_sub_free() => {
|
||||
app.store.update_user(&existing.id, None, None, None, None, Some(&sub))?;
|
||||
existing
|
||||
}
|
||||
Some(_) => bail!("the username '{name}' is already taken by a local account"),
|
||||
None if oc.auto_create => {
|
||||
let first = app.store.user_count()? == 0;
|
||||
let role = if is_admin || first { "admin" } else { "user" };
|
||||
let u = app.store.create_user(&name, if display.is_empty() { &name } else { &display }, role, None, Some(&sub))?;
|
||||
if first {
|
||||
app.store.adopt_orphans(&u.id)?;
|
||||
*app.setup_code.lock().unwrap() = None;
|
||||
}
|
||||
u
|
||||
}
|
||||
None => bail!("no CodeCraft account exists for '{name}'"),
|
||||
}
|
||||
};
|
||||
if user.disabled {
|
||||
bail!("this account is disabled");
|
||||
}
|
||||
// Keep the role in sync with the IdP group (never removing the last admin).
|
||||
if !oc.admin_group.is_empty() && user.is_admin() != is_admin && (is_admin || app.store.admin_count()? > 1) {
|
||||
app.store.update_user(&user.id, None, Some(if is_admin { "admin" } else { "user" }), None, None, None)?;
|
||||
}
|
||||
Ok(app.store.user(&user.id)?.context("user vanished")?)
|
||||
}
|
||||
|
||||
impl User {
|
||||
fn oidc_sub_free(&self) -> bool {
|
||||
!self.oidc
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn password_roundtrip() {
|
||||
let h = hash_password("correct horse").unwrap();
|
||||
assert!(verify_password("correct horse", &h));
|
||||
assert!(!verify_password("wrong", &h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usernames() {
|
||||
assert!(valid_username("zink"));
|
||||
assert!(!valid_username("../etc"));
|
||||
assert!(!valid_username(".hidden"));
|
||||
assert_eq!(sanitize_username("Richard.Zink@example.org"), "richard.zink");
|
||||
assert_eq!(sanitize_username("a/b c"), "a_b_c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_claims() {
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"sub":"1","aud":"x"}"#);
|
||||
let v = decode_jwt_claims(&format!("h.{payload}.s")).unwrap();
|
||||
assert_eq!(v["sub"], "1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Budgets: monthly limits tracked from recorded costs, plus the real balance
|
||||
//! for providers that expose one (OpenRouter, fal.ai), and price discovery.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::{ProviderConfig, expand_env};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{Datelike, TimeZone};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Start of the current month in local time (ms).
|
||||
pub fn month_start() -> i64 {
|
||||
let now = chrono::Local::now();
|
||||
chrono::Local
|
||||
.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
|
||||
.single()
|
||||
.map(|d| d.timestamp_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn is_openrouter(p: &ProviderConfig) -> bool {
|
||||
p.base_url.contains("openrouter.ai")
|
||||
}
|
||||
|
||||
/// Month-to-date cost from Anthropic's cost report (organisation-wide, USD).
|
||||
async fn anthropic_cost(p: &ProviderConfig) -> Result<Value> {
|
||||
let admin = expand_env(&p.admin_key);
|
||||
let start = chrono::DateTime::from_timestamp_millis(month_start()).unwrap_or_default();
|
||||
let end = chrono::Utc::now() + chrono::Duration::days(1);
|
||||
let base = if p.base_url.is_empty() { "https://api.anthropic.com/v1".to_string() } else { expand_env(&p.base_url).trim_end_matches('/').to_string() };
|
||||
let mut total_cents = 0.0;
|
||||
let mut page: Option<String> = None;
|
||||
for _ in 0..10 {
|
||||
let mut url = reqwest::Url::parse(&format!("{base}/organizations/cost_report"))?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("starting_at", &start.format("%Y-%m-%dT00:00:00Z").to_string())
|
||||
.append_pair("ending_at", &end.format("%Y-%m-%dT00:00:00Z").to_string())
|
||||
.append_pair("limit", "31");
|
||||
if let Some(pg) = &page {
|
||||
url.query_pairs_mut().append_pair("page", pg);
|
||||
}
|
||||
let resp = crate::provider::http_client()
|
||||
.get(url)
|
||||
.header("x-api-key", &admin)
|
||||
.header("anthropic-version", "2026-10-21")
|
||||
.timeout(Duration::from_secs(20))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(crate::provider::http_error(resp).await);
|
||||
}
|
||||
let v: Value = resp.json().await?;
|
||||
for bucket in v["data"].as_array().cloned().unwrap_or_default() {
|
||||
for r in bucket["results"].as_array().cloned().unwrap_or_default() {
|
||||
// Amounts are decimal strings in cents.
|
||||
total_cents += r["amount"].as_str().and_then(|a| a.parse::<f64>().ok()).or_else(|| r["amount"].as_f64()).unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
if v["has_more"].as_bool() != Some(true) {
|
||||
break;
|
||||
}
|
||||
page = v["next_page"].as_str().map(String::from);
|
||||
}
|
||||
Ok(json!({"currency": "USD", "spent_month": total_cents / 100.0, "scope": "organization"}))
|
||||
}
|
||||
|
||||
/// Balance or cost as reported by the provider, if it has an API for it.
|
||||
async fn remote_balance(p: &ProviderConfig) -> Option<Result<Value>> {
|
||||
if p.kind == crate::config::ProviderKind::Anthropic && !expand_env(&p.admin_key).is_empty() {
|
||||
return Some(anthropic_cost(p).await);
|
||||
}
|
||||
if !is_openrouter(p) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
async {
|
||||
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
||||
let resp = crate::provider::http_client()
|
||||
.get(format!("{base}/key"))
|
||||
.bearer_auth(expand_env(&p.api_key))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let v: Value = resp.json().await?;
|
||||
let d = &v["data"];
|
||||
Ok(json!({
|
||||
"currency": "USD",
|
||||
"limit": d["limit"],
|
||||
"used": d["usage"],
|
||||
"remaining": d.get("limit_remaining").cloned().unwrap_or_else(|| match (d["limit"].as_f64(), d["usage"].as_f64()) {
|
||||
(Some(l), Some(u)) => json!(l - u),
|
||||
_ => Value::Null,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
async fn fal_balance(admin_key: &str) -> Result<Value> {
|
||||
let resp = crate::provider::http_client()
|
||||
.get("https://api.fal.ai/v1/account/billing?expand=credits")
|
||||
.header("Authorization", format!("Key {admin_key}"))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(crate::provider::http_error(resp).await);
|
||||
}
|
||||
let v: Value = resp.json().await?;
|
||||
Ok(json!({
|
||||
"currency": v["credits"]["currency"],
|
||||
"remaining": v["credits"]["current_balance"],
|
||||
}))
|
||||
}
|
||||
|
||||
fn entry(name: &str, budget: Option<f64>, spent: f64, warn: f64) -> Value {
|
||||
let share = budget.filter(|b| *b > 0.0).map(|b| spent / b);
|
||||
json!({
|
||||
"provider": name,
|
||||
"budget": budget,
|
||||
"spent": spent,
|
||||
"remaining": budget.map(|b| b - spent),
|
||||
"share": share,
|
||||
"warning": share.is_some_and(|s| s >= warn),
|
||||
"exceeded": share.is_some_and(|s| s >= 1.0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Budget overview for the current month.
|
||||
pub async fn overview(app: &App) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let spent = app.store.cost_by_provider(month_start())?;
|
||||
let warn = cfg.server.budget_warning;
|
||||
let mut rows = Vec::new();
|
||||
let mut tasks = Vec::new();
|
||||
for (name, p) in &cfg.providers {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
rows.push(entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn));
|
||||
let p = p.clone();
|
||||
tasks.push(tokio::spawn(async move { remote_balance(&p).await }));
|
||||
}
|
||||
for (row, task) in rows.iter_mut().zip(tasks) {
|
||||
if let Ok(Some(res)) = task.await {
|
||||
row["remote"] = match res {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"error": format!("{e:#}")}),
|
||||
};
|
||||
}
|
||||
}
|
||||
if cfg.images.enabled {
|
||||
let mut row = entry("fal", cfg.images.monthly_budget, spent.get("fal").copied().unwrap_or(0.0), warn);
|
||||
let admin = expand_env(&cfg.images.admin_key);
|
||||
if !admin.is_empty() {
|
||||
row["remote"] = match fal_balance(&admin).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"error": format!("{e:#}")}),
|
||||
};
|
||||
}
|
||||
row["images"] = json!(true);
|
||||
rows.push(row);
|
||||
}
|
||||
let total: f64 = spent.values().sum();
|
||||
let global = (cfg.server.monthly_budget > 0.0).then_some(cfg.server.monthly_budget);
|
||||
Ok(json!({
|
||||
"month_start": month_start(),
|
||||
"currency": cfg.server.currency,
|
||||
"total": entry("total", global, total, warn),
|
||||
"providers": rows,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Per-token prices from the provider's model list, converted to per 1M tokens.
|
||||
pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let p = cfg.providers.get(provider).context("unknown provider")?;
|
||||
if !is_openrouter(p) {
|
||||
bail!("this provider does not publish prices via its API – enter them manually");
|
||||
}
|
||||
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
||||
let v: Value = crate::provider::http_client()
|
||||
.get(format!("{base}/models"))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let per_m = |x: &Value| x.as_str().and_then(|s| s.parse::<f64>().ok()).or_else(|| x.as_f64()).map(|f| (f * 1_000_000.0 * 10_000.0).round() / 10_000.0);
|
||||
let list: Vec<Value> = v["data"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": m["id"],
|
||||
"context": m["context_length"],
|
||||
"input_price": per_m(&m["pricing"]["prompt"]),
|
||||
"output_price": per_m(&m["pricing"]["completion"]),
|
||||
"cache_read_price": per_m(&m["pricing"]["input_cache_read"]),
|
||||
"cache_write_price": per_m(&m["pricing"]["input_cache_write"]),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"currency": "USD", "models": list}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn budget_entry_flags() {
|
||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||
assert_eq!(e["warning"], true);
|
||||
assert_eq!(e["exceeded"], false);
|
||||
assert_eq!(e["remaining"], 1.5);
|
||||
let none = super::entry("y", None, 3.0, 0.8);
|
||||
assert_eq!(none["warning"], false);
|
||||
}
|
||||
}
|
||||
+184
-11
@@ -19,6 +19,118 @@ pub struct Config {
|
||||
pub permissions: PermissionConfig,
|
||||
pub agents: BTreeMap<String, AgentConfig>,
|
||||
pub craft: CraftConfig,
|
||||
pub auth: AuthConfig,
|
||||
pub images: ImagesConfig,
|
||||
}
|
||||
|
||||
/// Image generation via fal.ai.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ImagesConfig {
|
||||
pub enabled: bool,
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub default_model: String,
|
||||
/// Directory (relative to the session/project directory) for generated images.
|
||||
pub output_dir: String,
|
||||
pub models: Vec<ImageModel>,
|
||||
/// Monthly budget for image generation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub monthly_budget: Option<f64>,
|
||||
/// Optional fal.ai Admin key to query the account balance.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub admin_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct ImageModel {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Price per image (statistics).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub price: Option<f64>,
|
||||
/// "image_size" (FLUX style enums) or "aspect_ratio" ("16:9" style).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size_param: Option<String>,
|
||||
/// Extra request fields sent as-is.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub options: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for ImagesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
base_url: "https://fal.run".into(),
|
||||
api_key: "${FAL_KEY}".into(),
|
||||
default_model: "fal-ai/flux/schnell".into(),
|
||||
output_dir: "generated".into(),
|
||||
models: vec![ImageModel { id: "fal-ai/flux/schnell".into(), name: Some("FLUX.1 schnell".into()), price: Some(0.003), ..Default::default() }],
|
||||
monthly_budget: None,
|
||||
admin_key: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct AuthConfig {
|
||||
/// Lifetime of a login in days.
|
||||
pub session_days: u32,
|
||||
/// External URL, e.g. https://codecraft.example.org (needed for OIDC).
|
||||
pub public_url: String,
|
||||
/// "auto" (secure when served via https), "true" or "false".
|
||||
pub cookie_secure: String,
|
||||
pub oidc: OidcConfig,
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
Self { session_days: 30, public_url: String::new(), cookie_secure: "auto".into(), oidc: OidcConfig::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct OidcConfig {
|
||||
pub enabled: bool,
|
||||
pub issuer: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub scopes: Vec<String>,
|
||||
/// Claim used as username.
|
||||
pub username_claim: String,
|
||||
pub groups_claim: String,
|
||||
/// Members of this group become admins (empty = roles are managed in CodeCraft).
|
||||
pub admin_group: String,
|
||||
/// Only members of this group may sign in (empty = everybody the IdP lets through).
|
||||
pub allowed_group: String,
|
||||
/// Create users on first login.
|
||||
pub auto_create: bool,
|
||||
/// Link an IdP login to an existing local user with the same username.
|
||||
/// Only enable if you trust the IdP's usernames.
|
||||
pub link_by_username: bool,
|
||||
pub button_label: String,
|
||||
}
|
||||
|
||||
impl Default for OidcConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
issuer: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
scopes: vec!["openid".into(), "profile".into(), "email".into(), "groups".into()],
|
||||
username_claim: "preferred_username".into(),
|
||||
groups_claim: "groups".into(),
|
||||
admin_group: String::new(),
|
||||
allowed_group: String::new(),
|
||||
auto_create: true,
|
||||
link_by_username: false,
|
||||
button_label: "Single sign-on".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -27,12 +139,19 @@ 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,
|
||||
/// Max. subagents running at the same time (all sessions).
|
||||
pub max_parallel_subagents: usize,
|
||||
/// MCP servers with more tools than this are loaded on demand (load = "auto").
|
||||
pub mcp_direct_limit: usize,
|
||||
/// Monthly budget for all providers together (0 = none).
|
||||
pub monthly_budget: f64,
|
||||
/// Warn when this share of a budget is used (0.0–1.0).
|
||||
pub budget_warning: f64,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
@@ -41,11 +160,14 @@ impl Default for ServerConfig {
|
||||
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(),
|
||||
max_parallel_subagents: 4,
|
||||
mcp_direct_limit: 15,
|
||||
monthly_budget: 0.0,
|
||||
budget_warning: 0.8,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +196,16 @@ pub struct ProviderConfig {
|
||||
pub models: Vec<ModelEntry>,
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
/// Max. simultaneous requests to this provider/host (None = unlimited).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_concurrency: Option<usize>,
|
||||
/// Monthly budget in the configured currency (tracked from recorded costs).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub monthly_budget: Option<f64>,
|
||||
/// Optional admin key to read the provider's own cost report
|
||||
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub admin_key: String,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -112,6 +244,12 @@ pub struct ModelConfig {
|
||||
pub cache_read_price: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write_price: Option<f64>,
|
||||
/// Max. simultaneous requests to this model (None = unlimited).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_concurrency: Option<usize>,
|
||||
/// Extra request body fields sent as-is (e.g. temperature, reasoning_budget_tokens).
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub options: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
@@ -151,6 +289,9 @@ pub struct McpConfig {
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
/// "auto" (default), "always" or "on_demand": how the server's tools reach the model.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub load: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||
@@ -180,6 +321,9 @@ pub struct AgentConfig {
|
||||
pub tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub prompt: Option<String>,
|
||||
/// Max. instances of this subagent running at the same time.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_parallel: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -224,10 +368,6 @@ impl Config {
|
||||
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).
|
||||
@@ -273,9 +413,12 @@ pub fn apply_patches(text: &str, patches: &[ConfigPatch]) -> Result<String> {
|
||||
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()));
|
||||
let entry = table.entry(key).or_insert_with(|| {
|
||||
let mut t = toml_edit::Table::new();
|
||||
// No empty "[agents]" header when only "[agents.x]" is needed.
|
||||
t.set_implicit(true);
|
||||
toml_edit::Item::Table(t)
|
||||
});
|
||||
// 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());
|
||||
@@ -327,6 +470,11 @@ fn json_to_toml(v: &serde_json::Value) -> Option<toml_edit::Value> {
|
||||
n.as_f64()?.into()
|
||||
}
|
||||
}
|
||||
J::String(s) if s.contains('\n') && !s.contains("'''") && !s.contains('\r') => {
|
||||
// Multi-line text (prompts) as a readable literal block.
|
||||
let body = if s.ends_with('\n') { s.clone() } else { format!("{s}\n") };
|
||||
format!("'''\n{body}'''").parse::<toml_edit::Value>().unwrap_or_else(|_| s.as_str().into())
|
||||
}
|
||||
J::String(s) => s.as_str().into(),
|
||||
J::Array(a) => {
|
||||
let mut arr = toml_edit::Array::new();
|
||||
@@ -369,12 +517,20 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
||||
};
|
||||
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") {
|
||||
for field in ["api_key", "admin_key"] {
|
||||
if let Some(k) = p.get_mut(field) {
|
||||
mask(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = v.pointer_mut("/server/auth_token") {
|
||||
}
|
||||
if let Some(t) = v.pointer_mut("/auth/oidc/client_secret") {
|
||||
mask(t);
|
||||
}
|
||||
if let Some(t) = v.pointer_mut("/images/api_key") {
|
||||
mask(t);
|
||||
}
|
||||
if let Some(t) = v.pointer_mut("/images/admin_key") {
|
||||
mask(t);
|
||||
}
|
||||
v
|
||||
@@ -408,6 +564,23 @@ mod tests {
|
||||
assert!(cfg.providers["gemini"].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_multiline_prompt_roundtrips() {
|
||||
let prompt = "You write docs.\nUse \"quotes\" and \\backslashes.";
|
||||
let out = apply_patches(
|
||||
EXAMPLE_CONFIG,
|
||||
&[ConfigPatch {
|
||||
path: vec!["agents".into(), "writer".into(), "prompt".into()],
|
||||
value: serde_json::json!(prompt),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.contains("prompt = '''\nYou write docs."));
|
||||
assert!(!out.contains("[agents]\n"));
|
||||
let cfg = Config::parse(&out).unwrap();
|
||||
assert_eq!(cfg.agents["writer"].prompt.as_deref().unwrap().trim_end(), prompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_adds_provider_section() {
|
||||
let out = apply_patches(
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
//! File manager API: browse, edit, upload, move and delete inside the user's folders.
|
||||
|
||||
use crate::api::{ApiError, ApiResult};
|
||||
use crate::app::App;
|
||||
use crate::store::User;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
type AppState = Arc<App>;
|
||||
|
||||
const MAX_TEXT: u64 = 2 * 1024 * 1024;
|
||||
|
||||
fn bad(msg: impl Into<String>) -> ApiError {
|
||||
ApiError(StatusCode::BAD_REQUEST, msg.into())
|
||||
}
|
||||
|
||||
/// Resolve a path and make sure it (or, for new files, its parent) lies inside
|
||||
/// one of the user's folders. Symlinks are resolved before the check.
|
||||
pub fn checked_path(app: &App, user: &User, raw: &str, must_exist: bool) -> Result<PathBuf, ApiError> {
|
||||
let roots = app.user_roots(Some(&user.id));
|
||||
let p = crate::tools::normalize(Path::new(raw.trim()));
|
||||
if !p.is_absolute() {
|
||||
return Err(bad("path must be absolute"));
|
||||
}
|
||||
let real = if p.exists() {
|
||||
std::fs::canonicalize(&p).map_err(|e| bad(e.to_string()))?
|
||||
} else {
|
||||
if must_exist {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, format!("{} does not exist", p.display())));
|
||||
}
|
||||
let parent = p.parent().ok_or_else(|| bad("invalid path"))?;
|
||||
let parent = std::fs::canonicalize(parent).map_err(|_| bad("parent directory does not exist"))?;
|
||||
parent.join(p.file_name().ok_or_else(|| bad("invalid path"))?)
|
||||
};
|
||||
if !roots.iter().any(|r| real.starts_with(r)) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "path is outside your folders".into()));
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
fn is_root(app: &App, user: &User, p: &Path) -> bool {
|
||||
app.user_roots(Some(&user.id)).iter().any(|r| r == p)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PathQuery {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
pub async fn list(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PathQuery>) -> ApiResult {
|
||||
let dir = checked_path(&app, &user, &q.path, true)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(bad("not a directory"));
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
for e in std::fs::read_dir(&dir)?.flatten() {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if !q.hidden && name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let meta = e.metadata().ok();
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
entries.push(json!({
|
||||
"name": name,
|
||||
"path": e.path(),
|
||||
"is_dir": is_dir,
|
||||
"size": meta.as_ref().map(|m| m.len()).unwrap_or(0),
|
||||
"mtime": meta.and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64),
|
||||
}));
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b["is_dir"].as_bool().cmp(&a["is_dir"].as_bool()).then_with(|| a["name"].as_str().unwrap_or("").to_lowercase().cmp(&b["name"].as_str().unwrap_or("").to_lowercase()))
|
||||
});
|
||||
let parent = if is_root(&app, &user, &dir) { Value::Null } else { json!(dir.parent()) };
|
||||
let repo = crate::git::repo_root(&dir);
|
||||
Ok(Json(json!({"path": dir, "parent": parent, "entries": entries, "repo": repo})))
|
||||
}
|
||||
|
||||
pub async fn read(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PathQuery>) -> ApiResult {
|
||||
let p = checked_path(&app, &user, &q.path, true)?;
|
||||
let meta = std::fs::metadata(&p)?;
|
||||
if meta.is_dir() {
|
||||
return Err(bad("is a directory"));
|
||||
}
|
||||
let mtime = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
if meta.len() > MAX_TEXT {
|
||||
return Ok(Json(json!({"path": p, "binary": true, "too_large": true, "size": meta.len(), "mtime": mtime})));
|
||||
}
|
||||
let bytes = tokio::fs::read(&p).await?;
|
||||
let binary = bytes.iter().take(8000).any(|b| *b == 0) || std::str::from_utf8(&bytes).is_err();
|
||||
Ok(Json(json!({
|
||||
"path": p,
|
||||
"binary": binary,
|
||||
"size": meta.len(),
|
||||
"mtime": mtime,
|
||||
"content": if binary { Value::Null } else { json!(String::from_utf8_lossy(&bytes)) },
|
||||
})))
|
||||
}
|
||||
|
||||
fn mime_for(p: &Path) -> &'static str {
|
||||
match p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("txt") | Some("md") | Some("log") => "text/plain; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RawQuery {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
download: bool,
|
||||
}
|
||||
|
||||
/// File bytes for previews and downloads. SVGs are served as attachments only,
|
||||
/// so scripts inside them never run in the app's origin.
|
||||
pub async fn raw(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<RawQuery>) -> Result<Response, ApiError> {
|
||||
let p = checked_path(&app, &user, &q.path, true)?;
|
||||
if p.is_dir() {
|
||||
return Err(bad("is a directory"));
|
||||
}
|
||||
let bytes = tokio::fs::read(&p).await?;
|
||||
let mime = mime_for(&p);
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("file").replace('"', "");
|
||||
let disposition = if q.download || mime == "image/svg+xml" || mime == "application/octet-stream" {
|
||||
format!("attachment; filename=\"{name}\"")
|
||||
} else {
|
||||
format!("inline; filename=\"{name}\"")
|
||||
};
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, mime.to_string()),
|
||||
(header::CONTENT_DISPOSITION, disposition),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
|
||||
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'".to_string()),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WriteBody {
|
||||
path: String,
|
||||
content: String,
|
||||
/// mtime the client loaded; a newer file on disk is a conflict.
|
||||
expected_mtime: Option<u64>,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
pub async fn write(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<WriteBody>) -> ApiResult {
|
||||
let p = checked_path(&app, &user, &b.path, false)?;
|
||||
if p.is_dir() {
|
||||
return Err(bad("is a directory"));
|
||||
}
|
||||
if let (Some(expected), false) = (b.expected_mtime, b.force) {
|
||||
let current = std::fs::metadata(&p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
if current.is_some_and(|c| c > expected) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "the file was changed on disk since you opened it".into()));
|
||||
}
|
||||
}
|
||||
tokio::fs::write(&p, b.content.as_bytes()).await?;
|
||||
let mtime = std::fs::metadata(&p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
Ok(Json(json!({"ok": true, "mtime": mtime})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateBody {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
dir: bool,
|
||||
}
|
||||
|
||||
pub async fn create(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<CreateBody>) -> ApiResult {
|
||||
let p = checked_path(&app, &user, &b.path, false)?;
|
||||
if p.exists() {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "already exists".into()));
|
||||
}
|
||||
if b.dir {
|
||||
tokio::fs::create_dir(&p).await?;
|
||||
} else {
|
||||
tokio::fs::write(&p, b"").await?;
|
||||
}
|
||||
Ok(Json(json!({"ok": true, "path": p})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MoveBody {
|
||||
from: String,
|
||||
to: String,
|
||||
#[serde(default)]
|
||||
copy: bool,
|
||||
}
|
||||
|
||||
fn copy_recursive(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
if from.is_dir() {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for e in std::fs::read_dir(from)? {
|
||||
let e = e?;
|
||||
copy_recursive(&e.path(), &to.join(e.file_name()))?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
std::fs::copy(from, to).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_or_copy(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<MoveBody>) -> ApiResult {
|
||||
let from = checked_path(&app, &user, &b.from, true)?;
|
||||
let to = checked_path(&app, &user, &b.to, false)?;
|
||||
if is_root(&app, &user, &from) {
|
||||
return Err(bad("a root folder cannot be moved"));
|
||||
}
|
||||
if to.exists() {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "target already exists".into()));
|
||||
}
|
||||
if to.starts_with(&from) {
|
||||
return Err(bad("cannot move a folder into itself"));
|
||||
}
|
||||
let (f, t) = (from.clone(), to.clone());
|
||||
tokio::task::spawn_blocking(move || if b.copy { copy_recursive(&f, &t) } else { std::fs::rename(&f, &t) }).await??;
|
||||
Ok(Json(json!({"ok": true, "path": to})))
|
||||
}
|
||||
|
||||
pub async fn remove(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PathQuery>) -> ApiResult {
|
||||
let p = checked_path(&app, &user, &q.path, true)?;
|
||||
if is_root(&app, &user, &p) {
|
||||
return Err(bad("a root folder cannot be deleted"));
|
||||
}
|
||||
if p.is_dir() {
|
||||
tokio::fs::remove_dir_all(&p).await?;
|
||||
} else {
|
||||
tokio::fs::remove_file(&p).await?;
|
||||
}
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UploadQuery {
|
||||
/// Target file path (absolute).
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Upload one file as the raw request body.
|
||||
pub async fn upload(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<UploadQuery>, body: Bytes) -> ApiResult {
|
||||
let p = checked_path(&app, &user, &q.path, false)?;
|
||||
if p.is_dir() {
|
||||
return Err(bad("is a directory"));
|
||||
}
|
||||
tokio::fs::write(&p, &body).await?;
|
||||
Ok(Json(json!({"ok": true, "path": p, "size": body.len()})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
path: String,
|
||||
q: String,
|
||||
}
|
||||
|
||||
/// File name search below a folder (respects .gitignore).
|
||||
pub async fn search(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<SearchQuery>) -> ApiResult {
|
||||
let root = checked_path(&app, &user, &q.path, true)?;
|
||||
let needle = q.q.to_lowercase();
|
||||
if needle.trim().is_empty() {
|
||||
return Ok(Json(json!([])));
|
||||
}
|
||||
let hits = tokio::task::spawn_blocking(move || {
|
||||
let mut out = Vec::new();
|
||||
for e in ignore::WalkBuilder::new(&root).hidden(true).build().flatten() {
|
||||
if e.depth() == 0 {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_lowercase();
|
||||
if name.contains(&needle) {
|
||||
out.push(json!({"path": e.path(), "is_dir": e.file_type().is_some_and(|t| t.is_dir())}));
|
||||
if out.len() >= 200 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(json!(hits)))
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Git client for the file manager. Runs the git CLI without a shell; file
|
||||
//! names always follow `--`, refs and URLs are validated.
|
||||
|
||||
use crate::api::{ApiError, ApiResult};
|
||||
use crate::app::App;
|
||||
use crate::files::checked_path;
|
||||
use crate::store::User;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::{Extension, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
type AppState = Arc<App>;
|
||||
|
||||
/// Nearest directory containing `.git`.
|
||||
pub fn repo_root(dir: &Path) -> Option<PathBuf> {
|
||||
let mut d = dir.to_path_buf();
|
||||
loop {
|
||||
if d.join(".git").exists() {
|
||||
return Some(d);
|
||||
}
|
||||
if !d.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Out {
|
||||
ok: bool,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||
let fut = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_PAGER", "cat")
|
||||
.env("LC_ALL", "C")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
let out = tokio::time::timeout(Duration::from_secs(timeout), fut)
|
||||
.await
|
||||
.map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, "git timed out".into()))?
|
||||
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot run git: {e}")))?;
|
||||
Ok(Out {
|
||||
ok: out.status.success(),
|
||||
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn fail(o: Out) -> ApiError {
|
||||
let msg = if o.stderr.trim().is_empty() { o.stdout } else { o.stderr };
|
||||
ApiError(StatusCode::BAD_REQUEST, msg.trim().to_string())
|
||||
}
|
||||
|
||||
async fn git_ok(dir: &Path, args: &[&str], timeout: u64) -> Result<String, ApiError> {
|
||||
let o = git(dir, args, timeout).await?;
|
||||
if o.ok { Ok(o.stdout) } else { Err(fail(o)) }
|
||||
}
|
||||
|
||||
fn valid_ref(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& !name.starts_with('-')
|
||||
&& !name.contains("..")
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GitBody {
|
||||
/// Repository directory, or any path inside it.
|
||||
path: String,
|
||||
action: String,
|
||||
#[serde(default)]
|
||||
files: Vec<String>,
|
||||
#[serde(default)]
|
||||
staged: bool,
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
amend: bool,
|
||||
branch: Option<String>,
|
||||
#[serde(default)]
|
||||
create: bool,
|
||||
rev: Option<String>,
|
||||
url: Option<String>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Repo-relative paths for the given absolute file paths (all checked).
|
||||
fn rel_files(app: &App, user: &User, repo: &Path, files: &[String]) -> Result<Vec<String>, ApiError> {
|
||||
files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let p = if Path::new(f).is_absolute() { PathBuf::from(f) } else { repo.join(f) };
|
||||
let p = checked_path(app, user, &p.to_string_lossy(), false)?;
|
||||
p.strip_prefix(repo)
|
||||
.map(|r| r.to_string_lossy().into_owned())
|
||||
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "file is outside the repository".into()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_status(raw: &str) -> Value {
|
||||
let mut branch = Value::Null;
|
||||
let mut upstream = Value::Null;
|
||||
let (mut ahead, mut behind) = (0, 0);
|
||||
let mut files = Vec::new();
|
||||
let mut entries = raw.split('\0').filter(|s| !s.is_empty());
|
||||
while let Some(e) = entries.next() {
|
||||
if let Some(h) = e.strip_prefix("## ") {
|
||||
// "main...origin/main [ahead 1, behind 2]" or "No commits yet on main"
|
||||
let (names, info) = h.split_once(" [").map(|(a, b)| (a, b.trim_end_matches(']'))).unwrap_or((h, ""));
|
||||
let names = names.trim_start_matches("No commits yet on ");
|
||||
let (b, u) = names.split_once("...").map(|(a, b)| (a, Some(b))).unwrap_or((names, None));
|
||||
branch = json!(b);
|
||||
upstream = json!(u);
|
||||
for part in info.split(", ") {
|
||||
if let Some(n) = part.strip_prefix("ahead ") {
|
||||
ahead = n.parse().unwrap_or(0);
|
||||
}
|
||||
if let Some(n) = part.strip_prefix("behind ") {
|
||||
behind = n.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if e.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (&e[0..1], &e[1..2]);
|
||||
let path = &e[3..];
|
||||
let mut item = json!({"path": path, "index": x, "worktree": y});
|
||||
// Renames/copies carry the original path as the next entry.
|
||||
if x == "R" || x == "C" {
|
||||
item["from"] = json!(entries.next());
|
||||
}
|
||||
files.push(item);
|
||||
}
|
||||
json!({"branch": branch, "upstream": upstream, "ahead": ahead, "behind": behind, "files": files})
|
||||
}
|
||||
|
||||
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
||||
let target = checked_path(&app, &user, &b.path, b.action != "clone")?;
|
||||
|
||||
// Actions that create a repository.
|
||||
match b.action.as_str() {
|
||||
"init" => {
|
||||
if !target.is_dir() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "not a directory".into()));
|
||||
}
|
||||
let out = git_ok(&target, &["init", "-q"], 30).await?;
|
||||
return Ok(Json(json!({"ok": true, "output": out})));
|
||||
}
|
||||
"clone" => {
|
||||
let url = b.url.as_deref().unwrap_or("").trim().to_string();
|
||||
let allowed = url.starts_with("https://") || url.starts_with("http://") || url.starts_with("ssh://") || url.starts_with("git@");
|
||||
if !allowed || url.contains(char::is_whitespace) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "unsupported repository URL".into()));
|
||||
}
|
||||
if target.exists() {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "target folder already exists".into()));
|
||||
}
|
||||
let parent = target.parent().unwrap_or(Path::new("/")).to_path_buf();
|
||||
let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("repo").to_string();
|
||||
let o = git(&parent, &["clone", "--", &url, &name], 600).await?;
|
||||
if !o.ok {
|
||||
return Err(fail(o));
|
||||
}
|
||||
return Ok(Json(json!({"ok": true, "path": target, "output": o.stderr})));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let start = if target.is_dir() { target.clone() } else { target.parent().unwrap_or(&target).to_path_buf() };
|
||||
let repo = repo_root(&start).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "not a git repository".into()))?;
|
||||
// The repository itself must be inside the user's folders.
|
||||
checked_path(&app, &user, &repo.to_string_lossy(), true)?;
|
||||
|
||||
let result = match b.action.as_str() {
|
||||
"status" => {
|
||||
let raw = git_ok(&repo, &["status", "--porcelain=v1", "-b", "-z", "--untracked-files=all"], 60).await?;
|
||||
let mut v = parse_status(&raw);
|
||||
v["repo"] = json!(repo);
|
||||
v
|
||||
}
|
||||
"diff" => {
|
||||
let files = rel_files(&app, &user, &repo, &b.files)?;
|
||||
let mut args = vec!["diff", "--no-color", "--no-ext-diff"];
|
||||
if b.staged {
|
||||
args.push("--cached");
|
||||
}
|
||||
args.push("--");
|
||||
args.extend(files.iter().map(String::as_str));
|
||||
let mut out = git_ok(&repo, &args, 60).await?;
|
||||
// Untracked files have no diff: show them as new.
|
||||
if out.is_empty() && !b.staged && files.len() == 1 {
|
||||
let o = git(&repo, &["diff", "--no-color", "--no-index", "--", "/dev/null", &files[0]], 60).await?;
|
||||
out = o.stdout;
|
||||
}
|
||||
out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len()));
|
||||
json!({"diff": out})
|
||||
}
|
||||
"stage" | "unstage" | "discard" => {
|
||||
let files = rel_files(&app, &user, &repo, &b.files)?;
|
||||
if files.is_empty() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "no files".into()));
|
||||
}
|
||||
let mut args: Vec<&str> = match b.action.as_str() {
|
||||
"stage" => vec!["add", "-A"],
|
||||
"unstage" => vec!["restore", "--staged"],
|
||||
_ => vec!["restore", "--worktree"],
|
||||
};
|
||||
args.push("--");
|
||||
args.extend(files.iter().map(String::as_str));
|
||||
git_ok(&repo, &args, 60).await?;
|
||||
json!({"ok": true})
|
||||
}
|
||||
"commit" => {
|
||||
let msg = b.message.as_deref().unwrap_or("").trim().to_string();
|
||||
if msg.is_empty() && !b.amend {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "commit message is empty".into()));
|
||||
}
|
||||
// Use the repository's identity if configured, otherwise the CodeCraft user.
|
||||
let has_identity = git(&repo, &["config", "user.email"], 10).await.map(|o| o.ok && !o.stdout.trim().is_empty()).unwrap_or(false);
|
||||
let name = format!("user.name={}", if user.display_name.is_empty() { &user.username } else { &user.display_name });
|
||||
let email = format!("user.email={}@users.codecraft.local", user.username);
|
||||
let mut args: Vec<&str> = Vec::new();
|
||||
if !has_identity {
|
||||
args.extend(["-c", &name, "-c", &email]);
|
||||
}
|
||||
args.push("commit");
|
||||
if b.amend {
|
||||
args.push("--amend");
|
||||
if msg.is_empty() {
|
||||
args.push("--no-edit");
|
||||
}
|
||||
}
|
||||
if !msg.is_empty() {
|
||||
args.extend(["-m", &msg]);
|
||||
}
|
||||
let out = git_ok(&repo, &args, 60).await?;
|
||||
json!({"ok": true, "output": out})
|
||||
}
|
||||
"log" => {
|
||||
let n = b.limit.unwrap_or(50).min(500).to_string();
|
||||
let raw = git(&repo, &["log", "-n", &n, "--pretty=format:%H%x1f%an%x1f%at%x1f%s%x1f%D%x1e"], 60).await?;
|
||||
let commits: Vec<Value> = raw
|
||||
.stdout
|
||||
.split('\u{1e}')
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|c| {
|
||||
let f: Vec<&str> = c.trim_start_matches('\n').split('\u{1f}').collect();
|
||||
json!({
|
||||
"hash": f.first(), "author": f.get(1),
|
||||
"time": f.get(2).and_then(|t| t.parse::<i64>().ok()).map(|t| t * 1000),
|
||||
"subject": f.get(3), "refs": f.get(4),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({"commits": commits})
|
||||
}
|
||||
"show" => {
|
||||
let rev = b.rev.as_deref().unwrap_or("");
|
||||
if rev.len() < 4 || !rev.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid commit".into()));
|
||||
}
|
||||
let mut out = git_ok(&repo, &["show", "--no-color", "--stat", "--patch", rev], 60).await?;
|
||||
out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len()));
|
||||
json!({"diff": out})
|
||||
}
|
||||
"branches" => {
|
||||
let raw = git_ok(&repo, &["branch", "-a", "--format=%(refname:short)%1f%(HEAD)%1f%(upstream:short)"], 30).await?;
|
||||
let list: Vec<Value> = raw
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let f: Vec<&str> = l.split('\u{1f}').collect();
|
||||
json!({"name": f.first(), "current": f.get(1) == Some(&"*"), "upstream": f.get(2).filter(|u| !u.is_empty())})
|
||||
})
|
||||
.collect();
|
||||
json!({"branches": list})
|
||||
}
|
||||
"checkout" => {
|
||||
let branch = b.branch.as_deref().unwrap_or("");
|
||||
if !valid_ref(branch) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid branch name".into()));
|
||||
}
|
||||
let args: Vec<&str> = if b.create { vec!["switch", "-c", branch] } else { vec!["switch", branch] };
|
||||
let o = git(&repo, &args, 60).await?;
|
||||
if !o.ok {
|
||||
return Err(fail(o));
|
||||
}
|
||||
json!({"ok": true, "output": o.stderr})
|
||||
}
|
||||
"fetch" | "pull" | "push" => {
|
||||
let args: Vec<&str> = match b.action.as_str() {
|
||||
"fetch" => vec!["fetch", "--prune"],
|
||||
"pull" => vec!["pull", "--ff-only"],
|
||||
_ => {
|
||||
let has_upstream = git(&repo, &["rev-parse", "--abbrev-ref", "@{u}"], 10).await.map(|o| o.ok).unwrap_or(false);
|
||||
if has_upstream { vec!["push"] } else { vec!["push", "-u", "origin", "HEAD"] }
|
||||
}
|
||||
};
|
||||
let o = git(&repo, &args, 300).await?;
|
||||
if !o.ok {
|
||||
return Err(fail(o));
|
||||
}
|
||||
json!({"ok": true, "output": format!("{}{}", o.stdout, o.stderr)})
|
||||
}
|
||||
other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("unknown action '{other}'"))),
|
||||
};
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_porcelain_status() {
|
||||
let raw = "## main...origin/main [ahead 2, behind 1]\0 M src/a.rs\0A new.txt\0R b.rs\0old.rs\0?? x y.txt\0";
|
||||
let v = parse_status(raw);
|
||||
assert_eq!(v["branch"], "main");
|
||||
assert_eq!(v["upstream"], "origin/main");
|
||||
assert_eq!(v["ahead"], 2);
|
||||
assert_eq!(v["behind"], 1);
|
||||
let files = v["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 4);
|
||||
assert_eq!(files[2]["from"], "old.rs");
|
||||
assert_eq!(files[3]["path"], "x y.txt");
|
||||
assert_eq!(parse_status("## No commits yet on main\0")["branch"], "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_are_validated() {
|
||||
assert!(valid_ref("feature/login-2"));
|
||||
assert!(!valid_ref("--upload-pack=x"));
|
||||
assert!(!valid_ref("a..b"));
|
||||
assert!(!valid_ref("a b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Image generation via fal.ai – as an agent tool and for the manual image page.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::{ImageModel, expand_env};
|
||||
use crate::store::{ImageRecord, UsageRecord};
|
||||
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||
use crate::types::{ImageData, new_id, now_ms};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Size presets offered in the UI and to the model (FLUX-style names).
|
||||
pub const SIZES: &[&str] = &["square_hd", "square", "landscape_4_3", "landscape_16_9", "portrait_4_3", "portrait_16_9"];
|
||||
|
||||
fn aspect_ratio(size: &str) -> &'static str {
|
||||
match size {
|
||||
"landscape_4_3" => "4:3",
|
||||
"landscape_16_9" => "16:9",
|
||||
"portrait_4_3" => "3:4",
|
||||
"portrait_16_9" => "9:16",
|
||||
_ => "1:1",
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GenerateRequest {
|
||||
pub prompt: String,
|
||||
pub model: Option<String>,
|
||||
pub size: Option<String>,
|
||||
pub count: u32,
|
||||
/// Directory the files are written to.
|
||||
pub dir: PathBuf,
|
||||
pub user_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
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(40).collect::<String>().trim_end_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn model_config(app: &App, id: &str) -> ImageModel {
|
||||
app.config()
|
||||
.images
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ImageModel { id: id.to_string(), ..Default::default() })
|
||||
}
|
||||
|
||||
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
|
||||
if let Some(rest) = url.strip_prefix("data:") {
|
||||
let (meta, data) = rest.split_once(',').context("malformed data URI")?;
|
||||
let ctype = meta.split(';').next().unwrap_or("image/png").to_string();
|
||||
return Ok((base64::engine::general_purpose::STANDARD.decode(data)?, ctype));
|
||||
}
|
||||
let resp = crate::provider::http_client().get(url).timeout(Duration::from_secs(120)).send().await?.error_for_status()?;
|
||||
let ctype = resp.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("image/png").to_string();
|
||||
Ok((resp.bytes().await?.to_vec(), ctype))
|
||||
}
|
||||
|
||||
/// Generate images, store them in `req.dir` and record them (history + cost).
|
||||
pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord>> {
|
||||
let cfg = app.config();
|
||||
let ic = &cfg.images;
|
||||
if !ic.enabled {
|
||||
bail!("image generation is disabled – enable [images] in the settings");
|
||||
}
|
||||
let key = expand_env(&ic.api_key);
|
||||
if key.is_empty() {
|
||||
bail!("no fal.ai API key configured (images.api_key, e.g. ${{FAL_KEY}})");
|
||||
}
|
||||
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
|
||||
let model = model_config(app, &model_id);
|
||||
let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into());
|
||||
let count = req.count.clamp(1, 4);
|
||||
|
||||
let mut body = json!({"prompt": req.prompt, "num_images": count, "output_format": "png"});
|
||||
match model.size_param.as_deref() {
|
||||
Some("aspect_ratio") => body["aspect_ratio"] = json!(aspect_ratio(&size)),
|
||||
Some("none") => {}
|
||||
_ => body["image_size"] = json!(size),
|
||||
}
|
||||
for (k, v) in &model.options {
|
||||
body[k] = v.clone();
|
||||
}
|
||||
|
||||
let url = format!("{}/{}", expand_env(&ic.base_url).trim_end_matches('/'), model_id.trim_matches('/'));
|
||||
let resp = crate::provider::http_client()
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Key {key}"))
|
||||
.timeout(Duration::from_secs(300))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("fal.ai request failed")?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(crate::provider::http_error(resp).await);
|
||||
}
|
||||
let out: Value = resp.json().await?;
|
||||
let images = out["images"].as_array().cloned().or_else(|| out["image"].as_object().map(|o| vec![Value::Object(o.clone())])).unwrap_or_default();
|
||||
if images.is_empty() {
|
||||
bail!("fal.ai returned no images: {}", out.to_string().chars().take(300).collect::<String>());
|
||||
}
|
||||
|
||||
tokio::fs::create_dir_all(&req.dir).await?;
|
||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||
let base = slug(&req.prompt);
|
||||
let mut records = Vec::new();
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
let src = img["url"].as_str().context("image without url")?;
|
||||
let (bytes, ctype) = download(src).await?;
|
||||
let ext = match ctype.as_str() {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
_ => "png",
|
||||
};
|
||||
let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
|
||||
let path = req.dir.join(name);
|
||||
tokio::fs::write(&path, &bytes).await?;
|
||||
let rec = ImageRecord {
|
||||
id: new_id(),
|
||||
user_id: req.user_id.clone(),
|
||||
session_id: req.session_id.clone(),
|
||||
prompt: req.prompt.clone(),
|
||||
model: model_id.clone(),
|
||||
path: path.display().to_string(),
|
||||
width: img["width"].as_i64().unwrap_or(0),
|
||||
height: img["height"].as_i64().unwrap_or(0),
|
||||
created_at: now_ms(),
|
||||
};
|
||||
app.store.add_image(&rec)?;
|
||||
records.push(rec);
|
||||
}
|
||||
|
||||
// Costs go into the normal statistics (provider "fal").
|
||||
let cost = model.price.unwrap_or(0.0) * records.len() as f64;
|
||||
let session = req.session_id.clone().unwrap_or_default();
|
||||
let _ = app.store.add_usage(&UsageRecord {
|
||||
ts: now_ms(),
|
||||
session_id: session.clone(),
|
||||
root_session_id: if session.is_empty() { String::new() } else { app.root_session_id(&session) },
|
||||
provider: "fal".into(),
|
||||
model: model_id,
|
||||
agent: "image".into(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
ttft_ms: 0,
|
||||
duration_ms: 0,
|
||||
cost,
|
||||
error: false,
|
||||
});
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// Downscaled copy for the model's context.
|
||||
fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
let img = image::open(path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
|
||||
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||
}
|
||||
|
||||
pub struct GenerateImage;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GenerateImage {
|
||||
fn name(&self) -> &str {
|
||||
"generate_image"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Generate images from a text prompt (fal.ai) and save them in the project. Use for \
|
||||
illustrations, icons, backgrounds or placeholder photos the user needs. Write a detailed \
|
||||
visual prompt in English (subject, style, composition, colours, lighting). Each image \
|
||||
costs money – generate only what is needed. Returns the file paths and a preview."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string"},
|
||||
"size": {"type": "string", "enum": SIZES},
|
||||
"count": {"type": "integer", "minimum": 1, "maximum": 4},
|
||||
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: images.output_dir)"},
|
||||
"model": {"type": "string", "description": "fal.ai model id (optional, default from the settings)"}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
})
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("Generate image: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let prompt = arg_str(&input, "prompt")?.to_string();
|
||||
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| ctx.app.config().images.output_dir.clone());
|
||||
let dir = ctx.resolve(&dir_arg)?;
|
||||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
|
||||
let records = generate(
|
||||
&ctx.app,
|
||||
GenerateRequest {
|
||||
prompt,
|
||||
model: input["model"].as_str().map(String::from),
|
||||
size: input["size"].as_str().map(String::from),
|
||||
count: input["count"].as_u64().unwrap_or(1) as u32,
|
||||
dir,
|
||||
user_id: owner,
|
||||
session_id: Some(ctx.session_id.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
let mut text = String::from("Generated:\n");
|
||||
let mut images = Vec::new();
|
||||
for r in &records {
|
||||
let rel = Path::new(&r.path).strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| r.path.clone());
|
||||
text.push_str(&format!("- {rel} ({}×{})\n", r.width, r.height));
|
||||
if let Some(p) = preview_for_model(Path::new(&r.path)) {
|
||||
images.push(p);
|
||||
}
|
||||
}
|
||||
let ids: Vec<&str> = records.iter().map(|r| r.id.as_str()).collect();
|
||||
Ok(ToolOutput { content: text, images, meta: Some(json!({"image_ids": ids})), ..Default::default() })
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -2,7 +2,12 @@
|
||||
|
||||
mod api;
|
||||
mod app;
|
||||
mod auth;
|
||||
mod budget;
|
||||
mod config;
|
||||
mod files;
|
||||
mod git;
|
||||
mod images;
|
||||
mod mcp;
|
||||
mod permission;
|
||||
mod prompts;
|
||||
@@ -30,9 +35,6 @@ async fn main() -> Result<()> {
|
||||
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);
|
||||
|
||||
+144
-10
@@ -30,6 +30,9 @@ pub struct McpToolInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: Value,
|
||||
/// The server marks the tool as read-only (annotations.readOnlyHint).
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -183,6 +186,7 @@ impl McpServer {
|
||||
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"})),
|
||||
read_only: t["annotations"]["readOnlyHint"].as_bool().unwrap_or(false),
|
||||
});
|
||||
}
|
||||
cursor = res["nextCursor"].as_str().map(String::from);
|
||||
@@ -498,20 +502,147 @@ impl McpManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Vec<Arc<dyn Tool>> {
|
||||
/// All MCP tools, split into tools given to the model directly and tools
|
||||
/// that are only reachable through mcp_search / mcp_call.
|
||||
pub fn tools(&self, cfg: &crate::config::Config) -> (Vec<Arc<dyn Tool>>, Vec<Arc<McpTool>>) {
|
||||
let servers = self.servers.lock().unwrap();
|
||||
let mut out: Vec<Arc<dyn Tool>> = Vec::new();
|
||||
let mut direct: Vec<Arc<dyn Tool>> = Vec::new();
|
||||
let mut deferred = 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(),
|
||||
}));
|
||||
let tools = server.tools.lock().unwrap();
|
||||
let load = cfg.mcp.get(sname).and_then(|m| m.load.clone()).unwrap_or_else(|| "auto".into());
|
||||
let on_demand = match load.as_str() {
|
||||
"always" => false,
|
||||
"on_demand" => true,
|
||||
_ => tools.len() > cfg.server.mcp_direct_limit,
|
||||
};
|
||||
for t in tools.iter() {
|
||||
let tool = Arc::new(McpTool { full_name: tool_name(sname, &t.name), server: server.clone(), info: t.clone() });
|
||||
if on_demand {
|
||||
deferred.push(tool);
|
||||
} else {
|
||||
direct.push(tool);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
(direct, deferred)
|
||||
}
|
||||
|
||||
/// Short overview of on-demand servers for the system prompt.
|
||||
pub fn deferred_summary(&self, cfg: &crate::config::Config) -> Vec<(String, usize, Vec<String>)> {
|
||||
let (_, deferred) = self.tools(cfg);
|
||||
let mut by_server: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for t in deferred {
|
||||
by_server.entry(t.server.name.clone()).or_default().push(t.info.name.clone());
|
||||
}
|
||||
by_server
|
||||
.into_iter()
|
||||
.map(|(s, names)| {
|
||||
let n = names.len();
|
||||
(s, n, names.into_iter().take(12).collect())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Search the on-demand MCP tools by keywords.
|
||||
pub struct McpSearch;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for McpSearch {
|
||||
fn name(&self) -> &str {
|
||||
"mcp_search"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Find tools of the on-demand MCP servers listed in the system prompt. Returns matching tools \
|
||||
with their input schema; call them with mcp_call. Search with a few keywords (e.g. \
|
||||
\"container restart\", \"light living room\")."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Keywords"},
|
||||
"server": {"type": "string", "description": "Only search this server (optional)"}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("Search MCP tools: {}", input["query"].as_str().unwrap_or(""))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let query = input["query"].as_str().unwrap_or("").to_lowercase();
|
||||
let server = input["server"].as_str();
|
||||
let words: Vec<&str> = query.split_whitespace().collect();
|
||||
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config());
|
||||
let mut scored: Vec<(usize, &Arc<McpTool>)> = deferred
|
||||
.iter()
|
||||
.filter(|t| server.is_none_or(|s| t.server.name == s))
|
||||
.map(|t| {
|
||||
let name = t.info.name.to_lowercase();
|
||||
let desc = t.info.description.to_lowercase();
|
||||
let score: usize = words.iter().map(|w| if name.contains(w) { 3 } else if desc.contains(w) { 1 } else { 0 }).sum();
|
||||
(score, t)
|
||||
})
|
||||
.filter(|(s, _)| *s > 0 || words.is_empty())
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
if scored.is_empty() {
|
||||
return Ok(ToolOutput::text("No matching tools. Try other keywords or another server."));
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (_, t) in scored.iter().take(8) {
|
||||
let desc: String = t.info.description.chars().take(300).collect();
|
||||
out.push_str(&format!("## {}\n{}\nInput schema: {}\n\n", t.full_name, desc, t.info.input_schema));
|
||||
}
|
||||
if scored.len() > 8 {
|
||||
out.push_str(&format!("({} more matches – refine the query)\n", scored.len() - 8));
|
||||
}
|
||||
Ok(ToolOutput::text(out))
|
||||
}
|
||||
}
|
||||
|
||||
/// Call an on-demand MCP tool found with mcp_search. Permissions are checked
|
||||
/// against the target tool by the runner.
|
||||
pub struct McpCall;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for McpCall {
|
||||
fn name(&self) -> &str {
|
||||
"mcp_call"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Call an MCP tool found with mcp_search, by its full name (mcp__<server>__<tool>).".into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool": {"type": "string", "description": "Full tool name, e.g. mcp__dockhand__list_containers"},
|
||||
"arguments": {"type": "object", "description": "Arguments matching the tool's input schema"}
|
||||
},
|
||||
"required": ["tool"]
|
||||
})
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let target = resolve_call_target(ctx, &input)?;
|
||||
target.run(input.get("arguments").cloned().unwrap_or(json!({})), ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the MCP tool an mcp_call refers to.
|
||||
pub fn resolve_call_target(ctx: &ToolCtx, input: &Value) -> Result<Arc<McpTool>> {
|
||||
let name = input["tool"].as_str().unwrap_or("");
|
||||
let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config());
|
||||
deferred
|
||||
.into_iter()
|
||||
.find(|t| t.full_name == name)
|
||||
.ok_or_else(|| anyhow!("unknown MCP tool '{name}' – use mcp_search to find tool names"))
|
||||
}
|
||||
|
||||
/// Tool names must match ^[a-zA-Z0-9_-]{1,64}$ for most providers.
|
||||
@@ -523,7 +654,7 @@ pub fn tool_name(server: &str, tool: &str) -> String {
|
||||
}
|
||||
|
||||
pub struct McpTool {
|
||||
full_name: String,
|
||||
pub full_name: String,
|
||||
server: Arc<McpServer>,
|
||||
info: McpToolInfo,
|
||||
}
|
||||
@@ -546,6 +677,9 @@ impl Tool for McpTool {
|
||||
fn title(&self, _input: &Value) -> String {
|
||||
format!("{} → {}", self.server.name, self.info.name)
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
self.info.read_only
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
+117
-2
@@ -81,6 +81,70 @@ pub fn bash_prefix(cmd: &str) -> String {
|
||||
cmd.split_whitespace().take(2).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Session permission modes.
|
||||
pub const MODES: &[&str] = &["default", "auto", "accept_edits", "bypass"];
|
||||
|
||||
/// Commands that only read. Checked per simple command (after splitting).
|
||||
const SAFE_COMMANDS: &[&str] = &[
|
||||
"ls", "ll", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "which", "whereis", "type", "file", "stat",
|
||||
"du", "df", "tree", "grep", "egrep", "fgrep", "rg", "ag", "sort", "uniq", "cut", "tr", "diff", "cmp", "basename",
|
||||
"dirname", "realpath", "readlink", "date", "uname", "whoami", "id", "hostname", "nproc", "free", "uptime", "ps",
|
||||
"jq", "yq", "md5sum", "sha1sum", "sha256sum", "column", "nl", "fold", "true", "false", "test", "[", "less", "more",
|
||||
];
|
||||
|
||||
const SAFE_GIT: &[&str] = &[
|
||||
"status", "diff", "log", "show", "rev-parse", "ls-files", "blame", "describe", "grep", "shortlog", "reflog", "remote",
|
||||
];
|
||||
|
||||
/// Strip harmless redirections so they don't count as file writes.
|
||||
fn strip_redirections(cmd: &str) -> String {
|
||||
cmd.replace("2>&1", "").replace("2>/dev/null", "").replace(">/dev/null", "").replace("> /dev/null", "")
|
||||
}
|
||||
|
||||
/// A simple command that only reads.
|
||||
pub fn is_safe_command(part: &str) -> bool {
|
||||
let cleaned = strip_redirections(part);
|
||||
if cleaned.contains('>') {
|
||||
return false;
|
||||
}
|
||||
let words: Vec<String> = shell_words::split(&cleaned).unwrap_or_else(|_| cleaned.split_whitespace().map(String::from).collect());
|
||||
// Leading VAR=value assignments are not safe (could change behaviour); keep it strict.
|
||||
let Some(cmd) = words.first() else { return true };
|
||||
let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
|
||||
if args.len() == 1 && matches!(args[0], "--version" | "-V" | "--help" | "-h") {
|
||||
return true;
|
||||
}
|
||||
match cmd.as_str() {
|
||||
c if SAFE_COMMANDS.contains(&c) => true,
|
||||
"find" => !args.iter().any(|a| matches!(*a, "-exec" | "-execdir" | "-delete" | "-ok" | "-okdir" | "-fprint" | "-fprintf" | "-fls")),
|
||||
"sed" => !args.iter().any(|a| a.starts_with("-i") || a.starts_with("--in-place")),
|
||||
"git" => match args.first() {
|
||||
Some(sub) if SAFE_GIT.contains(sub) => !(*sub == "remote" && args.len() > 1 && args[1] != "-v"),
|
||||
Some(&"branch") => !args.iter().any(|a| matches!(*a, "-d" | "-D" | "-m" | "-M" | "--delete" | "--move")),
|
||||
Some(&"stash") => args.get(1) == Some(&"list"),
|
||||
Some(&"tag") => args.len() == 1 || args.get(1) == Some(&"-l") || args.get(1) == Some(&"--list"),
|
||||
Some(&"config") => args.iter().any(|a| *a == "--get" || *a == "--list" || *a == "-l"),
|
||||
_ => false,
|
||||
},
|
||||
"cargo" => matches!(args.first(), Some(&"check") | Some(&"metadata") | Some(&"tree") | Some(&"--list")),
|
||||
"npm" | "pnpm" | "yarn" => matches!(args.first(), Some(&"ls") | Some(&"list") | Some(&"outdated") | Some(&"view") | Some(&"why")),
|
||||
"pip" | "pip3" => matches!(args.first(), Some(&"list") | Some(&"show") | Some(&"freeze")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that always need confirmation unless everything is allowed.
|
||||
pub fn is_dangerous(cmd: &str) -> bool {
|
||||
let c = format!(" {} ", cmd.replace('\n', " "));
|
||||
const PATTERNS: &[&str] = &[
|
||||
" rm -r", " rm -f", " rm -R", " rm --recursive", " sudo ", " su ", " git push", " git reset --hard", " git clean",
|
||||
" git checkout -- ", " git restore ", " git rebase", " git commit --amend", " chmod -R", " chown -R", " dd ",
|
||||
" mkfs", " shutdown", " reboot", " poweroff", " kill ", " pkill ", " killall ", " docker ", " docker-compose ",
|
||||
" systemctl ", " npm publish", " cargo publish", " | sh", " | bash", "|sh ", "|bash ", " > /dev/sd", " crontab ",
|
||||
];
|
||||
PATTERNS.iter().any(|p| c.contains(p))
|
||||
}
|
||||
|
||||
pub struct Evaluator<'a> {
|
||||
pub cfg: &'a PermissionConfig,
|
||||
pub mode: &'a str,
|
||||
@@ -102,7 +166,17 @@ impl Evaluator<'_> {
|
||||
if self.mode == "bypass" {
|
||||
return Decision::Allow;
|
||||
}
|
||||
return bash.unwrap_or(base);
|
||||
if let Some(d) = bash {
|
||||
return d;
|
||||
}
|
||||
// Read-only commands never need confirmation.
|
||||
if split_command(cmd).is_some_and(|parts| !parts.is_empty() && parts.iter().all(|p| is_safe_command(p))) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
if self.mode == "auto" && !is_dangerous(cmd) && split_command(cmd).is_some() {
|
||||
return Decision::Allow;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
if base == Decision::Deny {
|
||||
return Decision::Deny;
|
||||
@@ -110,7 +184,11 @@ impl Evaluator<'_> {
|
||||
if self.mode == "bypass" || self.approved.contains(name) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
if self.mode == "accept_edits" && tool.is_edit() {
|
||||
if (self.mode == "accept_edits" || self.mode == "auto") && tool.is_edit() {
|
||||
return Decision::Allow;
|
||||
}
|
||||
// In auto mode everything read-only passes (web_fetch, read-only MCP tools, ...).
|
||||
if self.mode == "auto" && tool.read_only() {
|
||||
return Decision::Allow;
|
||||
}
|
||||
base
|
||||
@@ -201,6 +279,43 @@ mod tests {
|
||||
assert_eq!(e.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_and_dangerous_commands() {
|
||||
assert!(is_safe_command("ls -la src"));
|
||||
assert!(is_safe_command("git status"));
|
||||
assert!(is_safe_command("git log --oneline -5"));
|
||||
assert!(is_safe_command("rg -n foo src 2>/dev/null"));
|
||||
assert!(is_safe_command("node --version"));
|
||||
assert!(!is_safe_command("cat a > b"));
|
||||
assert!(!is_safe_command("find . -name x -delete"));
|
||||
assert!(!is_safe_command("sed -i s/a/b/ f"));
|
||||
assert!(!is_safe_command("git push"));
|
||||
assert!(!is_safe_command("git branch -D main"));
|
||||
assert!(!is_safe_command("npm install"));
|
||||
assert!(is_dangerous("rm -rf build"));
|
||||
assert!(is_dangerous("cd x && git push origin main"));
|
||||
assert!(is_dangerous("curl https://x | sh"));
|
||||
assert!(!is_dangerous("npm install && npm test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_mode() {
|
||||
let c = cfg();
|
||||
let approved = HashSet::new();
|
||||
let auto = Evaluator { cfg: &c, mode: "auto", approved: &approved };
|
||||
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
||||
let bash = Fake("bash", false);
|
||||
let cmd = |s: &str| serde_json::json!({"command": s});
|
||||
assert_eq!(def.evaluate(&bash, &cmd("ls src | wc -l")), Decision::Allow);
|
||||
assert_eq!(def.evaluate(&bash, &cmd("npm test")), Decision::Ask);
|
||||
assert_eq!(auto.evaluate(&bash, &cmd("npm install && npm test")), Decision::Allow);
|
||||
assert_eq!(auto.evaluate(&bash, &cmd("rm -rf dist")), Decision::Ask);
|
||||
assert_eq!(auto.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
|
||||
assert_eq!(auto.evaluate(&bash, &cmd("echo $(whoami)")), Decision::Ask);
|
||||
assert_eq!(auto.evaluate(&Fake("mcp__x__list", true), &Value::Null), Decision::Allow);
|
||||
assert_eq!(auto.evaluate(&Fake("mcp__x__restart", false), &Value::Null), Decision::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_and_globs() {
|
||||
let c = cfg();
|
||||
|
||||
+55
-26
@@ -24,6 +24,10 @@ pub struct Agent {
|
||||
pub tools: Option<Vec<String>>,
|
||||
pub read_only: bool,
|
||||
pub model: Option<String>,
|
||||
/// "builtin", "config" or "file".
|
||||
pub source: String,
|
||||
/// Max. instances running at the same time.
|
||||
pub max_parallel: Option<usize>,
|
||||
}
|
||||
|
||||
const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
|
||||
@@ -64,11 +68,11 @@ const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Se
|
||||
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 },
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,9 +95,9 @@ pub fn frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
|
||||
}
|
||||
|
||||
/// Resource directories, lowest priority first: data dir, workspace, project.
|
||||
fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
||||
fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![app.data_dir().join(kind)];
|
||||
let ws = app.workspace();
|
||||
let ws = root.to_path_buf();
|
||||
let mut chain = vec![ws.clone()];
|
||||
if cwd != ws {
|
||||
chain.push(cwd.to_path_buf());
|
||||
@@ -105,12 +109,12 @@ fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
||||
dirs
|
||||
}
|
||||
|
||||
pub fn agents(app: &App, cwd: &Path) -> Vec<Agent> {
|
||||
pub fn agents(app: &App, root: &Path, 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") {
|
||||
for dir in resource_dirs(app, root, cwd, "agents") {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
@@ -129,6 +133,8 @@ pub fn agents(app: &App, cwd: &Path) -> Vec<Agent> {
|
||||
tools,
|
||||
read_only: false,
|
||||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||
source: "file".into(),
|
||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -142,23 +148,25 @@ pub fn agents(app: &App, cwd: &Path) -> Vec<Agent> {
|
||||
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)),
|
||||
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
|
||||
source: "config".into(),
|
||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||
});
|
||||
}
|
||||
map.into_values().collect()
|
||||
}
|
||||
|
||||
pub fn find_agent(app: &App, cwd: &Path, name: &str) -> Agent {
|
||||
agents(app, cwd)
|
||||
pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent {
|
||||
agents(app, root, 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)> {
|
||||
fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String)> {
|
||||
let mut dirs = vec![app.data_dir()];
|
||||
let ws = app.workspace();
|
||||
let ws = root.to_path_buf();
|
||||
let mut d = cwd.to_path_buf();
|
||||
let mut chain = Vec::new();
|
||||
loop {
|
||||
@@ -183,28 +191,49 @@ fn instruction_files(app: &App, cwd: &Path) -> Vec<(PathBuf, String)> {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn system_prompt(app: &App, agent: &Agent, cwd: &Path, model: &str) -> String {
|
||||
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str) -> String {
|
||||
let mut s = String::from(BASE_PROMPT);
|
||||
s.push_str("\n\n");
|
||||
s.push_str(&agent.prompt);
|
||||
if permission_mode == "auto" && !agent.read_only {
|
||||
s.push_str("\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.");
|
||||
}
|
||||
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",
|
||||
"\n\n# Environment\n- Working directory: {}\n- Project root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n",
|
||||
cwd.display(),
|
||||
app.workspace().display(),
|
||||
root.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 !agent.read_only || agent.name == "plan" {
|
||||
let subs: Vec<Agent> = agents(app, root, cwd).into_iter().filter(|a| a.mode != "primary").collect();
|
||||
if !subs.is_empty() {
|
||||
s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type). Delegate work that fits a subagent's description – especially writing, summarizing and translating – and use background=true for tasks whose result you do not need right away.\n");
|
||||
for a in &subs {
|
||||
let model = a.model.as_deref().map(|m| format!(" [model: {m}]")).unwrap_or_default();
|
||||
let ro = if a.read_only { " (read-only)" } else { "" };
|
||||
s.push_str(&format!("- {}{ro}{model}: {}\n", a.name, a.description));
|
||||
}
|
||||
}
|
||||
}
|
||||
let deferred = app.mcp.deferred_summary(&app.config());
|
||||
if !deferred.is_empty() {
|
||||
s.push_str("\n# On-demand MCP servers\nThese servers have many tools that are not listed directly. Find tools with mcp_search, then call them with mcp_call:\n");
|
||||
for (server, n, names) in &deferred {
|
||||
s.push_str(&format!("- {server} ({n} tools, e.g. {})\n", names.join(", ")));
|
||||
}
|
||||
}
|
||||
let skills = skills(app, root, 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);
|
||||
let files = instruction_files(app, root, 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 {
|
||||
@@ -226,9 +255,9 @@ pub struct Command {
|
||||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
pub fn commands(app: &App, cwd: &Path) -> Vec<Command> {
|
||||
pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec<Command> {
|
||||
let mut map = BTreeMap::new();
|
||||
for dir in resource_dirs(app, cwd, "commands") {
|
||||
for dir in resource_dirs(app, root, cwd, "commands") {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
@@ -249,10 +278,10 @@ pub fn commands(app: &App, cwd: &Path) -> Vec<Command> {
|
||||
}
|
||||
|
||||
/// 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>)> {
|
||||
pub fn expand_command(app: &App, root: &Path, 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 cmd = commands(app, root, 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() {
|
||||
@@ -274,9 +303,9 @@ pub struct Skill {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn skills(app: &App, cwd: &Path) -> Vec<Skill> {
|
||||
pub fn skills(app: &App, root: &Path, cwd: &Path) -> Vec<Skill> {
|
||||
let mut map = BTreeMap::new();
|
||||
for dir in resource_dirs(app, cwd, "skills") {
|
||||
for dir in resource_dirs(app, root, cwd, "skills") {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let p = e.path().join("SKILL.md");
|
||||
@@ -307,7 +336,7 @@ impl Tool for SkillTool {
|
||||
}
|
||||
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 {
|
||||
let Some(sk) = skills(&ctx.app, &ctx.root(), &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)?;
|
||||
|
||||
@@ -165,6 +165,9 @@ impl Provider for Anthropic {
|
||||
body["output_config"] = json!({"effort": effort});
|
||||
}
|
||||
|
||||
for (k, v) in &req.model.options {
|
||||
body[k] = v.clone();
|
||||
}
|
||||
let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(http_error(resp).await);
|
||||
|
||||
@@ -105,6 +105,13 @@ pub struct ProviderEntry {
|
||||
#[derive(Default)]
|
||||
pub struct Registry {
|
||||
pub providers: BTreeMap<String, ProviderEntry>,
|
||||
/// Concurrency limits keyed by "provider" and "provider/model".
|
||||
limits: BTreeMap<String, Arc<tokio::sync::Semaphore>>,
|
||||
}
|
||||
|
||||
/// Held while a request runs; dropping it frees the slot(s).
|
||||
pub struct Slot {
|
||||
_permits: Vec<tokio::sync::OwnedSemaphorePermit>,
|
||||
}
|
||||
|
||||
pub fn http_client() -> reqwest::Client {
|
||||
@@ -121,6 +128,7 @@ impl Registry {
|
||||
pub async fn build(cfg: &Config) -> Self {
|
||||
let client = http_client();
|
||||
let mut providers = BTreeMap::new();
|
||||
let mut limits = BTreeMap::new();
|
||||
for (name, pc) in &cfg.providers {
|
||||
if !pc.enabled {
|
||||
continue;
|
||||
@@ -131,6 +139,14 @@ impl Registry {
|
||||
ProviderKind::Openai => Arc::new(openai::OpenAi::new(client.clone(), ep)),
|
||||
};
|
||||
let mut models: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
|
||||
if let Some(n) = pc.max_concurrency.filter(|n| *n > 0) {
|
||||
limits.insert(name.clone(), Arc::new(tokio::sync::Semaphore::new(n)));
|
||||
}
|
||||
for m in &models {
|
||||
if let Some(n) = m.max_concurrency.filter(|n| *n > 0) {
|
||||
limits.insert(format!("{name}/{}", m.id), Arc::new(tokio::sync::Semaphore::new(n)));
|
||||
}
|
||||
}
|
||||
if models.is_empty() {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(15), provider.list_models()).await {
|
||||
Ok(Ok(m)) => models = m,
|
||||
@@ -143,7 +159,28 @@ impl Registry {
|
||||
ProviderEntry { name: name.clone(), provider, models },
|
||||
);
|
||||
}
|
||||
Self { providers }
|
||||
Self { providers, limits }
|
||||
}
|
||||
|
||||
/// Wait for a free slot on the model and its provider. Always acquired in the
|
||||
/// same order (model, then provider), so concurrent requests cannot deadlock.
|
||||
pub async fn acquire(&self, spec: &str) -> Slot {
|
||||
let provider = spec.split_once('/').map(|(p, _)| p).unwrap_or(spec);
|
||||
let mut permits = Vec::new();
|
||||
for key in [spec, provider] {
|
||||
if let Some(sem) = self.limits.get(key) {
|
||||
if let Ok(p) = sem.clone().acquire_owned().await {
|
||||
permits.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Slot { _permits: permits }
|
||||
}
|
||||
|
||||
/// True if a request to this model would have to wait.
|
||||
pub fn is_saturated(&self, spec: &str) -> bool {
|
||||
let provider = spec.split_once('/').map(|(p, _)| p).unwrap_or(spec);
|
||||
[spec, provider].iter().any(|k| self.limits.get(*k).is_some_and(|s| s.available_permits() == 0))
|
||||
}
|
||||
|
||||
/// Resolve "provider/model". Unknown models of a known provider are allowed
|
||||
|
||||
@@ -107,6 +107,55 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Splits streamed content into text and inline `<think>…</think>` reasoning.
|
||||
/// Tags may be split across chunks, so a possible partial tag is held back.
|
||||
#[derive(Default)]
|
||||
pub struct ThinkSplitter {
|
||||
in_think: bool,
|
||||
pending: String,
|
||||
}
|
||||
|
||||
const OPEN: &str = "<think>";
|
||||
const CLOSE: &str = "</think>";
|
||||
|
||||
impl ThinkSplitter {
|
||||
/// Returns (is_reasoning, text) pieces that are safe to emit.
|
||||
pub fn push(&mut self, chunk: &str) -> Vec<(bool, String)> {
|
||||
self.pending.push_str(chunk);
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let tag = if self.in_think { CLOSE } else { OPEN };
|
||||
if let Some(pos) = self.pending.find(tag) {
|
||||
let before: String = self.pending[..pos].to_string();
|
||||
if !before.is_empty() {
|
||||
out.push((self.in_think, before));
|
||||
}
|
||||
self.pending.drain(..pos + tag.len());
|
||||
self.in_think = !self.in_think;
|
||||
continue;
|
||||
}
|
||||
// Keep a trailing prefix of the tag (e.g. "<thi") until the next chunk.
|
||||
let keep = (1..tag.len()).rev().find(|n| self.pending.ends_with(&tag[..*n])).unwrap_or(0);
|
||||
let emit_len = self.pending.len() - keep;
|
||||
if emit_len > 0 {
|
||||
let text: String = self.pending.drain(..emit_len).collect();
|
||||
out.push((self.in_think, text));
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Leading whitespace after </think> is noise.
|
||||
out.retain(|(_, t)| !t.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Vec<(bool, String)> {
|
||||
if self.pending.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
vec![(self.in_think, std::mem::take(&mut self.pending))]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingCall {
|
||||
id: String,
|
||||
@@ -145,6 +194,9 @@ impl Provider for OpenAi {
|
||||
body["reasoning_effort"] = json!(effort);
|
||||
}
|
||||
|
||||
for (k, v) in &req.model.options {
|
||||
body[k] = v.clone();
|
||||
}
|
||||
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() {
|
||||
@@ -153,6 +205,7 @@ impl Provider for OpenAi {
|
||||
|
||||
let mut events = resp.bytes_stream().eventsource();
|
||||
let mut calls: BTreeMap<u64, PendingCall> = BTreeMap::new();
|
||||
let mut think = ThinkSplitter::default();
|
||||
let mut usage = Usage::default();
|
||||
let mut stop_reason = String::from("stop");
|
||||
|
||||
@@ -182,7 +235,11 @@ impl Provider for OpenAi {
|
||||
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();
|
||||
// Models served by llama.cpp & co. may inline their reasoning as <think>…</think>.
|
||||
for (is_reasoning, chunk) in think.push(t) {
|
||||
let ev = if is_reasoning { StreamEvent::ReasoningDelta(chunk) } else { StreamEvent::TextDelta(chunk) };
|
||||
tx.send(ev).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()) {
|
||||
@@ -211,6 +268,10 @@ impl Provider for OpenAi {
|
||||
}
|
||||
}
|
||||
|
||||
for (is_reasoning, chunk) in think.finish() {
|
||||
let ev = if is_reasoning { StreamEvent::ReasoningDelta(chunk) } else { StreamEvent::TextDelta(chunk) };
|
||||
tx.send(ev).await.ok();
|
||||
}
|
||||
for (i, pc) in calls {
|
||||
let input = if pc.args.trim().is_empty() {
|
||||
json!({})
|
||||
@@ -249,3 +310,34 @@ impl Provider for OpenAi {
|
||||
Ok(models)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ThinkSplitter;
|
||||
|
||||
fn run(chunks: &[&str]) -> (String, String) {
|
||||
let mut s = ThinkSplitter::default();
|
||||
let mut text = String::new();
|
||||
let mut reasoning = String::new();
|
||||
let mut parts: Vec<(bool, String)> = chunks.iter().flat_map(|c| s.push(c)).collect();
|
||||
parts.extend(s.finish());
|
||||
for (r, t) in parts {
|
||||
if r { reasoning.push_str(&t) } else { text.push_str(&t) }
|
||||
}
|
||||
(text, reasoning)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_think_tags_across_chunks() {
|
||||
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
|
||||
assert_eq!(reasoning, "plan the fix");
|
||||
assert_eq!(text, "Here is the answer.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_and_lookalikes_pass_through() {
|
||||
let (text, reasoning) = run(&["a < b and <thin", "g> stays"]);
|
||||
assert_eq!(text, "a < b and <thing> stays");
|
||||
assert!(reasoning.is_empty());
|
||||
}
|
||||
}
|
||||
+299
-66
@@ -20,30 +20,55 @@ 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.
|
||||
/// What happened to a prompt.
|
||||
pub enum Submitted {
|
||||
Started,
|
||||
Queued(crate::store::QueueItem),
|
||||
}
|
||||
|
||||
fn emit_queue(app: &App, session_id: &str) {
|
||||
let items = app.store.queue_list(session_id).unwrap_or_default();
|
||||
app.emit(Event::QueueUpdated { session_id: session_id.into(), items: serde_json::json!(items) });
|
||||
}
|
||||
|
||||
/// Take all queued messages (one user message's worth of parts).
|
||||
fn take_queue(app: &App, session_id: &str) -> Vec<Part> {
|
||||
let items = app.store.queue_take(session_id).unwrap_or_default();
|
||||
if !items.is_empty() {
|
||||
emit_queue(app, session_id);
|
||||
}
|
||||
items.into_iter().flat_map(|i| i.parts).collect()
|
||||
}
|
||||
|
||||
/// Start processing a user prompt. If the session is busy, the prompt (with
|
||||
/// attachments) is queued and delivered at the agent's next step.
|
||||
pub fn start_prompt(app: Arc<App>, session_id: String, parts: Vec<Part>) -> Result<()> {
|
||||
submit(app, session_id, parts, "user").map(|_| ())
|
||||
}
|
||||
|
||||
pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str) -> Result<Submitted> {
|
||||
// Queueing and the end-of-run check run under the same runtime lock, so a
|
||||
// message is never lost or delivered twice.
|
||||
let cancel = CancellationToken::new();
|
||||
let queued = app.with_runtime(&session_id, |r| -> Result<Option<crate::store::QueueItem>> {
|
||||
if r.cancel.is_some() {
|
||||
let item = crate::store::QueueItem { id: new_id(), parts: parts.clone(), source: source.into(), created_at: now_ms() };
|
||||
app.store.queue_add(&session_id, &item)?;
|
||||
Ok(Some(item))
|
||||
} else {
|
||||
r.cancel = Some(cancel.clone());
|
||||
Ok(None)
|
||||
}
|
||||
})?;
|
||||
if let Some(item) = queued {
|
||||
emit_queue(&app, &session_id);
|
||||
return Ok(Submitted::Queued(item));
|
||||
}
|
||||
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 {
|
||||
@@ -59,17 +84,31 @@ pub fn start_prompt(app: Arc<App>, session_id: String, parts: Vec<Part>) -> Resu
|
||||
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.
|
||||
// Messages queued after the last step start a new turn – unless the user
|
||||
// stopped the agent: then they stay queued until sent explicitly.
|
||||
let leftover = app.with_runtime(&session_id, |r| {
|
||||
r.cancel = None;
|
||||
std::mem::take(&mut r.queue)
|
||||
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
|
||||
});
|
||||
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") }]);
|
||||
if !leftover.is_empty() {
|
||||
let _ = start_prompt(app, session_id, leftover);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
Ok(Submitted::Started)
|
||||
}
|
||||
|
||||
/// Send queued messages now (after a stop or a server restart).
|
||||
pub fn flush_queue(app: Arc<App>, session_id: &str) -> Result<bool> {
|
||||
if app.is_busy(session_id) {
|
||||
return Ok(false);
|
||||
}
|
||||
let parts = take_queue(&app, session_id);
|
||||
if parts.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
start_prompt(app, session_id.to_string(), parts)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn abort(app: &App, session_id: &str) {
|
||||
@@ -77,17 +116,72 @@ pub fn abort(app: &App, session_id: &str) {
|
||||
if let Some(c) = &r.cancel {
|
||||
c.cancel();
|
||||
}
|
||||
r.queue.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/// Messages since the last compaction summary.
|
||||
/// Messages since the last compaction summary. Tool calls left without a
|
||||
/// result (run interrupted, e.g. by a server restart) get a synthetic result:
|
||||
/// providers reject histories with unanswered tool calls.
|
||||
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()
|
||||
let msgs: Vec<Message> = all.into_iter().skip(start).collect();
|
||||
let mut out: Vec<Message> = Vec::with_capacity(msgs.len());
|
||||
for (i, m) in msgs.iter().enumerate() {
|
||||
out.push(m.clone());
|
||||
if m.role != Role::Assistant {
|
||||
continue;
|
||||
}
|
||||
let answered: std::collections::HashSet<&str> = msgs
|
||||
.get(i + 1)
|
||||
.filter(|n| n.role == Role::User)
|
||||
.map(|n| {
|
||||
n.parts
|
||||
.iter()
|
||||
.filter_map(|p| if let Part::ToolResult { call_id, .. } = p { Some(call_id.as_str()) } else { None })
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let missing: Vec<Part> = m
|
||||
.parts
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Part::ToolCall { id, name, .. } if !answered.contains(id.as_str()) => {
|
||||
Some(tool_result(id, name, "Not executed: the run was interrupted (e.g. server restart).".into(), true, None))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Results must directly follow the call: merge into the next user message or insert one.
|
||||
match msgs.get(i + 1) {
|
||||
Some(n) if n.role == Role::User => {
|
||||
// The next message is pushed in the next iteration; prepend there.
|
||||
let mut next = n.clone();
|
||||
let mut parts = missing;
|
||||
parts.extend(next.parts);
|
||||
next.parts = parts;
|
||||
// Replace by pushing now and skipping the original.
|
||||
out.push(next);
|
||||
}
|
||||
_ => out.push(Message::new(Role::User, missing)),
|
||||
}
|
||||
}
|
||||
// Remove duplicates created by the merge above (same id pushed twice: keep the merged one).
|
||||
let mut dedup: Vec<Message> = Vec::with_capacity(out.len());
|
||||
for m in out {
|
||||
if let Some(last) = dedup.last() {
|
||||
if last.id == m.id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
dedup.push(m);
|
||||
}
|
||||
dedup
|
||||
}
|
||||
|
||||
fn normalize_tool_name(s: &str) -> String {
|
||||
@@ -95,8 +189,17 @@ fn normalize_tool_name(s: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
|
||||
let cfg = app.config();
|
||||
let mut all = tools::builtin_tools();
|
||||
all.extend(app.mcp.tools());
|
||||
if !cfg.images.enabled {
|
||||
all.retain(|t| t.name() != "generate_image");
|
||||
}
|
||||
let (direct, deferred) = app.mcp.tools(&cfg);
|
||||
all.extend(direct);
|
||||
if !deferred.is_empty() {
|
||||
all.push(Arc::new(crate::mcp::McpSearch));
|
||||
all.push(Arc::new(crate::mcp::McpCall));
|
||||
}
|
||||
all.into_iter()
|
||||
.filter(|t| !agent.read_only || t.read_only())
|
||||
// Craft tools only where they are useful.
|
||||
@@ -107,7 +210,7 @@ pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
|
||||
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")
|
||||
a == n || (t.name().starts_with("mcp") && a == "mcp")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -119,6 +222,17 @@ struct Step {
|
||||
stop_reason: String,
|
||||
}
|
||||
|
||||
/// Boxed with an explicit `Send` bound: subagents recurse into the agent loop,
|
||||
/// and the declared type breaks the cycle for the compiler.
|
||||
fn run_session_boxed(
|
||||
app: Arc<App>,
|
||||
session_id: String,
|
||||
cancel: CancellationToken,
|
||||
depth: usize,
|
||||
) -> futures::future::BoxFuture<'static, Result<String>> {
|
||||
Box::pin(async move { run_session(app, &session_id, cancel, depth).await })
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -128,7 +242,9 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
}
|
||||
let session = app.session(session_id)?;
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let agent = prompts::find_agent(&app, &cwd, &session.agent);
|
||||
let roots = app.user_roots(session.owner_id.as_deref());
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
|
||||
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
|
||||
let (provider, model) = app.registry().resolve(&model_spec)?;
|
||||
|
||||
@@ -144,7 +260,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
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),
|
||||
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode),
|
||||
messages: history,
|
||||
tools: tools.iter().map(|t| t.def()).collect(),
|
||||
max_tokens: model.max_output.unwrap_or(16_000),
|
||||
@@ -203,6 +319,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
cwd: cwd.clone(),
|
||||
cancel: cancel.clone(),
|
||||
agent: agent.name.clone(),
|
||||
roots: roots.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()));
|
||||
@@ -217,11 +334,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
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 });
|
||||
}
|
||||
// Deliver messages the user queued while we were working (with attachments).
|
||||
results.extend(take_queue(&app, session_id));
|
||||
save_user_parts(&app, session_id, results)?;
|
||||
}
|
||||
Ok(final_text)
|
||||
@@ -265,12 +379,6 @@ async fn execute_call(
|
||||
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(),
|
||||
@@ -278,10 +386,33 @@ async fn execute_call(
|
||||
cwd: base.cwd.clone(),
|
||||
cancel: base.cancel.clone(),
|
||||
agent: base.agent.clone(),
|
||||
roots: base.roots.clone(),
|
||||
};
|
||||
// mcp_call is judged (and approved) as the MCP tool it calls.
|
||||
let call_name = name;
|
||||
let (tool, input, name): (Arc<dyn Tool>, Value, String) = if name == "mcp_call" {
|
||||
match crate::mcp::resolve_call_target(&ctx, &input) {
|
||||
Ok(target) => {
|
||||
let args = input.get("arguments").cloned().unwrap_or(serde_json::json!({}));
|
||||
let n = target.full_name.clone();
|
||||
(target, args, n)
|
||||
}
|
||||
Err(e) => return tool_result(call_id, call_name, format!("Error: {e:#}"), true, None),
|
||||
}
|
||||
} else {
|
||||
(tool, input, name.to_string())
|
||||
};
|
||||
let name = name.as_str();
|
||||
|
||||
// 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)
|
||||
});
|
||||
match decision {
|
||||
Decision::Deny => {
|
||||
return tool_result(call_id, name, "Denied by the permission rules in the configuration.".into(), true, None);
|
||||
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
|
||||
}
|
||||
Decision::Ask => {
|
||||
let diff = tool.preview(&input, &ctx).await;
|
||||
@@ -294,7 +425,7 @@ async fn execute_call(
|
||||
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);
|
||||
return tool_result(call_id, call_name, text, true, None);
|
||||
}
|
||||
PermissionReply::Always => app.with_runtime(&root, |r| {
|
||||
if name == "bash" {
|
||||
@@ -321,40 +452,76 @@ async fn execute_call(
|
||||
match res {
|
||||
Ok(out) => Part::ToolResult {
|
||||
call_id: call_id.into(),
|
||||
name: name.into(),
|
||||
name: call_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),
|
||||
Err(e) => tool_result(call_id, call_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})))
|
||||
let agent_name = input["subagent_type"].as_str().unwrap_or("general").to_string();
|
||||
let background = input["background"].as_bool().unwrap_or(false);
|
||||
let (child, agent) = create_child(app, ctx, &agent_name, &description, prompt)?;
|
||||
let meta = serde_json::json!({
|
||||
"session_id": child.id, "agent": agent.name, "model": child.model, "background": background
|
||||
});
|
||||
|
||||
if background {
|
||||
let app = app.clone();
|
||||
let parent_id = ctx.session_id.clone();
|
||||
let child_id = child.id.clone();
|
||||
let agent_name = agent.name.clone();
|
||||
let desc = description.clone();
|
||||
// Background tasks outlive the current turn; they are stopped via their own session.
|
||||
let cancel = CancellationToken::new();
|
||||
tokio::spawn(async move {
|
||||
let res = run_child(app.clone(), &child, &agent, cancel, depth + 1).await;
|
||||
let body = match res {
|
||||
Ok(text) => text,
|
||||
Err(e) => format!("The background task failed: {e:#}"),
|
||||
};
|
||||
let text = format!(
|
||||
"<background-task-result agent=\"{agent_name}\" session=\"{child_id}\" description=\"{desc}\">\n{body}\n</background-task-result>"
|
||||
);
|
||||
// Injected into the running turn, or starts a new turn when the parent is idle.
|
||||
if app.store.get_session(&parent_id).ok().flatten().is_some() {
|
||||
if let Err(e) = submit(app.clone(), parent_id.clone(), vec![Part::Text { text }], "background") {
|
||||
tracing::warn!("could not deliver background result: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
return Ok(tools::ToolOutput::text(format!(
|
||||
"Started background task '{description}' with subagent '{}' (model {}). Its result will arrive \
|
||||
automatically as a message when it finishes – continue with other work and do not poll.",
|
||||
meta["agent"].as_str().unwrap_or(""),
|
||||
meta["model"].as_str().unwrap_or("")
|
||||
))
|
||||
.with_meta(meta));
|
||||
}
|
||||
|
||||
let text = run_child(app.clone(), &child, &agent, ctx.cancel.child_token(), depth + 1).await?;
|
||||
Ok(tools::ToolOutput::text(text).with_meta(meta))
|
||||
}
|
||||
|
||||
/// 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
|
||||
let (child, agent) = create_child(&app, ctx, agent, description, prompt)?;
|
||||
let text = run_child(app, &child, &agent, ctx.cancel.child_token(), 1).await?;
|
||||
Ok((child.id, text))
|
||||
}
|
||||
|
||||
async fn spawn_subagent(
|
||||
app: Arc<App>,
|
||||
ctx: &ToolCtx,
|
||||
agent_name: &str,
|
||||
description: &str,
|
||||
prompt: String,
|
||||
depth: usize,
|
||||
) -> Result<(String, String)> {
|
||||
/// Create the subagent session with its first message.
|
||||
fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> {
|
||||
let parent = app.session(&ctx.session_id)?;
|
||||
let parent_agent = prompts::find_agent(&app, &ctx.cwd, &parent.agent);
|
||||
let agents = prompts::agents(&app, &ctx.cwd);
|
||||
let root = ctx.root();
|
||||
let parent_agent = prompts::find_agent(app, &root, &ctx.cwd, &parent.agent);
|
||||
let agents = prompts::agents(app, &root, &ctx.cwd);
|
||||
let mut agent = agents
|
||||
.iter()
|
||||
.find(|a| a.name == agent_name && a.mode != "primary")
|
||||
@@ -367,6 +534,9 @@ async fn spawn_subagent(
|
||||
if parent_agent.read_only && !agent.read_only {
|
||||
agent = agents.into_iter().find(|a| a.name == "explore").unwrap_or(agent);
|
||||
}
|
||||
if let Some(m) = &agent.model {
|
||||
app.registry().resolve(m).map_err(|e| anyhow!("subagent '{}' has an unusable model: {e:#}", agent.name))?;
|
||||
}
|
||||
let now = now_ms();
|
||||
let child = Session {
|
||||
id: new_id(),
|
||||
@@ -376,6 +546,7 @@ async fn spawn_subagent(
|
||||
agent: agent.name.clone(),
|
||||
permission_mode: parent.permission_mode.clone(),
|
||||
parent_id: Some(parent.id.clone()),
|
||||
owner_id: parent.owner_id.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
todos: Value::Null,
|
||||
@@ -385,11 +556,34 @@ async fn spawn_subagent(
|
||||
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 });
|
||||
Ok((child, agent))
|
||||
}
|
||||
|
||||
/// Run a subagent session to completion, honouring the subagent limits.
|
||||
async fn run_child(app: Arc<App>, child: &Session, agent: &Agent, cancel: CancellationToken, depth: usize) -> Result<String> {
|
||||
// Registered like a normal run so the user can stop it from its own session.
|
||||
app.with_runtime(&child.id, |r| r.cancel = Some(cancel.clone()));
|
||||
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;
|
||||
// Only first-level subagents count against the limits: nested subagents would
|
||||
// otherwise deadlock waiting for a slot their own parent holds.
|
||||
let _slot = if depth <= 1 {
|
||||
let (slot, waited) = app.subagent_slot(&agent.name, agent.max_parallel, &child.id, &cancel).await;
|
||||
if waited {
|
||||
app.emit(Event::Waiting { session_id: child.id.clone(), reason: None });
|
||||
}
|
||||
slot
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let res = if cancel.is_cancelled() {
|
||||
Err(anyhow!("aborted"))
|
||||
} else {
|
||||
run_session_boxed(app.clone(), child.id.clone(), cancel, depth).await
|
||||
};
|
||||
app.with_runtime(&child.id, |r| r.cancel = None);
|
||||
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 }))
|
||||
Ok(if text.trim().is_empty() { "(subagent returned no text)".into() } else { text })
|
||||
}
|
||||
|
||||
fn is_retryable(e: &anyhow::Error) -> bool {
|
||||
@@ -415,7 +609,20 @@ async fn stream_step(
|
||||
let p = provider.clone();
|
||||
let r = req.clone();
|
||||
let c = cancel.clone();
|
||||
let handle = tokio::spawn(async move { p.stream(r, tx, c).await });
|
||||
// Wait for a free slot on the model/host; not counted as time to first token.
|
||||
let registry = app.registry();
|
||||
if registry.is_saturated(model_spec) {
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("model:{model_spec}")) });
|
||||
}
|
||||
let slot = tokio::select! {
|
||||
s = registry.acquire(model_spec) => s,
|
||||
_ = cancel.cancelled() => bail!("aborted"),
|
||||
};
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
|
||||
let handle = tokio::spawn(async move {
|
||||
let _slot = slot;
|
||||
p.stream(r, tx, c).await
|
||||
});
|
||||
|
||||
let started = Instant::now();
|
||||
let mut ttft: Option<u128> = None;
|
||||
@@ -516,9 +723,13 @@ async fn stream_step(
|
||||
}
|
||||
|
||||
/// Collect a complete, tool-free answer (titles, summaries).
|
||||
async fn complete_text(provider: Arc<dyn Provider>, req: ChatRequest, cancel: CancellationToken) -> Result<(String, Usage)> {
|
||||
async fn complete_text(app: &App, spec: &str, provider: Arc<dyn Provider>, req: ChatRequest, cancel: CancellationToken) -> Result<(String, Usage)> {
|
||||
let slot = app.registry().acquire(spec).await;
|
||||
let (tx, mut rx) = mpsc::channel(256);
|
||||
let handle = tokio::spawn(async move { provider.stream(req, tx, cancel).await });
|
||||
let handle = tokio::spawn(async move {
|
||||
let _slot = slot;
|
||||
provider.stream(req, tx, cancel).await
|
||||
});
|
||||
let mut text = String::new();
|
||||
let mut usage = Usage::default();
|
||||
while let Some(ev) = rx.recv().await {
|
||||
@@ -579,7 +790,7 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||
reasoning: false,
|
||||
model: model.clone(),
|
||||
};
|
||||
match complete_text(provider, req, CancellationToken::new()).await {
|
||||
match complete_text(&app, &spec, 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();
|
||||
@@ -625,7 +836,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
reasoning: false,
|
||||
model: model.clone(),
|
||||
};
|
||||
let (summary, usage) = complete_text(provider, req, cancel).await?;
|
||||
let (summary, usage) = complete_text(&app, &spec, provider, req, cancel).await?;
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "compaction");
|
||||
if summary.trim().is_empty() {
|
||||
bail!("compaction produced an empty summary");
|
||||
@@ -638,3 +849,25 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
app.save_session(s)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn call(id: &str) -> Part {
|
||||
Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_tool_calls_get_results() {
|
||||
let a = Message::new(Role::Assistant, vec![call("1"), call("2")]);
|
||||
let r = Message::new(Role::User, vec![tool_result("1", "bash", "ok".into(), false, None)]);
|
||||
let a2 = Message::new(Role::Assistant, vec![call("3")]);
|
||||
let h = active_history(vec![Message::new(Role::User, vec![Part::Text { text: "hi".into() }]), a, r, a2]);
|
||||
// user, assistant(1,2), user(result 2 + result 1), assistant(3), user(result 3)
|
||||
assert_eq!(h.len(), 5);
|
||||
let ids = |m: &Message| m.parts.iter().filter_map(|p| if let Part::ToolResult { call_id, .. } = p { Some(call_id.clone()) } else { None }).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&h[2]), vec!["2", "1"]);
|
||||
assert_eq!(ids(&h[4]), vec!["3"]);
|
||||
}
|
||||
}
|
||||
+473
-18
@@ -50,7 +50,74 @@ impl Store {
|
||||
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);",
|
||||
CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
oidc_sub TEXT UNIQUE,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
user_agent TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folder_access (
|
||||
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (folder_id, user_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
session_id TEXT,
|
||||
prompt TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS session_shares (
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (session_id, user_id)
|
||||
);",
|
||||
)?;
|
||||
// Columns added after the first release.
|
||||
for (table, column, ddl) in [
|
||||
("sessions", "owner_id", "ALTER TABLE sessions ADD COLUMN owner_id TEXT"),
|
||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||
] {
|
||||
let exists: bool = conn
|
||||
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
||||
.exists([column])?;
|
||||
if !exists {
|
||||
conn.execute(ddl, [])?;
|
||||
}
|
||||
}
|
||||
conn.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS sessions_owner ON sessions(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS usage_user ON usage(user_id);",
|
||||
)?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
@@ -58,9 +125,10 @@ impl Store {
|
||||
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],
|
||||
"INSERT INTO sessions (id, data, parent_id, updated_at, owner_id) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at,
|
||||
owner_id = excluded.owner_id",
|
||||
params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at, s.owner_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -74,12 +142,15 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Top-level sessions (subagent sessions are reachable through their parent).
|
||||
pub fn list_sessions(&self) -> Result<Vec<Session>> {
|
||||
/// `user = None` lists all sessions (admins); otherwise own and shared ones.
|
||||
pub fn list_sessions(&self, user: Option<&str>) -> 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",
|
||||
"SELECT data FROM sessions WHERE parent_id IS NULL AND (?1 IS NULL OR owner_id = ?1
|
||||
OR id IN (SELECT session_id FROM session_shares WHERE user_id = ?1))
|
||||
ORDER BY updated_at DESC LIMIT 500",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let rows = stmt.query_map([user], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(serde_json::from_str(&row?)?);
|
||||
@@ -197,8 +268,9 @@ impl Store {
|
||||
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)",
|
||||
ttft_ms, duration_ms, cost, error, user_id)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
|
||||
(SELECT owner_id FROM sessions WHERE id = ?3))",
|
||||
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,
|
||||
@@ -232,21 +304,22 @@ impl Store {
|
||||
|
||||
/// 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> {
|
||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> 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],
|
||||
&format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND (?3 IS NULL OR user_id = ?3)"),
|
||||
params![from, to, user],
|
||||
|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"
|
||||
"SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3
|
||||
AND (?4 IS NULL OR user_id = ?4) GROUP BY 1 ORDER BY 1"
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![from, to, shift], |r| {
|
||||
.query_map(params![from, to, shift, user], |r| {
|
||||
let mut v = agg_row(r, 1)?;
|
||||
v[key_name] = match r.get_ref(0)? {
|
||||
rusqlite::types::ValueRef::Integer(i) => json!(i),
|
||||
@@ -264,6 +337,10 @@ impl Store {
|
||||
let by_model = grouped("provider || '/' || model", "model")?;
|
||||
let by_provider = grouped("provider", "provider")?;
|
||||
let by_agent = grouped("agent", "agent")?;
|
||||
let by_user = grouped(
|
||||
"COALESCE((SELECT username FROM users WHERE users.id = usage.user_id), '(none)')",
|
||||
"user",
|
||||
)?;
|
||||
// Heatmap: weekday (0 = Sunday) x hour of day.
|
||||
let heatmap = grouped(
|
||||
"CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \
|
||||
@@ -274,9 +351,10 @@ impl Store {
|
||||
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"
|
||||
FROM usage WHERE ts >= ?1 AND ts < ?2 AND (?4 IS NULL OR user_id = ?4)
|
||||
GROUP BY 1, 2 ORDER BY 1"
|
||||
))?;
|
||||
stmt.query_map(params![from, to, shift], |r| {
|
||||
stmt.query_map(params![from, to, shift, user], |r| {
|
||||
Ok(json!({
|
||||
"day": r.get::<_, String>(0)?,
|
||||
"model": r.get::<_, String>(1)?,
|
||||
@@ -292,10 +370,10 @@ impl Store {
|
||||
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
|
||||
WHERE u.ts >= ?1 AND u.ts < ?2 AND (?3 IS NULL OR u.user_id = ?3)
|
||||
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
|
||||
))?;
|
||||
stmt.query_map(params![from, to], |r| {
|
||||
stmt.query_map(params![from, to, user], |r| {
|
||||
let mut v = agg_row(r, 2)?;
|
||||
v["session_id"] = json!(r.get::<_, String>(0)?);
|
||||
v["title"] = json!(r.get::<_, String>(1)?);
|
||||
@@ -310,8 +388,385 @@ impl Store {
|
||||
"by_model": by_model,
|
||||
"by_provider": by_provider,
|
||||
"by_agent": by_agent,
|
||||
"by_user": by_user,
|
||||
"heatmap": heatmap,
|
||||
"top_sessions": top_sessions,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// --- users & auth -------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
pub has_password: bool,
|
||||
pub oidc: bool,
|
||||
pub disabled: bool,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == "admin"
|
||||
}
|
||||
}
|
||||
|
||||
const USER_COLS: &str =
|
||||
"id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at";
|
||||
|
||||
fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
|
||||
Ok(User {
|
||||
id: r.get(0)?,
|
||||
username: r.get(1)?,
|
||||
display_name: r.get(2)?,
|
||||
role: r.get(3)?,
|
||||
has_password: r.get(4)?,
|
||||
oidc: r.get(5)?,
|
||||
disabled: r.get::<_, i64>(6)? != 0,
|
||||
created_at: r.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn user_count(&self) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
pub fn create_user(&self, username: &str, display_name: &str, role: &str, password_hash: Option<&str>, oidc_sub: Option<&str>) -> Result<User> {
|
||||
let id = crate::types::new_id();
|
||||
{
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, username, display_name, password_hash, role, oidc_sub, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![id, username, display_name, password_hash, role, oidc_sub, now_ms()],
|
||||
)?;
|
||||
}
|
||||
self.user(&id)?.ok_or_else(|| anyhow::anyhow!("user vanished"))
|
||||
}
|
||||
|
||||
pub fn user(&self, id: &str) -> Result<Option<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), [id], user_row).optional()?)
|
||||
}
|
||||
|
||||
pub fn user_by_name(&self, username: &str) -> Result<Option<(User, Option<String>)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
&format!("SELECT {USER_COLS}, password_hash FROM users WHERE username = ?1"),
|
||||
[username],
|
||||
|r| Ok((user_row(r)?, r.get::<_, Option<String>>(8)?)),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
pub fn user_by_oidc(&self, sub: &str) -> Result<Option<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE oidc_sub = ?1"), [sub], user_row).optional()?)
|
||||
}
|
||||
|
||||
pub fn users(&self) -> Result<Vec<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!("SELECT {USER_COLS} FROM users ORDER BY username"))?;
|
||||
let rows = stmt.query_map([], user_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Update selected fields; None leaves a field unchanged.
|
||||
pub fn update_user(&self, id: &str, display_name: Option<&str>, role: Option<&str>, disabled: Option<bool>, password_hash: Option<&str>, oidc_sub: Option<&str>) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE users SET display_name = COALESCE(?2, display_name), role = COALESCE(?3, role),
|
||||
disabled = COALESCE(?4, disabled), password_hash = COALESCE(?5, password_hash),
|
||||
oidc_sub = COALESCE(?6, oidc_sub) WHERE id = ?1",
|
||||
params![id, display_name, role, disabled.map(|d| d as i64), password_hash, oidc_sub],
|
||||
)?;
|
||||
if disabled == Some(true) {
|
||||
conn.execute("DELETE FROM auth_tokens WHERE user_id = ?1", [id])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_user(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM users WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn admin_count(&self) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row("SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0", [], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
pub fn add_token(&self, token_hash: &str, user_id: &str, expires_at: i64, user_agent: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?1", [now_ms()])?;
|
||||
conn.execute(
|
||||
"INSERT INTO auth_tokens (token_hash, user_id, created_at, expires_at, user_agent) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![token_hash, user_id, now_ms(), expires_at, user_agent],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The (enabled) user behind a valid login token.
|
||||
pub fn user_by_token(&self, token_hash: &str) -> Result<Option<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT {} FROM auth_tokens t JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = ?1 AND t.expires_at > ?2 AND u.disabled = 0",
|
||||
USER_COLS.split(", ").map(|c| format!("u.{c}")).collect::<Vec<_>>().join(", ")
|
||||
),
|
||||
params![token_hash, now_ms()],
|
||||
user_row,
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
pub fn delete_token(&self, token_hash: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM auth_tokens WHERE token_hash = ?1", [token_hash])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_user_tokens(&self, user_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM auth_tokens WHERE user_id = ?1", [user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shares(&self, session_id: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT user_id FROM session_shares WHERE session_id = ?1")?;
|
||||
let rows = stmt.query_map([session_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn set_shares(&self, session_id: &str, users: &[String]) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM session_shares WHERE session_id = ?1", [session_id])?;
|
||||
for u in users {
|
||||
conn.execute("INSERT OR IGNORE INTO session_shares (session_id, user_id) VALUES (?1, ?2)", params![session_id, u])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assign sessions without owner (created before user management) to a user.
|
||||
pub fn adopt_orphans(&self, user_id: &str) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let ids: Vec<(String, String)> = {
|
||||
let mut stmt = conn.prepare("SELECT id, data FROM sessions WHERE owner_id IS NULL")?;
|
||||
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (id, data) in &ids {
|
||||
let mut v: Value = serde_json::from_str(data)?;
|
||||
v["owner_id"] = json!(user_id);
|
||||
conn.execute("UPDATE sessions SET owner_id = ?2, data = ?3 WHERE id = ?1", params![id, user_id, v.to_string()])?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE usage SET user_id = ?1 WHERE user_id IS NULL",
|
||||
[user_id],
|
||||
)?;
|
||||
Ok(ids.len())
|
||||
}
|
||||
}
|
||||
|
||||
// --- shared folders -----------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Folder {
|
||||
pub id: String,
|
||||
/// Absolute path inside the workspace.
|
||||
pub path: String,
|
||||
pub users: Vec<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn folders(&self) -> Result<Vec<Folder>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.id, f.path, f.created_at, COALESCE(group_concat(a.user_id), '')
|
||||
FROM folders f LEFT JOIN folder_access a ON a.folder_id = f.id GROUP BY f.id ORDER BY f.path",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let users: String = r.get(3)?;
|
||||
Ok(Folder {
|
||||
id: r.get(0)?,
|
||||
path: r.get(1)?,
|
||||
created_at: r.get(2)?,
|
||||
users: users.split(',').filter(|u| !u.is_empty()).map(String::from).collect(),
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn add_folder(&self, path: &str) -> Result<String> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let id = crate::types::new_id();
|
||||
conn.execute("INSERT INTO folders (id, path, created_at) VALUES (?1, ?2, ?3)", params![id, path, now_ms()])?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn delete_folder(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM folders WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_folder_access(&self, id: &str, users: &[String]) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM folder_access WHERE folder_id = ?1", [id])?;
|
||||
for u in users {
|
||||
conn.execute("INSERT OR IGNORE INTO folder_access (folder_id, user_id) VALUES (?1, ?2)", params![id, u])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Paths of folders shared with a user.
|
||||
pub fn folders_for_user(&self, user_id: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.path FROM folders f JOIN folder_access a ON a.folder_id = f.id WHERE a.user_id = ?1 ORDER BY f.path",
|
||||
)?;
|
||||
let rows = stmt.query_map([user_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
// --- generated images ---------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ImageRecord {
|
||||
pub id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub prompt: String,
|
||||
pub model: String,
|
||||
pub path: String,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
|
||||
Ok(ImageRecord {
|
||||
id: r.get(0)?,
|
||||
user_id: r.get(1)?,
|
||||
session_id: r.get(2)?,
|
||||
prompt: r.get(3)?,
|
||||
model: r.get(4)?,
|
||||
path: r.get(5)?,
|
||||
width: r.get(6)?,
|
||||
height: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at";
|
||||
|
||||
impl Store {
|
||||
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Latest images; `user = None` lists everybody's (admins).
|
||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {IMAGE_COLS} FROM images WHERE ?1 IS NULL OR user_id = ?1 ORDER BY created_at DESC LIMIT ?2"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![user, limit], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn image(&self, id: &str) -> Result<Option<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(&format!("SELECT {IMAGE_COLS} FROM images WHERE id = ?1"), [id], image_row).optional()?)
|
||||
}
|
||||
|
||||
pub fn delete_image(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM images WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Cost per provider since `from` (ms).
|
||||
pub fn cost_by_provider(&self, from: i64) -> Result<std::collections::BTreeMap<String, f64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?;
|
||||
let rows = stmt.query_map([from], |r| Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?)))?;
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
for r in rows {
|
||||
let (p, c) = r?;
|
||||
out.insert(p, c);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- message queue -------------------------------------------------------------
|
||||
|
||||
/// A message waiting to be delivered to a busy session.
|
||||
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||
pub struct QueueItem {
|
||||
pub id: String,
|
||||
pub parts: Vec<crate::types::Part>,
|
||||
/// "user" or "background" (results of background subagents).
|
||||
pub source: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn queue_add(&self, session_id: &str, item: &QueueItem) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO queue (id, session_id, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![item.id, session_id, serde_json::to_string(item)?, item.created_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn queue_list(&self, session_id: &str) -> Result<Vec<QueueItem>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT data FROM queue WHERE session_id = ?1 ORDER BY created_at, rowid")?;
|
||||
let rows = stmt.query_map([session_id], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(serde_json::from_str(&r?)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Remove one item; false if it was already delivered.
|
||||
pub fn queue_remove(&self, session_id: &str, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.execute("DELETE FROM queue WHERE session_id = ?1 AND id = ?2", params![session_id, id])? > 0)
|
||||
}
|
||||
|
||||
/// Take all items for delivery.
|
||||
pub fn queue_take(&self, session_id: &str) -> Result<Vec<QueueItem>> {
|
||||
let items = self.queue_list(session_id)?;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
for it in &items {
|
||||
conn.execute("DELETE FROM queue WHERE id = ?1", [&it.id])?;
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,8 @@ impl Tool for Task {
|
||||
"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."
|
||||
Several task calls in one response run in parallel (subject to the configured limits). \
|
||||
With background=true you continue working and receive the result later."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
@@ -152,7 +153,8 @@ impl Tool for Task {
|
||||
"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)"}
|
||||
"subagent_type": {"type": "string", "description": "Subagent to use (default: general)"},
|
||||
"background": {"type": "boolean", "description": "Run in the background: returns immediately, the result arrives later as a message. Use for work you don't need right away (e.g. writing docs, summaries, translations)."}
|
||||
},
|
||||
"required": ["description", "prompt"]
|
||||
})
|
||||
|
||||
+21
-4
@@ -38,6 +38,7 @@ impl ToolOutput {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCtx {
|
||||
pub app: Arc<App>,
|
||||
pub session_id: String,
|
||||
@@ -46,6 +47,8 @@ pub struct ToolCtx {
|
||||
pub cancel: CancellationToken,
|
||||
/// Agent mode running this tool (subagents inherit restrictions).
|
||||
pub agent: String,
|
||||
/// Directories this session may access (the owner's roots).
|
||||
pub roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl ToolCtx {
|
||||
@@ -55,11 +58,24 @@ impl ToolCtx {
|
||||
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());
|
||||
if norm.starts_with("/tmp") || self.roots.iter().any(|r| norm.starts_with(normalize(r))) {
|
||||
// Symlinks must not lead out of the allowed directories either.
|
||||
if let Ok(real) = std::fs::canonicalize(&norm) {
|
||||
if !real.starts_with("/tmp") && !self.roots.iter().any(|r| real.starts_with(r)) {
|
||||
bail!("path {} resolves outside your directories", norm.display());
|
||||
}
|
||||
Ok(norm)
|
||||
}
|
||||
return Ok(norm);
|
||||
}
|
||||
let allowed: Vec<String> = self.roots.iter().map(|r| r.display().to_string()).collect();
|
||||
bail!("path {} is outside your directories ({})", norm.display(), allowed.join(", "))
|
||||
}
|
||||
|
||||
/// The root directory containing the working directory.
|
||||
pub fn root(&self) -> PathBuf {
|
||||
let mut roots = self.roots.clone();
|
||||
roots.sort_by_key(|r| std::cmp::Reverse(r.as_os_str().len()));
|
||||
roots.iter().find(|r| self.cwd.starts_with(r)).cloned().unwrap_or_else(|| self.cwd.clone())
|
||||
}
|
||||
|
||||
pub fn progress(&self, output: impl Into<String>) {
|
||||
@@ -131,6 +147,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(craft::BrowserSnapshot),
|
||||
Arc::new(craft::ImageCompare),
|
||||
Arc::new(crate::prompts::SkillTool),
|
||||
Arc::new(crate::images::GenerateImage),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ pub struct Session {
|
||||
pub permission_mode: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
/// User who owns the session (subagent sessions inherit it).
|
||||
#[serde(default)]
|
||||
pub owner_id: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
#[serde(default)]
|
||||
@@ -160,7 +163,36 @@ pub enum Event {
|
||||
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 },
|
||||
/// The session waits for a free model or subagent slot (reason None = no longer waiting).
|
||||
/// reason: "model:<provider/model>" or "subagent:<name>".
|
||||
Waiting { session_id: String, reason: Option<String> },
|
||||
McpStatus { servers: Value },
|
||||
/// Messages waiting for a busy session (full list).
|
||||
QueueUpdated { session_id: String, items: Value },
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn session_id(&self) -> Option<&str> {
|
||||
use Event::*;
|
||||
match self {
|
||||
SessionUpdated { session } => Some(&session.id),
|
||||
SessionDeleted { session_id }
|
||||
| SessionStatus { session_id, .. }
|
||||
| MessageAdded { session_id, .. }
|
||||
| TextDelta { session_id, .. }
|
||||
| ReasoningDelta { session_id, .. }
|
||||
| ToolStarted { session_id, .. }
|
||||
| ToolProgress { session_id, .. }
|
||||
| PermissionRequest { session_id, .. }
|
||||
| PermissionResolved { session_id, .. }
|
||||
| QuestionRequest { session_id, .. }
|
||||
| QuestionResolved { session_id, .. }
|
||||
| Error { session_id, .. }
|
||||
| Waiting { session_id, .. }
|
||||
| QueueUpdated { session_id, .. } => Some(session_id),
|
||||
McpStatus { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_id() -> String {
|
||||
|
||||
Generated
+632
@@ -8,6 +8,14 @@
|
||||
"name": "codecraft-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.15",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -1582,6 +1590,414 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz",
|
||||
"integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-angular": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz",
|
||||
"integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.1.2",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-cpp": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
|
||||
"integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/cpp": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-go": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
|
||||
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/go": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
|
||||
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-java": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
|
||||
"integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/java": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-jinja": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz",
|
||||
"integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-less": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz",
|
||||
"integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.2.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-liquid": {
|
||||
"version": "6.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.3.tgz",
|
||||
"integrity": "sha512-U9EnyzWuAx1TX3MZSKDXydKH734n1Aogk/ijKdW+jdIwmbf9zSPQYOIKggC/b2PTRjQb7ddvMktmxnfTLuv/SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-markdown": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
|
||||
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.7.1",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/markdown": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-php": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
|
||||
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/php": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-python": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
|
||||
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.3.2",
|
||||
"@codemirror/language": "^6.8.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/python": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-rust": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
|
||||
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/rust": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-sass": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz",
|
||||
"integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.2.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/sass": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-sql": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
|
||||
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-vue": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz",
|
||||
"integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.1.2",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-wast": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz",
|
||||
"integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-xml": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
|
||||
"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/xml": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-yaml": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
|
||||
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"@lezer/yaml": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
|
||||
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/streamparser": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language-data": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz",
|
||||
"integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-angular": "^0.1.0",
|
||||
"@codemirror/lang-cpp": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-go": "^6.0.0",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/lang-java": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/lang-jinja": "^6.0.0",
|
||||
"@codemirror/lang-json": "^6.0.0",
|
||||
"@codemirror/lang-less": "^6.0.0",
|
||||
"@codemirror/lang-liquid": "^6.0.0",
|
||||
"@codemirror/lang-markdown": "^6.0.0",
|
||||
"@codemirror/lang-php": "^6.0.0",
|
||||
"@codemirror/lang-python": "^6.0.0",
|
||||
"@codemirror/lang-rust": "^6.0.0",
|
||||
"@codemirror/lang-sass": "^6.0.0",
|
||||
"@codemirror/lang-sql": "^6.0.0",
|
||||
"@codemirror/lang-vue": "^0.1.1",
|
||||
"@codemirror/lang-wast": "^6.0.0",
|
||||
"@codemirror/lang-xml": "^6.0.0",
|
||||
"@codemirror/lang-yaml": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/legacy-modes": "^6.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/legacy-modes": {
|
||||
"version": "6.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.5.tgz",
|
||||
"integrity": "sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/streamparser": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
|
||||
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/streamparser": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
|
||||
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.15",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
|
||||
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||
@@ -1653,6 +2069,189 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.5.tgz",
|
||||
"integrity": "sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/cpp": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz",
|
||||
"integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
|
||||
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/go": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
|
||||
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/java": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.5.tgz",
|
||||
"integrity": "sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
|
||||
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.12.tgz",
|
||||
"integrity": "sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/markdown": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.1.tgz",
|
||||
"integrity": "sha512-M5YmEcKWO4mJ45Y1hECwqyu8cLv25EiXOK554PVOvDOWOP0olwelHHQUWr9sjF+d1bENVhy0nfJ8E57XZUTiXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/php": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.6.tgz",
|
||||
"integrity": "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/python": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
|
||||
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/rust": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz",
|
||||
"integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/sass": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz",
|
||||
"integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/xml": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
|
||||
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/yaml": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
|
||||
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.156.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
|
||||
@@ -3250,6 +3849,21 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||
@@ -3301,6 +3915,12 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7139,6 +7759,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-object": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-2.0.2.tgz",
|
||||
@@ -7723,6 +8349,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.15",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
+35
-18
@@ -1,39 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ApiError, connectEvents, get, getToken } from "./api";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
||||
import { useT } from "./i18n";
|
||||
import { navigate, useRoute } from "./router";
|
||||
import { applyEvent, loadSessions, resync, setState } from "./store";
|
||||
import type { Info } from "./types";
|
||||
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";
|
||||
import { Images } from "./components/Images";
|
||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||
|
||||
type AuthState = "checking" | "login" | "ok";
|
||||
// The file manager (editor, git) is loaded on demand to keep the chat small.
|
||||
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
|
||||
|
||||
export function App() {
|
||||
const t = useT();
|
||||
const route = useRoute();
|
||||
const [auth, setAuth] = useState<AuthState>("checking");
|
||||
const [info, setInfo] = useState<Info>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
useTitleActivity();
|
||||
|
||||
// Check whether a token is needed and whether ours works.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const refresh = 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");
|
||||
const i = await get<Info>("/info");
|
||||
setState(() => ({ me: i.user }));
|
||||
if (i.user) await loadSessions();
|
||||
setInfo(i);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setInfo((i) => (i ? { ...i, user: null } : i)));
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
const signedIn = !!info?.user;
|
||||
useEffect(() => {
|
||||
if (auth !== "ok") return;
|
||||
if (!signedIn) return;
|
||||
return connectEvents({
|
||||
onEvent: applyEvent,
|
||||
onOpen: () => {
|
||||
@@ -42,19 +51,27 @@ export function App() {
|
||||
},
|
||||
onClose: () => setState(() => ({ connected: false })),
|
||||
});
|
||||
}, [auth]);
|
||||
}, [signedIn]);
|
||||
|
||||
// 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")} />;
|
||||
if (error) return <div className="empty">{error}</div>;
|
||||
if (!info) return <div className="empty">{t("common.loading")}</div>;
|
||||
if (!info.user) return <Login info={info} onSuccess={refresh} />;
|
||||
|
||||
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 if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "files")
|
||||
content = (
|
||||
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
||||
<Files onMenu={() => setMenuOpen(true)} />
|
||||
</Suspense>
|
||||
);
|
||||
else content = <NewSession onMenu={() => setMenuOpen(true)} onCreated={(id) => navigate(`/s/${id}`)} />;
|
||||
|
||||
return (
|
||||
|
||||
+18
-15
@@ -1,14 +1,6 @@
|
||||
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);
|
||||
}
|
||||
// Authentication uses an HttpOnly session cookie set by /api/auth/login.
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
@@ -21,20 +13,32 @@ export class ApiError extends Error {
|
||||
|
||||
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 res = await fetch(`/api${path}`, { ...init, headers, body, credentials: "same-origin" });
|
||||
if (res.status === 401 && !path.startsWith("/auth/") && path !== "/info") onUnauthorized?.();
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
let data: any = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// Proxies answer with HTML error pages (e.g. 413 from nginx).
|
||||
const hint = res.status === 413 ? "request too large – the reverse proxy rejected the upload (client_max_body_size)" : res.statusText || "unexpected response";
|
||||
throw new ApiError(res.status, `HTTP ${res.status}: ${hint}`);
|
||||
}
|
||||
if (!res.ok) throw new ApiError(res.status, data?.error ?? res.statusText);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** Called when the session expired (set by the app to show the login). */
|
||||
let onUnauthorized: (() => void) | undefined;
|
||||
export function setUnauthorizedHandler(fn: () => void) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
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 });
|
||||
@@ -55,8 +59,7 @@ export function connectEvents(handlers: {
|
||||
|
||||
const open = () => {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const token = encodeURIComponent(getToken());
|
||||
ws = new WebSocket(`${proto}://${location.host}/api/ws?token=${token}`);
|
||||
ws = new WebSocket(`${proto}://${location.host}/api/ws`);
|
||||
ws.onopen = () => {
|
||||
retry = 0;
|
||||
handlers.onOpen();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore, type Draft } from "../store";
|
||||
import type { Part } from "../types";
|
||||
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
|
||||
/** Animated three-dot pulse. */
|
||||
export function Dots() {
|
||||
return (
|
||||
<span className="dots" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ small }: { small?: boolean }) {
|
||||
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/** What the agent is doing right now, derived from the streaming message. */
|
||||
export function ActivityLine({
|
||||
draft,
|
||||
results,
|
||||
waiting,
|
||||
permissionPending,
|
||||
}: {
|
||||
draft?: Draft;
|
||||
results: Map<string, ToolResult>;
|
||||
waiting?: string;
|
||||
permissionPending: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
let label: string;
|
||||
if (permissionPending) {
|
||||
label = t("activity.waitingForYou");
|
||||
} else if (waiting) {
|
||||
const [kind, ...rest] = waiting.split(":");
|
||||
const what = rest.join(":");
|
||||
label = kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what });
|
||||
} else {
|
||||
const last = draft?.parts[draft.parts.length - 1];
|
||||
const runningTool = draft?.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as
|
||||
| Extract<Part, { type: "tool_call" }>
|
||||
| undefined;
|
||||
if (runningTool) {
|
||||
label = runningTool.name === "task" ? t("activity.subagent", { name: runningTool.input?.description ?? "" }) : t("activity.tool", { tool: runningTool.name });
|
||||
} else if (last?.type === "text") {
|
||||
label = t("activity.writing");
|
||||
} else {
|
||||
label = t("activity.thinking");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className={`activity ${permissionPending ? "paused" : ""}`} role="status" aria-live="polite">
|
||||
{permissionPending ? <span className="pause-icon" aria-hidden="true" /> : <Dots />}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Thin animated bar shown under the top bar while a session works. */
|
||||
export function WorkBar({ active }: { active: boolean }) {
|
||||
return <div className={`workbar ${active ? "on" : ""}`} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/** Prefix the browser tab title while agents work or wait for input. */
|
||||
export function useTitleActivity() {
|
||||
const t = useT();
|
||||
const busyCount = useStore((s) => s.sessions.filter((x) => s.busy[x.id]).length);
|
||||
const waiting = useStore((s) => s.permissions.length + s.questions.length);
|
||||
useEffect(() => {
|
||||
const base = "CodeCraft";
|
||||
if (waiting > 0) document.title = `● ${t("activity.titleInput")} – ${base}`;
|
||||
else if (busyCount > 0) document.title = `⏳ ${t("activity.titleBusy", { n: busyCount })} – ${base}`;
|
||||
else document.title = base;
|
||||
}, [busyCount, waiting, t]);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCost, fmtPercent, useT } from "../i18n";
|
||||
|
||||
interface BudgetRow {
|
||||
provider: string;
|
||||
budget: number | null;
|
||||
spent: number;
|
||||
remaining: number | null;
|
||||
share: number | null;
|
||||
warning: boolean;
|
||||
exceeded: boolean;
|
||||
images?: boolean;
|
||||
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
|
||||
}
|
||||
|
||||
interface BudgetData {
|
||||
currency: string;
|
||||
total: BudgetRow;
|
||||
providers: BudgetRow[];
|
||||
}
|
||||
|
||||
function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
|
||||
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
|
||||
return (
|
||||
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div style={{ width: `${Math.min(share, 1) * 100}%`, background: color }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Remote({ r, t }: { r: BudgetRow["remote"]; t: ReturnType<typeof useT> }) {
|
||||
if (!r) return <span className="muted">–</span>;
|
||||
if (r.error) return <span className="muted" title={r.error}>⚠ {t("budget.remoteError")}</span>;
|
||||
const cur = r.currency ?? "USD";
|
||||
if (r.spent_month !== undefined) return <span title={t("budget.orgWide")}>{t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}</span>;
|
||||
if (r.remaining !== null && r.remaining !== undefined) return <span>{t("budget.balance", { v: fmtCost(r.remaining, cur) })}</span>;
|
||||
if (r.used !== null && r.used !== undefined) return <span>{t("budget.providerSpent", { v: fmtCost(r.used, cur) })}</span>;
|
||||
return <span className="muted">–</span>;
|
||||
}
|
||||
|
||||
/** Monthly budgets per provider (admins). */
|
||||
export function BudgetPanel() {
|
||||
const t = useT();
|
||||
const { data, error } = useFetch<BudgetData>("/budget");
|
||||
if (error || !data) return null;
|
||||
const rows = [data.total, ...data.providers];
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>{t("budget.title")}</h3>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("stats.provider")}</th>
|
||||
<th className="num">{t("budget.spent")}</th>
|
||||
<th className="num">{t("budget.budget")}</th>
|
||||
<th style={{ minWidth: 140 }} />
|
||||
<th className="num">{t("budget.remaining")}</th>
|
||||
<th>{t("budget.provider")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.provider}>
|
||||
<td>
|
||||
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
||||
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
||||
</td>
|
||||
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
||||
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
||||
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
||||
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
||||
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
||||
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
||||
</td>
|
||||
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>{t("budget.hint")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small header chip when the overall budget is nearly used up. */
|
||||
export function BudgetChip() {
|
||||
const t = useT();
|
||||
const { data } = useFetch<BudgetData>("/budget");
|
||||
const worst = data ? [data.total, ...data.providers].filter((r) => r.warning).sort((a, b) => (b.share ?? 0) - (a.share ?? 0))[0] : undefined;
|
||||
if (!worst) return null;
|
||||
return (
|
||||
<span className="chip warn hide-mobile" title={t("budget.chipHint")}>
|
||||
{t("budget.chip", { name: worst.provider === "total" ? t("stats.total") : worst.provider, p: fmtPercent(worst.share ?? 0) })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -190,7 +190,9 @@ export function Heatmap({
|
||||
const [tip, setTip] = useState<Tip>();
|
||||
const left = 40;
|
||||
const top = 18;
|
||||
const cell = Math.max(Math.floor((width - left) / 24), 8);
|
||||
// Cells stretch with the width but stay at most 26 px tall.
|
||||
const cellW = Math.max(Math.floor((width - left) / 24), 8);
|
||||
const cell = Math.min(cellW, 26);
|
||||
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)));
|
||||
@@ -200,7 +202,7 @@ export function Heatmap({
|
||||
<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">
|
||||
<text key={h} x={left + h * cellW + cellW / 2} y={12} textAnchor="middle">
|
||||
{h}
|
||||
</text>
|
||||
))}
|
||||
@@ -216,9 +218,9 @@ export function Heatmap({
|
||||
return (
|
||||
<rect
|
||||
key={`${d}-${h}`}
|
||||
x={left + h * cell + 1}
|
||||
x={left + h * cellW + 1}
|
||||
y={top + row * cell + 1}
|
||||
width={cell - 2}
|
||||
width={cellW - 2}
|
||||
height={cell - 2}
|
||||
rx={3}
|
||||
fill={SEQ[bin(v)]}
|
||||
|
||||
+105
-13
@@ -5,7 +5,12 @@ 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 { ActivityLine, Dots, WorkBar } from "./Activity";
|
||||
import { useReasoningMode } from "../prefs";
|
||||
import { Composer } from "./Composer";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import { QueueList } from "./QueueList";
|
||||
import { BudgetChip } from "./Budget";
|
||||
import { PermissionCard, QuestionCard } from "./Interaction";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
@@ -22,12 +27,29 @@ interface SessionStats {
|
||||
|
||||
function Reasoning({ text, live }: { text: string; live: boolean }) {
|
||||
const t = useT();
|
||||
if (!text.trim()) return null;
|
||||
const mode = useReasoningMode();
|
||||
const [toggled, setToggled] = useState<boolean | null>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const open = toggled ?? (mode === "expanded" || (mode === "live" && live));
|
||||
// Follow the stream while the model is thinking.
|
||||
useLayoutEffect(() => {
|
||||
if (live && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
}, [text, live]);
|
||||
if (mode === "hidden" || !text.trim()) return null;
|
||||
const words = text.trim().split(/\s+/).length;
|
||||
return (
|
||||
<details className="reasoning" open={live}>
|
||||
<summary>{t("chat.reasoning")}</summary>
|
||||
<div className="body">{text}</div>
|
||||
</details>
|
||||
<div className={`reasoning ${live ? "live" : ""}`}>
|
||||
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
|
||||
{live ? <Dots /> : <span aria-hidden="true">💭</span>}
|
||||
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
|
||||
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +59,11 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
|
||||
{parts.map((p, i) => {
|
||||
switch (p.type) {
|
||||
case "text":
|
||||
return <Markdown key={i} text={p.text} />;
|
||||
return (
|
||||
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
|
||||
<Markdown text={p.text} />
|
||||
</div>
|
||||
);
|
||||
case "reasoning":
|
||||
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
|
||||
case "tool_call":
|
||||
@@ -50,6 +76,32 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
|
||||
);
|
||||
}
|
||||
|
||||
const BG_RE = /^<background-task-result agent="([^"]*)" session="([^"]*)" description="([^"]*)">\n?([\s\S]*?)\n?<\/background-task-result>$/;
|
||||
|
||||
/** Result of a background subagent, delivered as a message. */
|
||||
function BackgroundResult({ text }: { text: string }) {
|
||||
const t = useT();
|
||||
const m = text.match(BG_RE);
|
||||
if (!m) return null;
|
||||
const [, agent, session, description, body] = m;
|
||||
return (
|
||||
<details className="tool" style={{ borderColor: "var(--aqua)" }}>
|
||||
<summary>
|
||||
<span className="tool-name">{agent}</span>
|
||||
<span className="tool-title">
|
||||
{t("chat.backgroundDone")}: {description}
|
||||
</span>
|
||||
<a href={`#/s/${session}`} onClick={(e) => e.stopPropagation()}>
|
||||
{t("tool.openSubagent")}
|
||||
</a>
|
||||
</summary>
|
||||
<div className="tool-body">
|
||||
<Markdown text={body} />
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel({ stats }: { stats: SessionStats }) {
|
||||
const t = useT();
|
||||
const a = stats.total;
|
||||
@@ -99,6 +151,10 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
const errors = useStore((s) => s.errors[sessionId]);
|
||||
const allPermissions = useStore((s) => s.permissions);
|
||||
const allQuestions = useStore((s) => s.questions);
|
||||
const waiting = useStore((s) => s.waiting[sessionId]);
|
||||
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const models = useModels();
|
||||
const [loadError, setLoadError] = useState<string>();
|
||||
const [text, setText] = useState("");
|
||||
@@ -210,24 +266,39 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<div className="path">{session.cwd}</div>
|
||||
</div>
|
||||
{ctxShare !== undefined && (
|
||||
<span className={`chip ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
|
||||
<span className={`chip hide-mobile ${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")}>
|
||||
<span className="hide-mobile">
|
||||
{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)}
|
||||
{fmtCompact(stats.total.output_tokens)}↓ ·{" "}
|
||||
</span>
|
||||
{fmtCost(stats.total.cost, stats.currency)}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && <BudgetChip />}
|
||||
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
||||
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
|
||||
{!readOnly && (
|
||||
<>
|
||||
{!session.parent_id && (
|
||||
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
|
||||
⇄
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
||||
<div
|
||||
className="chat-scroll"
|
||||
ref={scrollRef}
|
||||
@@ -256,13 +327,26 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const texts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => BG_RE.test(p.text));
|
||||
const texts = allTexts.filter((p) => !BG_RE.test(p.text));
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
if (texts.length === 0 && imgs.length === 0) return null;
|
||||
if (texts.length === 0 && imgs.length === 0) {
|
||||
return background.length ? (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
<BackgroundResult key={i} text={p.text} />
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
<BackgroundResult key={i} text={p.text} />
|
||||
))}
|
||||
<div className="msg-user">
|
||||
{!busy && (
|
||||
{!busy && !readOnly && (
|
||||
<div className="msg-actions">
|
||||
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
||||
✎
|
||||
@@ -286,8 +370,10 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<AssistantParts parts={draft.parts} results={results} running={busy} live />
|
||||
</div>
|
||||
)}
|
||||
{busy && !draft && <div className="msg-meta">{t("chat.thinking")}</div>}
|
||||
{openTodos.length > 0 && (
|
||||
{(busy || waiting) && (
|
||||
<ActivityLine draft={draft} results={results} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
||||
)}
|
||||
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
||||
<div className="todos">
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||
<TodoList todos={todos} />
|
||||
@@ -322,6 +408,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
||||
{readOnly ? (
|
||||
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
|
||||
) : (
|
||||
<Composer
|
||||
session={session}
|
||||
busy={busy}
|
||||
@@ -331,6 +422,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
onAbort={() => post(`/sessions/${sessionId}/abort`)}
|
||||
onUpdate={update}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { HighlightStyle, LanguageDescription, syntaxHighlighting } from "@codemirror/language";
|
||||
import { languages } from "@codemirror/language-data";
|
||||
import { indentWithTab } from "@codemirror/commands";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
// Colours come from the theme variables, so every CSS theme applies.
|
||||
const theme = EditorView.theme({
|
||||
"&": { height: "100%", backgroundColor: "var(--bg0)", color: "var(--fg1)", fontSize: "13px" },
|
||||
".cm-content": { fontFamily: "var(--mono)", caretColor: "var(--accent)" },
|
||||
".cm-gutters": { backgroundColor: "var(--bg1)", color: "var(--muted)", border: "none", borderRight: "1px solid var(--bg2)" },
|
||||
".cm-activeLine": { backgroundColor: "color-mix(in srgb, var(--bg2) 55%, transparent)" },
|
||||
".cm-activeLineGutter": { backgroundColor: "var(--bg2)" },
|
||||
"&.cm-focused .cm-cursor": { borderLeftColor: "var(--accent)" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "color-mix(in srgb, var(--blue) 35%, transparent) !important" },
|
||||
".cm-searchMatch": { backgroundColor: "color-mix(in srgb, var(--accent) 35%, transparent)" },
|
||||
".cm-panels": { backgroundColor: "var(--bg1)", color: "var(--fg1)" },
|
||||
".cm-tooltip": { backgroundColor: "var(--bg1)", border: "1px solid var(--bg3)" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
});
|
||||
|
||||
const highlight = HighlightStyle.define([
|
||||
{ tag: [tags.keyword, tags.controlKeyword, tags.operatorKeyword, tags.modifier], color: "var(--syntax-keyword)" },
|
||||
{ tag: [tags.string, tags.special(tags.string), tags.regexp], color: "var(--syntax-string)" },
|
||||
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--syntax-number)" },
|
||||
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "var(--syntax-comment)", fontStyle: "italic" },
|
||||
{ tag: [tags.function(tags.variableName), tags.function(tags.propertyName), tags.heading], color: "var(--syntax-function)" },
|
||||
{ tag: [tags.typeName, tags.className, tags.tagName, tags.namespace], color: "var(--syntax-type)" },
|
||||
{ tag: [tags.variableName, tags.propertyName, tags.attributeName], color: "var(--syntax-variable)" },
|
||||
{ tag: [tags.meta, tags.processingInstruction, tags.annotation, tags.link], color: "var(--syntax-meta)" },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
]);
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
filename,
|
||||
readOnly,
|
||||
onChange,
|
||||
onSave,
|
||||
}: {
|
||||
value: string;
|
||||
filename: string;
|
||||
readOnly?: boolean;
|
||||
onChange: (v: string) => void;
|
||||
onSave?: () => void;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const lang = useRef(new Compartment());
|
||||
const ro = useRef(new Compartment());
|
||||
const cb = useRef({ onChange, onSave });
|
||||
cb.current = { onChange, onSave };
|
||||
|
||||
// Create the editor once per file.
|
||||
useEffect(() => {
|
||||
const v = new EditorView({
|
||||
parent: host.current!,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab, { key: "Mod-s", preventDefault: true, run: () => (cb.current.onSave?.(), true) }]),
|
||||
theme,
|
||||
syntaxHighlighting(highlight),
|
||||
lang.current.of([]),
|
||||
ro.current.of(EditorState.readOnly.of(!!readOnly)),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((u) => u.docChanged && cb.current.onChange(u.state.doc.toString())),
|
||||
],
|
||||
}),
|
||||
});
|
||||
view.current = v;
|
||||
const desc = LanguageDescription.matchFilename(languages, filename);
|
||||
desc?.load().then((support) => v.dispatch({ effects: lang.current.reconfigure(support) }));
|
||||
return () => v.destroy();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filename]);
|
||||
|
||||
// External content changes (reload) replace the document.
|
||||
useEffect(() => {
|
||||
const v = view.current;
|
||||
if (v && v.state.doc.toString() !== value) {
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({ effects: ro.current.reconfigure(EditorState.readOnly.of(!!readOnly)) });
|
||||
}, [readOnly]);
|
||||
|
||||
return <div className="code-editor" ref={host} />;
|
||||
}
|
||||
@@ -14,16 +14,21 @@ const BUILTIN: { name: string; key: Key }[] = [
|
||||
{ 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);
|
||||
});
|
||||
const MAX_SIDE = 2000;
|
||||
|
||||
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
|
||||
async function readImage(file: File): Promise<ImageData> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(bitmap.width * scale);
|
||||
canvas.height = Math.round(bitmap.height * scale);
|
||||
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close();
|
||||
// PNG keeps screenshots crisp; photos become JPEG.
|
||||
const type = file.type === "image/png" && file.size < 1_500_000 ? "image/png" : "image/jpeg";
|
||||
const url = canvas.toDataURL(type, 0.85);
|
||||
return { media_type: type, data: url.slice(url.indexOf(",") + 1) };
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
@@ -97,8 +102,12 @@ export function Composer({
|
||||
};
|
||||
|
||||
const addFiles = async (files: FileList | File[]) => {
|
||||
try {
|
||||
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
|
||||
setImages((cur) => [...cur, ...imgs]);
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -163,6 +172,7 @@ export function Composer({
|
||||
aria-label={t("composer.placeholder")}
|
||||
/>
|
||||
<div className="composer-bar">
|
||||
<div className="composer-options">
|
||||
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
@@ -182,6 +192,7 @@ export function Composer({
|
||||
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
|
||||
title={t("composer.permissionMode")}
|
||||
>
|
||||
<option value="auto">{t("permissionMode.auto")}</option>
|
||||
<option value="default">{t("permissionMode.default")}</option>
|
||||
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
||||
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||
@@ -191,7 +202,8 @@ export function Composer({
|
||||
📎
|
||||
<input type="file" accept="image/*" multiple hidden onChange={(e) => e.target.files && addFiles(e.target.files)} />
|
||||
</label>
|
||||
<span className="grow" />
|
||||
</div>
|
||||
<div className="composer-actions">
|
||||
{busy && (
|
||||
<button className="danger" onClick={onAbort} title="Esc">
|
||||
■ {t("composer.stop")}
|
||||
@@ -203,5 +215,6 @@ export function Composer({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export function Diff({ diff }: { diff: string }) {
|
||||
const lines = diff.split("\n");
|
||||
const lines = diff.replace(/\n+$/, "").split("\n");
|
||||
return (
|
||||
<div className="diff">
|
||||
{lines.map((l, i) => {
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { del, get, post, put } from "../api";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCompact, fmtDate, useT } from "../i18n";
|
||||
import { Spinner } from "./Activity";
|
||||
import { Diff } from "./Diff";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { GitPanel, type GitStatus } from "./GitPanel";
|
||||
|
||||
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
mtime?: number;
|
||||
}
|
||||
|
||||
interface Listing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: Entry[];
|
||||
repo: string | null;
|
||||
}
|
||||
|
||||
interface OpenFile {
|
||||
path: string;
|
||||
content: string;
|
||||
saved: string;
|
||||
mtime?: number;
|
||||
binary: boolean;
|
||||
tooLarge?: boolean;
|
||||
}
|
||||
|
||||
/** What the right pane shows. */
|
||||
type Pane = { kind: "file"; file: OpenFile } | { kind: "diff"; title: string; diff: string } | { kind: "none" };
|
||||
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
|
||||
const rawUrl = (p: string, download = false) => `/api/files/raw?path=${encodeURIComponent(p)}${download ? "&download=true" : ""}`;
|
||||
const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
|
||||
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
|
||||
|
||||
function icon(e: Entry) {
|
||||
if (e.is_dir) return "📁";
|
||||
if (IMAGE.test(e.name)) return "🖼";
|
||||
if (/\.(md|txt)$/i.test(e.name)) return "📝";
|
||||
return "📄";
|
||||
}
|
||||
|
||||
export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const [dir, setDir] = useState<string>(() => localStorage.getItem("codecraft.filesDir") ?? "");
|
||||
const [listing, setListing] = useState<Listing>();
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [pane, setPane] = useState<Pane>({ kind: "none" });
|
||||
const [tab, setTab] = useState<"files" | "git">("files");
|
||||
const [git, setGit] = useState<GitStatus>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [hits, setHits] = useState<{ path: string; is_dir: boolean }[]>();
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [mobileEditor, setMobileEditor] = useState(false);
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (path: string) => {
|
||||
try {
|
||||
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
|
||||
setListing(l);
|
||||
setDir(l.path);
|
||||
localStorage.setItem("codecraft.filesDir", l.path);
|
||||
setError(undefined);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
if (roots[0] && path !== roots[0].path) void load(roots[0].path);
|
||||
}
|
||||
},
|
||||
[hidden, roots],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (roots.length) void load(dir || roots[0].path);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roots.length, hidden]);
|
||||
|
||||
const refreshGit = useCallback(async () => {
|
||||
if (!listing?.repo) return setGit(undefined);
|
||||
try {
|
||||
setGit(await post<GitStatus>("/git", { path: listing.repo, action: "status" }));
|
||||
} catch {
|
||||
setGit(undefined);
|
||||
}
|
||||
}, [listing?.repo]);
|
||||
useEffect(() => void refreshGit(), [refreshGit]);
|
||||
|
||||
// Git state per file name in the current directory.
|
||||
const gitMarks = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
if (!git || !listing) return m;
|
||||
for (const f of git.files) {
|
||||
const abs = `${git.repo}/${f.path}`;
|
||||
const code = f.index === "?" ? "U" : (f.index.trim() || f.worktree.trim());
|
||||
// Mark the file itself and every parent folder inside the listing.
|
||||
if (dirName(abs) === listing.path) m.set(abs, code);
|
||||
for (const e of listing.entries) if (e.is_dir && abs.startsWith(e.path + "/")) m.set(e.path, "•");
|
||||
}
|
||||
return m;
|
||||
}, [git, listing]);
|
||||
|
||||
const dirty = pane.kind === "file" && pane.file.content !== pane.file.saved;
|
||||
|
||||
const confirmDiscard = () => !dirty || confirm(t("files.discardChanges"));
|
||||
|
||||
const openFile = async (path: string) => {
|
||||
if (!confirmDiscard()) return;
|
||||
try {
|
||||
const r = await get<{ content?: string; binary: boolean; mtime?: number; too_large?: boolean }>(`/files/read?path=${encodeURIComponent(path)}`);
|
||||
setPane({ kind: "file", file: { path, content: r.content ?? "", saved: r.content ?? "", mtime: r.mtime, binary: r.binary, tooLarge: r.too_large } });
|
||||
setPreview(/\.md$/i.test(path));
|
||||
setMobileEditor(true);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const save = useCallback(
|
||||
async (force = false) => {
|
||||
if (pane.kind !== "file") return;
|
||||
const f = pane.file;
|
||||
try {
|
||||
const r = await put<{ mtime: number }>("/files/write", { path: f.path, content: f.content, expected_mtime: f.mtime, force });
|
||||
setPane({ kind: "file", file: { ...f, saved: f.content, mtime: r.mtime } });
|
||||
void refreshGit();
|
||||
} catch (e: any) {
|
||||
if (e.status === 409 && confirm(t("files.conflict"))) return save(true);
|
||||
alert(e.message);
|
||||
}
|
||||
},
|
||||
[pane, refreshGit, t],
|
||||
);
|
||||
|
||||
const act = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
if (listing) await load(listing.path);
|
||||
void refreshGit();
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const newEntry = (isDir: boolean) => {
|
||||
const name = prompt(isDir ? t("files.newFolderName") : t("files.newFileName"));
|
||||
if (!name || !listing) return;
|
||||
const path = `${listing.path}/${name}`;
|
||||
void act(async () => {
|
||||
await post("/files/create", { path, dir: isDir });
|
||||
if (!isDir) await openFile(path);
|
||||
});
|
||||
};
|
||||
|
||||
const rename = (e: Entry) => {
|
||||
const name = prompt(t("files.renameTo"), e.name);
|
||||
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
|
||||
};
|
||||
|
||||
const duplicate = (e: Entry) => {
|
||||
const name = prompt(t("files.copyTo"), `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}`);
|
||||
if (name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}`, copy: true }));
|
||||
};
|
||||
|
||||
const remove = (e: Entry) => {
|
||||
if (confirm(t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }))) {
|
||||
void act(() => del(`/files/delete?path=${encodeURIComponent(e.path)}`));
|
||||
if (pane.kind === "file" && pane.file.path.startsWith(e.path)) setPane({ kind: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
const upload = async (files: FileList | File[]) => {
|
||||
if (!listing) return;
|
||||
for (const f of [...files]) {
|
||||
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(`${listing.path}/${f.name}`)}`, { method: "PUT", body: f, credentials: "same-origin" });
|
||||
if (!res.ok) alert(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
|
||||
}
|
||||
await load(listing.path);
|
||||
void refreshGit();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.trim() || !listing) return setHits(undefined);
|
||||
const h = setTimeout(() => {
|
||||
get<{ path: string; is_dir: boolean }[]>(`/files/search?path=${encodeURIComponent(listing.path)}&q=${encodeURIComponent(search)}`).then(setHits).catch(() => setHits([]));
|
||||
}, 250);
|
||||
return () => clearTimeout(h);
|
||||
}, [search, listing]);
|
||||
|
||||
// Warn before leaving with unsaved changes.
|
||||
useEffect(() => {
|
||||
const h = (e: BeforeUnloadEvent) => dirty && e.preventDefault();
|
||||
window.addEventListener("beforeunload", h);
|
||||
return () => window.removeEventListener("beforeunload", h);
|
||||
}, [dirty]);
|
||||
|
||||
const root = roots.find((r) => listing?.path.startsWith(r.path));
|
||||
const crumbs = listing && root ? [root.path, ...listing.path.slice(root.path.length).split("/").filter(Boolean)] : [];
|
||||
|
||||
const list = (
|
||||
<div
|
||||
className={`files-list ${dragOver ? "drag" : ""}`}
|
||||
onDragOver={(e) => (e.preventDefault(), setDragOver(true))}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
void upload(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<div className="files-toolbar">
|
||||
{roots.length > 1 && (
|
||||
<select value={root?.path ?? ""} onChange={(e) => confirmDiscard() && load(e.target.value)} aria-label={t("images.folder")}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input className="files-search" placeholder={t("files.search")} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<button className="small" title={t("files.newFile")} onClick={() => newEntry(false)}>
|
||||
+ 📄
|
||||
</button>
|
||||
<button className="small" title={t("files.newFolder")} onClick={() => newEntry(true)}>
|
||||
+ 📁
|
||||
</button>
|
||||
<button className="small" title={t("files.upload")} onClick={() => uploadRef.current?.click()}>
|
||||
⤒
|
||||
</button>
|
||||
<input ref={uploadRef} type="file" multiple hidden onChange={(e) => e.target.files && upload(e.target.files)} />
|
||||
<button className={`small ${hidden ? "active" : ""}`} title={t("files.showHidden")} onClick={() => setHidden(!hidden)}>
|
||||
.*
|
||||
</button>
|
||||
</div>
|
||||
<div className="crumbs">
|
||||
{crumbs.map((c, i) => {
|
||||
const path = i === 0 ? c : `${crumbs[0]}/${crumbs.slice(1, i + 1).join("/")}`;
|
||||
return (
|
||||
<span key={path}>
|
||||
{i > 0 && " / "}
|
||||
<a href="#" onClick={(e) => (e.preventDefault(), load(path))}>
|
||||
{i === 0 ? root?.name : c}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="entries" role="list">
|
||||
{hits ? (
|
||||
hits.length === 0 ? (
|
||||
<div className="empty">{t("files.noHits")}</div>
|
||||
) : (
|
||||
hits.map((h) => (
|
||||
<button key={h.path} className="entry" onClick={() => (h.is_dir ? load(h.path) : openFile(h.path))}>
|
||||
<span>{h.is_dir ? "📁" : "📄"}</span>
|
||||
<span className="name">{h.path.slice((listing?.path.length ?? 0) + 1)}</span>
|
||||
</button>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{listing?.parent && (
|
||||
<button className="entry" onClick={() => load(listing.parent!)}>
|
||||
<span>↰</span>
|
||||
<span className="name">..</span>
|
||||
</button>
|
||||
)}
|
||||
{listing?.entries.map((e) => {
|
||||
const mark = gitMarks.get(e.path);
|
||||
const active = pane.kind === "file" && pane.file.path === e.path;
|
||||
return (
|
||||
<div key={e.path} className={`entry ${active ? "active" : ""}`} role="listitem">
|
||||
<button className="entry-main" onClick={() => (e.is_dir ? load(e.path) : openFile(e.path))}>
|
||||
<span>{icon(e)}</span>
|
||||
<span className="name">{e.name}</span>
|
||||
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark}</span>}
|
||||
{!e.is_dir && <span className="size">{fmtCompact(e.size)}B</span>}
|
||||
</button>
|
||||
<details className="entry-menu">
|
||||
<summary aria-label={t("files.actions")}>⋯</summary>
|
||||
<div className="menu">
|
||||
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
||||
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
||||
{!e.is_dir && (
|
||||
<a className="button" href={rawUrl(e.path, true)}>
|
||||
{t("files.download")}
|
||||
</a>
|
||||
)}
|
||||
<button className="danger" onClick={() => remove(e)}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{listing?.entries.length === 0 && <div className="empty">{t("files.emptyFolder")}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="drop-hint">{t("files.dropHint")}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const right = (() => {
|
||||
if (pane.kind === "diff") {
|
||||
return (
|
||||
<div className="editor-pane">
|
||||
<div className="editor-bar">
|
||||
<button className="ghost small mobile-only" onClick={() => setMobileEditor(false)}>
|
||||
←
|
||||
</button>
|
||||
<b className="grow">{pane.title}</b>
|
||||
</div>
|
||||
<div className="pane-scroll">{pane.diff ? <Diff diff={pane.diff} /> : <div className="empty">{t("git.noDiff")}</div>}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (pane.kind === "none") return <div className="editor-pane empty">{t("files.selectFile")}</div>;
|
||||
const f = pane.file;
|
||||
const isImage = IMAGE.test(f.path);
|
||||
const isMd = /\.md$/i.test(f.path);
|
||||
return (
|
||||
<div className="editor-pane">
|
||||
<div className="editor-bar">
|
||||
<button className="ghost small mobile-only" onClick={() => confirmDiscard() && setMobileEditor(false)}>
|
||||
←
|
||||
</button>
|
||||
<b className="grow" title={f.path}>
|
||||
{baseName(f.path)}
|
||||
{dirty && <span className="dirty"> ●</span>}
|
||||
</b>
|
||||
{isMd && (
|
||||
<button className="small" onClick={() => setPreview(!preview)}>
|
||||
{preview ? t("files.edit") : t("files.preview")}
|
||||
</button>
|
||||
)}
|
||||
<a className="button small" href={rawUrl(f.path, true)}>
|
||||
⤓
|
||||
</a>
|
||||
{!f.binary && (
|
||||
<button className="small primary" disabled={!dirty} onClick={() => save()}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isImage ? (
|
||||
<div className="pane-scroll image-preview">
|
||||
<img src={rawUrl(f.path)} alt={baseName(f.path)} />
|
||||
</div>
|
||||
) : f.binary ? (
|
||||
<div className="empty">{f.tooLarge ? t("files.tooLarge") : t("files.binary")}</div>
|
||||
) : isMd && preview ? (
|
||||
<div className="pane-scroll" style={{ padding: 16 }}>
|
||||
<Markdown text={f.content} />
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
||||
<CodeEditor
|
||||
key={f.path}
|
||||
value={f.content}
|
||||
filename={baseName(f.path)}
|
||||
onChange={(v) => setPane((p) => (p.kind === "file" ? { kind: "file", file: { ...p.file, content: v } } : p))}
|
||||
onSave={() => void save()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("nav.files")}</h1>
|
||||
<div className="path">{listing?.path}</div>
|
||||
</div>
|
||||
{listing?.repo && (
|
||||
<div className="mode-tabs" role="tablist">
|
||||
<button className={tab === "files" ? "active" : ""} onClick={() => setTab("files")}>
|
||||
{t("files.tabFiles")}
|
||||
</button>
|
||||
<button className={tab === "git" ? "active" : ""} onClick={() => setTab("git")}>
|
||||
Git{git && git.files.length > 0 ? ` (${git.files.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`files-layout ${mobileEditor ? "show-editor" : ""}`}>
|
||||
<div className="files-side">
|
||||
{tab === "git" && listing?.repo ? (
|
||||
<GitPanel
|
||||
repo={listing.repo}
|
||||
status={git}
|
||||
refresh={refreshGit}
|
||||
showDiff={(title, diff) => (setPane({ kind: "diff", title, diff }), setMobileEditor(true))}
|
||||
openFile={openFile}
|
||||
/>
|
||||
) : (
|
||||
list
|
||||
)}
|
||||
{!listing?.repo && listing && (
|
||||
<div className="git-init">
|
||||
<button className="small" onClick={() => act(() => post("/git", { path: listing.path, action: "init" }))}>
|
||||
{t("git.init")}
|
||||
</button>
|
||||
<button
|
||||
className="small"
|
||||
onClick={() => {
|
||||
const url = prompt(t("git.cloneUrl"));
|
||||
if (!url) return;
|
||||
const name = prompt(t("git.cloneFolder"), baseName(url).replace(/\.git$/, ""));
|
||||
if (name) void act(() => post("/git", { path: `${listing.path}/${name}`, action: "clone", url }));
|
||||
}}
|
||||
>
|
||||
{t("git.clone")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="files-main">{right}</div>
|
||||
</div>
|
||||
{pane.kind === "file" && pane.file.mtime && <div className="files-status">{fmtDate(pane.file.mtime)}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { fmtRelative, useT } from "../i18n";
|
||||
import { Spinner } from "./Activity";
|
||||
|
||||
export interface GitFile {
|
||||
path: string;
|
||||
index: string;
|
||||
worktree: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
repo: string;
|
||||
branch: string | null;
|
||||
upstream: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: GitFile[];
|
||||
}
|
||||
|
||||
interface Commit {
|
||||
hash: string;
|
||||
author: string;
|
||||
time: number;
|
||||
subject: string;
|
||||
refs: string;
|
||||
}
|
||||
|
||||
const LABEL: Record<string, string> = { M: "M", A: "A", D: "D", R: "R", C: "C", U: "!", "?": "U" };
|
||||
|
||||
export function GitPanel({
|
||||
repo,
|
||||
status,
|
||||
refresh,
|
||||
showDiff,
|
||||
openFile,
|
||||
}: {
|
||||
repo: string;
|
||||
status?: GitStatus;
|
||||
refresh: () => Promise<void>;
|
||||
showDiff: (title: string, diff: string) => void;
|
||||
openFile: (path: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [message, setMessage] = useState("");
|
||||
const [amend, setAmend] = useState(false);
|
||||
const [busy, setBusy] = useState<string>();
|
||||
const [output, setOutput] = useState<{ ok: boolean; text: string }>();
|
||||
const [branches, setBranches] = useState<{ name: string; current: boolean }[]>([]);
|
||||
const [log, setLog] = useState<Commit[]>([]);
|
||||
const [view, setView] = useState<"changes" | "history">("changes");
|
||||
|
||||
const call = async <T,>(action: string, extra: Record<string, unknown> = {}): Promise<T> => post<T>("/git", { path: repo, action, ...extra });
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>, showOutput = false) => {
|
||||
setBusy(label);
|
||||
setOutput(undefined);
|
||||
try {
|
||||
const r: any = await fn();
|
||||
if (showOutput) setOutput({ ok: true, text: (r?.output ?? "").trim() || t("git.done") });
|
||||
await refresh();
|
||||
void loadMeta();
|
||||
} catch (e: any) {
|
||||
setOutput({ ok: false, text: e.message });
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMeta = async () => {
|
||||
try {
|
||||
const [b, l] = await Promise.all([call<{ branches: any[] }>("branches"), call<{ commits: Commit[] }>("log", { limit: 60 })]);
|
||||
setBranches(b.branches.filter((x: any) => !String(x.name).startsWith("origin/HEAD")));
|
||||
setLog(l.commits);
|
||||
} catch {
|
||||
/* empty repository */
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => void loadMeta(), [repo]);
|
||||
|
||||
if (!status) return <div className="empty"><Spinner /></div>;
|
||||
const abs = (f: GitFile) => `${status.repo}/${f.path}`;
|
||||
const staged = status.files.filter((f) => f.index !== " " && f.index !== "?");
|
||||
const unstaged = status.files.filter((f) => f.worktree !== " " || f.index === "?");
|
||||
|
||||
const diff = async (f: GitFile, isStaged: boolean) => {
|
||||
try {
|
||||
const r = await call<{ diff: string }>("diff", { files: [abs(f)], staged: isStaged });
|
||||
showDiff(`${f.path}${isStaged ? ` (${t("git.staged")})` : ""}`, r.diff);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const fileRow = (f: GitFile, isStaged: boolean) => {
|
||||
const code = isStaged ? f.index : f.index === "?" ? "?" : f.worktree;
|
||||
return (
|
||||
<div key={`${isStaged}-${f.path}`} className="git-file">
|
||||
<span className={`git-mark m-${LABEL[code] ?? code}`}>{LABEL[code] ?? code}</span>
|
||||
<button className="ghost git-name" onClick={() => diff(f, isStaged)} title={f.path}>
|
||||
{f.path}
|
||||
</button>
|
||||
<button className="small ghost" title={t("git.openFile")} onClick={() => openFile(abs(f))}>
|
||||
↗
|
||||
</button>
|
||||
{isStaged ? (
|
||||
<button className="small" title={t("git.unstage")} onClick={() => run("unstage", () => call("unstage", { files: [abs(f)] }))}>
|
||||
−
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{f.index !== "?" && (
|
||||
<button className="small ghost danger" title={t("git.discard")} onClick={() => confirm(t("git.discardConfirm", { file: f.path })) && run("discard", () => call("discard", { files: [abs(f)] }))}>
|
||||
↺
|
||||
</button>
|
||||
)}
|
||||
<button className="small" title={t("git.stage")} onClick={() => run("stage", () => call("stage", { files: [abs(f)] }))}>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="git-panel">
|
||||
<div className="git-branch">
|
||||
<select
|
||||
value={branches.find((b) => b.current)?.name ?? status.branch ?? ""}
|
||||
onChange={(e) => run("checkout", () => call("checkout", { branch: e.target.value.replace(/^origin\//, "") }), true)}
|
||||
aria-label={t("git.branch")}
|
||||
>
|
||||
{!branches.length && <option>{status.branch}</option>}
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="small"
|
||||
title={t("git.newBranch")}
|
||||
onClick={() => {
|
||||
const name = prompt(t("git.newBranchName"));
|
||||
if (name) void run("checkout", () => call("checkout", { branch: name, create: true }), true);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{status.upstream && (
|
||||
<span className="chip" title={status.upstream}>
|
||||
↑{status.ahead} ↓{status.behind}
|
||||
</span>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
{(["fetch", "pull", "push"] as const).map((a) => (
|
||||
<button key={a} className="small" disabled={!!busy} onClick={() => run(a, () => call(a), true)}>
|
||||
{busy === a ? <Spinner small /> : t(`git.${a}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{output && <pre className={`git-output ${output.ok ? "" : "err"}`}>{output.text}</pre>}
|
||||
|
||||
<div className="mode-tabs" style={{ margin: "8px 0" }}>
|
||||
<button className={view === "changes" ? "active" : ""} onClick={() => setView("changes")}>
|
||||
{t("git.changes")} ({status.files.length})
|
||||
</button>
|
||||
<button className={view === "history" ? "active" : ""} onClick={() => setView("history")}>
|
||||
{t("git.history")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{view === "changes" ? (
|
||||
<>
|
||||
<div className="git-section">
|
||||
<div className="git-head">
|
||||
{t("git.staged")} ({staged.length})
|
||||
{staged.length > 0 && (
|
||||
<button className="small ghost" onClick={() => run("unstage", () => call("unstage", { files: staged.map(abs) }))}>
|
||||
{t("git.unstageAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{staged.map((f) => fileRow(f, true))}
|
||||
</div>
|
||||
<div className="git-section">
|
||||
<div className="git-head">
|
||||
{t("git.unstaged")} ({unstaged.length})
|
||||
{unstaged.length > 0 && (
|
||||
<button className="small ghost" onClick={() => run("stage", () => call("stage", { files: unstaged.map(abs) }))}>
|
||||
{t("git.stageAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{unstaged.map((f) => fileRow(f, false))}
|
||||
{status.files.length === 0 && <div className="empty" style={{ padding: 12 }}>{t("git.clean")}</div>}
|
||||
</div>
|
||||
<div className="git-commit">
|
||||
<textarea rows={3} placeholder={t("git.message")} value={message} onChange={(e) => setMessage(e.target.value)} />
|
||||
<div className="row">
|
||||
<label style={{ fontSize: 12.5 }}>
|
||||
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")}
|
||||
</label>
|
||||
<span className="spacer" />
|
||||
<button
|
||||
className="primary"
|
||||
disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))}
|
||||
onClick={() =>
|
||||
run(
|
||||
"commit",
|
||||
async () => {
|
||||
const r = await call("commit", { message, amend });
|
||||
setMessage("");
|
||||
setAmend(false);
|
||||
return r;
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy === "commit" ? <Spinner small /> : t("git.commit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="git-log">
|
||||
{log.length === 0 && <div className="empty">{t("git.noCommits")}</div>}
|
||||
{log.map((c) => (
|
||||
<button
|
||||
key={c.hash}
|
||||
className="git-commit-row"
|
||||
onClick={async () => {
|
||||
const r = await call<{ diff: string }>("show", { rev: c.hash });
|
||||
showDiff(`${c.hash.slice(0, 8)} ${c.subject}`, r.diff);
|
||||
}}
|
||||
>
|
||||
<span className="subject">{c.subject}</span>
|
||||
<span className="meta">
|
||||
{c.hash.slice(0, 7)} · {c.author} · {fmtRelative(c.time)}
|
||||
{c.refs && <span className="chip" style={{ marginLeft: 6 }}>{c.refs}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { del, post } from "../api";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCost, fmtDate, useT, type Key } from "../i18n";
|
||||
import { Spinner } from "./Activity";
|
||||
|
||||
interface ImageConfig {
|
||||
enabled: boolean;
|
||||
default_model: string;
|
||||
output_dir: string;
|
||||
sizes: string[];
|
||||
models: { id: string; name: string; price?: number | null }[];
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface ImageRecord {
|
||||
id: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
const fileUrl = (id: string) => `/api/images/${id}/file`;
|
||||
|
||||
export function Images({ onMenu }: { onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const { data: cfg } = useFetch<ImageConfig>("/images/config");
|
||||
const { data: images, reload } = useFetch<ImageRecord[]>("/images");
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
const [form, setForm] = useState({ prompt: "", model: "", size: "landscape_4_3", count: 1, root: "", folder: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [viewer, setViewer] = useState<ImageRecord>();
|
||||
|
||||
useEffect(() => {
|
||||
if (cfg && !form.model) setForm((f) => ({ ...f, model: cfg.default_model, folder: cfg.output_dir }));
|
||||
}, [cfg, form.model]);
|
||||
useEffect(() => {
|
||||
if (roots.length && !form.root) setForm((f) => ({ ...f, root: roots[0].path }));
|
||||
}, [roots, form.root]);
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const directory = form.root ? `${form.root}/${form.folder.replace(/^\/+/, "")}` : undefined;
|
||||
await post("/images", { prompt: form.prompt, model: form.model, size: form.size, count: form.count, directory });
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const price = cfg?.models.find((m) => m.id === form.model)?.price;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
☰
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("nav.images")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<div className="page-inner">
|
||||
{cfg && !cfg.enabled && <div className="notice">{t("images.disabled")}</div>}
|
||||
<div className="panel">
|
||||
<textarea
|
||||
style={{ width: "100%", minHeight: 90 }}
|
||||
placeholder={t("images.promptPlaceholder")}
|
||||
value={form.prompt}
|
||||
onChange={(e) => setForm({ ...form, prompt: e.target.value })}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.ctrlKey || e.metaKey) && form.prompt.trim() && generate()}
|
||||
/>
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
<select value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} aria-label={t("images.model")}>
|
||||
{cfg?.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={form.size} onChange={(e) => setForm({ ...form, size: e.target.value })} aria-label={t("images.size")}>
|
||||
{cfg?.sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`images.size.${s}` as Key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={form.count} onChange={(e) => setForm({ ...form, count: Number(e.target.value) })} aria-label={t("images.count")}>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{t("images.countN", { n })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{roots.length > 1 && (
|
||||
<select value={form.root} onChange={(e) => setForm({ ...form, root: e.target.value })} aria-label={t("images.folder")}>
|
||||
{roots.map((r) => (
|
||||
<option key={r.path} value={r.path}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input value={form.folder} onChange={(e) => setForm({ ...form, folder: e.target.value })} title={t("images.folder")} style={{ width: 140 }} />
|
||||
<span className="spacer" />
|
||||
{price ? <span className="chip">≈ {fmtCost(price * form.count, cfg!.currency)}</span> : null}
|
||||
<button className="primary" disabled={busy || !form.prompt.trim() || !cfg?.enabled} onClick={generate}>
|
||||
{busy ? <Spinner small /> : null} {busy ? t("images.generating") : t("images.generate")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="notice err" style={{ marginTop: 8 }}>{error}</div>}
|
||||
</div>
|
||||
|
||||
{images?.length === 0 && <div className="empty">{t("images.empty")}</div>}
|
||||
<div className="gallery">
|
||||
{busy &&
|
||||
Array.from({ length: form.count }, (_, i) => (
|
||||
<div key={`p${i}`} className="gallery-item placeholder">
|
||||
<Spinner />
|
||||
</div>
|
||||
))}
|
||||
{images?.map((img) => (
|
||||
<figure key={img.id} className="gallery-item">
|
||||
<img src={fileUrl(img.id)} alt={img.prompt} loading="lazy" onClick={() => setViewer(img)} />
|
||||
<figcaption>
|
||||
<div className="prompt" title={img.prompt}>
|
||||
{img.prompt}
|
||||
</div>
|
||||
<div className="meta">
|
||||
{img.model.split("/").slice(1).join("/")} · {fmtDate(img.created_at)}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 4 }}>
|
||||
<a className="button small" href={fileUrl(img.id)} download={img.path.split("/").pop()}>
|
||||
⤓
|
||||
</a>
|
||||
<button className="small" title={t("images.reuse")} onClick={() => setForm({ ...form, prompt: img.prompt, model: img.model })}>
|
||||
↺
|
||||
</button>
|
||||
<button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}>
|
||||
⧉
|
||||
</button>
|
||||
<button className="small danger" title={t("common.delete")} onClick={() => confirm(t("images.deleteConfirm")) && del(`/images/${img.id}`).then(reload)}>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{viewer && (
|
||||
<div className="modal-backdrop" onClick={() => setViewer(undefined)}>
|
||||
<figure className="viewer" onClick={(e) => e.stopPropagation()}>
|
||||
<img src={fileUrl(viewer.id)} alt={viewer.prompt} />
|
||||
<figcaption>
|
||||
{viewer.prompt}
|
||||
<div className="meta">
|
||||
{viewer.width}×{viewer.height} · {viewer.path}
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { get, setToken } from "../api";
|
||||
import { post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import type { Info } from "../types";
|
||||
|
||||
export function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
/** Sign-in, or creation of the first admin when no users exist yet. */
|
||||
export function Login({ info, onSuccess }: { info: Info; onSuccess: () => void }) {
|
||||
const t = useT();
|
||||
const [token, setValue] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
const setup = info.setup_required;
|
||||
const [form, setForm] = useState({ username: "", password: "", password2: "", code: "" });
|
||||
const [error, setError] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setToken(token.trim());
|
||||
setError(undefined);
|
||||
if (setup && form.password !== form.password2) return setError(t("login.mismatch"));
|
||||
setBusy(true);
|
||||
try {
|
||||
await get("/sessions");
|
||||
if (setup) await post("/auth/setup", { code: form.code, username: form.username, password: form.password });
|
||||
else await post("/auth/login", { username: form.username, password: form.password });
|
||||
onSuccess();
|
||||
} catch {
|
||||
setError(true);
|
||||
} catch (err: any) {
|
||||
setError(err.status === 401 ? t(setup ? "login.invalidCode" : "login.invalid") : err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
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")}
|
||||
<div style={{ color: "var(--fg3)" }}>{setup ? t("login.setupPrompt") : t("login.prompt")}</div>
|
||||
{setup && <input autoFocus placeholder={t("login.setupCode")} value={form.code} onChange={set("code")} autoComplete="one-time-code" />}
|
||||
<input autoFocus={!setup} placeholder={t("login.username")} value={form.username} onChange={set("username")} autoComplete="username" autoCapitalize="none" />
|
||||
<input type="password" placeholder={t("login.password")} value={form.password} onChange={set("password")} autoComplete={setup ? "new-password" : "current-password"} />
|
||||
{setup && <input type="password" placeholder={t("login.passwordRepeat")} value={form.password2} onChange={set("password2")} autoComplete="new-password" />}
|
||||
{error && <div style={{ color: "var(--red)" }}>{error}</div>}
|
||||
<button className="primary" type="submit" disabled={busy || !form.username || !form.password}>
|
||||
{setup ? t("login.createAdmin") : t("login.submit")}
|
||||
</button>
|
||||
{!setup && info.oidc && (
|
||||
<>
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("login.or")}</div>
|
||||
<a className="button" href="/api/auth/oidc/start">
|
||||
{info.oidc.label}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 }) {
|
||||
export function ModelSelect({ value, onChange, title, emptyLabel }: { value: string; onChange: (v: string) => void; title?: string; emptyLabel?: 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}>
|
||||
{emptyLabel !== undefined && <option value="">{emptyLabel}</option>}
|
||||
{!known && value && <option value={value}>{value}</option>}
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ModelSelect } from "./ModelSelect";
|
||||
interface DirListing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
root: string;
|
||||
workspace: string;
|
||||
dirs: { name: string; path: string; git: boolean }[];
|
||||
}
|
||||
@@ -16,6 +17,7 @@ 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 roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
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("");
|
||||
@@ -50,6 +52,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
};
|
||||
|
||||
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||
const currentRoot = listing?.root;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -65,6 +68,15 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
<div className="page-inner" style={{ maxWidth: 760 }}>
|
||||
<div className="panel">
|
||||
<h3>{t("new.directory")}</h3>
|
||||
{roots.length > 1 && (
|
||||
<div className="mode-tabs" style={{ marginBottom: 8, flexWrap: "wrap" }}>
|
||||
{roots.map((r) => (
|
||||
<button key={r.path} className={currentRoot === r.path ? "active" : ""} onClick={() => setPath(r.path)}>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<code className="grow" style={{ flex: 1 }}>
|
||||
{rel}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { del, post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore } from "../store";
|
||||
import type { QueueItem } from "../types";
|
||||
|
||||
const EMPTY: QueueItem[] = [];
|
||||
|
||||
/** Messages waiting for the agent; deletable until delivered. */
|
||||
export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolean }) {
|
||||
const t = useT();
|
||||
const items = useStore((s) => s.queue[sessionId] ?? EMPTY);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="queue">
|
||||
<div className="queue-head">
|
||||
<span>{busy ? t("queue.pending", { n: items.length }) : t("queue.stopped", { n: items.length })}</span>
|
||||
{!busy && (
|
||||
<button className="small primary" onClick={() => post(`/sessions/${sessionId}/queue/flush`).catch((e) => alert(e.message))}>
|
||||
{t("queue.sendNow")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{items.map((it) => {
|
||||
const text = it.parts.map((p) => (p.type === "text" ? p.text : "")).join(" ").trim();
|
||||
const images = it.parts.filter((p) => p.type === "image") as Extract<QueueItem["parts"][number], { type: "image" }>[];
|
||||
return (
|
||||
<div key={it.id} className="queue-item">
|
||||
{it.source === "background" && <span className="chip">{t("tool.background")}</span>}
|
||||
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
|
||||
{images.map((p, i) => (
|
||||
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
||||
))}
|
||||
{it.source !== "background" && (
|
||||
<button
|
||||
className="small ghost"
|
||||
aria-label={t("queue.remove")}
|
||||
title={t("queue.remove")}
|
||||
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => alert(e.message))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+171
-79
@@ -4,8 +4,13 @@ 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 { applyTheme, getTheme, THEMES, type Theme } from "../theme";
|
||||
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { Account } from "./settings/Account";
|
||||
import { AgentsEditor } from "./settings/AgentsEditor";
|
||||
import { Folders } from "./settings/Folders";
|
||||
import { Users } from "./settings/Users";
|
||||
|
||||
type Json = any;
|
||||
interface ConfigResponse {
|
||||
@@ -39,38 +44,13 @@ function useSaver(reload: () => void) {
|
||||
|
||||
// --- general ----------------------------------------------------------------
|
||||
|
||||
function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
/** Per-device settings (language, theme, notifications) – available to everyone. */
|
||||
function InterfaceSettings() {
|
||||
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 reasoning = useReasoningMode();
|
||||
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">
|
||||
@@ -91,10 +71,20 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
applyTheme(v);
|
||||
}}
|
||||
>
|
||||
<option value="system">{t("settings.themeSystem")}</option>
|
||||
<option value="dark">{t("settings.themeDark")}</option>
|
||||
<option value="light">{t("settings.themeLight")}</option>
|
||||
{THEMES.map((th) => (
|
||||
<option key={th.id} value={th.id}>
|
||||
{th.id === "system" ? t("settings.themeSystem") : th.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label>{t("settings.reasoningDisplay")}</label>
|
||||
<select value={reasoning} onChange={(e) => setReasoningMode(e.target.value as ReasoningMode)}>
|
||||
<option value="collapsed">{t("settings.reasoningCollapsed")}</option>
|
||||
<option value="hidden">{t("settings.reasoningHidden")}</option>
|
||||
<option value="live">{t("settings.reasoningLive")}</option>
|
||||
<option value="expanded">{t("settings.reasoningExpanded")}</option>
|
||||
</select>
|
||||
<div className="hint">{t("settings.reasoningHint")}</div>
|
||||
<label>{t("settings.notifications")}</label>
|
||||
<div>
|
||||
{notif === "granted" ? (
|
||||
@@ -108,6 +98,56 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<div className="hint">{t("settings.notificationsHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
max_parallel_subagents: s.max_parallel_subagents ?? 4,
|
||||
monthly_budget: s.monthly_budget ?? 0,
|
||||
budget_warning: s.budget_warning ?? 0.8,
|
||||
images_budget: cfg.config.images?.monthly_budget ?? "",
|
||||
clean_room: c.clean_room ?? true,
|
||||
chromium: c.chromium ?? "chromium",
|
||||
widths: (c.widths ?? []).join(", "),
|
||||
images_enabled: cfg.config.images?.enabled ?? false,
|
||||
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? "${FAL_KEY}"),
|
||||
images_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell",
|
||||
images_dir: cfg.config.images?.output_dir ?? "generated",
|
||||
});
|
||||
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: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
||||
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
||||
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
||||
{ path: ["images", "monthly_budget"], value: form.images_budget === "" ? null : Number(form.images_budget) },
|
||||
{ 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) },
|
||||
{ path: ["images", "enabled"], value: form.images_enabled },
|
||||
{ path: ["images", "default_model"], value: form.images_model },
|
||||
{ path: ["images", "output_dir"], value: form.images_dir },
|
||||
...(form.images_key ? [{ path: ["images", "api_key"], value: form.images_key }] : []),
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InterfaceSettings />
|
||||
<div className="panel">
|
||||
<h3>{t("settings.server")}</h3>
|
||||
<div className="form">
|
||||
@@ -123,6 +163,14 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<label>{t("settings.autoCompact")}</label>
|
||||
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
||||
<div className="hint">{t("settings.autoCompactHint")}</div>
|
||||
<label>{t("settings.monthlyBudgetTotal")}</label>
|
||||
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
||||
<label>{t("settings.budgetWarning")}</label>
|
||||
<input type="number" min={0} max={1} step={0.05} value={form.budget_warning} onChange={(e) => setForm({ ...form, budget_warning: e.target.value })} />
|
||||
<label>{t("settings.maxParallelSubagents")}</label>
|
||||
<input type="number" min={0} value={form.max_parallel_subagents} onChange={(e) => setForm({ ...form, max_parallel_subagents: e.target.value })} />
|
||||
<div className="hint">{t("settings.maxParallelSubagentsHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel">
|
||||
@@ -137,6 +185,28 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<input value={form.widths} onChange={(e) => setForm({ ...form, widths: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("settings.images")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("settings.imagesEnabled")}</label>
|
||||
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
<label>{t("settings.imagesKey")}</label>
|
||||
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "${FAL_KEY}"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
|
||||
<label>{t("settings.imagesDefault")}</label>
|
||||
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
|
||||
{(cfg.config.images?.models ?? []).map((m: any) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name ?? m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label>{t("settings.monthlyBudget")}</label>
|
||||
<input type="number" min={0} value={form.images_budget} placeholder="–" onChange={(e) => setForm({ ...form, images_budget: e.target.value })} />
|
||||
<label>{t("settings.imagesDir")}</label>
|
||||
<input value={form.images_dir} onChange={(e) => setForm({ ...form, images_dir: e.target.value })} />
|
||||
<div className="hint">{t("settings.imagesHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
{view}
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
@@ -159,6 +229,8 @@ interface ModelRow {
|
||||
output_price?: number;
|
||||
cache_read_price?: number;
|
||||
cache_write_price?: number;
|
||||
max_concurrency?: number;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
|
||||
@@ -187,7 +259,27 @@ function cleanModel(m: ModelRow) {
|
||||
|
||||
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 [form, setForm] = useState({ ...p, api_key: p.api_key === MASK ? "" : p.api_key, admin_key: "" });
|
||||
const [priceNote, setPriceNote] = useState<string>();
|
||||
|
||||
const fetchPrices = async () => {
|
||||
try {
|
||||
const r = await get<{ models: ModelRow[] }>(`/providers/${name}/prices`);
|
||||
const byId = new Map(r.models.map((m) => [m.id, m]));
|
||||
let n = 0;
|
||||
setModels((ms) =>
|
||||
ms.map((m) => {
|
||||
const f = byId.get(m.id);
|
||||
if (!f) return m;
|
||||
n++;
|
||||
return { ...m, input_price: f.input_price ?? m.input_price, output_price: f.output_price ?? m.output_price, cache_read_price: f.cache_read_price ?? m.cache_read_price, cache_write_price: f.cache_write_price ?? m.cache_write_price, context: m.context ?? f.context };
|
||||
}),
|
||||
);
|
||||
setPriceNote(t("settings.pricesFetched", { n }));
|
||||
} catch (e: any) {
|
||||
setPriceNote(e.message);
|
||||
}
|
||||
};
|
||||
const [models, setModels] = useState<ModelRow[]>(normalizeModels(p.models));
|
||||
const [open, setOpen] = useState(false);
|
||||
const discovered = useModels().find((x) => x.provider === name)?.models ?? [];
|
||||
@@ -204,9 +296,12 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
{ 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 },
|
||||
{ path: ["providers", name, "max_concurrency"], value: form.max_concurrency ? Number(form.max_concurrency) : null },
|
||||
{ path: ["providers", name, "monthly_budget"], value: form.monthly_budget ? Number(form.monthly_budget) : 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 });
|
||||
if (form.admin_key) patches.push({ path: ["providers", name, "admin_key"], value: form.admin_key });
|
||||
return savePatches(patches);
|
||||
});
|
||||
|
||||
@@ -252,10 +347,27 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
<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 })} />
|
||||
<label>{t("settings.monthlyBudget")}</label>
|
||||
<input type="number" min={0} step="1" value={form.monthly_budget ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||
{form.kind === "anthropic" && (
|
||||
<>
|
||||
<label>{t("settings.adminKey")}</label>
|
||||
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "${ANTHROPIC_ADMIN_KEY}"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
|
||||
<div className="hint">{t("settings.adminKeyHint")}</div>
|
||||
</>
|
||||
)}
|
||||
<label>{t("settings.maxConcurrency")}</label>
|
||||
<input type="number" min={1} value={form.max_concurrency ?? ""} placeholder="∞" onChange={(e) => setForm({ ...form, max_concurrency: e.target.value })} />
|
||||
<div className="hint">{t("settings.maxConcurrencyHint")}</div>
|
||||
</div>
|
||||
<h3 style={{ marginTop: 16 }}>
|
||||
{t("settings.models")}
|
||||
<span className="spacer" />
|
||||
{form.base_url?.includes("openrouter.ai") && (
|
||||
<button className="small" onClick={fetchPrices}>
|
||||
{t("settings.fetchPrices")}
|
||||
</button>
|
||||
)}
|
||||
{discovered.length > 0 && (
|
||||
<button className="small" onClick={addDiscovered}>
|
||||
{t("settings.addDiscovered", { n: discovered.length })}
|
||||
@@ -268,6 +380,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
<div className="hint" style={{ marginBottom: 6, fontSize: 12, color: "var(--muted)" }}>
|
||||
{t("settings.pricesHint")}
|
||||
</div>
|
||||
{priceNote && <div className="notice">{priceNote}</div>}
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
@@ -282,6 +395,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
<th>{t("settings.priceOut")}</th>
|
||||
<th>{t("settings.priceCacheRead")}</th>
|
||||
<th>{t("settings.priceCacheWrite")}</th>
|
||||
<th title={t("settings.maxConcurrencyHint")}>{t("settings.slots")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -316,6 +430,9 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
||||
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<input type="number" min={1} value={m.max_concurrency ?? ""} placeholder="∞" onChange={(e) => setModel(i, { max_concurrency: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||
×
|
||||
@@ -416,6 +533,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
||||
url: m.url ?? "",
|
||||
headers: mapToLines(m.headers, ": "),
|
||||
enabled: m.enabled ?? true,
|
||||
load: m.load ?? "auto",
|
||||
});
|
||||
const [open, setOpen] = useState(!!isNew);
|
||||
const { run, view } = useSaver(reload);
|
||||
@@ -424,6 +542,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
||||
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.load !== "auto") value.load = form.load;
|
||||
if (form.type === "stdio") {
|
||||
value.command = form.command;
|
||||
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
|
||||
@@ -486,6 +605,13 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
|
||||
)}
|
||||
<label>{t("settings.enabled")}</label>
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
<label>{t("mcp.load")}</label>
|
||||
<select value={form.load} onChange={(e) => setForm({ ...form, load: e.target.value })}>
|
||||
<option value="auto">{t("mcp.loadAuto")}</option>
|
||||
<option value="always">{t("mcp.loadAlways")}</option>
|
||||
<option value="on_demand">{t("mcp.loadOnDemand")}</option>
|
||||
</select>
|
||||
<div className="hint">{t("mcp.loadHint")}</div>
|
||||
</div>
|
||||
{status?.tools?.length ? (
|
||||
<details style={{ marginTop: 10 }}>
|
||||
@@ -620,47 +746,6 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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 }) {
|
||||
@@ -688,12 +773,15 @@ function Raw({ reload }: { reload: () => void }) {
|
||||
|
||||
// --- page -------------------------------------------------------------------
|
||||
|
||||
const TABS = ["general", "providers", "mcp", "permissions", "agents", "raw"] as const;
|
||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "account", "raw"] as const;
|
||||
const USER_TABS = ["general", "account"] 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");
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const TABS: readonly string[] = isAdmin ? ADMIN_TABS : USER_TABS;
|
||||
const active = TABS.includes(tab ?? "") ? tab! : "general";
|
||||
const { data: cfg, error, reload } = useFetch<ConfigResponse>(isAdmin ? "/config" : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -715,12 +803,16 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
||||
))}
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!cfg && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{isAdmin && !cfg && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{!isAdmin && active === "general" && <InterfaceSettings />}
|
||||
{active === "account" && <Account />}
|
||||
{cfg && active === "users" && <Users />}
|
||||
{cfg && active === "folders" && <Folders />}
|
||||
{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 />}
|
||||
{cfg && active === "agents" && <AgentsEditor rawAgents={cfg.config.agents ?? {}} reloadConfig={reload} />}
|
||||
{active === "raw" && <Raw reload={reload} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, put } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore } from "../store";
|
||||
|
||||
interface DirUser {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
/** Choose users a session is shared with (read-only for them). */
|
||||
export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const me = useStore((s) => s.me);
|
||||
const owner = useStore((s) => s.sessionById[sessionId]?.owner_id);
|
||||
const [users, setUsers] = useState<DirUser[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([get<DirUser[]>("/users/directory"), get<string[]>(`/sessions/${sessionId}/shares`)])
|
||||
.then(([u, s]) => {
|
||||
setUsers(u.filter((x) => x.id !== owner && x.id !== me?.id));
|
||||
setSelected(new Set(s));
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, [sessionId, owner, me?.id]);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await put(`/sessions/${sessionId}/shares`, { users: [...selected] });
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" role="dialog" aria-label={t("share.title")} onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("share.title")}</h3>
|
||||
<div style={{ fontSize: 13, color: "var(--muted)", marginBottom: 8 }}>{t("share.hint")}</div>
|
||||
<div className="check-list">
|
||||
{users.length === 0 && <div className="empty">{t("share.noUsers")}</div>}
|
||||
{users.map((u) => (
|
||||
<label key={u.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(u.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selected);
|
||||
if (e.target.checked) next.add(u.id);
|
||||
else next.delete(u.id);
|
||||
setSelected(next);
|
||||
}}
|
||||
/>
|
||||
{u.display_name || u.username} <span style={{ color: "var(--muted)" }}>@{u.username}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { post } from "../api";
|
||||
import { fmtRelative, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -24,6 +25,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
const connected = useStore((s) => s.connected);
|
||||
const permissions = useStore((s) => s.permissions);
|
||||
const questions = useStore((s) => s.questions);
|
||||
const me = useStore((s) => s.me);
|
||||
|
||||
const attention = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@@ -69,10 +71,11 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
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" />}
|
||||
{attention.has(s.id) ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy[s.id] && <i className="busy-ring" title={t("activity.working")} />}
|
||||
<span>{s.title || t("session.untitled")}</span>
|
||||
</div>
|
||||
<div className="sub">
|
||||
{s.shared && <span title={t("sidebar.shared")}>⇄ </span>}
|
||||
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -80,7 +83,22 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-user">
|
||||
<span title={me?.username}>
|
||||
{me?.display_name || me?.username}
|
||||
{me?.role === "admin" && <span className="chip" style={{ marginLeft: 6 }}>admin</span>}
|
||||
</span>
|
||||
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-foot">
|
||||
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
|
||||
{t("nav.files")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
|
||||
{t("nav.images")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
||||
{t("nav.stats")}
|
||||
</button>
|
||||
|
||||
@@ -2,8 +2,11 @@ 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 { useStore } from "../store";
|
||||
import type { User } from "../types";
|
||||
import type { Aggregate } from "../types";
|
||||
import { Heatmap, Legend, SERIES, StackedBars, type Series } from "./Charts";
|
||||
import { BudgetPanel } from "./Budget";
|
||||
|
||||
type Row<K extends string> = Aggregate & Record<K, string>;
|
||||
|
||||
@@ -14,6 +17,7 @@ interface StatsData {
|
||||
by_model: Row<"model">[];
|
||||
by_provider: Row<"provider">[];
|
||||
by_agent: Row<"agent">[];
|
||||
by_user: Row<"user">[];
|
||||
heatmap: (Aggregate & { slot: number })[];
|
||||
top_sessions: (Aggregate & { session_id: string; title: string })[];
|
||||
currency: string;
|
||||
@@ -55,6 +59,12 @@ function Kpi({ label, value, sub }: { label: string; value: string; sub?: string
|
||||
);
|
||||
}
|
||||
|
||||
/** Effective cost per 1M tokens (input incl. cache + output). */
|
||||
function perMillion(r: Aggregate, currency: string) {
|
||||
const tokens = r.input_tokens + r.cache_read_tokens + r.cache_write_tokens + r.output_tokens;
|
||||
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–";
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -71,6 +81,7 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
|
||||
<th className="num">{t("stats.outputSpeed")}</th>
|
||||
<th className="num">{t("stats.prefillSpeed")}</th>
|
||||
<th className="num">{t("stats.cost")}</th>
|
||||
<th className="num" title={t("stats.perMillionHint")}>{t("stats.perMillion")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -85,6 +96,7 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
|
||||
<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>
|
||||
<td className="num">{perMillion(r, currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -98,12 +110,15 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
const [range, setRange] = useState("30");
|
||||
const [heat, setHeat] = useState<HeatMetric>("requests");
|
||||
const [tableView, setTableView] = useState(false);
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
|
||||
const [userFilter, setUserFilter] = useState("");
|
||||
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 { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
|
||||
|
||||
const view = useMemo(() => {
|
||||
if (!data) return undefined;
|
||||
@@ -165,6 +180,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
|
||||
<option value="">{t("stats.allUsers")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button className="small" onClick={() => setTableView(!tableView)}>
|
||||
{tableView ? t("stats.chartView") : t("stats.tableView")}
|
||||
</button>
|
||||
@@ -173,6 +198,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="page-inner">
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!data && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{isAdmin && <BudgetPanel />}
|
||||
{data && tot && view && (
|
||||
<>
|
||||
<div className="kpis">
|
||||
@@ -183,7 +209,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<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`} />
|
||||
<Kpi label={t("stats.prefillSpeed")} value={`${fmtCompact(tot.prefill_tps)} tok/s`} />
|
||||
</div>
|
||||
|
||||
{tot.requests === 0 ? (
|
||||
@@ -236,6 +262,12 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && !userFilter && data.by_user.length > 1 && (
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byUser")}</h3>
|
||||
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
|
||||
</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}`)} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { Part, Todo } from "../types";
|
||||
import { Spinner } from "./Activity";
|
||||
import { Diff } from "./Diff";
|
||||
|
||||
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||
@@ -63,23 +64,34 @@ export function ToolCard({ call, result, running }: { call: ToolCall; result?: T
|
||||
const statusText = result ? (result.is_error ? "✗" : "✓") : running ? "…" : "–";
|
||||
const meta = result?.meta;
|
||||
|
||||
// The todo list is shown in its own panel; keep the card compact.
|
||||
// The live task list has its own panel; updates are a single line here.
|
||||
if (call.name === "todo_write" && !result?.is_error) {
|
||||
const todos: Todo[] = call.input?.todos ?? [];
|
||||
const done = todos.filter((x) => x.status === "completed").length;
|
||||
const current = todos.find((x) => x.status === "in_progress");
|
||||
return (
|
||||
<div className="todos">
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
||||
<TodoList todos={call.input?.todos ?? []} />
|
||||
<div className="todo-line">
|
||||
☑ {t("tool.todosUpdated", { done, total: todos.length })}
|
||||
{current && <span> · ▶ {current.content}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Short diffs are shown right away, like in a terminal agent.
|
||||
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||||
const openByDefault =
|
||||
(status === "err" && !!result) ||
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
||||
|
||||
return (
|
||||
<details className="tool" open={status === "err" && !!result}>
|
||||
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}>
|
||||
<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>
|
||||
{call.name === "task" && meta?.model && <span className="chip">{meta.model}</span>}
|
||||
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
|
||||
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
||||
</summary>
|
||||
<div className="tool-body">
|
||||
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||||
@@ -107,10 +119,18 @@ export function ToolCard({ call, result, running }: { call: ToolCall; result?: T
|
||||
<pre className="mono">{result.content}</pre>
|
||||
</>
|
||||
)}
|
||||
{result?.images?.map((img, i) => (
|
||||
<img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
{Array.isArray(meta?.image_ids) ? (
|
||||
<div className="gallery small">
|
||||
{meta.image_ids.map((id: string) => (
|
||||
<a key={id} href={`/api/images/${id}/file`} target="_blank" rel="noreferrer" className="gallery-item">
|
||||
<img src={`/api/images/${id}/file`} alt="" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
result?.images?.map((img, i) => <img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />)
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { post } from "../../api";
|
||||
import { useT } from "../../i18n";
|
||||
import { useStore } from "../../store";
|
||||
|
||||
export function Account() {
|
||||
const t = useT();
|
||||
const me = useStore((s) => s.me);
|
||||
const [form, setForm] = useState({ current: "", next: "", repeat: "" });
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
|
||||
const save = async () => {
|
||||
if (form.next !== form.repeat) return setNotice({ ok: false, text: t("login.mismatch") });
|
||||
try {
|
||||
await post("/auth/password", { current: form.current || undefined, new: form.next });
|
||||
setForm({ current: "", next: "", repeat: "" });
|
||||
setNotice({ ok: true, text: t("account.changed") });
|
||||
} catch (e: any) {
|
||||
setNotice({ ok: false, text: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{t("account.title")}: {me?.username}
|
||||
</h3>
|
||||
<div className="form">
|
||||
{me?.has_password && (
|
||||
<>
|
||||
<label>{t("account.current")}</label>
|
||||
<input type="password" value={form.current} onChange={(e) => setForm({ ...form, current: e.target.value })} autoComplete="current-password" />
|
||||
</>
|
||||
)}
|
||||
<label>{t("account.new")}</label>
|
||||
<input type="password" value={form.next} onChange={(e) => setForm({ ...form, next: e.target.value })} autoComplete="new-password" />
|
||||
<label>{t("login.passwordRepeat")}</label>
|
||||
<input type="password" value={form.repeat} onChange={(e) => setForm({ ...form, repeat: e.target.value })} autoComplete="new-password" />
|
||||
</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`} style={{ marginTop: 10 }}>{notice.text}</div>}
|
||||
<button className="primary" style={{ marginTop: 12 }} disabled={!form.next} onClick={save}>
|
||||
{t("account.change")}
|
||||
</button>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from "react";
|
||||
import { patch } from "../../api";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import type { AgentInfo } from "../../types";
|
||||
import { ModelSelect } from "../ModelSelect";
|
||||
|
||||
type Patch = { path: string[]; value: unknown };
|
||||
|
||||
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
|
||||
documentation, summaries, step-by-step guides and translations.
|
||||
Return only the finished text.`;
|
||||
|
||||
function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: boolean, text: string) => void }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
model: a.model ?? "",
|
||||
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
|
||||
description: raw?.description ?? "",
|
||||
tools: (raw?.tools ?? []).join(", "),
|
||||
prompt: raw?.prompt ?? "",
|
||||
mode: raw?.mode ?? a.mode,
|
||||
});
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
|
||||
|
||||
const save = async () => {
|
||||
const base = ["agents", a.name];
|
||||
const patches: Patch[] = [
|
||||
{ path: [...base, "model"], value: form.model || null },
|
||||
{ path: [...base, "max_parallel"], value: form.max_parallel ? Number(form.max_parallel) : null },
|
||||
];
|
||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||
if (!builtin) {
|
||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||
patches.push({ path: [...base, "tools"], value: form.tools.trim() ? form.tools.split(/[\s,]+/).filter(Boolean) : null });
|
||||
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
|
||||
}
|
||||
try {
|
||||
await patch("/config", { patches });
|
||||
onSaved(true, t("settings.saved"));
|
||||
} catch (e: any) {
|
||||
onSaved(false, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
if (!confirm(t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }))) return;
|
||||
try {
|
||||
await patch("/config", { patches: [{ path: ["agents", a.name], value: null }] });
|
||||
onSaved(true, t("settings.saved"));
|
||||
} catch (e: any) {
|
||||
onSaved(false, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<code>{a.name}</code>
|
||||
<span className="chip">{a.mode === "primary" ? t("agents.modePrimary") : t("agents.modeSubagent")}</span>
|
||||
<span className="chip">{t(`agents.source.${a.source}` as any)}</span>
|
||||
{a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||
<span className="spacer" />
|
||||
<button className="small" onClick={() => setOpen(!open)}>
|
||||
{open ? t("common.collapse") : t("common.edit")}
|
||||
</button>
|
||||
</h3>
|
||||
<div style={{ fontSize: 13, color: "var(--fg3)" }}>{a.description}</div>
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--muted)" }}>{t("agents.model")}:</span>
|
||||
<span className="chip">{a.model || t("agents.sessionModel")}</span>
|
||||
{a.max_parallel ? <span className="chip">{t("agents.maxParallelShort", { n: a.max_parallel })}</span> : null}
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
<div className="form" style={{ marginTop: 12 }}>
|
||||
<label>{t("agents.model")}</label>
|
||||
<ModelSelect value={form.model} onChange={(v) => setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} />
|
||||
<div className="hint">{t("agents.modelHint")}</div>
|
||||
{a.mode !== "primary" && (
|
||||
<>
|
||||
<label>{t("agents.maxParallel")}</label>
|
||||
<input type="number" min={1} value={form.max_parallel} placeholder="∞" onChange={(e) => setForm({ ...form, max_parallel: e.target.value })} />
|
||||
</>
|
||||
)}
|
||||
<label>{t("agents.description")}</label>
|
||||
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
|
||||
{!builtin && (
|
||||
<>
|
||||
<label>{t("agents.mode")}</label>
|
||||
<select value={form.mode} onChange={(e) => setForm({ ...form, mode: e.target.value })}>
|
||||
<option value="subagent">{t("agents.modeSubagent")}</option>
|
||||
<option value="primary">{t("agents.modePrimary")}</option>
|
||||
</select>
|
||||
<label>{t("agents.tools")}</label>
|
||||
<input value={form.tools} placeholder={t("agents.toolsAll")} onChange={(e) => setForm({ ...form, tools: e.target.value })} className="mono" />
|
||||
<label>{t("agents.prompt")}</label>
|
||||
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{a.source === "config" && (
|
||||
<button className="danger" onClick={reset}>
|
||||
{builtin ? t("agents.reset") : t("common.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
|
||||
const t = useT();
|
||||
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
|
||||
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const saved = (ok: boolean, text: string) => {
|
||||
setNotice({ ok, text });
|
||||
if (ok) {
|
||||
reload();
|
||||
reloadConfig();
|
||||
}
|
||||
};
|
||||
|
||||
const add = async (template: "writer" | "blank") => {
|
||||
const n = name.trim() || (template === "writer" ? "writer" : "");
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||
const value =
|
||||
template === "writer"
|
||||
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
|
||||
: { mode: "subagent", description: "", prompt: "" };
|
||||
try {
|
||||
await patch("/config", { patches: [{ path: ["agents", n], value }] });
|
||||
setName("");
|
||||
saved(true, t("settings.saved"));
|
||||
} catch (e: any) {
|
||||
saved(false, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const key = (a: AgentInfo) => a.name + JSON.stringify(rawAgents[a.name] ?? {}) + (a.model ?? "");
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("agents.intro")}</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
|
||||
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
|
||||
<div className="panel">
|
||||
<h3>{t("agents.add")}</h3>
|
||||
<div className="row">
|
||||
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
|
||||
<button onClick={() => add("blank")} disabled={!name.trim()}>
|
||||
+ {t("agents.addBlank")}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { del, post, put } from "../../api";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import type { User } from "../../types";
|
||||
|
||||
interface Folder {
|
||||
id: string;
|
||||
path: string;
|
||||
users: string[];
|
||||
}
|
||||
|
||||
/** Admin: folders inside the workspace shared with selected users. */
|
||||
export function Folders() {
|
||||
const t = useT();
|
||||
const { data, reload } = useFetch<{ workspace: string; folders: Folder[] }>("/folders");
|
||||
const users = (useFetch<User[]>("/users").data ?? []).filter((u) => u.role !== "admin");
|
||||
const [path, setPath] = useState("");
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
|
||||
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 toggle = (f: Folder, userId: string) => {
|
||||
const next = f.users.includes(userId) ? f.users.filter((u) => u !== userId) : [...f.users, userId];
|
||||
void run(() => put(`/folders/${f.id}/access`, { users: next }));
|
||||
};
|
||||
|
||||
const ws = data?.workspace ?? "";
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("folders.intro")}</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||
{data?.folders.length === 0 && <div className="empty">{t("folders.none")}</div>}
|
||||
{data?.folders.map((f) => (
|
||||
<div className="panel" key={f.id}>
|
||||
<h3>
|
||||
📁 <code>{f.path.slice(ws.length) || "/"}</code>
|
||||
<span className="spacer" />
|
||||
<button className="small danger" onClick={() => confirm(t("folders.removeConfirm")) && run(() => del(`/folders/${f.id}`))}>
|
||||
{t("folders.remove")}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="check-list">
|
||||
{users.length === 0 && <div style={{ color: "var(--muted)" }}>{t("folders.noUsers")}</div>}
|
||||
{users.map((u) => (
|
||||
<label key={u.id}>
|
||||
<input type="checkbox" checked={f.users.includes(u.id)} onChange={() => toggle(f, u.id)} />
|
||||
{u.display_name || u.username} <span style={{ color: "var(--muted)" }}>@{u.username}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="panel">
|
||||
<h3>{t("folders.add")}</h3>
|
||||
<div className="row">
|
||||
<code>{ws}/</code>
|
||||
<input value={path} onChange={(e) => setPath(e.target.value)} placeholder="team/project" style={{ flex: 1, minWidth: 160 }} />
|
||||
<button className="primary" disabled={!path.trim()} onClick={() => run(async () => (await post("/folders", { path }), setPath("")))}>
|
||||
+ {t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>{t("folders.addHint")}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { del, patch, post } from "../../api";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { fmtDate, useT } from "../../i18n";
|
||||
import { useStore } from "../../store";
|
||||
import type { User } from "../../types";
|
||||
|
||||
export function Users() {
|
||||
const t = useT();
|
||||
const me = useStore((s) => s.me);
|
||||
const { data: users, reload } = useFetch<User[]>("/users");
|
||||
const [form, setForm] = useState({ username: "", display_name: "", password: "", role: "user" });
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, ok = t("settings.saved")) => {
|
||||
try {
|
||||
await fn();
|
||||
setNotice({ ok: true, text: ok });
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
setNotice({ ok: false, text: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = (u: User) => {
|
||||
const pw = prompt(t("users.newPasswordFor", { name: u.username }));
|
||||
if (pw) void run(() => patch(`/users/${u.id}`, { password: pw }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("users.intro")}</div>
|
||||
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
|
||||
<div className="panel" style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("users.username")}</th>
|
||||
<th>{t("users.displayName")}</th>
|
||||
<th>{t("users.role")}</th>
|
||||
<th>{t("users.login")}</th>
|
||||
<th>{t("users.created")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users?.map((u) => (
|
||||
<tr key={u.id} style={{ opacity: u.disabled ? 0.5 : 1 }}>
|
||||
<td>
|
||||
<code>{u.username}</code>
|
||||
{u.id === me?.id && <span className="chip" style={{ marginLeft: 6 }}>{t("users.you")}</span>}
|
||||
</td>
|
||||
<td>{u.display_name}</td>
|
||||
<td>
|
||||
<select value={u.role} onChange={(e) => run(() => patch(`/users/${u.id}`, { role: e.target.value }))}>
|
||||
<option value="user">{t("users.roleUser")}</option>
|
||||
<option value="admin">{t("users.roleAdmin")}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.has_password && <span className="chip">{t("users.password")}</span>} {u.oidc && <span className="chip">SSO</span>}
|
||||
</td>
|
||||
<td>{fmtDate(u.created_at, false)}</td>
|
||||
<td>
|
||||
<div className="row" style={{ flexWrap: "nowrap" }}>
|
||||
<button className="small" onClick={() => resetPassword(u)}>
|
||||
{t("users.setPassword")}
|
||||
</button>
|
||||
<button className="small" onClick={() => run(() => patch(`/users/${u.id}`, { disabled: !u.disabled }))}>
|
||||
{u.disabled ? t("users.enable") : t("users.disable")}
|
||||
</button>
|
||||
{u.id !== me?.id && (
|
||||
<button className="small danger" onClick={() => confirm(t("users.deleteConfirm", { name: u.username })) && run(() => del(`/users/${u.id}`))}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("users.add")}</h3>
|
||||
<div className="form">
|
||||
<label>{t("users.username")}</label>
|
||||
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} autoCapitalize="none" />
|
||||
<div className="hint">{t("users.usernameHint")}</div>
|
||||
<label>{t("users.displayName")}</label>
|
||||
<input value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} />
|
||||
<label>{t("users.password")}</label>
|
||||
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} autoComplete="new-password" />
|
||||
<div className="hint">{t("users.passwordHint")}</div>
|
||||
<label>{t("users.role")}</label>
|
||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||
<option value="user">{t("users.roleUser")}</option>
|
||||
<option value="admin">{t("users.roleAdmin")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="primary"
|
||||
style={{ marginTop: 12 }}
|
||||
disabled={!form.username.trim()}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
await post("/users", { ...form, password: form.password || undefined });
|
||||
setForm({ username: "", display_name: "", password: "", role: "user" });
|
||||
})
|
||||
}
|
||||
>
|
||||
+ {t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+223
-8
@@ -14,8 +14,116 @@ export const de: Messages = {
|
||||
"nav.settings": "Einstellungen",
|
||||
"nav.stats": "Statistik",
|
||||
|
||||
"login.prompt": "Gib das auf dem Server konfigurierte Zugriffstoken ein.",
|
||||
"login.token": "Zugriffstoken",
|
||||
"nav.images": "Bilder",
|
||||
"images.disabled": "Bildgenerierung ist nicht eingerichtet: Ein Admin aktiviert sie unter Einstellungen → Allgemein (fal.ai-Key in FAL_KEY).",
|
||||
"images.promptPlaceholder": "Beschreibe das Bild: Motiv, Stil, Komposition, Farben, Licht… (Strg+Enter)",
|
||||
"images.model": "Modell",
|
||||
"images.size": "Format",
|
||||
"images.count": "Anzahl",
|
||||
"images.countN": "{n}×",
|
||||
"images.folder": "Ordner",
|
||||
"images.generate": "Erzeugen",
|
||||
"images.generating": "Erzeuge…",
|
||||
"images.empty": "Noch keine Bilder.",
|
||||
"images.reuse": "Prompt wiederverwenden",
|
||||
"images.copyPath": "Dateipfad kopieren",
|
||||
"images.deleteConfirm": "Dieses Bild löschen (inklusive Datei)?",
|
||||
"images.size.square_hd": "Quadrat HD",
|
||||
"images.size.square": "Quadrat",
|
||||
"images.size.landscape_4_3": "Quer 4:3",
|
||||
"images.size.landscape_16_9": "Quer 16:9",
|
||||
"images.size.portrait_4_3": "Hoch 3:4",
|
||||
"images.size.portrait_16_9": "Hoch 9:16",
|
||||
"settings.images": "Bildgenerierung (fal.ai)",
|
||||
"settings.imagesEnabled": "Aktiv",
|
||||
"settings.imagesKey": "API-Key",
|
||||
"settings.imagesDefault": "Standardmodell",
|
||||
"settings.imagesDir": "Ordner im Projekt",
|
||||
"settings.imagesHint": "Modelle und Preise pro Bild werden in der Config-Datei bearbeitet ([images]).",
|
||||
"budget.title": "Budgets in diesem Monat",
|
||||
"budget.spent": "Verbraucht",
|
||||
"budget.budget": "Budget",
|
||||
"budget.remaining": "Verbleibend",
|
||||
"budget.provider": "Laut Anbieter",
|
||||
"budget.balance": "Guthaben {v}",
|
||||
"budget.providerSpent": "Abgerechnet {v}",
|
||||
"budget.orgWide": "Organisationsweiter Kostenbericht des Anbieters",
|
||||
"budget.remoteError": "nicht verfügbar",
|
||||
"budget.hint": "Verbraucht = von CodeCraft mit deinen Preisen erfasste Kosten. Werte des Anbieters kommen aus deren APIs (OpenRouter, Anthropic mit Admin-Key, fal.ai mit Admin-Key).",
|
||||
"budget.chip": "Budget {name}: {p}",
|
||||
"budget.chipHint": "Ein Monatsbudget ist fast aufgebraucht – siehe Statistik.",
|
||||
"settings.monthlyBudget": "Monatsbudget",
|
||||
"settings.monthlyBudgetTotal": "Monatsbudget (gesamt)",
|
||||
"settings.monthlyBudgetHint": "In der eingestellten Währung; 0 = keins. Warnungen erscheinen im Chat-Kopf.",
|
||||
"settings.budgetWarning": "Warnen ab",
|
||||
"settings.adminKey": "Admin-Key (optional)",
|
||||
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
||||
"settings.fetchPrices": "Preise abrufen",
|
||||
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
||||
"stats.perMillion": "pro 1 Mio. Tok.",
|
||||
"stats.perMillionHint": "Effektive Kosten pro Million Tokens (Input inkl. Cache + Output)",
|
||||
"queue.pending": "{n} Nachricht(en) in der Warteschlange – werden beim nächsten Schritt übergeben",
|
||||
"queue.stopped": "{n} Nachricht(en) in der Warteschlange",
|
||||
"queue.sendNow": "Jetzt senden",
|
||||
"queue.remove": "Aus der Warteschlange entfernen",
|
||||
"queue.backgroundResult": "Ergebnis einer Hintergrundaufgabe",
|
||||
"permissionMode.auto": "Automatisch (fragt einmal, Sicheres läuft)",
|
||||
"nav.files": "Dateien",
|
||||
"files.search": "Dateien suchen…",
|
||||
"files.newFile": "Neue Datei",
|
||||
"files.newFolder": "Neuer Ordner",
|
||||
"files.upload": "Hochladen",
|
||||
"files.showHidden": "Versteckte Dateien zeigen",
|
||||
"files.newFileName": "Dateiname:",
|
||||
"files.newFolderName": "Ordnername:",
|
||||
"files.renameTo": "Neuer Name:",
|
||||
"files.copyTo": "Name der Kopie:",
|
||||
"files.rename": "Umbenennen",
|
||||
"files.duplicate": "Duplizieren",
|
||||
"files.download": "Herunterladen",
|
||||
"files.actions": "Aktionen",
|
||||
"files.deleteConfirm": "„{name}“ löschen?",
|
||||
"files.deleteFolderConfirm": "Den Ordner „{name}“ mit allem Inhalt löschen?",
|
||||
"files.noHits": "Keine Treffer",
|
||||
"files.emptyFolder": "Leerer Ordner",
|
||||
"files.dropHint": "Dateien hierher ziehen zum Hochladen",
|
||||
"files.selectFile": "Datei auswählen",
|
||||
"files.edit": "Bearbeiten",
|
||||
"files.preview": "Vorschau",
|
||||
"files.binary": "Binärdatei – zum Öffnen herunterladen.",
|
||||
"files.tooLarge": "Datei zu groß für den Editor – bitte herunterladen.",
|
||||
"files.discardChanges": "Ungespeicherte Änderungen verwerfen?",
|
||||
"files.conflict": "Die Datei wurde seit dem Öffnen geändert (z. B. von einem Agenten). Trotzdem überschreiben?",
|
||||
"files.tabFiles": "Dateien",
|
||||
"git.init": "Git-Repository anlegen",
|
||||
"git.clone": "Repository klonen",
|
||||
"git.cloneUrl": "Repository-URL (https://… oder git@…):",
|
||||
"git.cloneFolder": "Ordnername:",
|
||||
"git.branch": "Branch",
|
||||
"git.newBranch": "Neuer Branch",
|
||||
"git.newBranchName": "Name des neuen Branches:",
|
||||
"git.fetch": "Fetch",
|
||||
"git.pull": "Pull",
|
||||
"git.push": "Push",
|
||||
"git.done": "Erledigt.",
|
||||
"git.changes": "Änderungen",
|
||||
"git.history": "Verlauf",
|
||||
"git.staged": "Vorgemerkt",
|
||||
"git.unstaged": "Änderungen",
|
||||
"git.stage": "Vormerken",
|
||||
"git.unstage": "Nicht mehr vormerken",
|
||||
"git.stageAll": "Alle vormerken",
|
||||
"git.unstageAll": "Alle zurücknehmen",
|
||||
"git.discard": "Änderungen verwerfen",
|
||||
"git.discardConfirm": "Alle Änderungen an {file} verwerfen?",
|
||||
"git.openFile": "Datei öffnen",
|
||||
"git.clean": "Keine Änderungen.",
|
||||
"git.message": "Commit-Nachricht",
|
||||
"git.amend": "Letzten Commit ändern",
|
||||
"git.commit": "Commit",
|
||||
"git.noCommits": "Noch keine Commits.",
|
||||
"git.noDiff": "Keine Unterschiede.",
|
||||
"login.prompt": "Melde dich an, um fortzufahren.",
|
||||
"login.invalid": "Ungültiges Token.",
|
||||
"login.submit": "Anmelden",
|
||||
|
||||
@@ -40,9 +148,23 @@ export const de: Messages = {
|
||||
"new.firstPrompt": "Was soll erledigt werden? (optional)",
|
||||
"new.start": "Starten in {dir}",
|
||||
|
||||
"activity.thinking": "Denkt nach…",
|
||||
"activity.writing": "Schreibt…",
|
||||
"activity.tool": "Führt {tool} aus…",
|
||||
"activity.subagent": "Subagent arbeitet: {name}",
|
||||
"activity.waitingForYou": "Pausiert – wartet auf deine Antwort",
|
||||
"activity.working": "Arbeitet",
|
||||
"activity.titleBusy": "{n} aktiv",
|
||||
"activity.titleInput": "Eingabe nötig",
|
||||
"chat.reasoningLive": "Denkt nach…",
|
||||
"chat.reasoningDone": "Gedacht · {n} Wörter",
|
||||
"settings.reasoningDisplay": "Reasoning des Modells",
|
||||
"settings.reasoningCollapsed": "Eingeklappt (per Klick öffnen)",
|
||||
"settings.reasoningExpanded": "Immer aufgeklappt",
|
||||
"settings.reasoningHidden": "Aus",
|
||||
"settings.reasoningHint": "Die Überlegungen des Modells (sofern Modell und Provider sie liefern).",
|
||||
"settings.reasoningLive": "Beim Denken offen, danach einklappen",
|
||||
"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",
|
||||
@@ -66,7 +188,7 @@ export const de: Messages = {
|
||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||
"command.new": "Neue Session starten",
|
||||
|
||||
"permissionMode.default": "Vor Änderungen fragen",
|
||||
"permissionMode.default": "Fragen (außer Lesebefehle)",
|
||||
"permissionMode.accept_edits": "Änderungen automatisch annehmen",
|
||||
"permissionMode.bypass": "Alles erlauben",
|
||||
|
||||
@@ -75,6 +197,7 @@ export const de: Messages = {
|
||||
|
||||
"tool.input": "Eingabe",
|
||||
"tool.output": "Ausgabe",
|
||||
"tool.todosUpdated": "Aufgaben aktualisiert ({done}/{total} erledigt)",
|
||||
"tool.todos": "Aufgaben",
|
||||
"tool.openSubagent": "Subagent-Session öffnen",
|
||||
|
||||
@@ -107,8 +230,6 @@ export const de: Messages = {
|
||||
"settings.language": "Sprache",
|
||||
"settings.theme": "Design",
|
||||
"settings.themeSystem": "System",
|
||||
"settings.themeDark": "Dunkel",
|
||||
"settings.themeLight": "Hell",
|
||||
"settings.notifications": "Benachrichtigungen",
|
||||
"settings.notificationsEnable": "Aktivieren",
|
||||
"settings.notificationsOn": "Aktiv",
|
||||
@@ -159,6 +280,11 @@ export const de: Messages = {
|
||||
"settings.rawHint": "Die komplette Konfigurationsdatei inklusive Kommentaren. Sie wird vor dem Speichern geprüft.",
|
||||
"settings.revert": "Zurücksetzen",
|
||||
|
||||
"mcp.load": "Tools für das Modell",
|
||||
"mcp.loadAuto": "Automatisch (bei vielen Tools bei Bedarf)",
|
||||
"mcp.loadAlways": "Immer alle Tools mitsenden",
|
||||
"mcp.loadOnDemand": "Bei Bedarf (mcp_search / mcp_call)",
|
||||
"mcp.loadHint": "Jedes direkt mitgesendete Tool kostet bei jeder Anfrage Kontext. Große Server besser bei Bedarf laden.",
|
||||
"mcp.reconnect": "Alle neu verbinden",
|
||||
"mcp.none": "Keine MCP-Server konfiguriert.",
|
||||
"mcp.tools": "{n} Tools",
|
||||
@@ -183,7 +309,6 @@ export const de: Messages = {
|
||||
"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",
|
||||
@@ -225,4 +350,94 @@ export const de: Messages = {
|
||||
"stats.total": "Gesamt",
|
||||
"stats.other": "Sonstige",
|
||||
"stats.noData": "Keine Anfragen in diesem Zeitraum.",
|
||||
|
||||
"common.cancel": "Abbrechen",
|
||||
"nav.logout": "Abmelden",
|
||||
"login.username": "Benutzername",
|
||||
"login.password": "Passwort",
|
||||
"login.passwordRepeat": "Passwort wiederholen",
|
||||
"login.setupPrompt": "Noch keine Benutzer. Gib den Setup-Code aus dem Server-Log ein und lege den ersten Admin an.",
|
||||
"login.setupCode": "Setup-Code",
|
||||
"login.createAdmin": "Admin anlegen",
|
||||
"login.invalidCode": "Ungültiger Setup-Code.",
|
||||
"login.mismatch": "Die Passwörter stimmen nicht überein.",
|
||||
"login.or": "oder",
|
||||
"sidebar.shared": "Mit dir geteilt",
|
||||
"chat.readOnly": "nur lesen",
|
||||
"chat.readOnlyHint": "Diese Session wurde mit dir geteilt – du kannst mitlesen, aber nicht schreiben.",
|
||||
"chat.backgroundDone": "Hintergrundaufgabe fertig",
|
||||
"chat.waitingModel": "Warte auf freien Slot bei {model}…",
|
||||
"chat.waitingSubagent": "Warte auf freien Subagent-Slot ({agent})…",
|
||||
"chat.anySubagent": "Limit für alle Subagenten",
|
||||
"tool.background": "Hintergrund",
|
||||
"share.title": "Session teilen",
|
||||
"share.hint": "Ausgewählte Benutzer können diese Session lesend verfolgen.",
|
||||
"share.noUsers": "Keine weiteren Benutzer.",
|
||||
"settings.tab.users": "Benutzer",
|
||||
"settings.tab.folders": "Freigegebene Ordner",
|
||||
"settings.tab.account": "Konto",
|
||||
"settings.maxParallelSubagents": "Parallele Subagenten",
|
||||
"settings.maxParallelSubagentsHint": "Max. gleichzeitig laufende Subagenten über alle Sessions (0 = unbegrenzt).",
|
||||
"settings.maxConcurrency": "Parallele Anfragen",
|
||||
"settings.maxConcurrencyHint": "Max. gleichzeitige Anfragen an diesen Host/dieses Modell; weitere warten in einer Warteschlange. 1 für Server, die nur eine Anfrage gleichzeitig verarbeiten.",
|
||||
"settings.slots": "Slots",
|
||||
"account.title": "Konto",
|
||||
"account.current": "Aktuelles Passwort",
|
||||
"account.new": "Neues Passwort",
|
||||
"account.change": "Passwort ändern",
|
||||
"account.changed": "Passwort geändert. Andere Geräte wurden abgemeldet.",
|
||||
"account.hint": "Mindestens 8 Zeichen.",
|
||||
"users.intro": "Benutzer arbeiten in ihrem eigenen Ordner <workspace>/<benutzername> und in den für sie freigegebenen Ordnern. Admins können den ganzen Workspace nutzen und den Server verwalten.",
|
||||
"users.username": "Benutzername",
|
||||
"users.usernameHint": "Buchstaben, Ziffern, - _ . – gleichzeitig der Name des Benutzerordners.",
|
||||
"users.displayName": "Anzeigename",
|
||||
"users.role": "Rolle",
|
||||
"users.roleUser": "Benutzer",
|
||||
"users.roleAdmin": "Admin",
|
||||
"users.login": "Anmeldung",
|
||||
"users.password": "Passwort",
|
||||
"users.passwordHint": "Leer lassen für reine Single-Sign-on-Anmeldung.",
|
||||
"users.created": "Angelegt",
|
||||
"users.you": "du",
|
||||
"users.setPassword": "Passwort setzen",
|
||||
"users.newPasswordFor": "Neues Passwort für {name} (mind. 8 Zeichen):",
|
||||
"users.enable": "Aktivieren",
|
||||
"users.disable": "Deaktivieren",
|
||||
"users.deleteConfirm": "Benutzer „{name}“ löschen? Seine Sessions bleiben für Admins sichtbar.",
|
||||
"users.add": "Benutzer hinzufügen",
|
||||
"folders.intro": "Ordner im Workspace, in denen ausgewählte Benutzer zusätzlich zu ihrem eigenen Ordner arbeiten dürfen.",
|
||||
"folders.none": "Noch keine freigegebenen Ordner.",
|
||||
"folders.noUsers": "Keine Benutzer ohne Admin-Rolle.",
|
||||
"folders.add": "Ordner freigeben",
|
||||
"folders.addHint": "Der Ordner wird angelegt, falls er nicht existiert. Bestehende Projektordner lassen sich ebenso freigeben.",
|
||||
"folders.remove": "Freigabe aufheben",
|
||||
"folders.removeConfirm": "Freigabe dieses Ordners aufheben? Die Dateien bleiben erhalten.",
|
||||
"agents.intro": "Modi und Subagenten mit ihrem Modell. Ein Subagent kann auf einem anderen Modell oder Host laufen – z. B. ein kleines Modell, das im Hintergrund Doku oder Übersetzungen schreibt.",
|
||||
"agents.primaryTitle": "Modi",
|
||||
"agents.subagentTitle": "Subagenten",
|
||||
"agents.modePrimary": "Modus",
|
||||
"agents.modeSubagent": "Subagent",
|
||||
"agents.source.builtin": "eingebaut",
|
||||
"agents.source.config": "Config",
|
||||
"agents.source.file": "Datei",
|
||||
"agents.model": "Modell",
|
||||
"agents.sessionModel": "Session-Modell",
|
||||
"agents.modelHint": "Festes Modell für diesen Agenten; leer = das im Chat gewählte Modell.",
|
||||
"agents.maxParallel": "Max. parallel",
|
||||
"agents.maxParallelShort": "max. {n} parallel",
|
||||
"agents.description": "Beschreibung",
|
||||
"agents.descriptionHint": "Anhand dieser Beschreibung entscheidet der Hauptagent, wann er delegiert.",
|
||||
"agents.mode": "Art",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "alle Tools (z. B. read, glob, grep)",
|
||||
"agents.prompt": "Anweisungen",
|
||||
"agents.reset": "Zurücksetzen",
|
||||
"agents.resetConfirm": "„{name}“ auf die eingebauten Standardwerte zurücksetzen?",
|
||||
"agents.deleteConfirm": "Agent „{name}“ löschen?",
|
||||
"agents.add": "Agent hinzufügen",
|
||||
"agents.addWriter": "Writer (Doku, Zusammenfassungen, Übersetzungen)",
|
||||
"agents.addBlank": "Leerer Agent",
|
||||
"stats.user": "Benutzer",
|
||||
"stats.allUsers": "Alle Benutzer",
|
||||
"stats.byUser": "Nach Benutzer",
|
||||
};
|
||||
+223
-8
@@ -13,8 +13,116 @@ export const en = {
|
||||
"nav.settings": "Settings",
|
||||
"nav.stats": "Statistics",
|
||||
|
||||
"login.prompt": "Enter the access token configured on the server.",
|
||||
"login.token": "Access token",
|
||||
"nav.images": "Images",
|
||||
"images.disabled": "Image generation is not set up: an admin enables it under Settings → General (fal.ai key in FAL_KEY).",
|
||||
"images.promptPlaceholder": "Describe the image: subject, style, composition, colours, lighting… (Ctrl+Enter)",
|
||||
"images.model": "Model",
|
||||
"images.size": "Format",
|
||||
"images.count": "Number",
|
||||
"images.countN": "{n}×",
|
||||
"images.folder": "Folder",
|
||||
"images.generate": "Generate",
|
||||
"images.generating": "Generating…",
|
||||
"images.empty": "No images yet.",
|
||||
"images.reuse": "Reuse prompt",
|
||||
"images.copyPath": "Copy file path",
|
||||
"images.deleteConfirm": "Delete this image (including the file)?",
|
||||
"images.size.square_hd": "Square HD",
|
||||
"images.size.square": "Square",
|
||||
"images.size.landscape_4_3": "Landscape 4:3",
|
||||
"images.size.landscape_16_9": "Landscape 16:9",
|
||||
"images.size.portrait_4_3": "Portrait 3:4",
|
||||
"images.size.portrait_16_9": "Portrait 9:16",
|
||||
"settings.images": "Image generation (fal.ai)",
|
||||
"settings.imagesEnabled": "Enabled",
|
||||
"settings.imagesKey": "API key",
|
||||
"settings.imagesDefault": "Default model",
|
||||
"settings.imagesDir": "Folder in the project",
|
||||
"settings.imagesHint": "Models and prices per image are edited in the config file ([images]).",
|
||||
"budget.title": "Budgets this month",
|
||||
"budget.spent": "Spent",
|
||||
"budget.budget": "Budget",
|
||||
"budget.remaining": "Remaining",
|
||||
"budget.provider": "According to provider",
|
||||
"budget.balance": "Balance {v}",
|
||||
"budget.providerSpent": "Billed {v}",
|
||||
"budget.orgWide": "Organisation-wide cost report of the provider",
|
||||
"budget.remoteError": "not available",
|
||||
"budget.hint": "Spent = costs recorded by CodeCraft with your configured prices. Provider values come from their APIs (OpenRouter, Anthropic with admin key, fal.ai with admin key).",
|
||||
"budget.chip": "Budget {name}: {p}",
|
||||
"budget.chipHint": "A monthly budget is nearly used up – see Statistics.",
|
||||
"settings.monthlyBudget": "Monthly budget",
|
||||
"settings.monthlyBudgetTotal": "Monthly budget (total)",
|
||||
"settings.monthlyBudgetHint": "In the configured currency; 0 = none. Warnings appear in the chat header.",
|
||||
"settings.budgetWarning": "Warn at",
|
||||
"settings.adminKey": "Admin key (optional)",
|
||||
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
||||
"settings.fetchPrices": "Fetch prices",
|
||||
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
||||
"stats.perMillion": "per 1M tok",
|
||||
"stats.perMillionHint": "Effective cost per million tokens (input incl. cache + output)",
|
||||
"queue.pending": "{n} message(s) waiting – delivered at the agent's next step",
|
||||
"queue.stopped": "{n} message(s) waiting",
|
||||
"queue.sendNow": "Send now",
|
||||
"queue.remove": "Remove from queue",
|
||||
"queue.backgroundResult": "Result of a background task",
|
||||
"permissionMode.auto": "Automatic (asks once, safe steps run)",
|
||||
"nav.files": "Files",
|
||||
"files.search": "Search files…",
|
||||
"files.newFile": "New file",
|
||||
"files.newFolder": "New folder",
|
||||
"files.upload": "Upload",
|
||||
"files.showHidden": "Show hidden files",
|
||||
"files.newFileName": "File name:",
|
||||
"files.newFolderName": "Folder name:",
|
||||
"files.renameTo": "New name:",
|
||||
"files.copyTo": "Name of the copy:",
|
||||
"files.rename": "Rename",
|
||||
"files.duplicate": "Duplicate",
|
||||
"files.download": "Download",
|
||||
"files.actions": "Actions",
|
||||
"files.deleteConfirm": "Delete \"{name}\"?",
|
||||
"files.deleteFolderConfirm": "Delete the folder \"{name}\" with everything in it?",
|
||||
"files.noHits": "No matches",
|
||||
"files.emptyFolder": "Empty folder",
|
||||
"files.dropHint": "Drop files here to upload",
|
||||
"files.selectFile": "Select a file",
|
||||
"files.edit": "Edit",
|
||||
"files.preview": "Preview",
|
||||
"files.binary": "Binary file – download it to open.",
|
||||
"files.tooLarge": "File too large for the editor – download it.",
|
||||
"files.discardChanges": "Discard unsaved changes?",
|
||||
"files.conflict": "The file was changed on disk (e.g. by an agent) since you opened it. Overwrite it anyway?",
|
||||
"files.tabFiles": "Files",
|
||||
"git.init": "Initialise git repository",
|
||||
"git.clone": "Clone repository",
|
||||
"git.cloneUrl": "Repository URL (https://… or git@…):",
|
||||
"git.cloneFolder": "Folder name:",
|
||||
"git.branch": "Branch",
|
||||
"git.newBranch": "New branch",
|
||||
"git.newBranchName": "Name of the new branch:",
|
||||
"git.fetch": "Fetch",
|
||||
"git.pull": "Pull",
|
||||
"git.push": "Push",
|
||||
"git.done": "Done.",
|
||||
"git.changes": "Changes",
|
||||
"git.history": "History",
|
||||
"git.staged": "Staged",
|
||||
"git.unstaged": "Changes",
|
||||
"git.stage": "Stage",
|
||||
"git.unstage": "Unstage",
|
||||
"git.stageAll": "Stage all",
|
||||
"git.unstageAll": "Unstage all",
|
||||
"git.discard": "Discard changes",
|
||||
"git.discardConfirm": "Discard all changes to {file}?",
|
||||
"git.openFile": "Open file",
|
||||
"git.clean": "No changes.",
|
||||
"git.message": "Commit message",
|
||||
"git.amend": "Amend last commit",
|
||||
"git.commit": "Commit",
|
||||
"git.noCommits": "No commits yet.",
|
||||
"git.noDiff": "No differences.",
|
||||
"login.prompt": "Sign in to continue.",
|
||||
"login.invalid": "Invalid token.",
|
||||
"login.submit": "Sign in",
|
||||
|
||||
@@ -39,9 +147,23 @@ export const en = {
|
||||
"new.firstPrompt": "What should be done? (optional)",
|
||||
"new.start": "Start in {dir}",
|
||||
|
||||
"activity.thinking": "Thinking…",
|
||||
"activity.writing": "Writing…",
|
||||
"activity.tool": "Running {tool}…",
|
||||
"activity.subagent": "Subagent working: {name}",
|
||||
"activity.waitingForYou": "Paused – waiting for your answer",
|
||||
"activity.working": "Working",
|
||||
"activity.titleBusy": "{n} working",
|
||||
"activity.titleInput": "Input needed",
|
||||
"chat.reasoningLive": "Thinking…",
|
||||
"chat.reasoningDone": "Thought · {n} words",
|
||||
"settings.reasoningDisplay": "Model reasoning",
|
||||
"settings.reasoningCollapsed": "Collapsed (click to open)",
|
||||
"settings.reasoningExpanded": "Always expanded",
|
||||
"settings.reasoningHidden": "Off",
|
||||
"settings.reasoningHint": "Thinking of the model (if the model and provider return it).",
|
||||
"settings.reasoningLive": "Open while thinking, then collapse",
|
||||
"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",
|
||||
@@ -65,7 +187,7 @@ export const en = {
|
||||
"command.compact": "Summarize the conversation to free up context",
|
||||
"command.new": "Start a new session",
|
||||
|
||||
"permissionMode.default": "Ask before changes",
|
||||
"permissionMode.default": "Ask (except read-only commands)",
|
||||
"permissionMode.accept_edits": "Auto-accept edits",
|
||||
"permissionMode.bypass": "Allow everything",
|
||||
|
||||
@@ -74,6 +196,7 @@ export const en = {
|
||||
|
||||
"tool.input": "Input",
|
||||
"tool.output": "Output",
|
||||
"tool.todosUpdated": "Tasks updated ({done}/{total} done)",
|
||||
"tool.todos": "Tasks",
|
||||
"tool.openSubagent": "Open subagent session",
|
||||
|
||||
@@ -106,8 +229,6 @@ export const en = {
|
||||
"settings.language": "Language",
|
||||
"settings.theme": "Theme",
|
||||
"settings.themeSystem": "System",
|
||||
"settings.themeDark": "Dark",
|
||||
"settings.themeLight": "Light",
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.notificationsEnable": "Enable",
|
||||
"settings.notificationsOn": "Enabled",
|
||||
@@ -158,6 +279,11 @@ export const en = {
|
||||
"settings.rawHint": "The complete configuration file including comments. It is validated before saving.",
|
||||
"settings.revert": "Revert",
|
||||
|
||||
"mcp.load": "Tools for the model",
|
||||
"mcp.loadAuto": "Automatic (on demand when many tools)",
|
||||
"mcp.loadAlways": "Always send all tools",
|
||||
"mcp.loadOnDemand": "On demand (mcp_search / mcp_call)",
|
||||
"mcp.loadHint": "Every tool sent directly costs context on every request. Large servers are better loaded on demand.",
|
||||
"mcp.reconnect": "Reconnect all",
|
||||
"mcp.none": "No MCP servers configured.",
|
||||
"mcp.tools": "{n} tools",
|
||||
@@ -182,7 +308,6 @@ export const en = {
|
||||
"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",
|
||||
@@ -224,6 +349,96 @@ export const en = {
|
||||
"stats.total": "Total",
|
||||
"stats.other": "Other",
|
||||
"stats.noData": "No requests in this period.",
|
||||
|
||||
"common.cancel": "Cancel",
|
||||
"nav.logout": "Sign out",
|
||||
"login.username": "Username",
|
||||
"login.password": "Password",
|
||||
"login.passwordRepeat": "Repeat password",
|
||||
"login.setupPrompt": "No users yet. Enter the setup code from the server log and create the first admin.",
|
||||
"login.setupCode": "Setup code",
|
||||
"login.createAdmin": "Create admin",
|
||||
"login.invalidCode": "Invalid setup code.",
|
||||
"login.mismatch": "The passwords do not match.",
|
||||
"login.or": "or",
|
||||
"sidebar.shared": "Shared with you",
|
||||
"chat.readOnly": "read-only",
|
||||
"chat.readOnlyHint": "This session was shared with you – you can follow it, but not write.",
|
||||
"chat.backgroundDone": "Background task finished",
|
||||
"chat.waitingModel": "Waiting for a free slot on {model}…",
|
||||
"chat.waitingSubagent": "Waiting for a free subagent slot ({agent})…",
|
||||
"chat.anySubagent": "limit for all subagents",
|
||||
"tool.background": "background",
|
||||
"share.title": "Share session",
|
||||
"share.hint": "Selected users can follow this session read-only.",
|
||||
"share.noUsers": "No other users.",
|
||||
"settings.tab.users": "Users",
|
||||
"settings.tab.folders": "Shared folders",
|
||||
"settings.tab.account": "Account",
|
||||
"settings.maxParallelSubagents": "Parallel subagents",
|
||||
"settings.maxParallelSubagentsHint": "Max. subagents running at the same time across all sessions (0 = unlimited).",
|
||||
"settings.maxConcurrency": "Parallel requests",
|
||||
"settings.maxConcurrencyHint": "Max. simultaneous requests to this host/model; more wait in a queue. 1 for servers that process one request at a time.",
|
||||
"settings.slots": "Slots",
|
||||
"account.title": "Account",
|
||||
"account.current": "Current password",
|
||||
"account.new": "New password",
|
||||
"account.change": "Change password",
|
||||
"account.changed": "Password changed. Other devices were signed out.",
|
||||
"account.hint": "At least 8 characters.",
|
||||
"users.intro": "Users work in their own folder <workspace>/<username> plus the folders shared with them. Admins can use the whole workspace and manage the server.",
|
||||
"users.username": "Username",
|
||||
"users.usernameHint": "Letters, digits, - _ . – also the name of the user's folder.",
|
||||
"users.displayName": "Display name",
|
||||
"users.role": "Role",
|
||||
"users.roleUser": "User",
|
||||
"users.roleAdmin": "Admin",
|
||||
"users.login": "Sign-in",
|
||||
"users.password": "Password",
|
||||
"users.passwordHint": "Leave empty for single sign-on only.",
|
||||
"users.created": "Created",
|
||||
"users.you": "you",
|
||||
"users.setPassword": "Set password",
|
||||
"users.newPasswordFor": "New password for {name} (min. 8 characters):",
|
||||
"users.enable": "Enable",
|
||||
"users.disable": "Disable",
|
||||
"users.deleteConfirm": "Delete user \"{name}\"? Their sessions stay visible to admins.",
|
||||
"users.add": "Add user",
|
||||
"folders.intro": "Folders inside the workspace that selected users may work in, in addition to their own folder.",
|
||||
"folders.none": "No shared folders yet.",
|
||||
"folders.noUsers": "No users without admin role.",
|
||||
"folders.add": "Share a folder",
|
||||
"folders.addHint": "The folder is created if it does not exist. Existing project folders can be shared too.",
|
||||
"folders.remove": "Stop sharing",
|
||||
"folders.removeConfirm": "Stop sharing this folder? The files are kept.",
|
||||
"agents.intro": "Modes and subagents with their model. A subagent can run on another model or host – e.g. a small model that writes docs or translations in the background.",
|
||||
"agents.primaryTitle": "Modes",
|
||||
"agents.subagentTitle": "Subagents",
|
||||
"agents.modePrimary": "mode",
|
||||
"agents.modeSubagent": "subagent",
|
||||
"agents.source.builtin": "built-in",
|
||||
"agents.source.config": "config",
|
||||
"agents.source.file": "file",
|
||||
"agents.model": "Model",
|
||||
"agents.sessionModel": "Session model",
|
||||
"agents.modelHint": "Fixed model for this agent; empty = the model selected in the chat.",
|
||||
"agents.maxParallel": "Max. parallel",
|
||||
"agents.maxParallelShort": "max. {n} parallel",
|
||||
"agents.description": "Description",
|
||||
"agents.descriptionHint": "The main agent decides by this description when to delegate.",
|
||||
"agents.mode": "Type",
|
||||
"agents.tools": "Tools",
|
||||
"agents.toolsAll": "all tools (e.g. read, glob, grep)",
|
||||
"agents.prompt": "Instructions",
|
||||
"agents.reset": "Reset",
|
||||
"agents.resetConfirm": "Reset \"{name}\" to the built-in defaults?",
|
||||
"agents.deleteConfirm": "Delete agent \"{name}\"?",
|
||||
"agents.add": "Add agent",
|
||||
"agents.addWriter": "Writer (docs, summaries, translations)",
|
||||
"agents.addBlank": "Empty agent",
|
||||
"stats.user": "User",
|
||||
"stats.allUsers": "All users",
|
||||
"stats.byUser": "By user",
|
||||
};
|
||||
|
||||
export type Messages = Record<keyof typeof en, string>;
|
||||
@@ -15,11 +15,10 @@ export const languages: Record<string, { name: string; messages: Messages }> = {
|
||||
|
||||
const STORAGE_KEY = "codecraft.lang";
|
||||
|
||||
/** English is the default; other languages are chosen in the settings. */
|
||||
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";
|
||||
return saved && languages[saved] ? saved : "en";
|
||||
}
|
||||
|
||||
let current = detect();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Per-device display preferences (localStorage), observable from React.
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type ReasoningMode = "collapsed" | "live" | "expanded" | "hidden";
|
||||
|
||||
const KEY = "codecraft.reasoning";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getReasoningMode(): ReasoningMode {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === "live" || v === "expanded" || v === "hidden" ? v : "collapsed";
|
||||
}
|
||||
|
||||
export function setReasoningMode(mode: ReasoningMode) {
|
||||
localStorage.setItem(KEY, mode);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function useReasoningMode(): ReasoningMode {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
getReasoningMode,
|
||||
);
|
||||
}
|
||||
+24
-2
@@ -1,6 +1,8 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { get } from "./api";
|
||||
import type {
|
||||
QueueItem,
|
||||
User,
|
||||
McpServerStatus,
|
||||
Message,
|
||||
Part,
|
||||
@@ -22,11 +24,14 @@ export interface SessionError {
|
||||
}
|
||||
|
||||
export interface State {
|
||||
me: User | null;
|
||||
connected: boolean;
|
||||
sessions: Session[];
|
||||
/** Includes subagent sessions loaded on demand. */
|
||||
sessionById: Record<string, Session>;
|
||||
messages: Record<string, Message[]>;
|
||||
/** Sessions the current user may only read (shared with them). */
|
||||
readOnly: Record<string, boolean>;
|
||||
drafts: Record<string, Draft | undefined>;
|
||||
busy: Record<string, boolean>;
|
||||
progress: Record<string, string>;
|
||||
@@ -34,13 +39,19 @@ export interface State {
|
||||
questions: QuestionRequest[];
|
||||
errors: Record<string, SessionError[]>;
|
||||
mcp: Record<string, McpServerStatus>;
|
||||
/** Messages waiting for a busy session. */
|
||||
queue: Record<string, QueueItem[]>;
|
||||
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
|
||||
waiting: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
let state: State = {
|
||||
me: null,
|
||||
connected: false,
|
||||
sessions: [],
|
||||
sessionById: {},
|
||||
messages: {},
|
||||
readOnly: {},
|
||||
drafts: {},
|
||||
busy: {},
|
||||
progress: {},
|
||||
@@ -48,6 +59,8 @@ let state: State = {
|
||||
questions: [],
|
||||
errors: {},
|
||||
mcp: {},
|
||||
queue: {},
|
||||
waiting: {},
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -157,6 +170,12 @@ export function applyEvent(ev: ServerEvent) {
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "queue_updated":
|
||||
setState((s) => ({ queue: { ...s.queue, [ev.session_id]: ev.items } }));
|
||||
break;
|
||||
case "waiting":
|
||||
setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } }));
|
||||
break;
|
||||
case "mcp_status":
|
||||
setState(() => ({ mcp: ev.servers }));
|
||||
break;
|
||||
@@ -188,18 +207,21 @@ export async function loadSessions() {
|
||||
}
|
||||
|
||||
export async function loadSession(id: string) {
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean }>(`/sessions/${id}`);
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[] }>(`/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 },
|
||||
readOnly: { ...s.readOnly, [id]: !data.can_write },
|
||||
queue: { ...s.queue, [id]: data.queue ?? [] },
|
||||
}));
|
||||
}
|
||||
|
||||
/** After reconnects: reload everything that could have changed. */
|
||||
export async function resync() {
|
||||
const [pending, mcp] = await Promise.all([get<any[]>("/pending"), get("/mcp")]);
|
||||
const isAdmin = getState().me?.role === "admin";
|
||||
const [pending, mcp] = await Promise.all([get<any[]>("/pending"), isAdmin ? get("/mcp") : Promise.resolve({})]);
|
||||
setState(() => ({
|
||||
permissions: pending.filter((e) => e.type === "permission_request"),
|
||||
questions: pending.filter((e) => e.type === "question_request"),
|
||||
|
||||
+772
-137
@@ -1,126 +1,6 @@
|
||||
/* CodeCraft – Gruvbox theme (dark default, light via data-theme or OS). */
|
||||
/* CodeCraft – layout and components. Colours come exclusively from the theme
|
||||
variables defined in src/themes/*.css. */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg0: #282828;
|
||||
--bg1: #32302f;
|
||||
--bg2: #3c3836;
|
||||
--bg3: #504945;
|
||||
--fg0: #fbf1c7;
|
||||
--fg1: #ebdbb2;
|
||||
--fg2: #d5c4a1;
|
||||
--fg3: #a89984;
|
||||
--muted: #928374;
|
||||
--accent: #fabd2f;
|
||||
--accent2: #fe8019;
|
||||
--red: #fb4934;
|
||||
--green: #b8bb26;
|
||||
--aqua: #8ec07c;
|
||||
--blue: #83a598;
|
||||
--purple: #d3869b;
|
||||
--diff-add: rgba(184, 187, 38, 0.16);
|
||||
--diff-del: rgba(251, 73, 52, 0.16);
|
||||
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
|
||||
/* Data viz: validated categorical palette (dark steps on #282828). */
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
/* Sequential (heatmap), blue ramp: low → high on dark. */
|
||||
--seq-0: #32302f;
|
||||
--seq-1: #104281;
|
||||
--seq-2: #184f95;
|
||||
--seq-3: #256abf;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #6da7ec;
|
||||
--seq-6: #9ec5f4;
|
||||
--radius: 8px;
|
||||
--font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:where(:not([data-theme="dark"])) {
|
||||
color-scheme: light;
|
||||
--bg0: #fbf1c7;
|
||||
--bg1: #f2e5bc;
|
||||
--bg2: #ebdbb2;
|
||||
--bg3: #d5c4a1;
|
||||
--fg0: #282828;
|
||||
--fg1: #3c3836;
|
||||
--fg2: #504945;
|
||||
--fg3: #665c54;
|
||||
--muted: #7c6f64;
|
||||
--accent: #b57614;
|
||||
--accent2: #af3a03;
|
||||
--red: #9d0006;
|
||||
--green: #79740e;
|
||||
--aqua: #427b58;
|
||||
--blue: #076678;
|
||||
--purple: #8f3f71;
|
||||
--diff-add: rgba(121, 116, 14, 0.16);
|
||||
--diff-del: rgba(157, 0, 6, 0.12);
|
||||
--shadow: 0 6px 24px rgba(60, 56, 54, 0.18);
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--seq-0: #f2e5bc;
|
||||
--seq-1: #cde2fb;
|
||||
--seq-2: #9ec5f4;
|
||||
--seq-3: #6da7ec;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #256abf;
|
||||
--seq-6: #184f95;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg0: #fbf1c7;
|
||||
--bg1: #f2e5bc;
|
||||
--bg2: #ebdbb2;
|
||||
--bg3: #d5c4a1;
|
||||
--fg0: #282828;
|
||||
--fg1: #3c3836;
|
||||
--fg2: #504945;
|
||||
--fg3: #665c54;
|
||||
--muted: #7c6f64;
|
||||
--accent: #b57614;
|
||||
--accent2: #af3a03;
|
||||
--red: #9d0006;
|
||||
--green: #79740e;
|
||||
--aqua: #427b58;
|
||||
--blue: #076678;
|
||||
--purple: #8f3f71;
|
||||
--diff-add: rgba(121, 116, 14, 0.16);
|
||||
--diff-del: rgba(157, 0, 6, 0.12);
|
||||
--shadow: 0 6px 24px rgba(60, 56, 54, 0.18);
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--seq-0: #f2e5bc;
|
||||
--seq-1: #cde2fb;
|
||||
--seq-2: #9ec5f4;
|
||||
--seq-3: #6da7ec;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #256abf;
|
||||
--seq-6: #184f95;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -176,7 +56,7 @@ button:disabled {
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #282828;
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.danger {
|
||||
@@ -335,7 +215,8 @@ textarea:focus {
|
||||
border-top: 1px solid var(--bg2);
|
||||
padding: 8px;
|
||||
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
.sidebar-foot button {
|
||||
@@ -503,13 +384,41 @@ textarea:focus {
|
||||
padding-left: 10px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.reasoning summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.reasoning-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 2px 0;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.reasoning-head:hover {
|
||||
color: var(--fg2);
|
||||
}
|
||||
.reasoning .body {
|
||||
white-space: pre-wrap;
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reasoning.live .body {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.reasoning.live {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.streaming-text::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.5em;
|
||||
height: 1em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--muted);
|
||||
animation: blink 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
.tool {
|
||||
@@ -750,8 +659,29 @@ textarea:focus {
|
||||
font-size: 12.5px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.composer-bar .grow {
|
||||
.composer-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.composer-options > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.todo-line {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin: 4px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mode-tabs {
|
||||
display: inline-flex;
|
||||
@@ -768,7 +698,7 @@ textarea:focus {
|
||||
}
|
||||
.mode-tabs button.active {
|
||||
background: var(--accent);
|
||||
color: #282828;
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.attachments {
|
||||
@@ -916,6 +846,9 @@ table.grid input {
|
||||
width: 100%;
|
||||
min-width: 60px;
|
||||
}
|
||||
table.grid input[type="number"] {
|
||||
min-width: 92px;
|
||||
}
|
||||
.raw-editor {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
@@ -1123,46 +1056,83 @@ table.grid input {
|
||||
.chat {
|
||||
padding: 12px 10px 20px;
|
||||
}
|
||||
.hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
.topbar {
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
.topbar .path {
|
||||
display: none;
|
||||
}
|
||||
.topbar button.small {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
/* One scrollable row of options, actions stay visible. */
|
||||
.composer-bar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.composer-options {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.composer-options::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.composer-options select {
|
||||
max-width: 150px;
|
||||
}
|
||||
/* Avoid iOS zoom on focus. */
|
||||
.composer textarea,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- syntax highlighting (highlight.js classes, Gruvbox colours) ----------- */
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-built_in {
|
||||
color: var(--red);
|
||||
color: var(--syntax-keyword);
|
||||
}
|
||||
.hljs-string,
|
||||
.hljs-attr,
|
||||
.hljs-regexp {
|
||||
color: var(--green);
|
||||
color: var(--syntax-string);
|
||||
}
|
||||
.hljs-number,
|
||||
.hljs-literal {
|
||||
color: var(--purple);
|
||||
color: var(--syntax-number);
|
||||
}
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: var(--muted);
|
||||
color: var(--syntax-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-function {
|
||||
color: var(--aqua);
|
||||
color: var(--syntax-function);
|
||||
}
|
||||
.hljs-type,
|
||||
.hljs-class,
|
||||
.hljs-tag {
|
||||
color: var(--accent);
|
||||
color: var(--syntax-type);
|
||||
}
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-params {
|
||||
color: var(--blue);
|
||||
color: var(--syntax-variable);
|
||||
}
|
||||
.hljs-meta,
|
||||
.hljs-symbol {
|
||||
color: var(--accent2);
|
||||
color: var(--syntax-meta);
|
||||
}
|
||||
.hljs-addition {
|
||||
background: var(--diff-add);
|
||||
@@ -1170,3 +1140,668 @@ table.grid input {
|
||||
.hljs-deletion {
|
||||
background: var(--diff-del);
|
||||
}
|
||||
|
||||
a.button {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: var(--fg1);
|
||||
border: 1px solid var(--bg3);
|
||||
background: var(--bg2);
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
a.button:hover {
|
||||
border-color: var(--fg3);
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--bg2);
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.sidebar-user > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 30;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
width: min(440px, 100%);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.modal h3 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.check-list label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
}
|
||||
.readonly-bar {
|
||||
border-top: 1px solid var(--bg2);
|
||||
padding: 10px 14px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* --- activity animations ---------------------------------------------------- */
|
||||
|
||||
.activity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
font-size: 13px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
.activity.paused {
|
||||
color: var(--accent2);
|
||||
}
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.dots i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: dot-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.dots i:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.dots i:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes dot-bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-5px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.pause-icon {
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
border-left: 3px solid var(--accent2);
|
||||
border-right: 3px solid var(--accent2);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg3);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.spinner.small {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tool.running {
|
||||
border-color: var(--accent);
|
||||
animation: glow 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes glow {
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.busy-ring {
|
||||
flex-shrink: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg3);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Blinking caret at the end of streaming text. */
|
||||
.streaming .markdown > :last-child::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--accent);
|
||||
animation: blink 1s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Indeterminate progress bar under the top bar. */
|
||||
.workbar {
|
||||
height: 2px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.workbar.on::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 35%;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
animation: slide 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes slide {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dots i,
|
||||
.spinner,
|
||||
.busy-ring,
|
||||
.tool.running,
|
||||
.streaming .markdown > :last-child::after,
|
||||
.streaming-text::after,
|
||||
.workbar.on::before {
|
||||
animation: none;
|
||||
}
|
||||
.dots i {
|
||||
opacity: 1;
|
||||
}
|
||||
.workbar.on::before {
|
||||
width: 100%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- image gallery --------------------------------------------------------- */
|
||||
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.gallery.small {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
margin-top: 6px;
|
||||
}
|
||||
.gallery-item {
|
||||
margin: 0;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
background: var(--bg2);
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.gallery-item.placeholder {
|
||||
aspect-ratio: 4 / 3;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.gallery-item figcaption {
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.gallery-item .prompt {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.gallery-item .meta,
|
||||
.viewer .meta {
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
a.button.small {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.viewer {
|
||||
margin: 0;
|
||||
max-width: min(1200px, 96vw);
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--fg0);
|
||||
}
|
||||
.viewer img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.budget-bar {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.budget-bar > div {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* --- message queue --------------------------------------------------------- */
|
||||
.queue {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 6px 12px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.queue-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px dashed var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
padding: 5px 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
background: var(--bg1);
|
||||
}
|
||||
.queue-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.queue-item img {
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* --- file manager ------------------------------------------------------------ */
|
||||
|
||||
.files-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 360px) 1fr;
|
||||
}
|
||||
.files-side {
|
||||
border-right: 1px solid var(--bg2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.files-main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
.files-list.drag {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
.files-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.files-toolbar button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.files-search {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
.crumbs {
|
||||
padding: 0 10px 6px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
.entries {
|
||||
flex: 1;
|
||||
}
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
button.entry {
|
||||
padding: 6px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.entry:hover,
|
||||
.entry.active {
|
||||
background: var(--bg2);
|
||||
}
|
||||
.entry-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 6px 4px 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.entry .name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.entry .size {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.entry-menu {
|
||||
position: relative;
|
||||
}
|
||||
.entry-menu summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.entry-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.entry-menu .menu {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 100%;
|
||||
z-index: 5;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
min-width: 150px;
|
||||
padding: 4px;
|
||||
}
|
||||
.entry-menu .menu > * {
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.entry-menu .menu > *:hover {
|
||||
background: var(--bg2);
|
||||
}
|
||||
.drop-hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.git-mark {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
color: var(--accent2);
|
||||
}
|
||||
.git-mark.m-A,
|
||||
.git-mark.m-U {
|
||||
color: var(--green);
|
||||
}
|
||||
.git-mark.m-D {
|
||||
color: var(--red);
|
||||
}
|
||||
.git-mark.m-dot {
|
||||
color: var(--muted);
|
||||
}
|
||||
.editor-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.editor-pane.empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.editor-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
}
|
||||
.editor-bar .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dirty {
|
||||
color: var(--accent);
|
||||
}
|
||||
.code-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-editor .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
.pane-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.image-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: repeating-conic-gradient(var(--bg1) 0% 25%, var(--bg0) 0% 50%) 50% / 20px 20px;
|
||||
}
|
||||
.image-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.files-status {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.git-init {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--bg2);
|
||||
}
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* git panel */
|
||||
.git-panel {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.git-branch {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.git-branch select {
|
||||
max-width: 160px;
|
||||
}
|
||||
.git-output {
|
||||
font-size: 12px;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
white-space: pre-wrap;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.git-output.err {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.git-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.git-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.git-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.git-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 3px 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.git-commit textarea {
|
||||
width: 100%;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.git-log {
|
||||
display: grid;
|
||||
}
|
||||
.git-commit-row {
|
||||
display: grid;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
.git-commit-row:hover {
|
||||
background: var(--bg2);
|
||||
}
|
||||
.git-commit-row .subject {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.git-commit-row .meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.files-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.files-main {
|
||||
display: none;
|
||||
}
|
||||
.files-layout.show-editor .files-side {
|
||||
display: none;
|
||||
}
|
||||
.files-layout.show-editor .files-main {
|
||||
display: flex;
|
||||
}
|
||||
.mobile-only {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
+29
-6
@@ -1,15 +1,38 @@
|
||||
import "./themes";
|
||||
|
||||
const KEY = "codecraft.theme";
|
||||
|
||||
export type Theme = "system" | "dark" | "light";
|
||||
/** Available themes; "system" follows the OS light/dark preference. */
|
||||
export const THEMES = [
|
||||
{ id: "system", name: "System", dark: true },
|
||||
{ id: "gruvbox-dark", name: "Gruvbox Dark", dark: true },
|
||||
{ id: "gruvbox-light", name: "Gruvbox Light", dark: false },
|
||||
] as const;
|
||||
|
||||
export type Theme = (typeof THEMES)[number]["id"];
|
||||
|
||||
/** Themes used for "system" in dark and light OS mode. */
|
||||
const SYSTEM_DARK = "gruvbox-dark";
|
||||
const SYSTEM_LIGHT = "gruvbox-light";
|
||||
|
||||
export function getTheme(): Theme {
|
||||
return (localStorage.getItem(KEY) as Theme) ?? "system";
|
||||
const saved = localStorage.getItem(KEY);
|
||||
// Migrate the old values "dark"/"light".
|
||||
if (saved === "dark") return "gruvbox-dark";
|
||||
if (saved === "light") return "gruvbox-light";
|
||||
return THEMES.some((t) => t.id === saved) ? (saved as Theme) : "system";
|
||||
}
|
||||
|
||||
const media = matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
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");
|
||||
const resolved = theme === "system" ? (media.matches ? SYSTEM_DARK : SYSTEM_LIGHT) : theme;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
const color = getComputedStyle(document.documentElement).getPropertyValue("--theme-color").trim();
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", color || "#282828");
|
||||
}
|
||||
|
||||
media.addEventListener("change", () => {
|
||||
if (getTheme() === "system") applyTheme("system");
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Theme-independent tokens. */
|
||||
:root {
|
||||
--radius: 8px;
|
||||
--font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Gruvbox Dark – also the default before a theme is applied. */
|
||||
:root,
|
||||
:root[data-theme="gruvbox-dark"] {
|
||||
color-scheme: dark;
|
||||
--theme-color: #282828;
|
||||
|
||||
--bg0: #282828;
|
||||
--bg1: #32302f;
|
||||
--bg2: #3c3836;
|
||||
--bg3: #504945;
|
||||
--fg0: #fbf1c7;
|
||||
--fg1: #ebdbb2;
|
||||
--fg2: #d5c4a1;
|
||||
--fg3: #a89984;
|
||||
--muted: #928374;
|
||||
|
||||
--accent: #fabd2f;
|
||||
--accent2: #fe8019;
|
||||
--on-accent: #282828;
|
||||
--red: #fb4934;
|
||||
--green: #b8bb26;
|
||||
--aqua: #8ec07c;
|
||||
--blue: #83a598;
|
||||
--purple: #d3869b;
|
||||
|
||||
--diff-add: rgba(184, 187, 38, 0.16);
|
||||
--diff-del: rgba(251, 73, 52, 0.16);
|
||||
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
|
||||
--syntax-keyword: #fb4934;
|
||||
--syntax-string: #b8bb26;
|
||||
--syntax-number: #d3869b;
|
||||
--syntax-comment: #928374;
|
||||
--syntax-function: #8ec07c;
|
||||
--syntax-type: #fabd2f;
|
||||
--syntax-variable: #83a598;
|
||||
--syntax-meta: #fe8019;
|
||||
|
||||
/* Validated categorical palette (dark steps on #282828). */
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
--seq-0: #32302f;
|
||||
--seq-1: #104281;
|
||||
--seq-2: #184f95;
|
||||
--seq-3: #256abf;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #6da7ec;
|
||||
--seq-6: #9ec5f4;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Gruvbox Light */
|
||||
:root[data-theme="gruvbox-light"] {
|
||||
color-scheme: light;
|
||||
--theme-color: #fbf1c7;
|
||||
|
||||
--bg0: #fbf1c7;
|
||||
--bg1: #f2e5bc;
|
||||
--bg2: #ebdbb2;
|
||||
--bg3: #d5c4a1;
|
||||
--fg0: #282828;
|
||||
--fg1: #3c3836;
|
||||
--fg2: #504945;
|
||||
--fg3: #665c54;
|
||||
--muted: #7c6f64;
|
||||
|
||||
--accent: #b57614;
|
||||
--accent2: #af3a03;
|
||||
--on-accent: #fbf1c7;
|
||||
--red: #9d0006;
|
||||
--green: #79740e;
|
||||
--aqua: #427b58;
|
||||
--blue: #076678;
|
||||
--purple: #8f3f71;
|
||||
|
||||
--diff-add: rgba(121, 116, 14, 0.16);
|
||||
--diff-del: rgba(157, 0, 6, 0.12);
|
||||
--shadow: 0 6px 24px rgba(60, 56, 54, 0.18);
|
||||
|
||||
--syntax-keyword: #9d0006;
|
||||
--syntax-string: #79740e;
|
||||
--syntax-number: #8f3f71;
|
||||
--syntax-comment: #928374;
|
||||
--syntax-function: #427b58;
|
||||
--syntax-type: #b57614;
|
||||
--syntax-variable: #076678;
|
||||
--syntax-meta: #af3a03;
|
||||
|
||||
/* Validated categorical palette (light steps on #fbf1c7). */
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--seq-0: #f2e5bc;
|
||||
--seq-1: #cde2fb;
|
||||
--seq-2: #9ec5f4;
|
||||
--seq-3: #6da7ec;
|
||||
--seq-4: #3987e5;
|
||||
--seq-5: #256abf;
|
||||
--seq-6: #184f95;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// All bundled themes. Order of imports = cascade order; base first.
|
||||
import "./base.css";
|
||||
import "./gruvbox-dark.css";
|
||||
import "./gruvbox-light.css";
|
||||
+36
-1
@@ -48,13 +48,16 @@ export interface Session {
|
||||
cwd: string;
|
||||
model: string;
|
||||
agent: string;
|
||||
permission_mode: "default" | "accept_edits" | "bypass";
|
||||
permission_mode: "default" | "auto" | "accept_edits" | "bypass";
|
||||
parent_id?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
todos: Todo[] | null;
|
||||
last_usage?: Usage | null;
|
||||
busy?: boolean;
|
||||
owner_id?: string | null;
|
||||
/** Listed because it was shared with the current user. */
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
@@ -103,6 +106,8 @@ export type ServerEvent =
|
||||
| QuestionRequest
|
||||
| { type: "question_resolved"; id: string; session_id: string }
|
||||
| { type: "error"; session_id: string; message: string }
|
||||
| { type: "waiting"; session_id: string; reason: string | null }
|
||||
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||
| { type: "resync" };
|
||||
|
||||
@@ -129,6 +134,10 @@ export interface AgentInfo {
|
||||
description: string;
|
||||
mode: "primary" | "subagent";
|
||||
read_only: boolean;
|
||||
model?: string | null;
|
||||
tools?: string[] | null;
|
||||
source: "builtin" | "config" | "file";
|
||||
max_parallel?: number | null;
|
||||
}
|
||||
|
||||
export interface Aggregate {
|
||||
@@ -145,3 +154,29 @@ export interface Aggregate {
|
||||
cache_hit_rate: number;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: "admin" | "user";
|
||||
has_password: boolean;
|
||||
oidc: boolean;
|
||||
disabled: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
name: string;
|
||||
version: string;
|
||||
setup_required: boolean;
|
||||
oidc: { label: string } | null;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
parts: Part[];
|
||||
source: "user" | "background";
|
||||
created_at: number;
|
||||
}
|
||||
Reference in new issue
Block a user