Compare commits

...
28 Commits
Author SHA1 Message Date
Richard Zink c551171c18 Release v202611-00041 2026-11-15 22:19:56 +01:00
Richard ZinkandClaude Sonnet 5.5 4000084560 Software factory (review phase): per-user Gitea/GitHub/GitLab connections, spec chat, project creation, factory page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 22:19:45 +01:00
Richard Zink b5e2ef146c Release v202611-00040 2026-11-15 15:09:13 +01:00
Richard ZinkandClaude Sonnet 5.5 a2b9528e0b Fix uploaded images reaching Claude Code empty
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 15:09:11 +01:00
Richard Zink 81d9df4e6e Release v202611-00039 2026-11-15 14:59:12 +01:00
Richard ZinkandClaude Sonnet 5.5 ead75e78e0 Neutral chat background for colour tags; answer Claude Code's AskUserQuestion with the question card
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 14:59:10 +01:00
Richard Zink 77f9d4b0e0 Release v202611-00038 2026-11-15 14:37:31 +01:00
Richard ZinkandClaude Sonnet 5.5 103132a359 Sidebar chat menu: add Delete, remove colour and icon pickers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 14:37:29 +01:00
Richard Zink 5d5c0f8124 Release v202611-00037 2026-11-15 13:53:13 +01:00
Richard ZinkandClaude Sonnet 5.5 f77b101847 Ask about the chat's own folder when deleting a chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 13:53:10 +01:00
Richard Zink 12a3198e77 Release v202611-00036 2026-11-15 13:04:03 +01:00
Richard ZinkandClaude Sonnet 5.5 4e6f74d477 Offer all CodeCraft tools to the Claude Code CLI over an MCP bridge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 13:04:00 +01:00
Richard Zink 658148ada7 Release v202611-00035 2026-11-15 12:33:26 +01:00
Richard ZinkandClaude Sonnet 5.5 b6e4500233 Fix SSH key creation as a uid without passwd entry (ssh-keygen, ssh-keyscan, ssh-add wrappers)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 12:33:24 +01:00
Richard Zink 126c7719b9 Release v202611-00034 2026-11-15 12:15:04 +01:00
Richard ZinkandClaude Sonnet 5.5 fccaee4b94 Fix ssh/scp/sftp for containers running as a uid without passwd entry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 12:15:02 +01:00
Richard Zink d58b23d35f Release v202611-00033 2026-11-15 11:47:34 +01:00
Richard ZinkandClaude Sonnet 5.5 6e8e739906 SSH keys in account settings; wrappers for agents, terminal and git
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 11:47:31 +01:00
Richard Zink 247ddcf14a Release v202611-00032 2026-11-15 10:52:10 +01:00
Richard ZinkandClaude Sonnet 5.5 f0cd404f59 Tell agents about stored git credentials; give Claude Code the user's git config
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 10:52:07 +01:00
Richard Zink ec95f9b8ad Release v202611-00031 2026-11-15 09:54:04 +01:00
Richard ZinkandClaude Sonnet 5.5 99ba3835f3 Show images read by agents; keep tool-result images with Claude Code
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 09:54:00 +01:00
Richard Zink 92a99c6a65 Release v202611-00030 2026-11-15 09:24:58 +01:00
Richard ZinkandClaude Sonnet 5.5 4c7d67cabf Add /clear command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 09:24:56 +01:00
Richard Zink 4423148552 Release v202611-00029 2026-11-15 08:20:53 +01:00
Richard ZinkandClaude Sonnet 5.5 388ca96419 Stats history with year filter, folding and XLSX/CSV export; snippets, read-only links, commit suggestions, weekly review, offline copies, server speech-to-text, planning model, job reports; fal.ai health fix
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 08:20:52 +01:00
Richard Zink c08c6c7436 Release v202611-00028 2026-11-14 23:53:20 +01:00
Richard ZinkandClaude Sonnet 5.5 1b3e3af4b7 More chat icons; message menu inside the bubble
- 18 more icons (gamepad, computer, food, ...) with a test that keeps the
  server whitelist and the client picker identical.
- The ⋯ menu of a message moves into the bubble foot (next to the time); its
  popup opens upwards inside the bubble and is limited to the bubble's width.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 23:53:14 +01:00
58 changed files with 5863 additions and 153 deletions

No files matched your search

+3
View File
@@ -9,6 +9,9 @@ CODECRAFT_ADMIN_PASSWORD=
# Directory with your projects, mounted as /workspace. # Directory with your projects, mounted as /workspace.
WORKSPACE=/path/to/your/projects WORKSPACE=/path/to/your/projects
# Directory of the software factory (clones it works in), mounted as /factory.
FACTORY_DIR=./factory
# Host port and address (127.0.0.1 = only reachable through a local reverse proxy). # Host port and address (127.0.0.1 = only reachable through a local reverse proxy).
PORT=8787 PORT=8787
BIND_ADDR=127.0.0.1 BIND_ADDR=127.0.0.1
+139
View File
@@ -3,6 +3,145 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [v202611-00041] – 2026-11-15
### Added
- **Software factory** (sidebar → Factory): specs arrive as issues (label `factory`) on Gitea, GitHub or
GitLab and are worked off by agents in phases, each phase with its own model, agent, permission mode
and time limit. This version contains the **review phase**: an agent reads the spec and the code and
decides *approved*, *questions* (posted as an issue comment; your answer restarts the review) or
*rejected*, and writes acceptance scenarios that stay in the database, away from the builder. Build and
acceptance follow.
- **Connections per user**: every user sets up their own accounts (Gitea, GitHub, GitLab) with their own
token; repositories use the token of their connection or one of their own. Models are free per user,
project and phase.
- **New project with a model**: *Factory → New project* opens a spec chat. When the spec is done, the
*Create project* bar picks name, connection and owner, creates the repository (private), commits the
spec as `docs/specs/<name>.md` and opens the issue that starts the workflow.
- **Overview**: board with all issues by state, a tile per project, and per run the model, status,
duration, tokens and cost, with a link into the chat of that run.
- **Separate factory area**: repositories are cloned into their own mount `/factory` (`FACTORY_DIR` in
`.env`), not into the workspace. The API token is handed to git only as a header and never reaches
agents.
## [v202611-00040] – 2026-11-15
### Fixed
- **Images you upload reach Claude Code**: its chats read the history without the image bytes, so the
files it was pointed to were empty. Images without data are no longer written as empty files.
## [v202611-00039] – 2026-11-15
### Changed
- A chat's colour no longer tints the chat background (pane, message field, agent bubbles, top bar
fill); it still marks the top bar line, the list entry, the badge and your own bubbles.
### Fixed
- **Claude Code can ask questions again**: its `AskUserQuestion` was always rejected; now the question
appears as a question card in the chat (single and multiple choice) and the answers go back to it.
## [v202611-00038] – 2026-11-15
### Changed
- The chat menu in the sidebar has a **Delete** entry (with the question about the chat's own folder);
colour and icon selection moved out of it – they remain in the ⋯ menu inside the chat.
## [v202611-00037] – 2026-11-15
### Added
- **Deleting a chat asks about its folder**: if the chat has a folder of its own (inside your folders,
not shared with any other chat, no project folder), you can delete it with the chat – with the
number of files, size and a warning for git repositories. The server checks again before deleting.
## [v202611-00036] – 2026-11-15
### Added
- **Claude Code gets all CodeCraft tools** (image generation, 3D, OpenSCAD, browser, scheduling,
diagnostics, reverse engineering, skills, ask_user, the server's MCP tools …) over an MCP bridge,
as `mcp__codecraft__<name>`. Tools the CLI has itself (read, write, edit, bash, grep …) are not
offered twice. Calls go through the usual approval cards and rules.
## [v202611-00035] – 2026-11-15
### Fixed
- Creating an SSH key failed with "No user exists for uid 99": `ssh-keygen`, `ssh-keyscan` and
`ssh-add` get the same passwd workaround as `ssh`.
## [v202611-00034] – 2026-11-15
### Fixed
- `ssh`, `scp` and `sftp` failed with "No user exists for uid 99" because the container runs as a uid
without passwd entry; the image now wraps them with nss_wrapper (also used by the key wrappers).
## [v202611-00033] – 2026-11-15
### Added
- **SSH keys** (Settings → Account): generate (ed25519) or import keys; agents, the terminal and the
git panel use them automatically for `ssh`, `scp`, `sftp`, `rsync` and git over ssh. Hosts are
trusted on first contact; the public key can be copied for the server's `authorized_keys`.
## [v202611-00032] – 2026-11-15
### Fixed
- Agents now know that git credentials are stored (hosts, no secrets) and no longer search the
environment for them; the system prompt explains `git push` just works and how to read the token
for REST calls without printing it. Claude Code runs get the user's git credentials and identity too.
## [v202611-00031] – 2026-11-15
### Fixed
- Images read by an agent (Read tool) are shown right away; with Claude Code they were dropped.
## [v202611-00030] – 2026-11-15
### Added
- **`/clear`**: the model forgets the conversation so far; the chat keeps showing it, a divider marks
the cut. Also starts a fresh session with Claude Code.
## [v202611-00029] – 2026-11-15
### Added
- **Statistics**: sections and models fold; long history with a year filter (monthly bars for long
ranges); export of the shown figures as XLSX (all tables) or CSV (one table).
- **Prompt snippets**: own text blocks, inserted with `/name` in the message field (Settings → Account).
- **Read-only links**: share a chat's text with people without an account (valid 1–90 days, revocable).
- **Git panel**: the small model suggests a commit message for the staged changes.
- **Weekly review** as a push notification (Mondays 08:00: chats, checks, costs; opt-in).
- **Speech-to-text on the server** (`server.transcribe_model`) for browsers without dictation (Firefox).
- **Offline**: the chat list and the last opened chats stay readable without a connection.
- **Planning model** setting: plan mode always uses it, other modes the chat's model.
- **Automations** send their report as a push notification when a run ends (switchable).
### Fixed
- fal.ai: the provider check no longer reports "API key rejected" when only the model listing is not offered.
## [v202611-00028] – 2026-11-14
### Added
- **18 more chat icons** (gamepad, computer, food, coffee, home, book, code, camera, cart, heart,
star, globe, calendar, car, plane, bulb, money, palette).
### Changed
- **The ⋯ menu of a message sits inside its bubble** (in the foot next to the time, also in your own
bubbles); its popup opens upwards within the bubble and is no wider than the bubble.
## [v202611-00027] – 2026-11-14 ## [v202611-00027] – 2026-11-14
### Added ### Added
+8 -1
View File
@@ -85,7 +85,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \ build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \ chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
openscad xvfb xauth \ openscad xvfb xauth \
openssh-client rsync mc \ openssh-client libnss-wrapper rsync mc \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be # Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
# installed by agents: rustup component add rust-analyzer, go install …). # installed by agents: rustup component add rust-analyzer, go install …).
@@ -98,6 +98,13 @@ COPY --from=dockercli /docker /usr/local/bin/docker
COPY server/ghidra /app/ghidra COPY server/ghidra /app/ghidra
# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer). # git for agent shells: commits get the co-author trailer of the model (server.commit_trailer).
COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git
# ssh, scp, sftp and the ssh-* tools (the server creates keys with ssh-keygen) also work when the container runs as a uid without passwd entry.
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh
COPY --chmod=755 docker/ssh-nss /usr/local/bin/scp
COPY --chmod=755 docker/ssh-nss /usr/local/bin/sftp
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keygen
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keyscan
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-add
COPY server/resources /app/resources COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space # The agent's home lives on the data volume: tools it installs in user space
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds. # (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
+1 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00027" src="https://img.shields.io/badge/version-v202611--00027-d69e2e?style=flat-square"> <img alt="Version v202611-00041" src="https://img.shields.io/badge/version-v202611--00041-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square"> <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust"> <img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react"> <img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
+21
View File
@@ -205,6 +205,7 @@ pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua"
pub const SESSION_ICONS: &[&str] = &[ pub const SESSION_ICONS: &[&str] = &[
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock", "robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
"users", "toolbox", "plug", "scroll", "eye", "template", "users", "toolbox", "plug", "scroll", "eye", "template",
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
]; ];
impl Session { impl Session {
@@ -299,3 +300,23 @@ pub fn new_id() -> String {
pub fn now_ms() -> i64 { pub fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis() chrono::Utc::now().timestamp_millis()
} }
#[cfg(test)]
mod icon_tests {
use super::*;
/// The client's picker (ColorSwatches.tsx) and the server's whitelist must list the same names.
#[test]
fn session_icons_match_the_client() {
let ts = include_str!("../../web/src/components/ColorSwatches.tsx");
let list = ts.split("SESSION_ICONS = [").nth(1).and_then(|r| r.split("] as const").next()).expect("icon list in ColorSwatches.tsx");
let mut client: Vec<&str> = list.split('"').skip(1).step_by(2).collect();
let mut server: Vec<&str> = SESSION_ICONS.to_vec();
client.sort();
server.sort();
assert_eq!(client, server);
let mut dedup = server.clone();
dedup.dedup();
assert_eq!(dedup.len(), server.len(), "duplicate icon names");
}
}
+2
View File
@@ -34,6 +34,8 @@ services:
- codecraft-data:/data - codecraft-data:/data
# The projects the agents may work on. # The projects the agents may work on.
- ${WORKSPACE:-./workspace}:/workspace - ${WORKSPACE:-./workspace}:/workspace
# Separate area for the software factory (clones of the watched repositories).
- ${FACTORY_DIR:-./factory}:/factory
# Reach self-hosted models (Ollama, vLLM, ...) on the host. # Reach self-hosted models (Ollama, vLLM, ...) on the host.
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
+2
View File
@@ -30,6 +30,8 @@ services:
- codecraft-data:/data - codecraft-data:/data
# The projects the agents may work on. # The projects the agents may work on.
- ${WORKSPACE:-./workspace}:/workspace - ${WORKSPACE:-./workspace}:/workspace
# Separate area for the software factory (clones of the watched repositories).
- ${FACTORY_DIR:-./factory}:/factory
# Reach self-hosted models (Ollama, vLLM, ...) on the host. # Reach self-hosted models (Ollama, vLLM, ...) on the host.
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# ssh, scp and sftp for containers that run as an arbitrary uid (docker-compose `user:`):
# OpenSSH refuses to start without a passwd entry for the uid ("No user exists for uid N").
# Without an entry, nss_wrapper provides one for this process; otherwise nothing changes.
if ! getent passwd "$(id -u)" >/dev/null 2>&1; then
d=${TMPDIR:-/tmp}/codecraft-nss-$(id -u)
mkdir -p "$d" 2>/dev/null
printf 'codecraft:x:%s:%s:CodeCraft:%s:/bin/sh\n' "$(id -u)" "$(id -g)" "${HOME:-/}" > "$d/passwd"
printf 'codecraft:x:%s:\n' "$(id -g)" > "$d/group"
export NSS_WRAPPER_PASSWD="$d/passwd" NSS_WRAPPER_GROUP="$d/group"
export LD_PRELOAD="libnss_wrapper.so${LD_PRELOAD:+:$LD_PRELOAD}"
fi
exec "/usr/bin/$(basename "$0")" "$@"
+473 -12
View File
@@ -7,7 +7,7 @@ use crate::config::{self, ConfigPatch};
use crate::prompts; use crate::prompts;
use crate::runner; use crate::runner;
use crate::store::User; use crate::store::User;
use crate::types::{Event, ImageData, Part, Session, new_id, now_ms}; use crate::types::{Event, ImageData, Part, Role, Session, new_id, now_ms};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
@@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router {
.route("/agents/tools", get(agent_tools)) .route("/agents/tools", get(agent_tools))
.route("/agents/{name}", delete(delete_agent)) .route("/agents/{name}", delete(delete_agent))
.route("/commands", get(commands)) .route("/commands", get(commands))
.route("/transcribe", post(transcribe))
.route("/sessions", get(list_sessions).post(create_session)) .route("/sessions", get(list_sessions).post(create_session))
.route("/sessions/search", get(search_sessions)) .route("/sessions/search", get(search_sessions))
.route("/sessions/{id}/fork", post(fork_session)) .route("/sessions/{id}/fork", post(fork_session))
@@ -85,6 +86,7 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/search", get(search_in_session)) .route("/sessions/{id}/search", get(search_in_session))
.route("/sessions/{id}/media", get(session_media)) .route("/sessions/{id}/media", get(session_media))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/folder", get(session_folder))
.route("/sessions/{id}/prompt", post(prompt)) .route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort)) .route("/sessions/{id}/abort", post(abort))
.route("/sessions/{id}/compact", post(compact)) .route("/sessions/{id}/compact", post(compact))
@@ -99,6 +101,10 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/shells", get(list_shells)) .route("/sessions/{id}/shells", get(list_shells))
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell)) .route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
.route("/sessions/{id}/stats", get(session_stats)) .route("/sessions/{id}/stats", get(session_stats))
.route("/sessions/{id}/links", get(list_links).post(create_link))
.route("/sessions/{id}/links/{token}", delete(delete_link))
.route("/public/chat/{token}", get(public_chat))
.route("/mcp-bridge/{token}", post(crate::mcp_bridge::handle))
.route("/sessions/{id}/shares", get(get_shares).put(put_shares)) .route("/sessions/{id}/shares", get(get_shares).put(put_shares))
.route("/sessions/{id}/queue", get(get_queue)) .route("/sessions/{id}/queue", get(get_queue))
.route("/sessions/{id}/queue/flush", post(flush_queue)) .route("/sessions/{id}/queue/flush", post(flush_queue))
@@ -133,6 +139,7 @@ pub fn router(app: AppState) -> Router {
.route("/git", post(crate::git::handle)) .route("/git", post(crate::git::handle))
.route("/fs/dirs", get(list_dirs)) .route("/fs/dirs", get(list_dirs))
.route("/stats", get(stats)) .route("/stats", get(stats))
.route("/stats/export", get(stats_export))
.route("/budget", get(budget)) .route("/budget", get(budget))
.route("/audit", get(audit_log)) .route("/audit", get(audit_log))
.route("/backup", get(backup)) .route("/backup", get(backup))
@@ -152,6 +159,17 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}", delete(delete_image)) .route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file)) .route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings)) .route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/factory", get(get_factory).put(put_factory))
.route("/account/factory/test", post(test_factory))
.route("/factory/overview", get(factory_overview))
.route("/factory/poll", post(factory_poll))
.route("/account/factory/connection/test", post(test_factory_connection))
.route("/factory/drafts", post(factory_new_draft))
.route("/factory/drafts/{id}", get(factory_draft))
.route("/factory/drafts/{id}/create", post(factory_create_project))
.route("/account/ssh", get(get_ssh_keys).post(add_ssh_key))
.route("/account/ssh/{id}", delete(delete_ssh_key))
.route("/account/snippets", get(crate::cli_api::get_snippets).put(crate::cli_api::put_snippets))
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs)) .route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
.route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar)) .route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory)) .route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
@@ -321,7 +339,7 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) }; 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() { if !user.is_admin() {
// Users only need defaults, not the configuration. // Users only need defaults, not the configuration.
return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency}))); return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency, "transcribe": !cfg.server.transcribe_model.is_empty()})));
} }
Ok(Json(json!({ Ok(Json(json!({
"config": config::redacted_json(&cfg), "config": config::redacted_json(&cfg),
@@ -329,6 +347,7 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
"default_model": default_model, "default_model": default_model,
"small_model": cfg.server.small_model, "small_model": cfg.server.small_model,
"currency": cfg.server.currency, "currency": cfg.server.currency,
"transcribe": !cfg.server.transcribe_model.is_empty(),
}))) })))
} }
@@ -1046,8 +1065,83 @@ async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult { /// The folder a chat owns: inside the owner's folders (never one of them), not a link, and no
writable(&app, &user, &id)?; /// other chat works in it, below it or above it (then it belongs to a project, not to this chat).
/// Returns the folder and the titles of chats that prevent it.
fn own_folder(app: &App, session: &Session) -> (Option<PathBuf>, Vec<String>) {
let Ok(dir) = std::fs::canonicalize(&session.cwd) else { return (None, vec![]) };
if std::fs::symlink_metadata(&session.cwd).map(|m| m.file_type().is_symlink()).unwrap_or(true) || !dir.is_dir() {
return (None, vec![]);
}
let roots: Vec<PathBuf> = app.user_roots(session.owner_id.as_deref()).iter().filter_map(|r| std::fs::canonicalize(r).ok()).collect();
if !roots.iter().any(|r| dir.starts_with(r) && dir != *r) {
return (None, vec![]);
}
let mut blockers = Vec::new();
for (title, cwd) in app.store.other_session_cwds(&session.id).unwrap_or_default() {
let Ok(other) = std::fs::canonicalize(&cwd) else { continue };
if other.starts_with(&dir) || dir.starts_with(&other) {
blockers.push(if title.is_empty() { "(untitled)".to_string() } else { title });
}
}
if blockers.is_empty() { (Some(dir), blockers) } else { (None, blockers) }
}
/// Facts for the delete dialog: does the chat have a folder of its own, and what is in it?
async fn session_folder(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let session = writable(&app, &user, &id)?;
let (dir, blockers) = own_folder(&app, &session);
let Some(dir) = dir else { return Ok(Json(json!({"own": false, "shared_with": blockers}))) };
let (files, bytes, git, truncated) = tokio::task::spawn_blocking({
let dir = dir.clone();
move || {
let (mut files, mut bytes, mut seen) = (0u64, 0u64, 0u64);
let mut stack = vec![dir.clone()];
let mut truncated = false;
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
// Git's own files are not "the chat's files".
if e.file_name() == ".git" {
continue;
}
seen += 1;
if seen > 50_000 {
truncated = true;
stack.clear();
break;
}
let Ok(m) = e.metadata() else { continue };
if m.is_dir() && !e.file_type().map(|t| t.is_symlink()).unwrap_or(true) {
stack.push(e.path());
} else if m.is_file() {
files += 1;
bytes += m.len();
}
}
}
(files, bytes, dir.join(".git").exists(), truncated)
}
})
.await
.unwrap_or((0, 0, false, false));
Ok(Json(json!({"own": true, "path": dir, "files": files, "bytes": bytes, "git": git, "truncated": truncated})))
}
#[derive(Deserialize)]
struct DeleteQ {
/// Also delete the chat's own folder (only if it really is one – checked again here).
#[serde(default)]
delete_folder: bool,
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<DeleteQ>) -> ApiResult {
let session = writable(&app, &user, &id)?;
// Decide about the folder while the chat and all the others still exist.
let folder = if q.delete_folder { own_folder(&app, &session).0 } else { None };
if q.delete_folder && folder.is_none() {
return Err(ApiError(StatusCode::CONFLICT, "this chat has no folder of its own (other chats use it, or it is a project folder)".into()));
}
// Notify before deleting: visibility is checked against the stored session. // Notify before deleting: visibility is checked against the stored session.
app.emit(Event::SessionDeleted { session_id: id.clone() }); app.emit(Event::SessionDeleted { session_id: id.clone() });
runner::abort(&app, &id); runner::abort(&app, &id);
@@ -1061,7 +1155,20 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
tracing::warn!("image cleanup: {e:#}"); tracing::warn!("image cleanup: {e:#}");
} }
}); });
Ok(Json(json!({"ok": true}))) let mut folder_error = None;
let mut folder_deleted = false;
if let Some(dir) = folder {
let shown = dir.display().to_string();
match tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir)).await {
Ok(Ok(())) => {
folder_deleted = true;
app.audit(Some(&user), "session.delete_folder", &shown, "");
}
Ok(Err(e)) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
Err(e) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
}
}
Ok(Json(json!({"ok": true, "folder_deleted": folder_deleted, "folder_error": folder_error})))
} }
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes, /// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
@@ -1121,6 +1228,13 @@ pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text:
tokio::spawn(do_compact(app.clone(), id.clone())); tokio::spawn(do_compact(app.clone(), id.clone()));
return Ok(json!({"ok": true, "command": "compact"})); return Ok(json!({"ok": true, "command": "compact"}));
} }
if text == "/clear" {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
runner::clear_context(app, &id).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
return Ok(json!({"ok": true, "command": "clear"}));
}
let cwd = PathBuf::from(&session.cwd); let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd); let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut prompt_text = text.clone(); let mut prompt_text = text.clone();
@@ -1318,6 +1432,117 @@ async fn session_stats(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(v)) Ok(Json(v))
} }
// --- speech to text -----------------------------------------------------------
#[derive(Deserialize)]
struct TranscribeQ {
lang: Option<String>,
}
/// Dictation for browsers without speech recognition: the recorded audio (request body) goes
/// to the configured OpenAI-compatible transcription endpoint.
async fn transcribe(State(app): State<AppState>, Query(q): Query<TranscribeQ>, headers: axum::http::HeaderMap, body: axum::body::Bytes) -> ApiResult {
let cfg = app.config();
let spec = cfg.server.transcribe_model.clone();
let (prov, model) = spec.split_once('/').ok_or_else(|| ApiError(StatusCode::NOT_IMPLEMENTED, "speech-to-text is not configured (server.transcribe_model)".into()))?;
let p = cfg.providers.get(prov).filter(|p| p.enabled).ok_or_else(|| ApiError(StatusCode::NOT_IMPLEMENTED, format!("transcription provider '{prov}' is not available")))?;
if body.is_empty() || body.len() > 25 * 1024 * 1024 {
return Err(ApiError(StatusCode::BAD_REQUEST, "audio missing or larger than 25 MB".into()));
}
let mime = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("audio/webm").split(';').next().unwrap_or("audio/webm").to_string();
let ext = if mime.contains("mp4") || mime.contains("aac") { "m4a" } else if mime.contains("ogg") { "ogg" } else if mime.contains("wav") { "wav" } else { "webm" };
let part = reqwest::multipart::Part::bytes(body.to_vec()).file_name(format!("speech.{ext}")).mime_str(&mime).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e.to_string()))?;
let mut form = reqwest::multipart::Form::new().part("file", part).text("model", model.to_string());
if let Some(l) = q.lang.as_deref().filter(|l| l.len() == 2 && l.chars().all(|c| c.is_ascii_lowercase())) {
form = form.text("language", l.to_string());
}
let url = format!("{}/audio/transcriptions", p.base_url.trim_end_matches('/'));
let mut req = reqwest::Client::new().post(&url).multipart(form).timeout(std::time::Duration::from_secs(120));
if !p.api_key.is_empty() {
req = match &p.auth_header {
Some(h) => req.header(h.as_str(), format!("{}{}", p.auth_prefix.clone().unwrap_or_default(), p.api_key)),
None => req.bearer_auth(&p.api_key),
};
}
for (k, v) in &p.headers {
req = req.header(k.as_str(), v.as_str());
}
let resp = req.send().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("transcription request failed: {e}")))?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(ApiError(StatusCode::BAD_GATEWAY, format!("transcription failed ({status}): {}", text.chars().take(300).collect::<String>())));
}
let v: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({"text": text.trim()}));
Ok(Json(json!({"text": v["text"].as_str().unwrap_or("").trim()})))
}
// --- read-only links ----------------------------------------------------------
#[derive(Deserialize)]
struct LinkBody {
/// Validity in days (1–90, default 7).
days: Option<i64>,
}
fn link_json(base: &str, token: &str, created: i64, expires: i64) -> Value {
json!({"token": token, "url": format!("{base}/#/shared/{token}"), "created_at": created, "expires_at": expires})
}
async fn list_links(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
let base = public_base(&app, &headers);
let now = now_ms();
let links: Vec<Value> = app.store.share_links(&id)?.into_iter().filter(|(_, _, exp)| *exp > now).map(|(t, c, e)| link_json(&base, &t, c, e)).collect();
Ok(Json(json!(links)))
}
async fn create_link(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, body: Option<Json<LinkBody>>) -> ApiResult {
writable(&app, &user, &id)?;
let days = body.and_then(|b| b.0.days).unwrap_or(7).clamp(1, 90);
let now = now_ms();
let token = format!("{}{}", new_id(), new_id()).replace('-', "");
let expires = now + days * 86_400_000;
app.store.purge_share_links(now)?;
app.store.add_share_link(&token, &id, &user.id, now, expires)?;
app.audit(Some(&user), "session.link", &format!("{id}: {days} day(s)"), "");
Ok(Json(link_json(&public_base(&app, &headers), &token, now, expires)))
}
async fn delete_link(Extension(user): Extension<User>, State(app): State<AppState>, Path((id, token)): Path<(String, String)>) -> ApiResult {
writable(&app, &user, &id)?;
app.store.delete_share_link(&id, &token)?;
app.audit(Some(&user), "session.link.revoke", &id, "");
Ok(Json(json!({"ok": true})))
}
/// A chat for people without an account: only the conversation text (no tool output,
/// reasoning or attachments), readable as long as the link is valid.
async fn public_chat(State(app): State<AppState>, Path(token): Path<String>) -> ApiResult {
let gone = || ApiError(StatusCode::NOT_FOUND, "this link is invalid or has expired".into());
let id = app.store.share_link_session(&token, now_ms())?.ok_or_else(gone)?;
let session = app.session(&id).map_err(|_| gone())?;
let messages: Vec<Value> = app
.store
.messages(&id)?
.into_iter()
.filter(|m| matches!(m.role, Role::User | Role::Assistant))
.filter_map(|m| {
let text = m
.parts
.iter()
.filter_map(|p| match p {
Part::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
(!text.trim().is_empty()).then(|| json!({"id": m.id, "role": m.role, "text": text, "created_at": m.created_at}))
})
.collect();
Ok(Json(json!({"title": session.title, "updated_at": session.updated_at, "messages": messages})))
}
async fn get_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult { async fn get_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?; writable(&app, &user, &id)?;
Ok(Json(json!(app.store.shares(&id)?))) Ok(Json(json!(app.store.shares(&id)?)))
@@ -1968,6 +2193,45 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
Ok(Json(v)) Ok(Json(v))
} }
#[derive(Deserialize)]
struct StatsExportQuery {
from: Option<i64>,
to: Option<i64>,
tz: Option<i64>,
user: Option<String>,
/// "xlsx" (all tables) or "csv" (one table: see `table`).
format: Option<String>,
/// csv: total, day, month, model, provider, agent, user, session (default: day).
table: Option<String>,
lang: Option<String>,
}
/// The statistics of a range as a download.
async fn stats_export(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<StatsExportQuery>) -> Result<Response, ApiError> {
let to = q.to.unwrap_or_else(|| now_ms() + 1);
let from = q.from.unwrap_or(to - 30 * 86_400_000);
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["from"] = json!(from);
v["to"] = json!(to);
let de = q.lang.as_deref().is_some_and(|l| l.starts_with("de"));
let tables = crate::stats_export::tables(&v, de);
let stamp = chrono::Local::now().format("%Y-%m-%d");
let (body, ctype, name): (Vec<u8>, &str, String) = if q.format.as_deref() == Some("csv") {
let id = q.table.as_deref().unwrap_or("day");
let t = tables.iter().find(|t| t.id == id).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "unknown table".into()))?;
(crate::stats_export::csv(t, de).into_bytes(), "text/csv; charset=utf-8", format!("codecraft-stats-{id}-{stamp}.csv"))
} else {
let bytes = crate::stats_export::xlsx(&tables).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", format!("codecraft-stats-{stamp}.xlsx"))
};
Ok((
[(axum::http::header::CONTENT_TYPE, ctype.to_string()), (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\""))],
body,
)
.into_response())
}
// --- websocket -------------------------------------------------------------- // --- websocket --------------------------------------------------------------
// --- automations (scheduled agents) ------------------------------------------------ // --- automations (scheduled agents) ------------------------------------------------
@@ -2057,12 +2321,13 @@ struct UpdateJob {
model: Option<String>, model: Option<String>,
permission_mode: Option<String>, permission_mode: Option<String>,
timeout_minutes: Option<u64>, timeout_minutes: Option<u64>,
notify: Option<bool>,
} }
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult { async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
let job = own_job(&app, &user, &id)?; let job = own_job(&app, &user, &id)?;
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some(); let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some() || b.notify.is_some();
if changes { if changes {
let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation")); let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
if let Some(x) = b.name { if let Some(x) = b.name {
@@ -2086,6 +2351,9 @@ async fn update_job(Extension(user): Extension<User>, State(app): State<AppState
if let Some(x) = b.timeout_minutes { if let Some(x) = b.timeout_minutes {
st.timeout_minutes = x; st.timeout_minutes = x;
} }
if let Some(x) = b.notify {
st.notify = x;
}
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.store.job_set_settings(&job.id, &st)?; app.store.job_set_settings(&job.id, &st)?;
app.audit(Some(&user), "job.settings", &job.path, &st.schedule); app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
@@ -2258,6 +2526,57 @@ async fn run_job(Extension(user): Extension<User>, State(app): State<AppState>,
// --- git credentials --------------------------------------------------------------- // --- git credentials ---------------------------------------------------------------
// --- SSH keys -----------------------------------------------------------------
async fn get_ssh_keys(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
#[derive(Deserialize)]
struct NewSshKey {
name: String,
/// An existing private key to import; a new key is generated when missing.
private: Option<String>,
}
async fn add_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<NewSshKey>) -> ApiResult {
let name = b.name.trim().to_string();
if name.is_empty() || name.chars().count() > 60 {
return Err(ApiError(StatusCode::BAD_REQUEST, "give the key a name (up to 60 characters)".into()));
}
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
if s.keys.len() >= 10 {
return Err(ApiError(StatusCode::BAD_REQUEST, "at most 10 keys".into()));
}
let key = match b.private.as_deref().filter(|p| !p.trim().is_empty()) {
Some(p) => crate::sshkeys::import(&name, p).await,
None => crate::sshkeys::generate(&name).await,
}
.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
if s.keys.iter().any(|k| k.fingerprint == key.fingerprint && !key.fingerprint.is_empty()) {
return Err(ApiError(StatusCode::CONFLICT, "this key is already stored".into()));
}
s.keys.push(key);
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "ssh", &s)?;
app.audit(Some(&user), "ssh.key.add", &name, "");
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
async fn delete_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
let before = s.keys.len();
s.keys.retain(|k| k.id != id);
if s.keys.len() == before {
return Err(not_found("key"));
}
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "ssh", &s)?;
app.audit(Some(&user), "ssh.key.delete", &id, "");
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
async fn get_git_settings(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn get_git_settings(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?; let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?;
Ok(Json(json!(crate::gitcreds::masked(&s)))) Ok(Json(json!(crate::gitcreds::masked(&s))))
@@ -2273,6 +2592,132 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
Ok(Json(json!(crate::gitcreds::masked(&new)))) Ok(Json(json!(crate::gitcreds::masked(&new))))
} }
// --- software factory ---------------------------------------------------------------
async fn get_factory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
Ok(Json(json!(crate::factory::masked(&s))))
}
async fn put_factory(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<crate::factory::FactorySettings>) -> ApiResult {
let old: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let mut new = crate::factory::merge(&old, b);
for r in &mut new.repos {
r.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{}: {e:#}", r.url)))?;
}
app.store.set_user_setting(&user.id, "factory", &new)?;
let urls: Vec<&str> = new.repos.iter().map(|r| r.url.as_str()).collect();
app.audit(Some(&user), "factory.settings", urls.join(", "), &crate::auth::client_ip(&headers));
Ok(Json(json!(crate::factory::masked(&new))))
}
#[derive(Deserialize)]
struct FactoryTest {
url: String,
}
/// Check token and repository and create the missing status labels.
async fn test_factory(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<FactoryTest>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let cfg = s.repos.iter().find(|r| r.url == b.url.trim()).map(|r| s.resolve(r)).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "save the repository first".into()))?;
let cfg = &cfg;
let g = crate::factory::Forge::new(cfg).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let info = g.check().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let created = g.ensure_labels().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let dir = crate::factory::checkout(&app, &user, cfg).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"ok": true, "info": info, "labels_created": created, "folder": dir.display().to_string()})))
}
/// Everything the factory page shows: repositories, issues with state, runs with tokens/cost.
async fn factory_overview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let repos: Vec<Value> = s.repos.iter().map(|r| json!({"url": r.url, "enabled": r.enabled, "label": r.label, "max_parallel": r.max_parallel, "kind": s.resolve(r).kind, "has_token": !s.resolve(r).token.is_empty(), "models": {"review": r.review.model, "build": r.build.model, "qa": r.qa.model}, "running": app.store.factory_running(&r.url).unwrap_or(0)})).collect();
Ok(Json(json!({
"area": app.factory_dir().is_some(),
"repos": repos,
"issues": app.store.factory_issues(&user.id)?,
"runs": app.store.factory_runs(&user.id, None, None, 300)?,
})))
}
/// Look for new issues right now instead of waiting for the next poll.
async fn factory_poll(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let mut errors = Vec::new();
for cfg in s.repos.iter().filter(|r| r.enabled).map(|r| s.resolve(r)).filter(|r| !r.token.is_empty()) {
if let Err(e) = crate::factory::poll_repo(&app, &user, &cfg).await {
errors.push(format!("{}: {e:#}", cfg.url));
}
}
Ok(Json(json!({"errors": errors})))
}
#[derive(Deserialize)]
struct ConnTest {
id: String,
}
/// Test a connection: who the token belongs to and which organisations/groups it can use.
async fn test_factory_connection(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ConnTest>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let conn = s.connection(&b.id).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "save the connection first".into()))?;
let g = crate::factory::Forge::for_conn(conn).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let login = g.login().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let orgs = g.orgs().await.unwrap_or_default();
Ok(Json(json!({"login": login, "orgs": orgs})))
}
#[derive(Deserialize)]
struct NewDraft {
#[serde(default)]
model: Option<String>,
}
/// Start a chat in which the user writes a spec with a model.
async fn factory_new_draft(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<NewDraft>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let model = b.model.filter(|m| !m.is_empty()).unwrap_or(s.defaults.spec_model.clone());
let session = crate::factory::start_spec_chat(&app, &user, &model).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
Ok(Json(json!(session)))
}
fn draft_session(app: &AppState, user: &User, id: &str) -> Result<(crate::types::Session, std::path::PathBuf), ApiError> {
let session = app.session(id).map_err(|_| ApiError(StatusCode::NOT_FOUND, "unknown chat".into()))?;
let dir = crate::factory::draft_of(app, user, &session).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "not a spec chat".into()))?;
Ok((session, dir))
}
/// The state of a spec chat: spec text, the model's name proposal, connections to choose from.
async fn factory_draft(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let (_, dir) = draft_session(&app, &user, &id)?;
let spec = std::fs::read_to_string(dir.join("spec.md")).unwrap_or_default();
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let conns: Vec<Value> = s.connections.iter().map(|c| json!({"id": c.id, "name": c.name, "kind": c.kind, "owner": c.owner})).collect();
Ok(Json(json!({"spec": spec, "proposal": crate::factory::proposal(&app, &id), "connections": conns})))
}
#[derive(Deserialize)]
struct CreateProject {
connection: String,
name: String,
slug: String,
#[serde(default)]
description: String,
#[serde(default)]
owner: String,
}
/// Create the repository, file the spec and open the issue that starts the workflow.
async fn factory_create_project(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, Json(b): Json<CreateProject>) -> ApiResult {
let (_, dir) = draft_session(&app, &user, &id)?;
let spec = std::fs::read_to_string(dir.join("spec.md")).unwrap_or_default();
let mut s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let res = crate::factory::create_project(&app, &user, &mut s, &spec, &b.connection, &b.name, &b.slug, &b.description, &b.owner).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "factory", &s)?;
app.audit(Some(&user), "factory.project.create", res["repo"].as_str().unwrap_or(""), &crate::auth::client_ip(&headers));
Ok(Json(res))
}
// --- web terminal ------------------------------------------------------------------ // --- web terminal ------------------------------------------------------------------
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -2360,22 +2805,38 @@ async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<Ap
async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let on: bool = app.store.user_setting(&user.id, "cost_push")?; let on: bool = app.store.user_setting(&user.id, "cost_push")?;
Ok(Json(json!({"enabled": on}))) let weekly: bool = app.store.user_setting(&user.id, "weekly_push")?;
Ok(Json(json!({"enabled": on, "weekly": weekly})))
}
#[derive(Deserialize)]
struct DigestQ {
#[serde(default)]
weekly: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct DigestBody { struct DigestBody {
enabled: bool, enabled: Option<bool>,
weekly: Option<bool>,
} }
async fn put_digest(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<DigestBody>) -> ApiResult { async fn put_digest(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<DigestBody>) -> ApiResult {
app.store.set_user_setting(&user.id, "cost_push", &b.enabled)?; if let Some(v) = b.enabled {
Ok(Json(json!({"enabled": b.enabled}))) app.store.set_user_setting(&user.id, "cost_push", &v)?;
}
if let Some(v) = b.weekly {
app.store.set_user_setting(&user.id, "weekly_push", &v)?;
}
let on: bool = app.store.user_setting(&user.id, "cost_push")?;
let weekly: bool = app.store.user_setting(&user.id, "weekly_push")?;
Ok(Json(json!({"enabled": on, "weekly": weekly})))
} }
/// Send today's summary right away (preview). /// Send today's summary right away (preview).
async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<DigestQ>) -> ApiResult {
let payload = crate::digest::summary(&app, &user.id).unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"})); let found = if q.weekly { crate::digest::weekly(&app, &user.id) } else { crate::digest::summary(&app, &user.id) };
let payload = found.unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"}));
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await; let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]}))) Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]})))
} }
+18 -1
View File
@@ -189,6 +189,20 @@ impl App {
std::fs::canonicalize(&ws).unwrap_or(ws) std::fs::canonicalize(&ws).unwrap_or(ws)
} }
/// Separate area of the software factory (mounted outside the workspace); None when absent.
pub fn factory_dir(&self) -> Option<PathBuf> {
let p = std::env::var("CODECRAFT_FACTORY_DIR").ok().filter(|p| !p.is_empty()).unwrap_or_else(|| "/factory".into());
let p = PathBuf::from(p);
p.is_dir().then(|| std::fs::canonicalize(&p).unwrap_or(p))
}
/// The user's own folder in the factory area (created on demand).
pub fn factory_home(&self, user: &crate::store::User) -> Option<PathBuf> {
let dir = self.factory_dir()?.join(&user.username);
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
/// Directories a session owner may work in: admins (and legacy sessions /// Directories a session owner may work in: admins (and legacy sessions
/// without owner) the whole workspace, users their own folder plus shared folders. /// without owner) the whole workspace, users their own folder plus shared folders.
pub fn user_roots(&self, owner_id: Option<&str>) -> Vec<PathBuf> { pub fn user_roots(&self, owner_id: Option<&str>) -> Vec<PathBuf> {
@@ -196,12 +210,15 @@ impl App {
let Some(user) = owner_id.and_then(|id| self.store.user(id).ok().flatten()) else { let Some(user) = owner_id.and_then(|id| self.store.user(id).ok().flatten()) else {
return vec![ws]; return vec![ws];
}; };
// The user's factory folder (if the area is mounted) is an additional root.
let factory = self.factory_dir().map(|d| d.join(&user.username)).filter(|d| d.is_dir());
if user.is_admin() { if user.is_admin() {
return vec![ws]; return std::iter::once(ws).chain(factory).collect();
} }
let own = ws.join(&user.username); let own = ws.join(&user.username);
let _ = std::fs::create_dir_all(&own); let _ = std::fs::create_dir_all(&own);
let mut roots = vec![own]; let mut roots = vec![own];
roots.extend(factory);
for p in self.store.folders_for_user(&user.id).unwrap_or_default() { for p in self.store.folders_for_user(&user.id).unwrap_or_default() {
let p = PathBuf::from(p); let p = PathBuf::from(p);
if p.starts_with(&ws) && p.is_dir() { if p.starts_with(&ws) && p.is_dir() {
+6
View File
@@ -186,6 +186,12 @@ fn is_public(path: &str) -> bool {
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"] ["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter() .iter()
.any(|p| path.ends_with(p)) .any(|p| path.ends_with(p))
// Read-only chat links (the token is the credential).
|| path.starts_with("/public/chat/")
|| path.starts_with("/api/public/chat/")
// MCP bridge for the Claude Code CLI (per-run token).
|| path.starts_with("/mcp-bridge/")
|| path.starts_with("/api/mcp-bridge/")
} }
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response { pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
+167 -11
View File
@@ -11,7 +11,7 @@ use crate::config::ModelConfig;
use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command}; use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command};
use crate::provider::{ChatRequest, Provider, StreamEvent}; use crate::provider::{ChatRequest, Provider, StreamEvent};
use crate::store::UsageRecord; use crate::store::UsageRecord;
use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms}; use crate::types::{Event, ImageData, Message, Part, Role, Session, Usage, new_id, now_ms};
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -97,14 +97,66 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul
c.env(k, v); c.env(k, v);
} }
} }
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").args(args); c.env("TERM", "xterm-256color").env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").args(args);
// The user's git credentials and identity (the CLI runs git for the agent).
if let Some(user) = owner.and_then(|id| app.store.user(id).ok().flatten()) {
if let Some(cfg) = crate::gitcreds::global_config(app, &user) {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(crate::gitcreds::identity_env(app, &user.id));
c.envs(crate::sshkeys::env(app, &user));
}
Ok(c) Ok(c)
} }
// --- tool mapping ----------------------------------------------------------------------- // --- tool mapping -----------------------------------------------------------------------
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours. /// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
/// Claude Code's `AskUserQuestion` input → our questions (it writes `multiSelect`).
fn cc_questions(input: &Value) -> Vec<crate::types::Question> {
input["questions"]
.as_array()
.into_iter()
.flatten()
.filter_map(|q| {
let question = q["question"].as_str()?.to_string();
let options = q["options"]
.as_array()
.into_iter()
.flatten()
.filter_map(|o| Some(crate::types::QuestionOption { label: o["label"].as_str()?.to_string(), description: o["description"].as_str().unwrap_or("").to_string() }))
.collect();
Some(crate::types::Question { question, header: q["header"].as_str().unwrap_or("").to_string(), options, multi_select: q["multiSelect"].as_bool().or(q["multi_select"].as_bool()).unwrap_or(false) })
})
.collect()
}
/// Our answers (question → text or list) as the CLI expects them: question → text, lists joined by ", ".
fn cc_answers(answers: &serde_json::Map<String, Value>) -> Value {
Value::Object(
answers
.iter()
.map(|(q, a)| {
let text = match a {
Value::String(s) => s.clone(),
Value::Array(l) => l.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "),
other => other.to_string(),
};
(q.clone(), Value::String(text))
})
.collect(),
)
}
fn map_tool(name: &str, input: &Value) -> (String, Value) { fn map_tool(name: &str, input: &Value) -> (String, Value) {
// Questions to the user show up as our question card / ask_user call.
if name == "AskUserQuestion" {
return ("ask_user".to_string(), json!({"questions": cc_questions(input)}));
}
// Our own tools reach the CLI over MCP: shown and judged under their usual names.
if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
return (ours.to_string(), input.clone());
}
let mut v = input.clone(); let mut v = input.clone();
let rename = |v: &mut Value, from: &str, to: &str| { let rename = |v: &mut Value, from: &str, to: &str| {
if let Some(x) = v.get(from).cloned() { if let Some(x) = v.get(from).cloned() {
@@ -141,6 +193,8 @@ impl ToolInfo for CcTool {
} }
fn read_only(&self) -> bool { fn read_only(&self) -> bool {
matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list") matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list")
// One of our tools (MCP bridge): as it declares itself.
|| crate::tools::builtin_tools().iter().any(|t| t.name() == self.name && t.read_only())
} }
fn is_edit(&self) -> bool { fn is_edit(&self) -> bool {
matches!(self.name.as_str(), "write" | "edit") matches!(self.name.as_str(), "write" | "edit")
@@ -170,16 +224,16 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
match name { match name {
"bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()), "bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()),
"write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(), "write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(),
_ => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()), _ => match crate::tools::builtin_tools().iter().find(|t| t.name() == name) {
Some(t) => t.title(input),
None => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()),
},
} }
} }
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection). /// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) { async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
let (name, input) = map_tool(claude_name, claude_input); let (name, input) = map_tool(claude_name, claude_input);
if claude_name == "AskUserQuestion" {
return (false, Some("Questions to the user are not available in this mode – decide yourself or state your assumption.".into()));
}
let info = CcTool { name: name.clone() }; let info = CcTool { name: name.clone() };
let cfg = app.config(); let cfg = app.config();
let root = app.root_session_id(&session.id); let root = app.root_session_id(&session.id);
@@ -286,6 +340,20 @@ fn result_text(content: &Value) -> String {
} }
} }
/// Images inside a tool result (the CLI's Read of a picture): base64 blocks.
fn result_images(content: &Value) -> Vec<ImageData> {
content
.as_array()
.into_iter()
.flatten()
.filter(|b| b["type"] == "image" && b["source"]["type"] == "base64")
.filter_map(|b| {
let data = b["source"]["data"].as_str().filter(|d| !d.is_empty())?;
Some(ImageData { media_type: b["source"]["media_type"].as_str().unwrap_or("image/png").to_string(), data: data.to_string(), blob: None, size: None })
})
.collect()
}
/// Images of the prompt as files the CLI can read (it has no inline image input in this mode). /// Images of the prompt as files the CLI can read (it has no inline image input in this mode).
fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> { fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
use base64::Engine; use base64::Engine;
@@ -293,7 +361,12 @@ fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
let mut out = Vec::new(); let mut out = Vec::new();
for (i, p) in parts.iter().enumerate() { for (i, p) in parts.iter().enumerate() {
let Part::Image { image } = p else { continue }; let Part::Image { image } = p else { continue };
// An image without bytes (an unresolved reference) would become an empty file.
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue }; let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue };
if bytes.is_empty() {
tracing::warn!("claude code: image {i} of the message has no data – not saved");
continue;
}
let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png"); let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png");
let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms())); let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms()));
if std::fs::create_dir_all(&dir).is_ok() { if std::fs::create_dir_all(&dir).is_ok() {
@@ -311,7 +384,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
let session = app.session(session_id)?; let session = app.session(session_id)?;
let owner = session.owner_id.clone(); let owner = session.owner_id.clone();
let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string(); let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string();
let history = crate::runner::active_history(app.store.messages(session_id)?); // With the bytes of the images filled in (they are stored as references).
let history = crate::runner::active_history(app.store.messages_for_model(session_id)?);
let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else { let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else {
bail!("nothing to do: no user message"); bail!("nothing to do: no user message");
}; };
@@ -343,8 +417,18 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
if let Some(r) = &resume { if let Some(r) = &resume {
args.extend(["--resume".into(), r.clone()]); args.extend(["--resume".into(), r.clone()]);
} }
if !session.instructions.trim().is_empty() { // CodeCraft's own tools for the CLI (MCP, per-run token).
args.extend(["--append-system-prompt".into(), session.instructions.trim().to_string()]); let bridge = crate::mcp_bridge::register(session_id);
args.extend(["--mcp-config".into(), crate::mcp_bridge::config_json(&app, &bridge.token)]);
let mut appended = session.instructions.trim().to_string();
if let Some(note) = crate::mcp_bridge::prompt_section(&crate::mcp_bridge::tools_for(&app, &session)) {
appended = format!("{appended}\n{note}").trim().to_string();
}
for extra in owner.as_deref().into_iter().flat_map(|u| [crate::gitcreds::prompt_section(&app, u), crate::sshkeys::prompt_section(&app, u)]).flatten() {
appended = format!("{appended}\n{extra}").trim().to_string();
}
if !appended.is_empty() {
args.extend(["--append-system-prompt".into(), appended]);
} }
let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?; let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?;
@@ -376,6 +460,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
let mut cur: Option<Cur> = None; let mut cur: Option<Cur> = None;
let mut calls: HashMap<String, String> = HashMap::new(); let mut calls: HashMap<String, String> = HashMap::new();
let mut diffs: HashMap<String, String> = HashMap::new(); let mut diffs: HashMap<String, String> = HashMap::new();
// Answers to AskUserQuestion calls, kept as the result's data for the question card.
let mut asked: HashMap<String, Value> = HashMap::new();
let mut final_text = String::new(); let mut final_text = String::new();
let mut got_result = false; let mut got_result = false;
let mut failure: Option<String> = None; let mut failure: Option<String> = None;
@@ -457,8 +543,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
} }
let id = b["tool_use_id"].as_str().unwrap_or("").to_string(); let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
let is_error = b["is_error"].as_bool().unwrap_or(false); let is_error = b["is_error"].as_bool().unwrap_or(false);
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) }; let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})).or_else(|| asked.remove(&id).map(|a| json!({"answers": a}))) };
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: vec![], is_error, meta }); parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta });
} }
if !parts.is_empty() { if !parts.is_empty() {
let m = Message::new(Role::User, parts); let m = Message::new(Role::User, parts);
@@ -470,6 +556,30 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
"control_request" if ev["request"]["subtype"] == "can_use_tool" => { "control_request" if ev["request"]["subtype"] == "can_use_tool" => {
finalize(&app, session_id, &mut cur, &model_id); finalize(&app, session_id, &mut cur, &model_id);
let req = &ev["request"]; let req = &ev["request"];
// A question for the user: shown as a question card; the answers go back to the CLI.
if req["tool_name"] == "AskUserQuestion" {
let questions = cc_questions(&req["input"]);
let response = if questions.is_empty() {
json!({"behavior": "deny", "message": "No usable question was given."})
} else {
match app.ask_user(session_id, "agent", questions, cancel.clone()).await {
Ok(Some(answers)) => {
let mut input = req["input"].clone();
input["answers"] = cc_answers(&answers);
if let Some(id) = req["tool_use_id"].as_str() {
asked.insert(id.to_string(), Value::Object(answers));
}
json!({"behavior": "allow", "updatedInput": input})
}
_ => json!({"behavior": "deny", "message": "The user dismissed the questions without answering."}),
}
};
let reply = json!({"type": "control_response", "response": {"subtype": "success", "request_id": ev["request_id"], "response": response}});
if stdin.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
break;
}
continue;
}
let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await; let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
if allow { if allow {
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]); let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
@@ -852,6 +962,52 @@ pub async fn update() -> Result<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn uploaded_images_are_saved_with_their_bytes_only() {
let dir = std::env::temp_dir().join(format!("cc-uploads-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let parts = vec![
Part::Image { image: ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None } },
// A reference whose bytes were not filled in: must not become an empty file.
Part::Image { image: ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None } },
];
let saved = save_images(&dir.display().to_string(), &parts);
assert_eq!(saved.len(), 1);
assert_eq!(std::fs::read(&saved[0]).unwrap(), b"ABC");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn questions_roundtrip_with_the_cli_format() {
let input = json!({"questions": [
{"question": "Which database?", "header": "DB", "multiSelect": false, "options": [{"label": "SQLite", "description": "file"}, {"label": "Postgres"}]},
{"question": "Which extras?", "header": "Extras", "multiSelect": true, "options": [{"label": "Auth"}, {"label": "Docs"}]},
{"nonsense": true}
]});
let qs = cc_questions(&input);
assert_eq!(qs.len(), 2);
assert_eq!((qs[0].question.as_str(), qs[0].options.len(), qs[0].multi_select), ("Which database?", 2, false));
assert!(qs[1].multi_select);
// The tool card shows it as our ask_user.
let (name, shown) = map_tool("AskUserQuestion", &input);
assert_eq!(name, "ask_user");
assert_eq!(shown["questions"][1]["multi_select"], true);
// Answers: text stays, a multiple choice is joined.
let answers = json!({"Which database?": "SQLite", "Which extras?": ["Auth", "Docs"]});
let out = cc_answers(answers.as_object().unwrap());
assert_eq!(out["Which database?"], "SQLite");
assert_eq!(out["Which extras?"], "Auth, Docs");
}
#[test]
fn tool_result_images_are_kept() {
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
let imgs = result_images(&content);
assert_eq!(imgs.len(), 1);
assert_eq!((imgs[0].media_type.as_str(), imgs[0].data.as_str()), ("image/jpeg", "QUJD"));
assert!(result_images(&json!("plain text")).is_empty());
}
use super::*; use super::*;
#[test] #[test]
+23
View File
@@ -231,6 +231,29 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(b)) Ok(Json(b))
} }
/// Personal prompt snippets (`/name` in the composer): [{id, name, text}].
pub async fn get_snippets(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let v: Value = app.store.user_setting(&user.id, "snippets")?;
Ok(Json(if v.is_array() { v } else { json!([]) }))
}
pub async fn put_snippets(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
let ok = b.as_array().is_some_and(|a| {
a.len() <= 100
&& a.iter().all(|x| {
let name = x["name"].as_str().unwrap_or("");
!name.is_empty() && name.len() <= 40 && !name.contains(char::is_whitespace) && x["text"].as_str().is_some_and(|t| !t.is_empty() && t.len() <= 8000)
})
});
if !ok {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid snippets (name: one word, up to 40 characters; text: up to 8000)".into()));
}
let clean: Vec<Value> = b.as_array().unwrap().iter().map(|x| json!({"id": x["id"].as_str().filter(|i| i.len() <= 64).map(str::to_string).unwrap_or_else(crate::types::new_id), "name": x["name"], "text": x["text"]})).collect();
let v = Value::Array(clean);
app.store.set_user_setting(&user.id, "snippets", &v)?;
Ok(Json(v))
}
/// Order and pinning of the groups (folders, projects) in the chat list – per user, so /// Order and pinning of the groups (folders, projects) in the chat list – per user, so
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}. /// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
+4
View File
@@ -302,6 +302,9 @@ pub struct ServerConfig {
pub data_dir: String, pub data_dir: String,
pub default_model: String, pub default_model: String,
pub small_model: String, pub small_model: String,
/// Speech-to-text for browsers without dictation (Firefox): "provider/model" of an
/// OpenAI-compatible /audio/transcriptions endpoint, e.g. "openai/whisper-1". Empty = off.
pub transcribe_model: String,
pub auto_compact_threshold: f64, pub auto_compact_threshold: f64,
/// Currency label for cost statistics (display only). /// Currency label for cost statistics (display only).
pub currency: String, pub currency: String,
@@ -337,6 +340,7 @@ impl Default for ServerConfig {
data_dir: "./data".into(), data_dir: "./data".into(),
default_model: String::new(), default_model: String::new(),
small_model: String::new(), small_model: String::new(),
transcribe_model: String::new(),
auto_compact_threshold: 0.85, auto_compact_threshold: 0.85,
currency: "USD".into(), currency: "USD".into(),
max_parallel_subagents: 4, max_parallel_subagents: 4,
+49 -1
View File
@@ -2,7 +2,7 @@
//! user via the user setting "cost_push"). //! user via the user setting "cost_push").
use crate::app::App; use crate::app::App;
use chrono::{Local, Timelike}; use chrono::{Datelike, Local, Timelike};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -54,11 +54,59 @@ pub fn summary(app: &App, user_id: &str) -> Option<Value> {
})) }))
} }
/// The last seven days for one user (None when nothing happened).
pub fn weekly(app: &App, user_id: &str) -> Option<Value> {
let now = Local::now();
let since = now.timestamp_millis() - 7 * 86_400_000;
let offset = now.offset().local_minus_utc() as i64 / 60;
let stats = app.store.global_stats(since, now.timestamp_millis() + 1, offset, Some(user_id)).ok()?;
let requests = stats["total"]["requests"].as_i64().unwrap_or(0);
let (chats, failed, checks) = app.store.weekly_counts(user_id, since).ok()?;
if requests == 0 && chats == 0 {
return None;
}
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let currency = app.config().server.currency.clone();
let cost = money(stats["total"]["cost"].as_f64().unwrap_or(0.0), &currency, de);
let top = stats["top_sessions"].as_array().and_then(|a| a.first()).and_then(|s| s["title"].as_str()).filter(|t| !t.is_empty()).map(str::to_string);
let mut parts = if de { vec![format!("{chats} Chats aktiv"), format!("{requests} Anfragen · {cost}")] } else { vec![format!("{chats} chats active"), format!("{requests} requests · {cost}")] };
if checks > 0 {
parts.push(if de { format!("Checks: {failed} von {checks} fehlgeschlagen") } else { format!("checks: {failed} of {checks} failed") });
}
if let Some(t) = top {
parts.push(if de { format!("teuerster Chat: {t}") } else { format!("most expensive chat: {t}") });
}
Some(json!({
"kind": "digest",
"title": if de { "CodeCraft – Wochenrückblick" } else { "CodeCraft – weekly review" },
"body": parts.join("\n"),
"tag": "weekly-digest",
"url": "/#/stats",
}))
}
pub fn spawn(app: Arc<App>) { pub fn spawn(app: Arc<App>) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
tokio::time::sleep(Duration::from_secs(60)).await; tokio::time::sleep(Duration::from_secs(60)).await;
let now = Local::now(); let now = Local::now();
// Weekly review: Monday morning, opt-in (user setting "weekly_push").
if now.hour() == 8 && now.weekday() == chrono::Weekday::Mon {
let week = now.format("%G-W%V").to_string();
for user in app.store.users().unwrap_or_default() {
let on: bool = app.store.user_setting(&user.id, "weekly_push").unwrap_or(false);
let last: String = app.store.user_setting(&user.id, "weekly_push_last").unwrap_or_default();
if !on || user.disabled || last == week {
continue;
}
let _ = app.store.set_user_setting(&user.id, "weekly_push_last", &week);
if let Some(payload) = weekly(&app, &user.id) {
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
tracing::info!(user = %user.username, sent, errors = errors.len(), "weekly review");
}
}
}
if now.hour() != HOUR { if now.hour() != HOUR {
continue; continue;
} }
File diff suppressed because it is too large. Load diff
+13 -2
View File
@@ -174,7 +174,8 @@ fn parse_status(raw: &str) -> Value {
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult { pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
let spec = crate::sandbox::spec_for(&app, Some(&user.id)); let spec = crate::sandbox::spec_for(&app, Some(&user.id));
let cfg = crate::gitcreds::global_config(&app, &user); let cfg = crate::gitcreds::global_config(&app, &user);
let env = crate::gitcreds::identity_env(&app, &user.id); let mut env = crate::gitcreds::identity_env(&app, &user.id);
env.extend(crate::sshkeys::env(&app, &user));
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
} }
@@ -254,6 +255,15 @@ async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
git_ok(&repo, &args, 60).await?; git_ok(&repo, &args, 60).await?;
json!({"ok": true}) json!({"ok": true})
} }
"suggest" => {
let diff = git_ok(&repo, &["diff", "--cached", "--no-color", "--no-ext-diff", "--stat", "--patch", "-U2"], 60).await?;
if diff.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "nothing is staged".into()));
}
let recent = git_ok(&repo, &["log", "-n", "8", "--pretty=%s"], 15).await.unwrap_or_default();
let message = crate::runner::suggest_commit_message(&app, &user.id, &diff, &recent).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, e.to_string()))?;
json!({"message": message})
}
"commit" => { "commit" => {
let msg = b.message.as_deref().unwrap_or("").trim().to_string(); let msg = b.message.as_deref().unwrap_or("").trim().to_string();
if msg.is_empty() && !b.amend { if msg.is_empty() && !b.amend {
@@ -392,7 +402,8 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> { pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
let spec = crate::sandbox::spec_for(app, Some(&user.id)); let spec = crate::sandbox::spec_for(app, Some(&user.id));
let cfg = crate::gitcreds::global_config(app, user); let cfg = crate::gitcreds::global_config(app, user);
let env = crate::gitcreds::identity_env(app, &user.id); let mut env = crate::gitcreds::identity_env(app, &user.id);
env.extend(crate::sshkeys::env(app, user));
let s = session.clone(); let s = session.clone();
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?; let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) { if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
+42
View File
@@ -53,6 +53,35 @@ pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
env env
} }
/// What an agent needs to know about the stored git access (no secrets): git already has the
/// credentials, so it must not look for them. None when nothing is stored.
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
render_prompt(&app.store.user_setting(user_id, "git").unwrap_or_default())
}
fn render_prompt(s: &GitSettings) -> Option<String> {
let creds: Vec<&GitCredential> = s.credentials.iter().filter(|c| !c.host.trim().is_empty() && !c.token.is_empty()).collect();
if creds.is_empty() {
return None;
}
let mut t = String::from("\n# Git access\nThe user has stored git credentials for these hosts (HTTPS):\n");
for c in &creds {
t.push_str(&format!("- {} (user {})\n", c.host.trim(), c.username.trim()));
}
t.push_str(
"git uses them automatically through its credential helper: `git clone`, `fetch`, `pull` and `push` with an https:// URL of these hosts just work, no login or token needed. \
Do NOT ask the user for a token and do NOT search the environment, config files, `gh`, SSH keys or the keychain for credentials – they are not there, and the command line shows none because git keeps them in its credential store. \
Use https:// remote URLs (SSH keys are not set up). \
For a REST API of such a host (pull requests, issues, releases) take the token straight from git without printing it, e.g. \
`curl -s -H \"Authorization: token $(printf 'protocol=https\\nhost=HOST\\n\\n' | git credential fill | sed -n 's/^password=//p')\" https://HOST/api/...` \
(GitHub: `Authorization: Bearer`). Never echo the token or write it into files, commits or your answer.\n",
);
if !s.name.trim().is_empty() || !s.email.trim().is_empty() {
t.push_str(&format!("Commits are made as {} <{}> automatically.\n", s.name.trim(), s.email.trim()));
}
Some(t)
}
pub fn home_of(app: &App, user: &User) -> PathBuf { pub fn home_of(app: &App, user: &User) -> PathBuf {
app.data_dir().join("users").join(&user.username).join("home") app.data_dir().join("users").join(&user.username).join("home")
} }
@@ -159,6 +188,19 @@ pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn prompt_names_hosts_but_never_tokens() {
assert!(render_prompt(&GitSettings::default()).is_none());
let settings = GitSettings { name: "Ann".into(), email: "ann@x.org".into(), credentials: vec![GitCredential { host: "git.x.org".into(), username: "ann".into(), token: "s3cret-token".into(), protocol: String::new() }] };
let p = render_prompt(&settings).unwrap();
println!("{p}");
assert!(p.contains("git.x.org (user ann)") && p.contains("git credential fill") && p.contains("Ann <ann@x.org>"));
assert!(!p.contains("s3cret-token"));
// Entries without a token are not offered.
let none = GitSettings { credentials: vec![GitCredential { host: "h".into(), username: "u".into(), ..Default::default() }], ..Default::default() };
assert!(render_prompt(&none).is_none());
}
#[test] #[test]
fn keeps_masked_tokens() { fn keeps_masked_tokens() {
let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() }; let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() };
+13 -2
View File
@@ -42,8 +42,9 @@ pub async fn probe(provider: Arc<dyn crate::provider::Provider>) -> (ProviderSta
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0), Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
Err(_) => (Some("timed out".to_string()), 0), Err(_) => (Some("timed out".to_string()), 0),
}; };
// The host answered but has no model list: reachable. // The host answered but has no model list: reachable. (fal.ai's router answers 403
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405"))); // "endpoint_not_priced" for /models: the key is fine, the listing is just not offered.)
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405") || e.contains("endpoint_not_priced")));
let state = match &error { let state = match &error {
None => "ok", None => "ok",
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth", Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
@@ -83,3 +84,13 @@ pub fn spawn_checker(app: Arc<App>) {
} }
}); });
} }
#[cfg(test)]
mod tests {
#[test]
fn unpriced_model_listing_is_not_an_auth_failure() {
let e = r#"provider returned HTTP 403 Forbidden: {"detail":"Endpoint openrouter/router/openai/v1/models is not available: no billing definition.","type":"endpoint_not_priced"}"#.to_string();
let kept = Some(e).filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405") || e.contains("endpoint_not_priced")));
assert!(kept.is_none());
}
}
+46
View File
@@ -68,6 +68,13 @@ pub struct JobSettings {
/// Chat in which the user works out the prompt with a model. /// Chat in which the user works out the prompt with a model.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>, pub assistant_session: Option<String>,
/// Push notification with the report when a run ends.
#[serde(default = "default_notify")]
pub notify: bool,
}
fn default_notify() -> bool {
true
} }
impl JobSettings { impl JobSettings {
@@ -81,6 +88,7 @@ impl JobSettings {
timeout_minutes: default_timeout(), timeout_minutes: default_timeout(),
cwd: None, cwd: None,
assistant_session: None, assistant_session: None,
notify: true,
} }
} }
@@ -121,6 +129,7 @@ pub struct JobSpec {
pub cwd: Option<String>, pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>, pub assistant_session: Option<String>,
pub notify: bool,
pub prompt: String, pub prompt: String,
} }
@@ -135,6 +144,7 @@ impl JobSpec {
timeout_minutes: s.timeout_minutes, timeout_minutes: s.timeout_minutes,
cwd: s.cwd.clone(), cwd: s.cwd.clone(),
assistant_session: s.assistant_session.clone(), assistant_session: s.assistant_session.clone(),
notify: s.notify,
prompt, prompt,
} }
} }
@@ -149,6 +159,7 @@ impl JobSpec {
timeout_minutes: self.timeout_minutes, timeout_minutes: self.timeout_minutes,
cwd: self.cwd.clone(), cwd: self.cwd.clone(),
assistant_session: self.assistant_session.clone(), assistant_session: self.assistant_session.clone(),
notify: self.notify,
} }
} }
} }
@@ -218,6 +229,7 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60), timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()), cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
assistant_session: None, assistant_session: None,
notify: meta.get("notify").map(|v| unquote(v) != "false").unwrap_or(true),
prompt: body.trim().to_string(), prompt: body.trim().to_string(),
}) })
} }
@@ -342,6 +354,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
let job_id = job.id.clone(); let job_id = job.id.clone();
let sid = session.id.clone(); let sid = session.id.clone();
let timeout = Duration::from_secs(spec.timeout_minutes * 60); let timeout = Duration::from_secs(spec.timeout_minutes * 60);
let (spec_name, spec_notify) = (spec.name.clone(), spec.notify);
tokio::spawn(async move { tokio::spawn(async move {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await; tokio::time::sleep(Duration::from_secs(2)).await;
@@ -349,15 +362,48 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
if started.elapsed() > timeout { if started.elapsed() > timeout {
crate::runner::abort(&app2, &sid); crate::runner::abort(&app2, &sid);
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout"); let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
report(&app2, &sid, &spec_name, spec_notify, "timeout").await;
return; return;
} }
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
} }
let _ = app2.store.job_finished(&job_id, Some(&sid), "done"); let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
report(&app2, &sid, &spec_name, spec_notify, "done").await;
}); });
Ok(session.id) Ok(session.id)
} }
/// Push the report of a finished run (the agent's last message) to the owner.
async fn report(app: &App, session_id: &str, name: &str, notify: bool, status: &str) {
if !notify {
return;
}
let Ok(session) = app.session(session_id) else { return };
let Some(owner) = session.owner_id.clone() else { return };
let prefs: Value = app.store.user_setting(&owner, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let last = app
.store
.messages(session_id)
.unwrap_or_default()
.into_iter()
.rev()
.find(|m| matches!(m.role, crate::types::Role::Assistant))
.map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n"))
.unwrap_or_default();
let flat: String = last.split_whitespace().collect::<Vec<_>>().join(" ");
let body: String = if flat.chars().count() > 220 { format!("{}…", flat.chars().take(220).collect::<String>()) } else { flat };
let title = match (status, de) {
("timeout", true) => format!("{name} – Zeitlimit erreicht"),
("timeout", false) => format!("{name} – timed out"),
(_, true) => format!("{name} – fertig"),
(_, false) => format!("{name} – finished"),
};
let payload = json!({"kind": "job", "title": title, "body": body, "tag": format!("job-{session_id}"), "url": format!("/#/s/{session_id}")});
let (sent, _) = crate::push::send_to_user(app, &owner, &payload).await;
tracing::info!(job = name, sent, "automation report");
}
/// Background scheduler: checks every 30 s which jobs are due. /// Background scheduler: checks every 30 s which jobs are due.
pub fn spawn_scheduler(app: Arc<App>) { pub fn spawn_scheduler(app: Arc<App>) {
tokio::spawn(async move { tokio::spawn(async move {
+5
View File
@@ -13,6 +13,7 @@ mod config;
mod digest; mod digest;
mod docs; mod docs;
mod export; mod export;
mod factory;
mod files; mod files;
mod git; mod git;
mod health; mod health;
@@ -24,6 +25,7 @@ mod lsp;
mod mentions; mod mentions;
mod metrics; mod metrics;
mod mcp; mod mcp;
mod mcp_bridge;
mod media; mod media;
mod mesh3d; mod mesh3d;
mod permission; mod permission;
@@ -34,6 +36,8 @@ mod reverse;
mod runner; mod runner;
mod sandbox; mod sandbox;
mod shells; mod shells;
mod sshkeys;
mod stats_export;
mod store; mod store;
mod telegram; mod telegram;
mod terminal; mod terminal;
@@ -75,6 +79,7 @@ async fn main() -> Result<()> {
push::spawn_listener(app.clone()); push::spawn_listener(app.clone());
terminal::spawn_reaper(app.clone()); terminal::spawn_reaper(app.clone());
jobs::spawn_scheduler(app.clone()); jobs::spawn_scheduler(app.clone());
factory::spawn(app.clone());
update::spawn_checker(app.clone()); update::spawn_checker(app.clone());
health::spawn_checker(app.clone()); health::spawn_checker(app.clone());
digest::spawn(app.clone()); digest::spawn(app.clone());
+252
View File
@@ -0,0 +1,252 @@
//! CodeCraft's tools for the Claude Code CLI, as an MCP server (streamable HTTP, JSON answers).
//!
//! The CLI only knows its own tools. For every run the server registers a secret token and
//! passes `--mcp-config` with `http://127.0.0.1:<port>/api/mcp-bridge/<token>`; the CLI then
//! lists and calls the tools like any MCP tool (`mcp__codecraft__<name>`). The token is the
//! credential (the route is public) and binds the calls to the chat that started the run.
//! Permissions are asked by the CLI through the usual `can_use_tool` request, so the approval
//! cards, rules and project approvals work as for the CLI's own tools.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use tokio_util::sync::CancellationToken;
pub const SERVER: &str = "codecraft";
/// Prefix the CLI gives our tools.
pub const PREFIX: &str = "mcp__codecraft__";
/// Our tools the CLI brings itself (same job): not offered twice.
const CLI_HAS: &[&str] = &["read", "write", "edit", "list", "glob", "grep", "bash", "bash_output", "kill_shell", "web_fetch", "web_search", "todo_write", "task", "change_directory"];
static TOKENS: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(Default::default);
/// A registered run; the token stops working when this is dropped.
pub struct Registration {
pub token: String,
}
impl Drop for Registration {
fn drop(&mut self) {
TOKENS.lock().unwrap().remove(&self.token);
}
}
pub fn register(session_id: &str) -> Registration {
let token = format!("{}{}", crate::types::new_id(), crate::types::new_id());
TOKENS.lock().unwrap().insert(token.clone(), session_id.to_string());
Registration { token }
}
/// `--mcp-config` value for a run.
pub fn config_json(app: &App, token: &str) -> String {
let port = app.config().server.bind.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(8787);
json!({"mcpServers": {SERVER: {"type": "http", "url": format!("http://127.0.0.1:{port}/api/mcp-bridge/{token}")}}}).to_string()
}
/// The tools the CLI gets for a chat: what the chat's own agent would have (mode, images,
/// the server's MCP tools …) without the ones the CLI has itself.
pub fn tools_for(app: &Arc<App>, session: &crate::types::Session) -> Vec<Arc<dyn Tool>> {
let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let agent = crate::prompts::find_agent_for(app, &root, &cwd, &session.agent, session.owner_id.as_deref());
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
crate::runner::available_tools(app, &agent, owner.as_ref()).into_iter().filter(|t| !CLI_HAS.contains(&t.name())).collect()
}
/// Short note for the system prompt: what the extra tools are called.
pub fn prompt_section(tools: &[Arc<dyn Tool>]) -> Option<String> {
if tools.is_empty() {
return None;
}
let names: Vec<String> = tools.iter().map(|t| format!("{PREFIX}{}", t.name())).collect();
Some(format!(
"\n# CodeCraft tools\nBesides your own tools you have the tools of the CodeCraft server (MCP server `{SERVER}`): {}. \
Use them for what they do – e.g. image generation (generate_image), 3D models, OpenSCAD, scheduling automations, diagnostics, the browser. \
If one is not in your tool list, load it with ToolSearch (query `select:<name>`).\n",
names.join(", ")
))
}
// --- protocol ----------------------------------------------------------------------------
fn tools_list(tools: &[Arc<dyn Tool>]) -> Value {
json!({"tools": tools.iter().map(|t| json!({"name": t.name(), "description": t.description(), "inputSchema": t.schema()})).collect::<Vec<_>>()})
}
fn call_result(res: anyhow::Result<ToolOutput>) -> Value {
match res {
Ok(out) => {
let mut content = vec![json!({"type": "text", "text": out.content})];
for img in out.images.iter().filter(|i| !i.data.is_empty()) {
content.push(json!({"type": "image", "data": img.data, "mimeType": img.media_type}));
}
json!({"content": content, "isError": out.is_error})
}
Err(e) => json!({"content": [{"type": "text", "text": format!("Error: {e:#}")}], "isError": true}),
}
}
fn answer(id: &Value, result: Value) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
}
fn failure(id: &Value, code: i64, message: &str) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})).into_response()
}
pub async fn handle(State(app): State<Arc<App>>, Path(token): Path<String>, Json(req): Json<Value>) -> Response {
let Some(session_id) = TOKENS.lock().unwrap().get(&token).cloned() else {
return (StatusCode::NOT_FOUND, "unknown or finished run").into_response();
};
let id = req["id"].clone();
let method = req["method"].as_str().unwrap_or("");
// Notifications have no answer.
if req.get("id").is_none() || method.starts_with("notifications/") {
return StatusCode::ACCEPTED.into_response();
}
match method {
"initialize" => answer(
&id,
json!({
"protocolVersion": req["params"]["protocolVersion"].as_str().unwrap_or("2025-03-26"),
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")},
}),
),
"ping" => answer(&id, json!({})),
"tools/list" | "tools/call" => {
let Ok(session) = app.session(&session_id) else { return failure(&id, -32000, "the chat is gone") };
let tools = tools_for(&app, &session);
if method == "tools/list" {
return answer(&id, tools_list(&tools));
}
let name = req["params"]["name"].as_str().unwrap_or("");
let Some(tool) = tools.iter().find(|t| t.name() == name).cloned() else {
return failure(&id, -32602, &format!("unknown tool '{name}'"));
};
let cwd = PathBuf::from(&session.cwd);
let agent = crate::prompts::find_agent_for(&app, &app.root_for(session.owner_id.as_deref(), &cwd), &cwd, &session.agent, session.owner_id.as_deref());
let ctx = ToolCtx {
app: app.clone(),
session_id: session_id.clone(),
call_id: crate::types::new_id(),
cwd,
cancel: CancellationToken::new(),
agent: agent.name.clone(),
roots: app.user_roots(session.owner_id.as_deref()),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room: session.clean_room(app.config().craft.clean_room) && agent.name == "craft",
owner_id: None,
};
let input = req["params"]["arguments"].clone();
let input = if input.is_null() { json!({}) } else { input };
answer(&id, call_result(tool.run(input, &ctx).await))
}
_ => failure(&id, -32601, "method not found"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ImageData;
#[test]
fn registration_lives_as_long_as_the_run() {
let r = register("s1");
assert_eq!(TOKENS.lock().unwrap().get(&r.token).map(String::as_str), Some("s1"));
let t = r.token.clone();
drop(r);
assert!(!TOKENS.lock().unwrap().contains_key(&t));
}
#[test]
fn tool_results_carry_text_and_images() {
let out = ToolOutput {
content: "saved".into(),
images: vec![ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None }, ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None }],
..Default::default()
};
let v = call_result(Ok(out));
assert_eq!(v["isError"], false);
let c = v["content"].as_array().unwrap();
assert_eq!(c.len(), 2); // the image without data is left out
assert_eq!((c[1]["type"].as_str(), c[1]["mimeType"].as_str()), (Some("image"), Some("image/png")));
assert_eq!(call_result(Err(anyhow::anyhow!("boom")))["isError"], true);
}
#[test]
fn cli_builtins_are_not_offered_twice() {
let all = crate::tools::builtin_tools();
let ours: Vec<&str> = all.iter().map(|t| t.name()).filter(|n| !CLI_HAS.contains(n)).collect();
for n in ["generate_image", "generate_3d", "openscad", "browser", "schedule", "remember", "diagnostics", "ask_user", "skill"] {
assert!(ours.contains(&n), "{n} missing");
}
assert!(!ours.contains(&"read") && !ours.contains(&"bash"));
let list = tools_list(&all.into_iter().filter(|t| t.name() == "remember").collect::<Vec<_>>());
assert!(list["tools"][0]["inputSchema"].is_object());
}
/// The whole protocol against a real App: initialize, list, call, unknown token.
#[tokio::test]
async fn protocol_roundtrip_with_a_real_app() {
let dir = std::env::temp_dir().join(format!("cc-bridge-{}", std::process::id()));
std::fs::create_dir_all(dir.join("ws")).unwrap();
let toml = crate::config::EXAMPLE_CONFIG.lines().map(|l| {
if l.starts_with("workspace = ") { format!("workspace = \"{}\"", dir.join("ws").display()) }
else if l.starts_with("data_dir = ") { format!("data_dir = \"{}\"", dir.join("data").display()) }
else { l.to_string() }
}).collect::<Vec<_>>().join("\n");
let cfg = crate::config::Config::parse(&toml).unwrap();
let app = App::new(dir.join("config.toml"), cfg).await.unwrap();
let now = crate::types::now_ms();
let session = crate::types::Session {
id: crate::types::new_id(), title: String::new(), cwd: dir.join("ws").display().to_string(), model: "x/y".into(), agent: "build".into(), permission_mode: "bypass".into(),
parent_id: None, owner_id: None, created_at: now, updated_at: now, todos: Value::Null, last_usage: None, clean_room: None, phase: None, worktree: None,
pinned: false, archived: false, folder: None, instructions: String::new(), telegram_quiet: true, summary: String::new(), color: None, icon: None,
};
let session = app.save_session(session).unwrap();
let reg = register(&session.id);
let rpc = |req: Value| {
let app = app.clone();
let token = reg.token.clone();
async move {
let resp = handle(State(app), Path(token), Json(req)).await;
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, serde_json::from_slice::<Value>(&bytes).unwrap_or(Value::Null))
}
};
let (st, init) = rpc(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert!(init["result"]["capabilities"]["tools"].is_object());
let (st, _) = rpc(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await;
assert_eq!(st, StatusCode::ACCEPTED);
let (_, list) = rpc(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).await;
let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"remember") && names.contains(&"openscad") && names.contains(&"browser"), "{names:?}");
assert!(!names.contains(&"read") && !names.contains(&"bash"), "{names:?}");
// A real tool call: `remember` writes the project memory.
let (_, call) = rpc(json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "remember", "arguments": {"note": "bridge works"}}})).await;
assert_eq!(call["result"]["isError"], false, "{call}");
let mem = std::fs::read_to_string(dir.join("ws/.codecraft/memory.md")).unwrap_or_default();
assert!(mem.contains("bridge works"), "{mem}");
let (_, bad) = rpc(json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "bash", "arguments": {"command": "id"}}})).await;
assert!(bad["error"].is_object());
// A finished run's token is dead.
let token = reg.token.clone();
drop(reg);
let resp = handle(State(app.clone()), Path(token), Json(json!({"jsonrpc": "2.0", "id": 5, "method": "ping"}))).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let _ = std::fs::remove_dir_all(&dir);
}
}
+6
View File
@@ -298,6 +298,12 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim())); s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
} }
} }
if let Some(git) = owner.and_then(|u| crate::gitcreds::prompt_section(app, &u.id)) {
s.push_str(&git);
}
if let Some(ssh) = owner.and_then(|u| crate::sshkeys::prompt_section(app, &u.id)) {
s.push_str(&ssh);
}
match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) { match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())), Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())),
Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"), Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"),
+85
View File
@@ -1248,6 +1248,58 @@ async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input
Ok(strip_think(&text)) Ok(strip_think(&text))
} }
/// A commit message for staged changes (small model; the diff is cut to its context window).
/// `recent` are the latest commit subjects, so the style matches the repository.
pub async fn suggest_commit_message(app: &App, user_id: &str, diff: &str, recent: &str) -> Result<String> {
let cfg = app.config();
let spec = if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
cfg.server.small_model.clone()
} else {
app.registry().first_model_excluding(crate::claude_code::PROVIDER).ok_or_else(|| anyhow!("no model available"))?
};
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("low".into());
let max_out = 200u64;
let system = "You write a git commit message for the staged changes. Reply with the message only: a subject line of at most 72 characters in the imperative mood, then optionally a blank line and up to 4 short bullet points on what changed and why. No quotes, no code fences. Match the style of the recent commit subjects (language, prefixes).";
let ctx = model.context.unwrap_or(32_000);
let room = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 500).max(600) as usize * 5 / 2;
let input = format!("<recent-commits>\n{}\n</recent-commits>\n<staged-diff>\n{}\n</staged-diff>", fit(recent, 600), fit(diff, room.saturating_sub(700).clamp(500, 40_000)));
let req = ChatRequest {
system: system.into(),
messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])],
tools: vec![],
max_tokens: max_out,
reasoning: false,
session_id: None,
model: model.clone(),
};
let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?;
let (p, m) = spec.split_once('/').unwrap_or(("", spec.as_str()));
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: "cli:git-commit".into(),
root_session_id: "cli:git-commit".into(),
provider: p.into(),
model: m.into(),
agent: "commit-message".into(),
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
ttft_ms: 0,
duration_ms: 0,
cost: model.cost(&usage),
error: false,
user_id: Some(user_id.to_string()),
allowance: usage.allowance,
});
let msg = strip_think(&text).trim().trim_matches('`').trim().to_string();
if msg.is_empty() {
bail!("the model returned no message");
}
Ok(msg)
}
fn message_text(m: &Message) -> String { fn message_text(m: &Message) -> String {
m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n") m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")
} }
@@ -1671,10 +1723,43 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
Ok(()) Ok(())
} }
/// Text of the marker `/clear` puts into the history (the chat view recognises it).
pub const CLEARED_MARKER: &str = "[Context cleared by the user. Earlier messages are no longer part of this conversation – start fresh and do not refer to them.]";
/// `/clear`: the model forgets the conversation so far (the chat keeps showing it). Like a
/// compaction without a summary: history before the marker is not sent any more.
pub fn clear_context(app: &App, session_id: &str) -> Result<()> {
save_user_parts(app, session_id, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }])?;
app.update_session(session_id, |s| {
s.last_usage = None;
s.todos = Value::Null;
})?;
app.with_runtime(session_id, |r| r.read_files.clear());
// Claude Code continues its own conversation: start a new one.
if let Some(owner) = app.session(session_id).ok().and_then(|s| s.owner_id) {
let _ = app.store.set_user_setting(&owner, &format!("claude_sid:{session_id}"), &Option::<String>::None);
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn cleared_marker_starts_a_new_history() {
let mk = |role, parts| Message::new(role, parts);
let all = vec![
mk(Role::User, vec![Part::Text { text: "old question".into() }]),
mk(Role::Assistant, vec![Part::Text { text: "old answer".into() }]),
mk(Role::User, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }]),
mk(Role::User, vec![Part::Text { text: "new question".into() }]),
];
let active = active_history(all);
assert_eq!(active.len(), 2);
assert!(!active.iter().any(|m| message_text(m).contains("old")));
}
#[test] #[test]
fn fit_keeps_head_and_tail_within_budget() { fn fit_keeps_head_and_tail_within_budget() {
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000)); let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
+8 -1
View File
@@ -132,7 +132,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
if new_session { if new_session {
push(&[&"--new-session"]); push(&[&"--new-session"]);
} }
let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin"); // SSH wrappers (user's keys); only when keys are set up.
let ssh_wrapper = spec.home.join(crate::sshkeys::SANDBOX_BIN).join("ssh");
let ssh_bin = format!("{home}/{}", crate::sshkeys::SANDBOX_BIN);
let path = format!("{}{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin", if ssh_wrapper.exists() { format!("{ssh_bin}:") } else { String::new() });
let npm = format!("{home}/.npm-global"); let npm = format!("{home}/.npm-global");
let pipx = format!("{home}/.local/pipx"); let pipx = format!("{home}/.local/pipx");
let pipx_bin = format!("{home}/.local/bin"); let pipx_bin = format!("{home}/.local/bin");
@@ -152,6 +155,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
] { ] {
push(&[&"--setenv", &k, &v]); push(&[&"--setenv", &k, &v]);
} }
if ssh_wrapper.exists() {
let cmd = format!("{ssh_bin}/ssh");
push(&[&"--setenv", &"GIT_SSH_COMMAND", &cmd]);
}
for (k, v) in &spec.env { for (k, v) in &spec.env {
push(&[&"--setenv", k, v]); push(&[&"--setenv", k, v]);
} }
+299
View File
@@ -0,0 +1,299 @@
//! Per-user SSH keys for agents, terminals and the git panel.
//!
//! The keys live in the database and are rendered into the user's home (`.ssh/`), like the git
//! credentials. OpenSSH takes `~` from the passwd entry, not from `$HOME`, so plain `ssh` would
//! not look into the sandbox home. A small `ssh`/`scp`/`sftp` wrapper directory goes first in PATH
//! and applies the managed config with `-F`; `GIT_SSH_COMMAND` points at the wrapper. Two
//! variants: one for the sandbox (paths through `${HOME}`) and one for unsandboxed runs (admins;
//! absolute host paths).
use crate::app::App;
use crate::store::User;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshKey {
pub id: String,
pub name: String,
pub public: String,
pub fingerprint: String,
/// OpenSSH private key (never sent to the UI).
#[serde(default)]
pub private: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshSettings {
#[serde(default)]
pub keys: Vec<SshKey>,
}
/// Keys without the private part, for the UI.
pub fn masked(s: &SshSettings) -> Vec<SshKey> {
s.keys.iter().map(|k| SshKey { private: String::new(), ..k.clone() }).collect()
}
fn ssh_dir(home: &Path) -> PathBuf {
home.join(".ssh")
}
/// Wrappers for unsandboxed runs.
fn bin_dir(home: &Path) -> PathBuf {
home.join(".codecraft-bin")
}
/// Wrappers inside the sandbox (see `sandbox::bwrap_args`).
pub const SANDBOX_BIN: &str = ".codecraft-sbin";
fn key_file(id: &str) -> String {
format!("codecraft_{id}")
}
/// SSH client config. `sandbox`: paths through `${HOME}` (the home is mounted at a different
/// place there); otherwise absolute paths below `home` (wrapper for unsandboxed runs).
pub fn render_config(keys: &[SshKey], home: &Path, sandbox: bool) -> String {
let mut c = String::from("# Managed by CodeCraft (Settings → Account → SSH keys). Do not edit.\nHost *\n");
for k in keys {
if sandbox {
c.push_str(&format!("\tIdentityFile ${{HOME}}/.ssh/{}\n", key_file(&k.id)));
} else {
c.push_str(&format!("\tIdentityFile {}\n", ssh_dir(home).join(key_file(&k.id)).display()));
}
}
// Trust a host on first contact and refuse changed keys afterwards.
c.push_str("\tStrictHostKeyChecking accept-new\n\tServerAliveInterval 30\n");
if sandbox {
c.push_str("\tUserKnownHostsFile ${HOME}/.ssh/known_hosts\n");
} else {
c.push_str(&format!("\tUserKnownHostsFile {}\n", ssh_dir(home).join("known_hosts").display()));
}
c
}
#[cfg(unix)]
fn chmod(p: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
Ok(())
}
#[cfg(not(unix))]
fn chmod(_: &Path, _: u32) -> Result<()> {
Ok(())
}
/// Write keys, config and wrappers into the user's home (removes files of deleted keys).
pub fn write_files(app: &App, user: &User, s: &SshSettings) -> Result<()> {
let home = crate::gitcreds::home_of(app, user);
let dir = ssh_dir(&home);
std::fs::create_dir_all(&dir)?;
chmod(&dir, 0o700)?;
// Old key files.
for e in std::fs::read_dir(&dir)?.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if n.starts_with("codecraft_") && !s.keys.iter().any(|k| n == key_file(&k.id) || n == format!("{}.pub", key_file(&k.id))) {
let _ = std::fs::remove_file(e.path());
}
}
if s.keys.is_empty() {
// No keys: no wrappers either, ssh behaves as usual.
let _ = std::fs::remove_dir_all(bin_dir(&home));
let _ = std::fs::remove_dir_all(home.join(SANDBOX_BIN));
for f in ["config-sandbox", "config-host"] {
let _ = std::fs::remove_file(dir.join(f));
}
return Ok(());
}
for k in &s.keys {
let p = dir.join(key_file(&k.id));
// The file is replaced, not appended: remove it first so the mode applies.
let _ = std::fs::remove_file(&p);
std::fs::write(&p, &k.private)?;
chmod(&p, 0o600)?;
let pubp = dir.join(format!("{}.pub", key_file(&k.id)));
std::fs::write(&pubp, format!("{}\n", k.public.trim()))?;
chmod(&pubp, 0o644)?;
}
// The user's own ~/.ssh/config (if any) is included after the managed part.
let own = dir.join("config");
let sandbox_cfg = dir.join("config-sandbox");
std::fs::write(&sandbox_cfg, format!("{}\nInclude /home/{}/.ssh/config\n", render_config(&s.keys, &home, true), user.username))?;
chmod(&sandbox_cfg, 0o600)?;
let host_cfg = dir.join("config-host");
std::fs::write(&host_cfg, format!("{}\nInclude {}\n", render_config(&s.keys, &home, false), own.display()))?;
chmod(&host_cfg, 0o600)?;
for (bin, config) in [(bin_dir(&home), format!("'{}'", host_cfg.display())), (home.join(SANDBOX_BIN), "\"$HOME/.ssh/config-sandbox\"".to_string())] {
std::fs::create_dir_all(&bin)?;
for name in ["ssh", "scp", "sftp"] {
// The image's /usr/local/bin wrappers cope with a uid without passwd entry.
let local = format!("/usr/local/bin/{name}");
let real = if Path::new(&local).exists() { local } else { format!("/usr/bin/{name}") };
let p = bin.join(name);
std::fs::write(&p, format!("#!/bin/sh\nexec {real} -F {config} \"$@\"\n"))?;
chmod(&p, 0o755)?;
}
}
Ok(())
}
/// Environment for processes that run without the sandbox: the wrappers first in PATH, git over
/// SSH through the wrapper. Empty when the user has no keys.
pub fn env(app: &App, user: &User) -> Vec<(String, String)> {
let s: SshSettings = app.store.user_setting(&user.id, "ssh").unwrap_or_default();
if s.keys.is_empty() {
return Vec::new();
}
let bin = bin_dir(&crate::gitcreds::home_of(app, user));
if !bin.join("ssh").exists() {
return Vec::new();
}
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()));
vec![("PATH".into(), path), ("GIT_SSH_COMMAND".into(), bin.join("ssh").display().to_string())]
}
/// What an agent needs to know (public keys only). None without keys.
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
render_prompt(&app.store.user_setting(user_id, "ssh").unwrap_or_default())
}
fn render_prompt(s: &SshSettings) -> Option<String> {
if s.keys.is_empty() {
return None;
}
let names: Vec<&str> = s.keys.iter().map(|k| k.name.as_str()).collect();
Some(format!(
"\n# SSH access\nThe user has SSH keys set up ({}). `ssh`, `scp`, `sftp`, `rsync -e ssh` and `git` over ssh:// or git@host use them automatically – \
no password, no `ssh-add`, no agent. A host is trusted on first contact. Do NOT generate new keys or search for keys or passwords; \
if a server refuses the connection, tell the user that their public key must be added to the server's authorized_keys. \
Commands run without a terminal, so password or passphrase prompts fail.\n",
names.join(", ")
))
}
// --- creating keys ---------------------------------------------------------------------
async fn keygen(args: &[&str], stdin: Option<&str>) -> Result<String> {
let mut cmd = Command::new("ssh-keygen");
cmd.args(args).env_clear().env("PATH", std::env::var("PATH").unwrap_or_default()).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().context("ssh-keygen is not installed on the server")?;
if let Some(mut si) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
if let Some(t) = stdin {
let _ = si.write_all(t.as_bytes()).await;
}
}
let out = tokio::time::timeout(std::time::Duration::from_secs(20), child.wait_with_output()).await.map_err(|_| anyhow!("ssh-keygen timed out"))??;
if !out.status.success() {
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn temp_dir() -> Result<PathBuf> {
let d = std::env::temp_dir().join(format!("cc-ssh-{}", crate::types::new_id()));
std::fs::create_dir_all(&d)?;
chmod(&d, 0o700)?;
Ok(d)
}
/// A new ed25519 key without passphrase.
pub async fn generate(name: &str) -> Result<SshKey> {
let d = temp_dir()?;
let f = d.join("key");
let comment = format!("codecraft-{}", name.replace(char::is_whitespace, "-"));
let res = async {
keygen(&["-q", "-t", "ed25519", "-N", "", "-C", &comment, "-f", &f.to_string_lossy()], None).await?;
let private = std::fs::read_to_string(&f)?;
let public = std::fs::read_to_string(d.join("key.pub"))?;
finish(name, private, public.trim().to_string()).await
}
.await;
let _ = std::fs::remove_dir_all(&d);
res
}
/// An existing private key (OpenSSH or PEM, without passphrase).
pub async fn import(name: &str, private: &str) -> Result<SshKey> {
let mut text = private.trim().replace("\r\n", "\n");
if !text.starts_with("-----BEGIN ") || !text.contains("PRIVATE KEY-----") {
bail!("that does not look like a private key (-----BEGIN … PRIVATE KEY-----)");
}
text.push('\n');
let d = temp_dir()?;
let f = d.join("key");
let res = async {
std::fs::write(&f, &text)?;
chmod(&f, 0o600)?;
let public = keygen(&["-y", "-P", "", "-f", &f.to_string_lossy()], None).await.map_err(|e| anyhow!("cannot read the key (keys with a passphrase are not supported): {e}"))?;
finish(name, text.clone(), public.trim().to_string()).await
}
.await;
let _ = std::fs::remove_dir_all(&d);
res
}
async fn finish(name: &str, private: String, public: String) -> Result<SshKey> {
let fp = keygen(&["-l", "-f", "-"], Some(&format!("{public}\n"))).await.unwrap_or_default();
let fingerprint = fp.split_whitespace().nth(1).unwrap_or("").to_string();
Ok(SshKey { id: crate::types::new_id().chars().take(10).collect(), name: name.to_string(), public, fingerprint, private })
}
#[cfg(test)]
mod tests {
use super::*;
fn key(id: &str) -> SshKey {
SshKey { id: id.into(), name: "laptop".into(), public: "ssh-ed25519 AAAA x".into(), fingerprint: "SHA256:x".into(), private: "SECRET".into() }
}
#[test]
fn config_has_both_path_styles() {
let home = Path::new("/data/users/ann/home");
let t = render_config(&[key("k1")], home, true);
assert!(t.contains("IdentityFile ${HOME}/.ssh/codecraft_k1") && t.contains("UserKnownHostsFile ${HOME}/.ssh/known_hosts"));
assert!(t.contains("StrictHostKeyChecking accept-new"));
let a = render_config(&[key("k1")], home, false);
assert!(a.contains("IdentityFile /data/users/ann/home/.ssh/codecraft_k1"));
assert!(a.contains("UserKnownHostsFile /data/users/ann/home/.ssh/known_hosts"));
}
#[test]
fn sandbox_config_resolves_through_home() {
if std::process::Command::new("ssh").arg("-V").output().is_err() {
return;
}
let d = std::env::temp_dir().join(format!("cc-sshcfg-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let cfg = d.join("cfg");
std::fs::write(&cfg, render_config(&[key("k1")], Path::new("/unused"), true)).unwrap();
let out = std::process::Command::new("ssh").arg("-F").arg(&cfg).args(["-G", "example.org"]).env("HOME", "/sandbox/home").output().unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
// `-G` shows identity files unexpanded (expanded when used, ssh_config(5)).
assert!(text.contains("identityfile ${home}/.ssh/codecraft_k1"), "{text}");
assert!(text.contains("userknownhostsfile /sandbox/home/.ssh/known_hosts"), "{text}");
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn prompt_and_ui_view_never_contain_the_private_key() {
let s = SshSettings { keys: vec![key("k1")] };
assert!(masked(&s).iter().all(|k| k.private.is_empty()));
let p = render_prompt(&s).unwrap();
assert!(p.contains("laptop") && !p.contains("SECRET"));
assert!(render_prompt(&SshSettings::default()).is_none());
}
#[tokio::test]
async fn generated_and_imported_keys_match() {
if std::process::Command::new("ssh-keygen").arg("-?").output().is_err() {
return; // not installed here
}
let k = generate("test key").await.unwrap();
assert!(k.public.starts_with("ssh-ed25519 ") && k.fingerprint.starts_with("SHA256:") && k.private.contains("PRIVATE KEY"));
let again = import("copy", &k.private).await.unwrap();
assert_eq!(again.fingerprint, k.fingerprint);
assert!(import("bad", "hello").await.is_err());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Statistics as CSV (one table) or XLSX (all tables, one sheet each).
use serde_json::Value;
use std::io::Write;
/// A table: header row and rows of cells.
pub struct Table {
pub id: &'static str,
pub title: String,
pub rows: Vec<Vec<Cell>>,
}
pub enum Cell {
Text(String),
Num(f64),
}
fn num(v: &Value, k: &str) -> Cell {
Cell::Num(v[k].as_f64().unwrap_or(0.0))
}
/// Column titles (English, German).
fn columns(first: &str, de: bool) -> Vec<Cell> {
let t: [&str; 10] = if de {
["Anfragen", "Input-Tokens", "Cache gelesen", "Cache geschrieben", "Output-Tokens", "Fehler", "Ø Latenz (ms)", "Output (Tok/s)", "Prefill (Tok/s)", "Kosten"]
} else {
["Requests", "Input tokens", "Cache read", "Cache write", "Output tokens", "Errors", "Avg TTFT (ms)", "Output (tok/s)", "Prefill (tok/s)", "Cost"]
};
std::iter::once(first).chain(t).map(|s| Cell::Text(s.to_string())).collect()
}
fn agg_table(id: &'static str, title: &str, first: &str, key: &str, rows: &Value, de: bool) -> Table {
let mut out = vec![columns(first, de)];
for r in rows.as_array().into_iter().flatten() {
out.push(vec![
Cell::Text(r[key].as_str().unwrap_or("").to_string()),
num(r, "requests"),
num(r, "input_tokens"),
num(r, "cache_read_tokens"),
num(r, "cache_write_tokens"),
num(r, "output_tokens"),
num(r, "errors"),
num(r, "avg_ttft_ms"),
num(r, "output_tps"),
num(r, "prefill_tps"),
num(r, "cost"),
]);
}
Table { id, title: title.to_string(), rows: out }
}
/// All tables of a statistics result (as returned by `Store::global_stats`).
pub fn tables(stats: &Value, de: bool) -> Vec<Table> {
let (day, month, model, provider, agent, user, session) = if de {
("Tag", "Monat", "Modell", "Anbieter", "Modus", "Benutzer", "Chat")
} else {
("Day", "Month", "Model", "Provider", "Agent", "User", "Chat")
};
let mut out = vec![
agg_table("day", if de { "Pro Tag" } else { "Per day" }, day, "day", &stats["by_day"], de),
agg_table("month", if de { "Pro Monat" } else { "Per month" }, month, "month", &stats["by_month"], de),
agg_table("model", model, model, "model", &stats["by_model"], de),
agg_table("provider", provider, provider, "provider", &stats["by_provider"], de),
agg_table("agent", agent, agent, "agent", &stats["by_agent"], de),
agg_table("user", user, user, "user", &stats["by_user"], de),
agg_table("session", if de { "Teuerste Chats" } else { "Top chats" }, session, "title", &stats["top_sessions"], de),
];
// The grand total first, as a one-row table.
let mut total = vec![columns(if de { "Zeitraum" } else { "Range" }, de)];
let t = &stats["total"];
total.push(vec![
Cell::Text(format!("{} – {}", date(stats["from"].as_i64().unwrap_or(0)), date(stats["to"].as_i64().unwrap_or(0)))),
num(t, "requests"),
num(t, "input_tokens"),
num(t, "cache_read_tokens"),
num(t, "cache_write_tokens"),
num(t, "output_tokens"),
num(t, "errors"),
num(t, "avg_ttft_ms"),
num(t, "output_tps"),
num(t, "prefill_tps"),
num(t, "cost"),
]);
out.insert(0, Table { id: "total", title: (if de { "Gesamt" } else { "Total" }).to_string(), rows: total });
out
}
fn date(ms: i64) -> String {
if ms <= 0 {
return "-".into();
}
chrono::DateTime::from_timestamp_millis(ms).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()
}
// --- CSV -----------------------------------------------------------------------------
/// CSV for spreadsheets: `;` and decimal comma in German, `,` and decimal point otherwise;
/// UTF-8 with BOM (Excel). Cells starting with = + - @ are prefixed against formula injection.
pub fn csv(table: &Table, de: bool) -> String {
let sep = if de { ';' } else { ',' };
let mut out = String::from('\u{feff}');
for row in &table.rows {
let line: Vec<String> = row
.iter()
.map(|c| match c {
Cell::Num(n) => {
let s = if n.fract() == 0.0 { format!("{n:.0}") } else { format!("{n:.4}") };
if de { s.replace('.', ",") } else { s }
}
Cell::Text(t) => {
let t = if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{t}") } else { t.clone() };
if t.contains([sep, '"', '\n', '\r']) { format!("\"{}\"", t.replace('"', "\"\"")) } else { t }
}
})
.collect();
out.push_str(&line.join(&sep.to_string()));
out.push_str("\r\n");
}
out
}
// --- XLSX ----------------------------------------------------------------------------
fn esc(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.map(|c| match c {
'&' => "&amp;".to_string(),
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
c => c.to_string(),
})
.collect()
}
fn col_name(mut i: usize) -> String {
let mut s = String::new();
loop {
s.insert(0, (b'A' + (i % 26) as u8) as char);
if i < 26 {
break;
}
i = i / 26 - 1;
}
s
}
fn sheet_xml(t: &Table) -> String {
let mut x = String::from(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><cols><col min="1" max="1" width="34" customWidth="1"/><col min="2" max="11" width="16" customWidth="1"/></cols><sheetData>"#);
for (r, row) in t.rows.iter().enumerate() {
x.push_str(&format!(r#"<row r="{}">"#, r + 1));
for (c, cell) in row.iter().enumerate() {
let at = format!("{}{}", col_name(c), r + 1);
let style = if r == 0 { r#" s="1""# } else { "" };
match cell {
// Text as inline strings: no shared string table needed.
Cell::Text(s) => x.push_str(&format!(r#"<c r="{at}" t="inlineStr"{style}><is><t xml:space="preserve">{}</t></is></c>"#, esc(s))),
Cell::Num(n) => x.push_str(&format!(r#"<c r="{at}"{style}><v>{n}</v></c>"#)),
}
}
x.push_str("</row>");
}
x.push_str("</sheetData></worksheet>");
x
}
/// Sheet names: at most 31 characters, none of []:*?/\
fn sheet_name(s: &str) -> String {
s.chars().filter(|c| !"[]:*?/\\".contains(*c)).take(31).collect()
}
pub fn xlsx(tables: &[Table]) -> anyhow::Result<Vec<u8>> {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut put = |name: &str, body: &str| -> anyhow::Result<()> {
z.start_file(name, opts)?;
z.write_all(body.as_bytes())?;
Ok(())
};
let mut types = String::from(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>"#);
let mut sheets = String::new();
let mut rels = String::new();
for (i, t) in tables.iter().enumerate() {
let n = i + 1;
types.push_str(&format!(r#"<Override PartName="/xl/worksheets/sheet{n}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>"#));
sheets.push_str(&format!(r#"<sheet name="{}" sheetId="{n}" r:id="rId{n}"/>"#, esc(&sheet_name(&t.title))));
rels.push_str(&format!(r#"<Relationship Id="rId{n}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{n}.xml"/>"#));
}
types.push_str("</Types>");
let styles_id = tables.len() + 1;
rels.push_str(&format!(r#"<Relationship Id="rId{styles_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#));
put("[Content_Types].xml", &types)?;
put("_rels/.rels", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#)?;
put("xl/workbook.xml", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{sheets}</sheets></workbook>"#))?;
put("xl/_rels/workbook.xml.rels", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"#))?;
// Style 1 = bold header.
put("xl/styles.xml", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs></styleSheet>"#)?;
for (i, t) in tables.iter().enumerate() {
put(&format!("xl/worksheets/sheet{}.xml", i + 1), &sheet_xml(t))?;
}
z.finish()?;
}
Ok(buf.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample() -> Value {
json!({
"from": 0, "to": 1_700_000_000_000i64,
"total": {"requests": 3, "cost": 1.5},
"by_day": [{"day": "2026-01-02", "requests": 2, "cost": 1.25, "input_tokens": 10}],
"by_month": [], "by_model": [{"model": "=evil, \"x\"", "requests": 1}],
"by_provider": [], "by_agent": [], "by_user": [], "top_sessions": []
})
}
#[test]
fn csv_uses_locale_and_blocks_formulas() {
let tabs = tables(&sample(), true);
let day = tabs.iter().find(|t| t.id == "day").unwrap();
let c = csv(day, true);
assert!(c.starts_with('\u{feff}'));
assert!(c.contains("2026-01-02;2;10;0;0;0;0;0;0;0;1,2500"), "{c}");
let model = csv(tabs.iter().find(|t| t.id == "model").unwrap(), false);
assert!(model.contains("\"'=evil, \"\"x\"\"\""), "{model}");
}
#[test]
fn xlsx_is_a_zip_with_every_sheet() {
let tabs = tables(&sample(), false);
let bytes = xlsx(&tabs).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
for i in 1..=tabs.len() {
assert!(z.by_name(&format!("xl/worksheets/sheet{i}.xml")).is_ok());
}
let mut wb = String::new();
std::io::Read::read_to_string(&mut z.by_name("xl/workbook.xml").unwrap(), &mut wb).unwrap();
assert!(wb.contains("Per day") && wb.contains("Total"));
assert_eq!(col_name(0), "A");
assert_eq!(col_name(26), "AA");
}
}
+233
View File
@@ -163,6 +163,47 @@ impl Store {
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
PRIMARY KEY (cwd, key) PRIMARY KEY (cwd, key)
); );
CREATE TABLE IF NOT EXISTS share_links (
token TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
owner_id TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS share_links_session ON share_links(session_id);
CREATE TABLE IF NOT EXISTS factory_issues (
repo_url TEXT NOT NULL,
number INTEGER NOT NULL,
owner_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'new',
attempts INTEGER NOT NULL DEFAULT 0,
rounds INTEGER NOT NULL DEFAULT 0,
last_comment INTEGER NOT NULL DEFAULT 0,
scenarios TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
pr_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (repo_url, number)
);
CREATE TABLE IF NOT EXISTS factory_runs (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
repo_url TEXT NOT NULL,
issue INTEGER NOT NULL,
phase TEXT NOT NULL,
round INTEGER NOT NULL DEFAULT 0,
session_id TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
started_at INTEGER NOT NULL,
ended_at INTEGER,
status TEXT NOT NULL DEFAULT 'running',
result TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS factory_runs_issue ON factory_runs(repo_url, issue);
CREATE TABLE IF NOT EXISTS project_defaults ( CREATE TABLE IF NOT EXISTS project_defaults (
cwd TEXT PRIMARY KEY, cwd TEXT PRIMARY KEY,
data TEXT NOT NULL data TEXT NOT NULL
@@ -401,6 +442,16 @@ impl Store {
Ok(()) Ok(())
} }
/// (title, cwd) of all other chats (subagent sessions of this chat excluded).
pub fn other_session_cwds(&self, id: &str) -> Result<Vec<(String, String)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT COALESCE(json_extract(data, '$.title'), ''), COALESCE(json_extract(data, '$.cwd'), '') FROM sessions WHERE id != ?1 AND COALESCE(parent_id, '') != ?1",
)?;
let rows = stmt.query_map([id], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> { pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
// Images go to the blob table (encoded before taking the lock). // Images go to the blob table (encoded before taking the lock).
let mut m = m.clone(); let mut m = m.clone();
@@ -744,6 +795,9 @@ impl Store {
let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')"; let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
let by_day = grouped(day_sql, "day")?; let by_day = grouped(day_sql, "day")?;
// Long ranges are shown per month.
let by_month = grouped("strftime('%Y-%m', (ts + ?3) / 1000, 'unixepoch')", "month")?;
let first_ts: Option<i64> = conn.query_row("SELECT MIN(ts) FROM usage WHERE (?1 IS NULL OR user_id = ?1)", params![user], |r| r.get(0))?;
let by_model = grouped("provider || '/' || model", "model")?; let by_model = grouped("provider || '/' || model", "model")?;
let by_provider = grouped("provider", "provider")?; let by_provider = grouped("provider", "provider")?;
let by_agent = grouped("agent", "agent")?; let by_agent = grouped("agent", "agent")?;
@@ -795,6 +849,8 @@ impl Store {
Ok(json!({ Ok(json!({
"total": total, "total": total,
"by_day": by_day, "by_day": by_day,
"by_month": by_month,
"first_ts": first_ts,
"by_day_model": by_day_model, "by_day_model": by_day_model,
"by_model": by_model, "by_model": by_model,
"by_provider": by_provider, "by_provider": by_provider,
@@ -1404,6 +1460,97 @@ impl Store {
Ok(()) Ok(())
} }
// --- software factory ---------------------------------------------------------
pub fn factory_issue(&self, repo: &str, number: i64) -> Result<Option<Value>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT title, author, state, attempts, rounds, last_comment, scenarios, branch, pr_url, note, created_at, updated_at, owner_id FROM factory_issues WHERE repo_url = ?1 AND number = ?2", params![repo, number], |r| {
Ok(json!({"repo": repo, "number": number, "title": r.get::<_, String>(0)?, "author": r.get::<_, String>(1)?, "state": r.get::<_, String>(2)?, "attempts": r.get::<_, i64>(3)?, "rounds": r.get::<_, i64>(4)?, "last_comment": r.get::<_, i64>(5)?, "scenarios": r.get::<_, String>(6)?, "branch": r.get::<_, String>(7)?, "pr_url": r.get::<_, String>(8)?, "note": r.get::<_, String>(9)?, "created_at": r.get::<_, i64>(10)?, "updated_at": r.get::<_, i64>(11)?, "owner_id": r.get::<_, String>(12)?}))
})
.optional()?)
}
pub fn factory_issue_add(&self, repo: &str, number: i64, owner: &str, title: &str, author: &str) -> Result<()> {
let now = crate::types::now_ms();
self.conn.lock().unwrap().execute(
"INSERT OR IGNORE INTO factory_issues (repo_url, number, owner_id, title, author, created_at, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?6)",
params![repo, number, owner, title, author, now],
)?;
Ok(())
}
/// Update the given columns (whitelisted) of an issue.
pub fn factory_issue_set(&self, repo: &str, number: i64, fields: &[(&str, Value)]) -> Result<()> {
const COLS: [&str; 10] = ["title", "state", "attempts", "rounds", "last_comment", "scenarios", "branch", "pr_url", "note", "author"];
let conn = self.conn.lock().unwrap();
for (k, v) in fields {
if !COLS.contains(k) {
anyhow::bail!("unknown column {k}");
}
let sql = format!("UPDATE factory_issues SET {k} = ?3, updated_at = ?4 WHERE repo_url = ?1 AND number = ?2");
let now = crate::types::now_ms();
match v {
Value::Number(n) => conn.execute(&sql, params![repo, number, n.as_i64().unwrap_or(0), now])?,
other => conn.execute(&sql, params![repo, number, other.as_str().unwrap_or(""), now])?,
};
}
Ok(())
}
pub fn factory_issues(&self, owner: &str) -> Result<Vec<Value>> {
let nums: Vec<(String, i64)> = {
let conn = self.conn.lock().unwrap();
let mut st = conn.prepare("SELECT repo_url, number FROM factory_issues WHERE owner_id = ?1 ORDER BY updated_at DESC")?;
st.query_map([owner], |r| Ok((r.get(0)?, r.get(1)?)))?.filter_map(|r| r.ok()).collect()
};
Ok(nums.into_iter().filter_map(|(r, n)| self.factory_issue(&r, n).ok().flatten()).collect())
}
pub fn factory_run_add(&self, id: &str, owner: &str, repo: &str, issue: i64, phase: &str, round: i64, session: &str, model: &str) -> Result<()> {
self.conn.lock().unwrap().execute(
"INSERT INTO factory_runs (id, owner_id, repo_url, issue, phase, round, session_id, model, started_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
params![id, owner, repo, issue, phase, round, session, model, crate::types::now_ms()],
)?;
Ok(())
}
pub fn factory_run_end(&self, id: &str, status: &str, result: &str) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE factory_runs SET ended_at = ?2, status = ?3, result = ?4 WHERE id = ?1", params![id, crate::types::now_ms(), status, result])?;
Ok(())
}
pub fn factory_running(&self, repo: &str) -> Result<i64> {
Ok(self.conn.lock().unwrap().query_row("SELECT COUNT(*) FROM factory_runs WHERE repo_url = ?1 AND status = 'running'", [repo], |r| r.get(0))?)
}
/// Runs with token and cost totals (joined from the usage table).
pub fn factory_runs(&self, owner: &str, repo: Option<&str>, issue: Option<i64>, limit: i64) -> Result<Vec<Value>> {
let conn = self.conn.lock().unwrap();
let mut st = conn.prepare(
"SELECT f.id, f.repo_url, f.issue, f.phase, f.round, f.session_id, f.model, f.started_at, f.ended_at, f.status, f.result,
COALESCE((SELECT SUM(input_tokens + cache_read_tokens + cache_write_tokens) FROM usage WHERE session_id = f.session_id),0),
COALESCE((SELECT SUM(output_tokens) FROM usage WHERE session_id = f.session_id),0),
COALESCE((SELECT SUM(cost) FROM usage WHERE session_id = f.session_id),0.0)
FROM factory_runs f WHERE f.owner_id = ?1 AND (?2 IS NULL OR f.repo_url = ?2) AND (?3 IS NULL OR f.issue = ?3)
ORDER BY f.started_at DESC LIMIT ?4",
)?;
let rows = st.query_map(params![owner, repo, issue, limit], |r| {
Ok(json!({"id": r.get::<_, String>(0)?, "repo": r.get::<_, String>(1)?, "issue": r.get::<_, i64>(2)?, "phase": r.get::<_, String>(3)?, "round": r.get::<_, i64>(4)?, "session_id": r.get::<_, String>(5)?, "model": r.get::<_, String>(6)?, "started_at": r.get::<_, i64>(7)?, "ended_at": r.get::<_, Option<i64>>(8)?, "status": r.get::<_, String>(9)?, "result": r.get::<_, String>(10)?, "input_tokens": r.get::<_, i64>(11)?, "output_tokens": r.get::<_, i64>(12)?, "cost": r.get::<_, f64>(13)?}))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// After a restart: runs that were still going are over; their issues go back one step.
pub fn factory_recover(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n = conn.execute("UPDATE factory_runs SET status = 'interrupted', ended_at = ?1 WHERE status = 'running'", [crate::types::now_ms()])?;
conn.execute("UPDATE factory_issues SET state = 'new' WHERE state = 'review'", [])?;
conn.execute("UPDATE factory_issues SET state = 'approved' WHERE state = 'building'", [])?;
conn.execute("UPDATE factory_issues SET state = 'built' WHERE state = 'qa'", [])?;
Ok(n)
}
pub fn job_touch(&self, id: &str, ts: i64) -> Result<()> { pub fn job_touch(&self, id: &str, ts: i64) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE jobs SET last_run = ?2 WHERE id = ?1", rusqlite::params![id, ts])?; self.conn.lock().unwrap().execute("UPDATE jobs SET last_run = ?2 WHERE id = ?1", rusqlite::params![id, ts])?;
Ok(()) Ok(())
@@ -1526,6 +1673,59 @@ impl Store {
} }
} }
// --- weekly overview ----------------------------------------------------------------------
impl Store {
/// (chats with activity, failed checks, checks run) of a user's own top-level chats since `since` (ms).
pub fn weekly_counts(&self, user_id: &str, since: i64) -> Result<(i64, i64, i64)> {
let conn = self.conn.lock().unwrap();
let chats: i64 = conn.query_row("SELECT COUNT(*) FROM sessions WHERE owner_id = ?1 AND parent_id IS NULL AND updated_at >= ?2", params![user_id, since], |r| r.get(0))?;
let (failed, total): (i64, i64) = conn.query_row(
"SELECT COALESCE(SUM(c.status IN ('failed','timeout')),0), COUNT(*) FROM turn_checks c JOIN sessions s ON s.id = c.session_id WHERE s.owner_id = ?1 AND c.ts >= ?2",
params![user_id, since],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok((chats, failed, total))
}
}
// --- read-only links ----------------------------------------------------------------------
impl Store {
pub fn add_share_link(&self, token: &str, session_id: &str, owner_id: &str, created_at: i64, expires_at: i64) -> Result<()> {
self.conn.lock().unwrap().execute(
"INSERT INTO share_links (token, session_id, owner_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![token, session_id, owner_id, created_at, expires_at],
)?;
Ok(())
}
/// (token, created_at, expires_at) of the links of a chat, newest first.
pub fn share_links(&self, session_id: &str) -> Result<Vec<(String, i64, i64)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT token, created_at, expires_at FROM share_links WHERE session_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map([session_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// The chat a link points to while it is valid.
pub fn share_link_session(&self, token: &str, now: i64) -> Result<Option<String>> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT session_id FROM share_links WHERE token = ?1 AND expires_at > ?2", params![token, now], |r| r.get(0)).optional()?)
}
pub fn delete_share_link(&self, session_id: &str, token: &str) -> Result<()> {
self.conn.lock().unwrap().execute("DELETE FROM share_links WHERE session_id = ?1 AND token = ?2", params![session_id, token])?;
Ok(())
}
/// Links are dropped with their chat and when expired.
pub fn purge_share_links(&self, now: i64) -> Result<()> {
self.conn.lock().unwrap().execute("DELETE FROM share_links WHERE expires_at <= ?1 OR session_id NOT IN (SELECT id FROM sessions)", params![now])?;
Ok(())
}
}
// --- defaults of new chats in a project ------------------------------------------------------- // --- defaults of new chats in a project -------------------------------------------------------
impl Store { impl Store {
@@ -1710,3 +1910,36 @@ fn snippet_in(m: &Message, needle: &str) -> Option<String> {
} }
None None
} }
#[cfg(test)]
mod link_tests {
use super::*;
#[test]
fn share_links_expire_and_can_be_revoked() {
let dir = std::env::temp_dir().join(format!("cc-links-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let store = Store::open(&dir.join("t.db")).unwrap();
store.add_share_link("tok1", "s1", "u", 1_000, 5_000).unwrap();
store.add_share_link("tok2", "s1", "u", 2_000, 9_000).unwrap();
// Valid until the expiry time, then gone.
assert_eq!(store.share_link_session("tok1", 4_999).unwrap().as_deref(), Some("s1"));
assert_eq!(store.share_link_session("tok1", 5_000).unwrap(), None);
assert_eq!(store.share_link_session("nope", 1).unwrap(), None);
// Revoking removes only the named link of that chat.
store.delete_share_link("other", "tok2").unwrap();
assert!(store.share_link_session("tok2", 3_000).unwrap().is_some());
store.delete_share_link("s1", "tok2").unwrap();
assert_eq!(store.share_link_session("tok2", 3_000).unwrap(), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn weekly_counts_of_an_empty_user_are_zero() {
let dir = std::env::temp_dir().join(format!("cc-weekly-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let store = Store::open(&dir.join("t.db")).unwrap();
assert_eq!(store.weekly_counts("nobody", 0).unwrap(), (0, 0, 0));
std::fs::remove_dir_all(&dir).unwrap();
}
}
+2 -1
View File
@@ -162,7 +162,8 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
.current_dir(cwd) .current_dir(cwd)
.env("TERM", "xterm-256color") .env("TERM", "xterm-256color")
.env("CODECRAFT", "1") .env("CODECRAFT", "1")
.envs(crate::gitcreds::identity_env(app, &user.id)); .envs(crate::gitcreds::identity_env(app, &user.id))
.envs(crate::sshkeys::env(app, user));
match crate::gitcreds::global_config(app, user) { match crate::gitcreds::global_config(app, user) {
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg), Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
None => c, None => c,
+1
View File
@@ -155,6 +155,7 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
c.env("GIT_CONFIG_GLOBAL", cfg); c.env("GIT_CONFIG_GLOBAL", cfg);
} }
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id)); c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
c.envs(crate::sshkeys::env(&ctx.app, &u));
} }
c c
} }
+21 -3
View File
@@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { connectEvents, get, setUnauthorizedHandler } from "./api"; import { connectEvents, get, setUnauthorizedHandler } from "./api";
import { useT } from "./i18n"; import { useT } from "./i18n";
import { navigate, useRoute, closePane, MAX_PANES } from "./router"; import { navigate, useRoute, closePane, MAX_PANES } from "./router";
import { applyEvent, loadSessions, resync, setState } from "./store"; import { applyEvent, loadSessions, resync, setState, useStore } from "./store";
import type { Info } from "./types"; import type { Info } from "./types";
import { ImageViewer } from "./components/ImageViewer"; import { ImageViewer } from "./components/ImageViewer";
import { ProjectPage } from "./components/ProjectPage"; import { ProjectPage } from "./components/ProjectPage";
@@ -13,6 +13,7 @@ import { NewSession } from "./components/NewSession";
import { Settings } from "./components/Settings"; import { Settings } from "./components/Settings";
import { Stats } from "./components/Stats"; import { Stats } from "./components/Stats";
import { Images } from "./components/Images"; import { Images } from "./components/Images";
import { Factory } from "./components/Factory";
import { Jobs } from "./components/Jobs"; import { Jobs } from "./components/Jobs";
import { JobEditor } from "./components/JobEditor"; import { JobEditor } from "./components/JobEditor";
import { Spinner, useTitleActivity } from "./components/Activity"; import { Spinner, useTitleActivity } from "./components/Activity";
@@ -22,6 +23,8 @@ import { WhatsNew } from "./components/WhatsNew";
import { ShareTarget } from "./components/ShareTarget"; import { ShareTarget } from "./components/ShareTarget";
import { Palette } from "./components/Palette"; import { Palette } from "./components/Palette";
import { pendingShare } from "./share"; import { pendingShare } from "./share";
import { cacheInfo, cachedInfo, isOffline } from "./offline";
import { PublicChat } from "./components/PublicChat";
// The file manager (editor, git) is loaded on demand to keep the chat small. // The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files }))); const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -34,11 +37,21 @@ export function App() {
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [shared, setShared] = useState(pendingShare); const [shared, setShared] = useState(pendingShare);
const offline = useStore((s) => s.offline);
useTitleActivity(); useTitleActivity();
const refresh = async () => { const refresh = async () => {
try { try {
const i = await get<Info>("/info"); let i: Info;
try {
i = await get<Info>("/info");
cacheInfo(i);
} catch (e) {
// No connection: open with the saved copy (chats are read-only).
const saved = isOffline(e) ? cachedInfo() : undefined;
if (!saved) throw e;
i = saved;
}
setState(() => ({ me: i.user, update: i.update ?? null })); setState(() => ({ me: i.user, update: i.update ?? null }));
if (i.user) { if (i.user) {
await loadSessions(); await loadSessions();
@@ -71,6 +84,7 @@ export function App() {
// Close the mobile menu on navigation. // Close the mobile menu on navigation.
useEffect(() => setMenuOpen(false), [route.join("/")]); useEffect(() => setMenuOpen(false), [route.join("/")]);
if (route[0] === "shared" && route[1]) return <PublicChat token={route[1]} />;
if (error) return <div className="empty">{error}</div>; if (error) return <div className="empty">{error}</div>;
if (!info) return <div className="empty">{t("common.loading")}</div>; if (!info) return <div className="empty">{t("common.loading")}</div>;
if (!info.user) if (!info.user)
@@ -97,6 +111,7 @@ export function App() {
); );
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />; else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "p" && arg) content = <ProjectPage key={arg} cwd={decodeURIComponent(arg)} onMenu={() => setMenuOpen(true)} />; else if (section === "p" && arg) content = <ProjectPage key={arg} cwd={decodeURIComponent(arg)} onMenu={() => setMenuOpen(true)} />;
else if (section === "factory") content = <Factory onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />; else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />; else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />; else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
@@ -119,7 +134,10 @@ export function App() {
<div className="app"> <div className="app">
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />} {menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} /> <Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
<main className="main">{content}</main> <main className="main">
{offline && <div className="offline-bar" role="status">{t("offline.banner")}</div>}
{content}
</main>
<WhatsNew version={info.version} /> <WhatsNew version={info.version} />
<Palette /> <Palette />
<ImageViewer /> <ImageViewer />
+46
View File
@@ -0,0 +1,46 @@
import { del, get } from "./api";
import { choiceDialog, confirmDialog, toast } from "./dialogs";
import { t } from "./i18n";
import { formatSize } from "./components/FileFigure";
interface FolderInfo {
own: boolean;
path?: string;
files?: number;
bytes?: number;
git?: boolean;
truncated?: boolean;
}
/**
* Delete a chat after asking. A chat with a folder of its own (not shared with other chats, not a
* project) gets the question whether the folder goes with it. Resolves to true when it was deleted.
*/
export async function deleteChat(session: { id: string; parent_id?: string | null }): Promise<boolean> {
const folder = session.parent_id ? undefined : await get<FolderInfo>(`/sessions/${session.id}/folder`).catch(() => undefined);
let withFolder = false;
if (folder?.own) {
const size = `${folder.truncated ? "> " : ""}${t("chat.folderFiles", { n: folder.files ?? 0 })}, ${formatSize(folder.bytes ?? 0)}`;
const choice = await choiceDialog({
title: t("chat.deleteTitle"),
message: `${t("chat.deleteWithFolder", { path: folder.path ?? "", size })}${folder.git ? `\n\n${t("chat.folderHasGit")}` : ""}`,
choices: [
{ value: "chat", label: t("chat.deleteOnlyChat") },
{ value: "both", label: t("chat.deleteChatAndFolder"), danger: true },
],
});
if (!choice) return false;
withFolder = choice === "both";
} else if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) {
return false;
}
try {
const r = await del<{ folder_deleted?: boolean; folder_error?: string }>(`/sessions/${session.id}${withFolder ? "?delete_folder=true" : ""}`);
if (r?.folder_error) toast(r.folder_error);
else if (r?.folder_deleted) toast(t("chat.folderDeleted"), "success");
return true;
} catch (e: any) {
toast(e.message);
return false;
}
}
+22 -12
View File
@@ -1,3 +1,4 @@
import { DraftBar, isDraftChat } from "./FactoryDraft";
import { takePrefill } from "../share"; import { takePrefill } from "../share";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure"; import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
@@ -24,7 +25,8 @@ import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog"; import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel"; import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard"; import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { promptDialog, toast } from "../dialogs";
import { deleteChat } from "../chatActions";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
import { ColorSwatches, IconPicker } from "./ColorSwatches"; import { ColorSwatches, IconPicker } from "./ColorSwatches";
import { ProjectPermissions } from "./ProjectPermissions"; import { ProjectPermissions } from "./ProjectPermissions";
@@ -381,16 +383,25 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa
</button> </button>
{answerText && ( {answerText && (
<> <>
{answer && <MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />}
<Markdown text={answerText} baseDir={baseDir} /> <Markdown text={answerText} baseDir={baseDir} />
<ImageRefs text={answerText} baseDir={baseDir} /> <ImageRefs text={answerText} baseDir={baseDir} />
{answer && <MsgTime ms={answer.created_at} />} {answer && <MsgFoot ms={answer.created_at} menu={<MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />} />}
</> </>
)} )}
</div> </div>
); );
} }
/** Foot of a bubble: time on the left, the ⋯ menu inside the bubble on the right. */
function MsgFoot({ ms, menu }: { ms: number; menu: ReactNode }) {
return (
<div className="msg-foot">
<MsgTime ms={ms} />
{menu}
</div>
);
}
/** Text parts of a message. */ /** Text parts of a message. */
const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n"); const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n");
@@ -424,9 +435,8 @@ const AssistantOrNote = memo(function AssistantOrNote({
if (m.role === "assistant") { if (m.role === "assistant") {
return ( return (
<div className={`${cls} ${textOfMessage(m).trim() ? "bubble agent" : ""}`} data-mid={m.id}> <div className={`${cls} ${textOfMessage(m).trim() ? "bubble agent" : ""}`} data-mid={m.id}>
<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} /> <AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
<MsgTime ms={m.created_at} /> <MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />} />
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />} {regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
</div> </div>
); );
@@ -451,13 +461,15 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
return ( return (
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}> <details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
<summary> <summary>
{summary.auto {summary.text.startsWith("[Context cleared")
? t("chat.cleared")
: summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" }) ? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens : summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) }) ? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")} : t("chat.compacted")}
</summary> </summary>
{summaryOpen && <Markdown text={summary.text} />} {summaryOpen && !summary.text.startsWith("[Context cleared") && <Markdown text={summary.text} />}
</details> </details>
); );
} }
@@ -470,10 +482,8 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
{background.map((p, i) => ( {background.map((p, i) => (
<HarnessMessage key={i} text={p.text} /> <HarnessMessage key={i} text={p.text} />
))} ))}
<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />
<div className="msg-user"> <div className="msg-user">
<UserText text={texts.map((p) => p.text).join("\n\n")} /> <UserText text={texts.map((p) => p.text).join("\n\n")} />
<MsgTime ms={m.created_at} />
{imgs.length > 0 && ( {imgs.length > 0 && (
<div className="user-files"> <div className="user-files">
{imgs.map((p, i) => ( {imgs.map((p, i) => (
@@ -483,6 +493,7 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
))} ))}
</div> </div>
)} )}
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />} />
</div> </div>
</div> </div>
); );
@@ -748,9 +759,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
}; };
const remove = async () => { const remove = async () => {
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; if (await deleteChat(session)) navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
await del(`/sessions/${sessionId}`);
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
}; };
// Context usage of the last request relative to the model's window. // Context usage of the last request relative to the model's window.
@@ -1081,6 +1090,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />} {editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />} {sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />} {rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && isDraftChat(session.cwd) && <DraftBar sessionId={sessionId} />}
{!readOnly && <OutboxBar sessionId={sessionId} />} {!readOnly && <OutboxBar sessionId={sessionId} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />} {!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? ( {readOnly ? (
+1
View File
@@ -8,6 +8,7 @@ export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue
export const SESSION_ICONS = [ export const SESSION_ICONS = [
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock", "robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
"users", "toolbox", "plug", "scroll", "eye", "template", "users", "toolbox", "plug", "scroll", "eye", "template",
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
] as const; ] as const;
/** Row of colour dots; the selected one is ringed, a second click removes the tag. */ /** Row of colour dots; the selected one is ringed, a second click removes the tag. */
+8 -3
View File
@@ -20,6 +20,7 @@ interface Suggestion {
const BUILTIN: { name: string; key: Key }[] = [ const BUILTIN: { name: string; key: Key }[] = [
{ name: "compact", key: "command.compact" }, { name: "compact", key: "command.compact" },
{ name: "clear", key: "command.clear" },
{ name: "new", key: "command.new" }, { name: "new", key: "command.new" },
]; ];
@@ -78,6 +79,7 @@ export function Composer({
const [mention, setMention] = useState<{ start: number; end: number; q: string }>(); const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]); const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary"); const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
const snippets = useFetch<{ id: string; name: string; text: string }[]>("/account/snippets").data;
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data; const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
// Auto-grow. // Auto-grow.
@@ -134,11 +136,14 @@ export function Composer({
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })), ...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
...(cmdData?.commands ?? []), ...(cmdData?.commands ?? []),
]; ];
return all const mine = (snippets ?? [])
.filter((s) => s.name.toLowerCase().startsWith(m[1].toLowerCase()))
.map((s) => ({ key: `snip:${s.id}`, label: `/${s.name}`, description: s.text.replace(/\s+/g, " ").slice(0, 80), apply: () => (setText(s.text), requestAnimationFrame(() => ref.current?.focus())) }));
const cmds = all
.filter((c) => c.name.startsWith(m[1])) .filter((c) => c.name.startsWith(m[1]))
.slice(0, 12)
.map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) })); .map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) }));
}, [text, cmdData, t, mention, mentionHits]); return [...mine, ...cmds].slice(0, 12);
}, [text, cmdData, snippets, t, mention, mentionHits]);
const send = () => { const send = () => {
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return; if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
+676
View File
@@ -0,0 +1,676 @@
import { useEffect, useMemo, useState } from "react";
import { get, post, put } from "../api";
import { fmtCompact, fmtCost, fmtRelative, useT } from "../i18n";
import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
import { ModelSelect } from "./ModelSelect";
type Phase = "review" | "build" | "qa";
const PHASES: Phase[] = ["review", "build", "qa"];
interface PhaseCfg {
model: string;
agent: string;
permission_mode: string;
timeout_minutes: number;
extra_prompt: string;
}
interface RepoCfg {
url: string;
kind: string;
connection: string;
token: string;
label: string;
max_parallel: number;
allowed_users: string[];
enabled: boolean;
review: PhaseCfg;
build: PhaseCfg;
qa: PhaseCfg;
}
interface Connection {
id: string;
name: string;
kind: "gitea" | "github" | "gitlab";
url: string;
token: string;
owner: string;
}
interface Defaults {
spec_model: string;
review: PhaseCfg;
build: PhaseCfg;
qa: PhaseCfg;
}
interface Settings {
connections: Connection[];
defaults: Defaults;
repos: RepoCfg[];
}
interface Issue {
repo: string;
number: number;
title: string;
author: string;
state: string;
note: string;
pr_url: string;
rounds: number;
created_at: number;
updated_at: number;
}
interface Run {
id: string;
repo: string;
issue: number;
phase: Phase;
round: number;
session_id: string;
model: string;
started_at: number;
ended_at: number | null;
status: string;
input_tokens: number;
output_tokens: number;
cost: number;
}
interface RepoInfo {
url: string;
kind: string;
enabled: boolean;
label: string;
max_parallel: number;
has_token: boolean;
models: Record<Phase, string>;
running: number;
}
interface Overview {
area: boolean;
repos: RepoInfo[];
issues: Issue[];
runs: Run[];
}
/** Board columns: a column collects one or more issue states. */
const COLUMNS: { id: string; states: string[] }[] = [
{ id: "review", states: ["new", "review"] },
{ id: "question", states: ["question"] },
{ id: "approved", states: ["approved"] },
{ id: "build", states: ["building", "built"] },
{ id: "qa", states: ["qa"] },
{ id: "done", states: ["done"] },
{ id: "failed", states: ["failed", "rejected"] },
];
const KIND_NAME: Record<string, string> = { gitea: "Gitea", github: "GitHub", gitlab: "GitLab" };
const repoName = (url: string) => url.replace(/\.git$/, "").split("/").slice(-2).join("/");
const issueUrl = (repo: string, n: number) => `${repo.replace(/\.git$/, "")}/issues/${n}`;
function fmtDuration(ms: number) {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min`;
}
const runMs = (r: Run, now: number) => (r.ended_at ?? now) - r.started_at;
/** Software factory: spec issues in Gitea are reviewed, built and accepted by agents with a model per phase. */
export function Factory({ onMenu }: { onMenu: () => void }) {
const t = useT();
const [tab, setTab] = useState<"overview" | "settings">("overview");
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("factory.title")}</h1>
</div>
<div className="segmented">
{(["overview", "settings"] as const).map((x) => (
<button key={x} className={tab === x ? "active" : ""} onClick={() => setTab(x)}>
{t(`factory.tab.${x}` as any)}
</button>
))}
</div>
</div>
<div className="page">
<div className="page-inner" style={{ maxWidth: 1280 }}>
{tab === "overview" ? <FactoryOverview onSettings={() => setTab("settings")} /> : <FactorySettings />}
</div>
</div>
</>
);
}
function FactoryOverview({ onSettings }: { onSettings: () => void }) {
const t = useT();
const [data, setData] = useState<Overview>();
const [repo, setRepo] = useState<string>("");
const [sel, setSel] = useState<string>("");
const [now, setNow] = useState(Date.now());
const [busy, setBusy] = useState(false);
const reload = () =>
get<Overview>("/factory/overview")
.then(setData)
.catch((e) => toast(e.message));
useEffect(() => {
void reload();
const h = setInterval(() => {
void reload();
setNow(Date.now());
}, 5000);
return () => clearInterval(h);
}, []);
const newProject = async () => {
setBusy(true);
try {
const s = await post<{ id: string }>("/factory/drafts", {});
navigate(`/s/${s.id}`);
} catch (e: any) {
toast(e.message);
setBusy(false);
}
};
const issues = useMemo(() => (data?.issues ?? []).filter((i) => !repo || i.repo === repo), [data, repo]);
const runs = useMemo(() => (data?.runs ?? []).filter((r) => !repo || r.repo === repo), [data, repo]);
if (!data) return <div className="empty"><Spinner /></div>;
if (data.repos.length === 0)
return (
<div className="empty">
<p>{t("factory.empty")}</p>
<div className="row" style={{ gap: 8, justifyContent: "center" }}>
<button className="primary" onClick={newProject} disabled={busy}>
<Icon name="plus" /> {t("factory.newProject")}
</button>
<button onClick={onSettings}>{t("factory.tab.settings")}</button>
</div>
</div>
);
const sum = (f: (r: Run) => number) => runs.reduce((a, r) => a + f(r), 0);
const finished = runs.filter((r) => r.ended_at);
const running = runs.filter((r) => !r.ended_at);
const done = issues.filter((i) => i.state === "done").length;
const failed = issues.filter((i) => ["failed", "rejected"].includes(i.state)).length;
const runsOf = (i: Issue) => data.runs.filter((r) => r.repo === i.repo && r.issue === i.number);
const selected = issues.find((i) => `${i.repo}#${i.number}` === sel);
const shown = selected ? runsOf(selected) : runs.slice(0, 12);
const poll = async () => {
setBusy(true);
try {
const r = await post<{ errors: string[] }>("/factory/poll", {});
if (r.errors.length) toast(r.errors.join("\n"));
await reload();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
return (
<>
{!data.area && <div className="notice err">{t("factory.noArea")}</div>}
<div className="kpis">
<div className="kpi">
<div className="k-label">{t("factory.kpi.running")}</div>
<div className="k-value">{running.length}</div>
<div className="k-sub">{t("factory.kpi.issues", { n: issues.length })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.done")}</div>
<div className="k-value">{done}</div>
<div className="k-sub">{t("factory.kpi.failed", { n: failed })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.tokens")}</div>
<div className="k-value">{fmtCompact(sum((r) => r.input_tokens + r.output_tokens))}</div>
<div className="k-sub">
{fmtCompact(sum((r) => r.input_tokens))} {t("factory.in")} · {fmtCompact(sum((r) => r.output_tokens))} {t("factory.out")}
</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.cost")}</div>
<div className="k-value">{fmtCost(sum((r) => r.cost), "USD")}</div>
<div className="k-sub">{t("factory.kpi.runs", { n: runs.length })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.time")}</div>
<div className="k-value">{finished.length ? fmtDuration(sum((r) => runMs(r, now)) / finished.length) : "–"}</div>
<div className="k-sub">{t("factory.kpi.perRun")}</div>
</div>
</div>
<div className="factory-repos">
<button className={`factory-repo ${repo === "" ? "active" : ""}`} onClick={() => setRepo("")}>
<b>{t("factory.allRepos")}</b>
<span className="hint">{t("factory.kpi.issues", { n: data.issues.length })}</span>
</button>
{data.repos.map((r) => {
const mine = data.issues.filter((i) => i.repo === r.url);
return (
<button key={r.url} className={`factory-repo ${repo === r.url ? "active" : ""} ${r.enabled ? "" : "off"}`} onClick={() => setRepo(r.url)}>
<b>{repoName(r.url)}</b>
<span className="chip">{KIND_NAME[r.kind] ?? r.kind}</span>
<span className="hint">
{r.running > 0 && <span className="factory-pulse" />}
{t("factory.repoLine", { n: mine.length, run: r.running })}
</span>
<span className="hint mono">{PHASES.map((p) => r.models[p] || "·").map((m) => m.split("/").pop()).join(" → ")}</span>
{!r.has_token && <span className="chip warn">{t("factory.noToken")}</span>}
</button>
);
})}
<button className="small primary" onClick={newProject} disabled={busy}>
<Icon name="plus" /> {t("factory.newProject")}
</button>
<button className="small" onClick={poll} disabled={busy} title={t("factory.pollNow")}>
<Icon name="refresh" /> {t("factory.pollNow")}
</button>
</div>
<div className="factory-board">
{COLUMNS.map((c) => {
const cards = issues.filter((i) => c.states.includes(i.state));
return (
<div key={c.id} className="factory-col">
<h4>
{t(`factory.col.${c.id}` as any)} <span className="chip">{cards.length}</span>
</h4>
{cards.map((i) => {
const rs = runsOf(i);
const active = rs.find((r) => !r.ended_at);
const latest = active ?? rs[0];
const tokens = rs.reduce((a, r) => a + r.input_tokens + r.output_tokens, 0);
return (
<button key={`${i.repo}#${i.number}`} className={`factory-card ${sel === `${i.repo}#${i.number}` ? "active" : ""} ${active ? "running" : ""}`} onClick={() => setSel(sel === `${i.repo}#${i.number}` ? "" : `${i.repo}#${i.number}`)}>
<span className="hint">
{repoName(i.repo)} #{i.number}
</span>
<b>{i.title || `#${i.number}`}</b>
{latest && (
<span className="hint">
{active && <span className="factory-pulse" />}
{t(`factory.phase.${latest.phase}` as any)} · {(latest.model || "").split("/").pop()} · {fmtDuration(runMs(latest, now))}
</span>
)}
{tokens > 0 && <span className="hint">{fmtCompact(tokens)} {t("factory.tokens")}</span>}
</button>
);
})}
</div>
);
})}
</div>
<div className="panel" style={{ marginTop: 16 }}>
<div className="row">
<h3 style={{ margin: 0 }}>{selected ? `${repoName(selected.repo)} #${selected.number} – ${selected.title}` : t("factory.latestRuns")}</h3>
<span className="spacer" />
{selected && (
<>
<a className="small ghost button-link" href={issueUrl(selected.repo, selected.number)} target="_blank" rel="noreferrer">
<Icon name="external" /> Gitea
</a>
{selected.pr_url && (
<a className="small ghost button-link" href={selected.pr_url} target="_blank" rel="noreferrer">
<Icon name="external" /> {t("factory.pr")}
</a>
)}
</>
)}
</div>
{selected?.note && <div className="hint" style={{ margin: "6px 0" }}>{selected.note}</div>}
{shown.length === 0 && <div className="hint">{t("factory.noRuns")}</div>}
{shown.length > 0 && (
<div className="table-wrap">
<table className="grid factory-runs">
<thead>
<tr>
{!selected && <th>{t("factory.issue")}</th>}
<th>{t("factory.phase")}</th>
<th>{t("factory.model")}</th>
<th>{t("factory.status")}</th>
<th>{t("factory.started")}</th>
<th>{t("factory.duration")}</th>
<th>{t("factory.tokens")}</th>
<th>{t("factory.cost")}</th>
<th />
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.id}>
{!selected && (
<td>
{repoName(r.repo)} #{r.issue}
</td>
)}
<td>
{t(`factory.phase.${r.phase}` as any)}
{r.round > 0 && <span className="hint"> · {t("factory.round", { n: r.round })}</span>}
</td>
<td className="mono">{r.model}</td>
<td>
<span className={`chip ${r.status === "done" ? "ok" : r.status === "running" ? "" : "warn"}`}>
{r.status === "running" && <span className="factory-pulse" />}
{t(`factory.runStatus.${r.status}` as any) || r.status}
</span>
</td>
<td>{fmtRelative(r.started_at)}</td>
<td>{fmtDuration(runMs(r, now))}</td>
<td title={`${r.input_tokens} / ${r.output_tokens}`}>
{fmtCompact(r.input_tokens)} / {fmtCompact(r.output_tokens)}
</td>
<td>{fmtCost(r.cost, "USD")}</td>
<td>
<button className="small" onClick={() => navigate(`/s/${r.session_id}`)}>
<Icon name="eye" /> {t("factory.openChat")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
const blankPhase = (): PhaseCfg => ({ model: "", agent: "codecraft", permission_mode: "auto", timeout_minutes: 60, extra_prompt: "" });
const blankRepo = (connection = "", kind = "gitea"): RepoCfg => ({ url: "", kind, connection, token: "", label: "factory", max_parallel: 1, allowed_users: [], enabled: true, review: blankPhase(), build: blankPhase(), qa: blankPhase() });
const blankConn = (): Connection => ({ id: "", name: "", kind: "gitea", url: "", token: "", owner: "" });
const MODES = ["default", "auto", "accept_edits", "bypass"];
function PhaseForm({ phase, value, onChange }: { phase: Phase; value: PhaseCfg; onChange: (patch: Partial<PhaseCfg>) => void }) {
const t = useT();
return (
<div className="factory-phase">
<h4>{t(`factory.phase.${phase}` as any)}</h4>
<label>
<span>{t("factory.model")}</span>
<ModelSelect value={value.model} onChange={(model) => onChange({ model })} emptyLabel={t("jobEditor.defaultModel")} />
</label>
<label>
<span>{t("factory.agent")}</span>
<select value={value.agent} onChange={(e) => onChange({ agent: e.target.value })}>
<option value="codecraft">CodeCraft</option>
<option value="claude-code">Claude Code</option>
</select>
</label>
<label>
<span>{t("factory.mode")}</span>
<select value={value.permission_mode} onChange={(e) => onChange({ permission_mode: e.target.value })}>
{MODES.map((m) => (
<option key={m} value={m}>
{t(`permissionMode.${m}` as any)}
</option>
))}
</select>
</label>
<label>
<span>{t("factory.timeout")}</span>
<input type="number" min={1} value={value.timeout_minutes} onChange={(e) => onChange({ timeout_minutes: Number(e.target.value) || 60 })} />
</label>
<label>
<span>{t("factory.extraPrompt")}</span>
<textarea rows={3} value={value.extra_prompt} onChange={(e) => onChange({ extra_prompt: e.target.value })} />
</label>
</div>
);
}
const URL_PLACEHOLDER: Record<string, string> = { gitea: "https://git.example.org", github: "https://github.com", gitlab: "https://gitlab.com" };
function FactorySettings() {
const t = useT();
const [st, setSt] = useState<Settings>();
const [busy, setBusy] = useState<string>();
const [orgs, setOrgs] = useState<Record<string, string[]>>({});
useEffect(() => {
get<Settings>("/account/factory")
.then(setSt)
.catch((e) => toast(e.message));
}, []);
if (!st) return <div className="empty"><Spinner /></div>;
const { connections: conns, repos } = st;
const setConns = (connections: Connection[]) => setSt({ ...st, connections });
const setRepos = (r: RepoCfg[]) => setSt({ ...st, repos: r });
const upConn = (i: number, patch: Partial<Connection>) => setConns(conns.map((c, k) => (k === i ? { ...c, ...patch } : c)));
const up = (i: number, patch: Partial<RepoCfg>) => setRepos(repos.map((r, k) => (k === i ? { ...r, ...patch } : r)));
const upPhase = (i: number, p: Phase, patch: Partial<PhaseCfg>) => up(i, { [p]: { ...repos[i][p], ...patch } } as any);
const upDefault = (p: Phase, patch: Partial<PhaseCfg>) => setSt({ ...st, defaults: { ...st.defaults, [p]: { ...st.defaults[p], ...patch } } });
const save = async () => {
const s = await put<Settings>("/account/factory", st);
setSt(s);
return s;
};
const act = async (key: string, f: () => Promise<void>) => {
setBusy(key);
try {
await f();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(undefined);
}
};
return (
<>
<div className="notice">{t("factory.settingsHint")}</div>
<h3 className="factory-h">{t("factory.connections")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("factory.connectionsHint")}</div>
{conns.map((c, i) => (
<div key={i} className="panel">
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<h3 style={{ margin: 0 }}>{c.name || KIND_NAME[c.kind]}</h3>
<span className="spacer" />
<button
className="small"
disabled={busy !== undefined}
onClick={() =>
act(`c${i}`, async () => {
const saved = await save();
const id = saved.connections[i]?.id;
const r = await post<{ login: string; orgs: string[] }>("/account/factory/connection/test", { id });
setOrgs({ ...orgs, [id]: r.orgs });
toast(t("factory.connOk", { login: r.login, n: r.orgs.length }), "success");
})
}
>
{busy === `c${i}` ? <Spinner small /> : <Icon name="plug" />} {t("factory.testConn")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("factory.connDeleteConfirm", { name: c.name || KIND_NAME[c.kind] }), danger: true })) setConns(conns.filter((_, k) => k !== i));
}}
>
<Icon name="trash" />
</button>
</div>
<div className="factory-form">
<label>
<span>{t("factory.connName")}</span>
<input value={c.name} onChange={(e) => upConn(i, { name: e.target.value })} />
</label>
<label>
<span>{t("factory.kind")}</span>
<select value={c.kind} onChange={(e) => upConn(i, { kind: e.target.value as Connection["kind"] })}>
{Object.entries(KIND_NAME).map(([k, n]) => (
<option key={k} value={k}>
{n}
</option>
))}
</select>
</label>
<label>
<span>{t("factory.serviceUrl")}</span>
<input value={c.url} placeholder={URL_PLACEHOLDER[c.kind]} onChange={(e) => upConn(i, { url: e.target.value })} />
</label>
<label>
<span>{t("factory.token")}</span>
<input type="password" autoComplete="new-password" value={c.token} placeholder={t("factory.tokenPlaceholder")} onChange={(e) => upConn(i, { token: e.target.value })} onFocus={(e) => c.token.startsWith("•") && e.currentTarget.select()} />
<span className="hint">{c.token.startsWith("•") ? t("factory.tokenSet") : t(`factory.tokenHint.${c.kind}` as any)}</span>
</label>
<label>
<span>{t("factory.defaultOwner")}</span>
<input list={`orgs-${c.id}`} value={c.owner} placeholder={t("draft.ownerPlaceholder")} onChange={(e) => upConn(i, { owner: e.target.value })} />
<datalist id={`orgs-${c.id}`}>
{(orgs[c.id] ?? []).map((o) => (
<option key={o} value={o} />
))}
</datalist>
</label>
</div>
</div>
))}
<button onClick={() => setConns([...conns, blankConn()])}>
<Icon name="plus" /> {t("factory.addConn")}
</button>
<h3 className="factory-h">{t("factory.defaults")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("factory.defaultsHint")}</div>
<div className="panel">
<label className="factory-form" style={{ margin: 0 }}>
<span style={{ fontSize: 12 }}>{t("factory.specModel")}</span>
<ModelSelect value={st.defaults.spec_model} onChange={(spec_model) => setSt({ ...st, defaults: { ...st.defaults, spec_model } })} emptyLabel={t("jobEditor.defaultModel")} />
</label>
<div className="factory-phases" style={{ marginTop: 10 }}>
{PHASES.map((p) => (
<PhaseForm key={p} phase={p} value={st.defaults[p]} onChange={(patch) => upDefault(p, patch)} />
))}
</div>
</div>
<h3 className="factory-h">{t("factory.repos")}</h3>
{repos.map((r, i) => (
<div key={i} className="panel">
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
<h3 style={{ margin: 0 }}>{r.url ? repoName(r.url) : t("factory.newRepo")}</h3>
<span className="chip">{KIND_NAME[conns.find((c) => c.id === r.connection)?.kind ?? r.kind] ?? r.kind}</span>
<span className="spacer" />
<label className="switch">
<input type="checkbox" checked={r.enabled} onChange={(e) => up(i, { enabled: e.target.checked })} />
<span>{t(r.enabled ? "jobs.on" : "jobs.off")}</span>
</label>
<button
className="small"
disabled={busy !== undefined}
onClick={() =>
act(`t${i}`, async () => {
await save();
const res = await post<{ info: { login: string; repo: string; can_push: boolean }; labels_created: string[]; folder: string }>("/account/factory/test", { url: r.url });
toast(t("factory.testOk", { login: res.info.login, repo: res.info.repo, n: res.labels_created.length }), "success");
})
}
>
{busy === `t${i}` ? <Spinner small /> : <Icon name="plug" />} {t("factory.test")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("factory.deleteConfirm", { name: repoName(r.url) || "?" }), danger: true })) setRepos(repos.filter((_, k) => k !== i));
}}
>
<Icon name="trash" />
</button>
</div>
<div className="factory-form">
<label>
<span>{t("factory.connection")}</span>
<select
value={r.connection}
onChange={(e) => up(i, { connection: e.target.value, kind: conns.find((c) => c.id === e.target.value)?.kind ?? r.kind })}
>
<option value="">{t("factory.noConnection")}</option>
{conns
.filter((c) => c.id)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name || KIND_NAME[c.kind]}
</option>
))}
</select>
</label>
{!r.connection && (
<label>
<span>{t("factory.kind")}</span>
<select value={r.kind} onChange={(e) => up(i, { kind: e.target.value })}>
{Object.entries(KIND_NAME).map(([k, n]) => (
<option key={k} value={k}>
{n}
</option>
))}
</select>
</label>
)}
<label>
<span>{t("factory.url")}</span>
<input value={r.url} placeholder="https://host/owner/repo.git" onChange={(e) => up(i, { url: e.target.value })} />
</label>
<label>
<span>{t("factory.repoToken")}</span>
<input type="password" autoComplete="new-password" value={r.token} placeholder={t("factory.repoTokenPlaceholder")} onChange={(e) => up(i, { token: e.target.value })} onFocus={(e) => r.token.startsWith("•") && e.currentTarget.select()} />
<span className="hint">{r.token.startsWith("•") ? t("factory.tokenSet") : t("factory.repoTokenHint")}</span>
</label>
<label>
<span>{t("factory.label")}</span>
<input value={r.label} onChange={(e) => up(i, { label: e.target.value })} />
</label>
<label>
<span>{t("factory.parallel")}</span>
<input type="number" min={1} max={4} value={r.max_parallel} onChange={(e) => up(i, { max_parallel: Number(e.target.value) || 1 })} />
</label>
<label>
<span>{t("factory.allowed")}</span>
<input value={r.allowed_users.join(", ")} placeholder={t("factory.allowedPlaceholder")} onChange={(e) => up(i, { allowed_users: e.target.value.split(",").map((u) => u.trim()).filter(Boolean) })} />
</label>
</div>
<div className="factory-phases">
{PHASES.map((p) => (
<PhaseForm key={p} phase={p} value={r[p]} onChange={(patch) => upPhase(i, p, patch)} />
))}
</div>
</div>
))}
<div className="row" style={{ gap: 8, marginTop: 12 }}>
<button onClick={() => setRepos([...repos, blankRepo(conns.find((c) => c.id)?.id ?? "", conns.find((c) => c.id)?.kind ?? "gitea")])}>
<Icon name="plus" /> {t("factory.addRepo")}
</button>
<button
className="primary"
disabled={busy !== undefined}
onClick={() =>
act("save", async () => {
await save();
toast(t("factory.saved"), "success");
})
}
>
{busy === "save" ? <Spinner small /> : <Icon name="check" />} {t("common.save")}
</button>
</div>
</>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import { get, post } from "../api";
import { useT } from "../i18n";
import { navigate } from "../router";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
interface Draft {
spec: string;
proposal: { name?: string; slug?: string; description?: string } | null;
connections: { id: string; name: string; kind: string; owner: string }[];
}
export const isDraftChat = (cwd: string) => /\/_drafts\/[^/]+$/.test(cwd);
const slugify = (s: string) =>
s
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
/** Bar above the composer of a spec chat: file the spec as a new repository and start the workflow. */
export function DraftBar({ sessionId }: { sessionId: string }) {
const t = useT();
const [open, setOpen] = useState(false);
return (
<>
<div className="draft-bar">
<Icon name="scroll" />
<span>{t("draft.hint")}</span>
<span className="spacer" />
<button className="small primary" onClick={() => setOpen(true)}>
<Icon name="check" /> {t("draft.create")}
</button>
</div>
{open && <CreateProjectDialog sessionId={sessionId} onClose={() => setOpen(false)} />}
</>
);
}
function CreateProjectDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
const t = useT();
const [draft, setDraft] = useState<Draft>();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [description, setDescription] = useState("");
const [connection, setConnection] = useState("");
const [owner, setOwner] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
get<Draft>(`/factory/drafts/${sessionId}`)
.then((d) => {
setDraft(d);
setName(d.proposal?.name ?? "");
setSlug(d.proposal?.slug ? slugify(d.proposal.slug) : "");
setSlugTouched(!!d.proposal?.slug);
setDescription(d.proposal?.description ?? "");
setConnection(d.connections[0]?.id ?? "");
setOwner(d.connections[0]?.owner ?? "");
})
.catch((e) => {
toast(e.message);
onClose();
});
}, [sessionId]);
if (!draft)
return (
<div className="modal-backdrop">
<div className="modal">
<Spinner />
</div>
</div>
);
const conn = draft.connections.find((c) => c.id === connection);
const ready = draft.spec.trim() && name.trim() && slug.length >= 2 && connection;
const create = async () => {
setBusy(true);
try {
const r = await post<{ repo: string; issue: number }>(`/factory/drafts/${sessionId}/create`, { connection, name, slug, description, owner });
await post("/factory/poll", {}).catch(() => undefined);
toast(t("draft.created", { repo: r.repo, n: r.issue }), "success");
onClose();
navigate("/factory");
} catch (e: any) {
toast(e.message);
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={busy ? undefined : onClose}>
<div className="modal draft-modal" role="dialog" aria-label={t("draft.create")} onClick={(e) => e.stopPropagation()}>
<h3>{t("draft.create")}</h3>
{draft.connections.length === 0 ? (
<div className="notice err">
{t("draft.noConnection")}{" "}
<button className="link-btn" onClick={() => navigate("/factory")}>
{t("factory.tab.settings")}
</button>
</div>
) : (
<>
{!draft.spec.trim() && <div className="notice err">{t("draft.noSpec")}</div>}
<div className="factory-form">
<label>
<span>{t("draft.name")}</span>
<input
autoFocus
value={name}
onChange={(e) => {
setName(e.target.value);
if (!slugTouched) setSlug(slugify(e.target.value));
}}
/>
</label>
<label>
<span>{t("draft.slug")}</span>
<input
value={slug}
className="mono"
onChange={(e) => {
setSlug(slugify(e.target.value));
setSlugTouched(true);
}}
/>
</label>
<label style={{ gridColumn: "1 / -1" }}>
<span>{t("draft.description")}</span>
<input value={description} onChange={(e) => setDescription(e.target.value)} />
</label>
<label>
<span>{t("draft.connection")}</span>
<select
value={connection}
onChange={(e) => {
setConnection(e.target.value);
setOwner(draft.connections.find((c) => c.id === e.target.value)?.owner ?? "");
}}
>
{draft.connections.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.kind} ({c.kind})
</option>
))}
</select>
</label>
<label>
<span>{t("draft.owner")}</span>
<input value={owner} placeholder={t("draft.ownerPlaceholder")} onChange={(e) => setOwner(e.target.value)} />
</label>
</div>
<div className="hint" style={{ margin: "6px 0" }}>
{t("draft.private", { conn: conn?.name || "" })}
</div>
<details>
<summary>{t("draft.specPreview")}</summary>
<pre className="draft-spec">{draft.spec || "–"}</pre>
</details>
</>
)}
<div className="row" style={{ justifyContent: "flex-end", gap: 8, marginTop: 12 }}>
<button onClick={onClose} disabled={busy}>
{t("common.cancel")}
</button>
<button className="primary" onClick={create} disabled={!ready || busy}>
{busy ? <Spinner small /> : <Icon name="check" />} {t("draft.createGo")}
</button>
</div>
</div>
</div>
);
}
+7
View File
@@ -207,6 +207,13 @@ export function GitPanel({
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")} <input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")}
</label> </label>
<span className="spacer" /> <span className="spacer" />
<button
disabled={!!busy || staged.length === 0}
title={t("git.suggestHint")}
onClick={() => run("suggest", async () => setMessage((await call<{ message: string }>("suggest")).message))}
>
{busy === "suggest" ? <Spinner small /> : t("git.suggest")}
</button>
<button <button
className="primary" className="primary"
disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))} disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))}
+91
View File
@@ -34,6 +34,97 @@ const P: Record<string, ReactNode> = {
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />, download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />, compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />, trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
gamepad: (
<>
<path d="M7 8h10a4 4 0 0 1 4 4.2l-.6 3.8a2.4 2.4 0 0 1-4.1 1.3L14 15h-4l-2.3 2.3a2.4 2.4 0 0 1-4.1-1.3L3 12.2A4 4 0 0 1 7 8Z" />
<path d="M8 10.5v3M6.5 12h3M15.5 11h.01M17.5 13h.01" />
</>
),
computer: (
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
</>
),
food: (
<>
<path d="M5 3v6a2 2 0 0 0 4 0V3M7 11v10" />
<path d="M17 21V3c-2.6 1.4-3.5 4.2-3.5 7.5H17" />
</>
),
home: (
<>
<path d="M4 11.5 12 4l8 7.5V20H4Z" />
<path d="M10 20v-5h4v5" />
</>
),
book: (
<>
<path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3Z" />
<path d="M5 17a3 3 0 0 1 3-3h11" />
</>
),
code: <path d="m8 7-5 5 5 5M16 7l5 5-5 5M14 5l-4 14" />,
camera: (
<>
<path d="M4 8h3l1.5-2h7L17 8h3v11H4Z" />
<circle cx="12" cy="13" r="3.5" />
</>
),
cart: (
<>
<path d="M3 4h2.5l2 11h10l2-8H7" />
<circle cx="9" cy="19" r="1.4" />
<circle cx="17" cy="19" r="1.4" />
</>
),
heart: <path d="M12 20s-8-4.6-8-10.5A4.5 4.5 0 0 1 12 7a4.5 4.5 0 0 1 8 2.5C20 15.4 12 20 12 20Z" />,
star: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9L12 16.9l-5.2 2.8 1-5.9-4.3-4.1 5.9-.8Z" />,
globe: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M3.5 12h17M12 3.5c3 3 3 14 0 17M12 3.5c-3 3-3 14 0 17" />
</>
),
calendar: (
<>
<rect x="4" y="5" width="16" height="15" rx="2" />
<path d="M4 10h16M8 3v4M16 3v4" />
</>
),
coffee: (
<>
<path d="M5 9h11v5a5 5 0 0 1-5 5H10a5 5 0 0 1-5-5Z" />
<path d="M16 10h1.5a2.5 2.5 0 0 1 0 5H16M8 3v3M12 3v3" />
</>
),
car: (
<>
<path d="M3.5 16v-4l2-5h13l2 5v4Z" />
<path d="M3.5 12h17" />
<circle cx="7.5" cy="16" r="1.5" />
<circle cx="16.5" cy="16" r="1.5" />
</>
),
plane: (
<>
<path d="M21 3 3 10.5l6.5 2.5L12 20Z" />
<path d="M9.5 13 21 3" />
</>
),
bulb: <path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.3 1 2.1h5c0-.8.4-1.6 1-2.1A6 6 0 0 0 12 3Z" />,
money: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 1.9 0 2.7 5 1.2 5 4 0 1.1-1 2-2.5 2-1.1 0-2.1-.5-2.6-1.4M12 6.5V8M12 16v1.5" />
</>
),
palette: (
<>
<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.3 0 1.8-1 1.3-2-.6-1.2.2-2.5 1.6-2.5H17a3.5 3.5 0 0 0 3.5-3.5C20.5 7 16.8 3.5 12 3.5Z" />
<path d="M7.5 11h.01M9.5 7.5h.01M14 7.5h.01" />
</>
),
reply: <path d="M9 7 4 12l5 5M4 12h9a7 7 0 0 1 7 7" />, reply: <path d="M9 7 4 12l5 5M4 12h9a7 7 0 0 1 7 7" />,
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />, edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />, undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
+6
View File
@@ -20,6 +20,7 @@ interface Settings {
model: string; model: string;
permission_mode: string; permission_mode: string;
timeout_minutes: number; timeout_minutes: number;
notify: boolean;
} }
const settingsOf = (j: JobInfo): Settings => ({ const settingsOf = (j: JobInfo): Settings => ({
@@ -29,6 +30,7 @@ const settingsOf = (j: JobInfo): Settings => ({
model: j.spec?.model ?? "", model: j.spec?.model ?? "",
permission_mode: j.spec?.permission_mode ?? "auto", permission_mode: j.spec?.permission_mode ?? "auto",
timeout_minutes: j.spec?.timeout_minutes ?? 60, timeout_minutes: j.spec?.timeout_minutes ?? 60,
notify: j.spec?.notify ?? true,
}); });
type Tab = "prompt" | "settings" | "assistant"; type Tab = "prompt" | "settings" | "assistant";
@@ -254,6 +256,10 @@ export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) {
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} /> <input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
</label> </label>
</div> </div>
<label className="row" style={{ gap: 6, alignItems: "center", marginTop: 6 }}>
<input type="checkbox" checked={settings.notify} onChange={(e) => up({ notify: e.target.checked })} />
{t("jobEditor.notify")}
</label>
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>} {settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
</section> </section>
<section className="job-editor-prompt"> <section className="job-editor-prompt">
+1 -1
View File
@@ -17,7 +17,7 @@ export interface JobInfo {
next_run?: number | null; next_run?: number | null;
error?: string; error?: string;
webhook?: boolean; webhook?: boolean;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null }; spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; notify?: boolean; prompt: string; assistant_session?: string | null };
} }
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"); const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { get } from "../api";
import { fmtMsgTime, useT } from "../i18n";
import { Markdown } from "./Markdown";
interface Shared {
title: string;
messages: { id: string; role: "user" | "assistant"; text: string; created_at: number }[];
}
/** A chat opened through a read-only link (no account needed). */
export function PublicChat({ token }: { token: string }) {
const t = useT();
const [chat, setChat] = useState<Shared>();
const [error, setError] = useState<string>();
useEffect(() => {
get<Shared>(`/public/chat/${encodeURIComponent(token)}`)
.then((c) => (setChat(c), (document.title = `${c.title || "Chat"} – CodeCraft`)))
.catch((e) => setError(e.message));
}, [token]);
if (error) return <div className="empty">{error}</div>;
if (!chat) return <div className="empty">{t("common.loading")}</div>;
return (
<div className="public-chat">
<header>
<h1>{chat.title || "Chat"}</h1>
<div className="hint">{t("link.readOnly")}</div>
</header>
<div className="chat-scroll">
{chat.messages.map((m) => (
<div key={m.id} className={m.role === "user" ? "bubble user-msg" : "bubble agent"}>
<Markdown text={m.text} />
<div className="msg-foot">
<span className="hint">{fmtMsgTime(m.created_at)}</span>
</div>
</div>
))}
</div>
</div>
);
}
+38 -1
View File
@@ -146,8 +146,11 @@ function VersionInfo() {
function DigestToggle() { function DigestToggle() {
const t = useT(); const t = useT();
const [on, setOn] = useState<boolean>(); const [on, setOn] = useState<boolean>();
const [weekly, setWeekly] = useState(false);
useEffect(() => { useEffect(() => {
get<{ enabled: boolean }>("/push/digest").then((r) => setOn(r.enabled)).catch(() => {}); get<{ enabled: boolean; weekly?: boolean }>("/push/digest")
.then((r) => (setOn(r.enabled), setWeekly(!!r.weekly)))
.catch(() => {});
}, []); }, []);
if (on === undefined) return null; if (on === undefined) return null;
return ( return (
@@ -164,6 +167,30 @@ function DigestToggle() {
/> />
{t("settings.digest")} {t("settings.digest")}
</label> </label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input
type="checkbox"
checked={weekly}
onChange={(e) => {
const v = e.target.checked;
setWeekly(v);
put("/push/digest", { weekly: v }).catch((err) => (setWeekly(!v), toast(err.message)));
}}
/>
{t("settings.weeklyDigest")}
</label>
{weekly && (
<button
className="small ghost"
onClick={() =>
post<{ sent: number; errors: string[] }>("/push/digest?weekly=true")
.then((r) => toast(r.sent ? t("settings.pushTestSent") : r.errors.join("; ") || t("settings.pushTestNone"), r.sent ? "success" : "error"))
.catch((e) => toast(e.message))
}
>
{t("settings.digestNow")}
</button>
)}
{on && ( {on && (
<button <button
className="small ghost" className="small ghost"
@@ -246,6 +273,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
default_model: s.default_model ?? "", default_model: s.default_model ?? "",
small_model: s.small_model ?? "", small_model: s.small_model ?? "",
plan_model: ((cfg.config as any).agents?.plan?.model ?? "") as string,
transcribe_model: ((s as any).transcribe_model ?? "") as string,
currency: s.currency ?? "USD", currency: s.currency ?? "USD",
auto_compact_threshold: s.auto_compact_threshold ?? 0.85, auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string, commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
@@ -270,6 +299,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
savePatches([ savePatches([
{ path: ["server", "default_model"], value: form.default_model }, { path: ["server", "default_model"], value: form.default_model },
{ path: ["server", "small_model"], value: form.small_model }, { path: ["server", "small_model"], value: form.small_model },
{ path: ["agents", "plan", "model"], value: form.plan_model || null },
{ path: ["server", "transcribe_model"], value: form.transcribe_model.trim() },
{ path: ["server", "currency"], value: form.currency }, { path: ["server", "currency"], value: form.currency },
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) }, { path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() }, { path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
@@ -302,6 +333,12 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<label>{t("settings.smallModel")}</label> <label>{t("settings.smallModel")}</label>
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} /> <ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
<div className="hint">{t("settings.smallModelHint")}</div> <div className="hint">{t("settings.smallModelHint")}</div>
<label>{t("settings.planModel")}</label>
<ModelSelect value={form.plan_model} emptyLabel={t("settings.sameAsChat")} onChange={(v) => setForm({ ...form, plan_model: v })} />
<div className="hint">{t("settings.planModelHint")}</div>
<label>{t("settings.transcribeModel")}</label>
<input value={form.transcribe_model} placeholder="openai/whisper-1" onChange={(e) => setForm({ ...form, transcribe_model: e.target.value })} />
<div className="hint">{t("settings.transcribeHint")}</div>
<label>{t("settings.currency")}</label> <label>{t("settings.currency")}</label>
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} /> <input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
<label>{t("settings.autoCompact")}</label> <label>{t("settings.autoCompact")}</label>
+52 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { get, put } from "../api"; import { del, get, post, put } from "../api";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { useStore } from "../store"; import { useStore } from "../store";
import { toast } from "../dialogs";
import { fmtDate } from "../i18n";
interface DirUser { interface DirUser {
id: string; id: string;
@@ -9,6 +11,54 @@ interface DirUser {
display_name: string; display_name: string;
} }
interface Link {
token: string;
url: string;
expires_at: number;
}
/** Read-only links for people without an account. */
function Links({ sessionId }: { sessionId: string }) {
const t = useT();
const [links, setLinks] = useState<Link[]>([]);
const [days, setDays] = useState(7);
const load = () => get<Link[]>(`/sessions/${sessionId}/links`).then(setLinks).catch(() => undefined);
useEffect(() => void load(), [sessionId]);
const create = async () => {
try {
const l = await post<Link>(`/sessions/${sessionId}/links`, { days });
await navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"), () => undefined);
void load();
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="share-links">
<h4>{t("link.title")}</h4>
<div className="hint">{t("link.hint")}</div>
{links.map((l) => (
<div key={l.token} className="row share-link">
<input readOnly value={l.url} onFocus={(e) => e.currentTarget.select()} aria-label={t("link.title")} />
<span className="hint">{t("link.until", { date: fmtDate(l.expires_at, false) })}</span>
<button onClick={() => navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"))}>{t("link.copy")}</button>
<button onClick={() => del(`/sessions/${sessionId}/links/${l.token}`).then(load).catch((e) => toast(e.message))}>{t("link.revoke")}</button>
</div>
))}
<div className="row" style={{ marginTop: 6 }}>
<select value={days} onChange={(e) => setDays(Number(e.target.value))} aria-label={t("link.validity")}>
{[1, 7, 30, 90].map((d) => (
<option key={d} value={d}>
{t("link.days", { n: d })}
</option>
))}
</select>
<button onClick={create}>{t("link.create")}</button>
</div>
</div>
);
}
/** Choose users a session is shared with (read-only for them). */ /** Choose users a session is shared with (read-only for them). */
export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) { export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
const t = useT(); const t = useT();
@@ -59,6 +109,7 @@ export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose
</label> </label>
))} ))}
</div> </div>
<Links sessionId={sessionId} />
{error && <div className="notice err">{error}</div>} {error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}> <div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button> <button onClick={onClose}>{t("common.cancel")}</button>
+17 -3
View File
@@ -1,4 +1,4 @@
import { ColorSwatches, IconPicker } from "./ColorSwatches"; import { deleteChat } from "../chatActions";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds"; import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
@@ -98,11 +98,21 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")} <Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
</button> </button>
)} )}
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}> <button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")} <Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button> </button>
{!s.shared && (
<button
role="menuitem"
className="danger"
onClick={async () => {
setMenu(false);
if ((await deleteChat(s)) && active) navigate("/new");
}}
>
<Icon name="trash" /> {t("chat.delete")}
</button>
)}
</div> </div>
)} )}
</div> </div>
@@ -481,6 +491,10 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
<Icon name="clock" className="nav-icon" /> <Icon name="clock" className="nav-icon" />
{t("nav.jobs")} {t("nav.jobs")}
</button> </button>
<button className={`ghost ${section === "factory" ? "active" : ""}`} onClick={() => navigate("/factory")}>
<Icon name="toolbox" className="nav-icon" />
{t("nav.factory")}
</button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}> <button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<Icon name="chart" className="nav-icon" /> <Icon name="chart" className="nav-icon" />
{t("nav.stats")} {t("nav.stats")}
+186 -56
View File
@@ -1,5 +1,5 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { useMemo, useState } from "react"; import { Fragment, useEffect, useMemo, useState } from "react";
import { useFetch } from "../hooks"; import { useFetch } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n"; import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
@@ -15,6 +15,9 @@ interface StatsData {
total: Aggregate; total: Aggregate;
by_day: Row<"day">[]; by_day: Row<"day">[];
by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[]; by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[];
by_month: Row<"month">[];
/** First recorded request (any range): the years to choose from. */
first_ts: number | null;
by_model: Row<"model">[]; by_model: Row<"model">[];
by_provider: Row<"provider">[]; by_provider: Row<"provider">[];
by_agent: Row<"agent">[]; by_agent: Row<"agent">[];
@@ -37,6 +40,16 @@ const RANGES: { key: string; days: number; label: Key }[] = [
type HeatMetric = "requests" | "tokens" | "cost"; type HeatMetric = "requests" | "tokens" | "cost";
/** All months (YYYY-MM, local time) from the month of `fromMs` to the month of `toMs`. */
function monthRange(fromMs: number, toMs: number): string[] {
const out: string[] = [];
const d = new Date(fromMs);
d.setDate(1);
d.setHours(0, 0, 0, 0);
for (; d.getTime() <= toMs && out.length < 600; d.setMonth(d.getMonth() + 1)) out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
return out;
}
function dayKey(ms: number) { function dayKey(ms: number) {
const d = new Date(ms); const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
@@ -51,6 +64,44 @@ function dayRange(fromMs: number, toMs: number, firstDataDay?: string): string[]
return out; return out;
} }
const FOLD_KEY = "codecraft.statsFolded";
function foldedSet(): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(FOLD_KEY) ?? "[]"));
} catch {
return new Set();
}
}
/** Panel with a title that folds the content; the state is remembered per device. */
function Fold({ id, title, children }: { id: string; title: React.ReactNode; children: React.ReactNode }) {
const [closed, setClosed] = useState(() => foldedSet().has(id));
const toggle = () => {
const next = !closed;
setClosed(next);
try {
const set = foldedSet();
if (next) set.add(id);
else set.delete(id);
localStorage.setItem(FOLD_KEY, JSON.stringify([...set]));
} catch {
/* storage unavailable */
}
};
return (
<div className={`panel fold${closed ? " closed" : ""}`}>
<h3>
<button className="fold-toggle" aria-expanded={!closed} onClick={toggle}>
<span className="fold-caret" aria-hidden="true" />
{title}
</button>
</h3>
{!closed && children}
</div>
);
}
function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) { function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
return ( return (
<div className="kpi"> <div className="kpi">
@@ -67,8 +118,39 @@ function perMillion(r: Aggregate, currency: string) {
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–"; 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 }) { function AggTable({
rows,
keyName,
label,
currency,
link,
group,
}: {
rows: (Aggregate & Record<string, any>)[];
keyName: string;
label: string;
currency: string;
link?: (r: any) => void;
/** Group key of a row (e.g. the provider of a model): groups fold. */
group?: (r: any) => string;
}) {
const t = useT(); const t = useT();
const [shut, setShut] = useState<Set<string>>(new Set());
const groups = group ? [...new Set(rows.map(group))] : [];
const row = (r: (typeof rows)[number]) => (
<tr key={r[keyName]}>
<td className={group ? "fold-indent" : undefined}>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
<td className="num">{fmtNumber(r.prefill_tps)}</td>
<td className="num">{fmtCost(r.cost, currency)}</td>
<td className="num">{perMillion(r, currency)}</td>
</tr>
);
return ( return (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<table className="grid"> <table className="grid">
@@ -87,20 +169,33 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r) => ( {group
<tr key={r[keyName]}> ? groups.map((g) => {
<td>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td> const members = rows.filter((r) => group(r) === g);
<td className="num">{fmtNumber(r.requests)}</td> const open = !shut.has(g);
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td> return (
<td className="num">{fmtCompact(r.cache_read_tokens)}</td> <Fragment key={g}>
<td className="num">{fmtCompact(r.output_tokens)}</td> <tr className="fold-group">
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td> <td>
<td className="num">{fmtNumber(r.output_tps, 1)}</td> <button
<td className="num">{fmtNumber(r.prefill_tps)}</td> className="fold-toggle"
<td className="num">{fmtCost(r.cost, currency)}</td> aria-expanded={open}
<td className="num">{perMillion(r, currency)}</td> onClick={() => setShut((x) => (x.has(g) ? new Set([...x].filter((k) => k !== g)) : new Set(x).add(g)))}
</tr> >
))} <span className="fold-caret" aria-hidden="true" />
{g} <span className="hint">({members.length})</span>
</button>
</td>
<td className="num">{fmtNumber(members.reduce((a, r) => a + r.requests, 0))}</td>
<td colSpan={6} />
<td className="num">{fmtCost(members.reduce((a, r) => a + r.cost, 0), currency)}</td>
<td />
</tr>
{open && members.map(row)}
</Fragment>
);
})
: rows.map(row)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -117,16 +212,40 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
const isAdmin = useStore((s) => s.me?.role === "admin"); const isAdmin = useStore((s) => s.me?.role === "admin");
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? []; const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
const [userFilter, setUserFilter] = useState(""); const [userFilter, setUserFilter] = useState("");
const days = RANGES.find((r) => r.key === range)!.days;
const tz = -new Date().getTimezoneOffset(); const tz = -new Date().getTimezoneOffset();
// Stable per range: rounded to the minute so the URL doesn't change on every render. // 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 now = Math.floor(Date.now() / 60000) * 60000 + 60000;
const from = days ? now - days * 86400000 : 0; // "y2025": that calendar year (local time); otherwise a rolling window.
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`); const year = /^y(\d{4})$/.test(range) ? Number(range.slice(1)) : 0;
const days = RANGES.find((r) => r.key === range)?.days ?? 0;
const from = year ? new Date(year, 0, 1).getTime() : days ? now - days * 86400000 : 0;
const to = year ? Math.min(now, new Date(year + 1, 0, 1).getTime()) : now;
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${to}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
/** Download of what is shown: all tables as XLSX, or one table as CSV. */
const exportAs = (what: string) => {
if (!what) return;
const q = `from=${from}&to=${to}&tz=${tz}&lang=${getLang()}${userFilter ? `&user=${userFilter}` : ""}`;
const a = document.createElement("a");
a.href = `/api/stats/export?${q}&format=${what === "xlsx" ? "xlsx" : "csv&table=" + what.slice(4)}`;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
};
// Years since the first recorded request (the data survives indefinitely).
const [firstYear, setFirstYear] = useState(new Date().getFullYear());
useEffect(() => {
if (data?.first_ts) setFirstYear(new Date(data.first_ts).getFullYear());
}, [data?.first_ts]);
const years = Array.from({ length: new Date().getFullYear() - firstYear + 1 }, (_, i) => new Date().getFullYear() - i);
const spanDays = ((data?.to ?? now) - (data?.from || data?.first_ts || now)) / 86400000;
// Long ranges: one bar per month instead of one per day.
const monthly = spanDays > 120;
const view = useMemo(() => { const view = useMemo(() => {
if (!data) return undefined; if (!data) return undefined;
const cats = dayRange(data.from, data.to, data.by_day[0]?.day); const bucket = (day: string) => (monthly ? day.slice(0, 7) : day);
const cats = monthly ? monthRange(data.from || data.first_ts || data.to, data.to) : dayRange(data.from, data.to, data.by_day[0]?.day);
// Up to 7 models by cost get their own color; the rest fold into "Other". // Up to 7 models by cost get their own color; the rest fold into "Other".
const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests); const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests);
const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model); const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model);
@@ -135,7 +254,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
const costByDay = cats.map(() => ({}) as Record<string, number>); const costByDay = cats.map(() => ({}) as Record<string, number>);
const idx = new Map(cats.map((c, i) => [c, i])); const idx = new Map(cats.map((c, i) => [c, i]));
for (const r of data.by_day_model) { for (const r of data.by_day_model) {
const i = idx.get(r.day); const i = idx.get(bucket(r.day));
if (i === undefined) continue; if (i === undefined) continue;
const key = named.includes(r.model) ? r.model : other; const key = named.includes(r.model) ? r.model : other;
costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost; costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost;
@@ -146,7 +265,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
{ key: "output", label: t("stats.output"), color: SERIES[2] }, { key: "output", label: t("stats.output"), color: SERIES[2] },
]; ];
const tokensByDay = cats.map((c): Record<string, number> => { const tokensByDay = cats.map((c): Record<string, number> => {
const d = data.by_day.find((x) => x.day === c); const d = monthly ? data.by_month.find((x) => x.month === c) : data.by_day.find((x) => x.day === c);
return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {}; return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {};
}); });
// Rolling window: the last 14 days, today at the bottom. // Rolling window: the last 14 days, today at the bottom.
@@ -174,10 +293,12 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) }; return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) };
}); });
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows }; return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows };
}, [data, heat, t]); }, [data, heat, t, monthly]);
const dayLabel = (d: string) => const dayLabel = (d: string) =>
new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`)); monthly
? new Intl.DateTimeFormat(getLang(), { month: "short", year: "2-digit" }).format(new Date(`${d}-01T00:00:00`))
: new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`));
const cur = data?.currency ?? "USD"; const cur = data?.currency ?? "USD";
const tot = data?.total; const tot = data?.total;
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v)); const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
@@ -203,6 +324,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
</button> </button>
))} ))}
</div> </div>
{years.length > 1 && (
<select value={year ? range : ""} onChange={(e) => e.target.value && setRange(e.target.value)} aria-label={t("stats.year")} className={year ? "active" : ""}>
<option value="">{t("stats.year")}</option>
{years.map((y) => (
<option key={y} value={`y${y}`}>
{y}
</option>
))}
</select>
)}
{isAdmin && ( {isAdmin && (
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}> <select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
<option value="">{t("stats.allUsers")}</option> <option value="">{t("stats.allUsers")}</option>
@@ -213,6 +344,15 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
))} ))}
</select> </select>
)} )}
<select value="" onChange={(e) => exportAs(e.target.value)} aria-label={t("stats.export")} title={t("stats.exportHint")}>
<option value="">{t("stats.export")}</option>
<option value="xlsx">{t("stats.exportXlsx")}</option>
{(["day", "month", "model", "provider", "agent", "user", "session"] as const).map((k) => (
<option key={k} value={`csv:${k}`}>
{t("stats.exportCsv", { table: t(`stats.table.${k}` as Key) })}
</option>
))}
</select>
<button className="small" onClick={() => setTableView(!tableView)}> <button className="small" onClick={() => setTableView(!tableView)}>
{tableView ? t("stats.chartView") : t("stats.tableView")} {tableView ? t("stats.chartView") : t("stats.tableView")}
</button> </button>
@@ -238,61 +378,51 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
{tot.requests === 0 ? ( {tot.requests === 0 ? (
<div className="empty">{t("stats.noData")}</div> <div className="empty">{t("stats.noData")}</div>
) : tableView ? ( ) : tableView ? (
<div className="panel"> <Fold id="perDay" title={t("stats.perDay")}>
<h3>{t("stats.perDay")}</h3>
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} /> <AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
</div> </Fold>
) : ( ) : (
<> <>
<div className="panel"> <Fold id="costPerDay" title={t(monthly ? "stats.costPerMonth" : "stats.costPerDay")}>
<h3>{t("stats.costPerDay")}</h3>
<Legend series={view.modelSeries} /> <Legend series={view.modelSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} /> <StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
</div> </Fold>
<div className="panel"> <Fold id="tokensPerDay" title={t(monthly ? "stats.tokensPerMonth" : "stats.tokensPerDay")}>
<h3>{t("stats.tokensPerDay")}</h3>
<Legend series={view.tokenSeries} /> <Legend series={view.tokenSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} /> <StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
</div> </Fold>
<div className="panel"> <Fold id="heatmap" title={t("stats.heatmap")}>
<h3> <div className="row" style={{ justifyContent: "flex-end", marginBottom: 6 }}>
{t("stats.heatmap")} <select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)} aria-label={t("stats.heatmap")}>
<span className="spacer" />
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)}>
<option value="requests">{t("stats.requests")}</option> <option value="requests">{t("stats.requests")}</option>
<option value="tokens">{t("stats.tokens")}</option> <option value="tokens">{t("stats.tokens")}</option>
<option value="cost">{t("stats.cost")}</option> <option value="cost">{t("stats.cost")}</option>
</select> </select>
</h3> </div>
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} /> <Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
</div> </Fold>
</> </>
)} )}
{tot.requests > 0 && ( {tot.requests > 0 && (
<> <>
<div className="panel"> <Fold id="byModel" title={t("stats.byModel")}>
<h3>{t("stats.byModel")}</h3> <AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} group={(r) => String(r.model).split("/")[0]} />
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} /> </Fold>
</div> <Fold id="byProvider" title={t("stats.byProvider")}>
<div className="panel">
<h3>{t("stats.byProvider")}</h3>
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} /> <AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
</div> </Fold>
<div className="panel"> <Fold id="byAgent" title={t("stats.byAgent")}>
<h3>{t("stats.byAgent")}</h3>
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} /> <AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
</div> </Fold>
{isAdmin && !userFilter && data.by_user.length > 1 && ( {isAdmin && !userFilter && data.by_user.length > 1 && (
<div className="panel"> <Fold id="byUser" title={t("stats.byUser")}>
<h3>{t("stats.byUser")}</h3>
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} /> <AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
</div> </Fold>
)} )}
<div className="panel"> <Fold id="topSessions" title={t("stats.topSessions")}>
<h3>{t("stats.topSessions")}</h3>
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} /> <AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
</div> </Fold>
</> </>
)} )}
</> </>
+1 -1
View File
@@ -121,7 +121,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0; const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
const openByDefault = const openByDefault =
(status === "err" && !!result) || (status === "err" && !!result) ||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d")); (!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || (result.images?.length ?? 0) > 0 || call.name === "generate_image" || call.name === "generate_3d"));
const open = toggled ?? openByDefault; const open = toggled ?? openByDefault;
return ( return (
+78 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getLang, useT } from "../i18n"; import { getLang, useT } from "../i18n";
import { toast } from "../dialogs"; import { toast } from "../dialogs";
import { get } from "../api";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
/* Web Speech API (Chrome/Edge/Safari; Firefox has none). */ /* Web Speech API (Chrome/Edge/Safari; Firefox has none). */
@@ -18,10 +19,86 @@ const Impl: (new () => Recognition) | undefined = (window as any).SpeechRecognit
export const voiceSupported = () => !!Impl; export const voiceSupported = () => !!Impl;
/** Server-side transcription is on (server.transcribe_model) – for browsers without dictation. */
function useServerSpeech(): boolean {
const [on, setOn] = useState(false);
useEffect(() => {
if (Impl) return;
get<{ transcribe?: boolean }>("/config")
.then((c) => setOn(!!c.transcribe))
.catch(() => undefined);
}, []);
return on;
}
/** Record with the microphone; the audio is transcribed by the server. */
function RecorderButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const t = useT();
const [state, setState] = useState<"idle" | "recording" | "sending">("idle");
const rec = useRef<MediaRecorder | null>(null);
useEffect(() => () => rec.current?.stream.getTracks().forEach((x) => x.stop()), []);
const toggle = async () => {
if (state === "recording") {
rec.current?.stop();
return;
}
if (state === "sending") return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const r = new MediaRecorder(stream);
const chunks: Blob[] = [];
r.ondataavailable = (e) => e.data.size && chunks.push(e.data);
r.onstop = async () => {
stream.getTracks().forEach((x) => x.stop());
setState("sending");
onInterim(t("voice.transcribing"));
try {
const blob = new Blob(chunks, { type: r.mimeType || "audio/webm" });
const res = await fetch(`/api/transcribe?lang=${getLang()}`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": blob.type }, body: blob });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? data.message ?? `HTTP ${res.status}`);
if (data.text) onText(String(data.text));
} catch (e: any) {
toast(`${t("voice.error")}: ${e.message}`);
} finally {
onInterim("");
setState("idle");
}
};
rec.current = r;
r.start();
setState("recording");
} catch {
toast(t("voice.denied"));
}
};
return (
<button
type="button"
className={`ghost small voice-btn ${state === "recording" ? "on" : ""}`}
disabled={state === "sending"}
onClick={() => void toggle()}
title={t(state === "recording" ? "voice.stop" : "voice.start")}
aria-label={t(state === "recording" ? "voice.stop" : "voice.start")}
aria-pressed={state === "recording"}
>
<Icon name="mic" />
</button>
);
}
const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" }; const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" };
/** Microphone button: dictated text is appended to the message. */ /** Microphone button: dictated text is appended to the message. */
export function VoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) { export function VoiceButton(props: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const server = useServerSpeech();
if (!Impl) return server ? <RecorderButton {...props} /> : null;
return <BrowserVoiceButton {...props} />;
}
function BrowserVoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const t = useT(); const t = useT();
const [listening, setListening] = useState(false); const [listening, setListening] = useState(false);
const rec = useRef<Recognition | null>(null); const rec = useRef<Recognition | null>(null);
+4
View File
@@ -3,9 +3,11 @@ import { post } from "../../api";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { GitAccount } from "./GitAccount"; import { GitAccount } from "./GitAccount";
import { SshKeys } from "./SshKeys";
import { ApiTokens } from "./ApiTokens"; import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall"; import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory"; import { Memory } from "./Memory";
import { Snippets } from "./Snippets";
import { ClaudeCode } from "./ClaudeCode"; import { ClaudeCode } from "./ClaudeCode";
import { Telegram } from "./Telegram"; import { Telegram } from "./Telegram";
@@ -51,7 +53,9 @@ export function Account() {
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div> <div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div> </div>
<Memory /> <Memory />
<Snippets />
<GitAccount /> <GitAccount />
<SshKeys />
<CliInstall /> <CliInstall />
<ClaudeCode /> <ClaudeCode />
<Telegram /> <Telegram />
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
export interface Snippet {
id?: string;
name: string;
text: string;
}
/** Personal prompt snippets: typed as `/name` in the composer. */
export function Snippets() {
const t = useT();
const [items, setItems] = useState<Snippet[]>();
useEffect(() => {
get<Snippet[]>("/account/snippets")
.then(setItems)
.catch((e) => toast(e.message));
}, []);
if (!items) return null;
const set = (i: number, patch: Partial<Snippet>) => setItems(items.map((s, j) => (j === i ? { ...s, ...patch } : s)));
const save = () => {
const clean = items.filter((s) => s.name.trim() || s.text.trim()).map((s) => ({ ...s, name: s.name.trim().replace(/^\/+/, "").replace(/\s+/g, "-") }));
put<Snippet[]>("/account/snippets", clean)
.then((r) => (setItems(r), toast(t("settings.saved"), "success")))
.catch((e) => toast(e.message));
};
return (
<div className="panel">
<h3>
<Icon name="sparkle" /> {t("snippets.title")}
</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("snippets.hint")}
</div>
{items.map((s, i) => (
<div key={s.id ?? i} className="snippet-row">
<div className="row">
<span className="snippet-slash">/</span>
<input value={s.name} placeholder={t("snippets.name")} maxLength={40} onChange={(e) => set(i, { name: e.target.value })} aria-label={t("snippets.name")} />
<button title={t("common.delete")} aria-label={t("common.delete")} onClick={() => setItems(items.filter((_, j) => j !== i))}>
<Icon name="trash" />
</button>
</div>
<textarea rows={2} value={s.text} placeholder={t("snippets.text")} onChange={(e) => set(i, { text: e.target.value })} aria-label={t("snippets.text")} />
</div>
))}
<div className="row" style={{ marginTop: 8 }}>
<button onClick={() => setItems([...items, { name: "", text: "" }])}>
<Icon name="plus" /> {t("snippets.add")}
</button>
<span className="grow" />
<button className="primary" onClick={save}>
{t("common.save")}
</button>
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import { del, get, post } from "../../api";
import { useT } from "../../i18n";
import { confirmDialog, toast } from "../../dialogs";
import { Icon } from "../Icon";
interface Key {
id: string;
name: string;
public: string;
fingerprint: string;
}
/** SSH keys of the user: used by agents, the terminal and the git panel to reach other servers. */
export function SshKeys() {
const t = useT();
const [keys, setKeys] = useState<Key[]>();
const [name, setName] = useState("");
const [priv, setPriv] = useState("");
const [importing, setImporting] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
get<Key[]>("/account/ssh")
.then(setKeys)
.catch((e) => toast(e.message));
}, []);
if (!keys) return null;
const add = async () => {
setBusy(true);
try {
setKeys(await post<Key[]>("/account/ssh", { name, private: importing ? priv : undefined }));
setName("");
setPriv("");
toast(t("ssh.added"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const copy = (text: string) => navigator.clipboard?.writeText(text).then(() => toast(t("ssh.copied"), "success"), () => toast(t("ssh.copyFailed")));
return (
<div className="panel">
<h3>
<Icon name="lock" /> {t("ssh.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("ssh.hint")}
</div>
{keys.length === 0 && <div className="hint">{t("ssh.none")}</div>}
{keys.map((k) => (
<div key={k.id} className="ssh-key">
<div className="row">
<b>{k.name}</b>
<span className="hint mono">{k.fingerprint}</span>
<span className="spacer" />
<button className="small" onClick={() => copy(k.public)}>
<Icon name="copy" /> {t("ssh.copyPublic")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("ssh.deleteConfirm", { name: k.name }), danger: true })) {
del<Key[]>(`/account/ssh/${k.id}`).then(setKeys).catch((e) => toast(e.message));
}
}}
>
<Icon name="trash" />
</button>
</div>
<input readOnly value={k.public} onFocus={(e) => e.currentTarget.select()} aria-label={t("ssh.public")} className="mono" />
</div>
))}
<div className="row" style={{ marginTop: 10, flexWrap: "wrap" }}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("ssh.name")} maxLength={60} aria-label={t("ssh.name")} style={{ flex: "1 1 160px" }} />
<button className="primary" disabled={busy || !name.trim() || (importing && !priv.trim())} onClick={add}>
{importing ? t("ssh.import") : t("ssh.generate")}
</button>
<label className="row" style={{ gap: 6 }}>
<input type="checkbox" checked={importing} onChange={(e) => setImporting(e.target.checked)} />
{t("ssh.importExisting")}
</label>
</div>
{importing && (
<>
<textarea rows={5} className="mono" value={priv} onChange={(e) => setPriv(e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" aria-label={t("ssh.private")} style={{ width: "100%", marginTop: 8 }} spellCheck={false} autoComplete="off" />
<div className="hint">{t("ssh.importHint")}</div>
</>
)}
</div>
);
}
+36 -8
View File
@@ -20,8 +20,15 @@ interface PromptOpts {
confirmLabel?: string; confirmLabel?: string;
} }
interface ChoiceOpts {
title?: string;
message: string;
choices: { value: string; label: string; danger?: boolean }[];
}
type Dialog = type Dialog =
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts) | ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
| ({ kind: "choice"; id: number; resolve: (v: string | null) => void } & ChoiceOpts)
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts); | ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
interface Toast { interface Toast {
@@ -45,6 +52,14 @@ export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
}); });
} }
/** Several actions to choose from (plus Cancel): resolves to the chosen value, or null. */
export function choiceDialog(opts: ChoiceOpts): Promise<string | null> {
return new Promise((resolve) => {
dialogs = [...dialogs, { kind: "choice", id: ++seq, resolve, ...opts }];
emit();
});
}
export function promptDialog(opts: PromptOpts | string): Promise<string | null> { export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
const o = typeof opts === "string" ? { message: opts } : opts; const o = typeof opts === "string" ? { message: opts } : opts;
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -108,14 +123,27 @@ function DialogView({ d }: { d: Dialog }) {
style={{ width: "100%" }} style={{ width: "100%" }}
/> />
)} )}
<div className="row dialog-actions"> {d.kind === "choice" ? (
<button type="button" onClick={() => close(d, cancelValue)}> <div className="row dialog-actions dialog-choices">
{t("common.cancel")} <button type="button" onClick={() => close(d, null)}>
</button> {t("common.cancel")}
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}> </button>
{d.confirmLabel ?? t("common.ok")} {d.choices.map((c, i) => (
</button> <button key={c.value} ref={i === d.choices.length - 1 ? ok : undefined} type="button" className={c.danger ? "danger-solid" : i === d.choices.length - 1 ? "primary" : undefined} onClick={() => close(d, c.value)}>
</div> {c.label}
</button>
))}
</div>
) : (
<div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")}
</button>
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
{d.confirmLabel ?? t("common.ok")}
</button>
</div>
)}
</form> </form>
</div> </div>
); );
+173
View File
@@ -1101,4 +1101,177 @@ export const de: Messages = {
"stats.user": "Benutzer", "stats.user": "Benutzer",
"stats.allUsers": "Alle Benutzer", "stats.allUsers": "Alle Benutzer",
"stats.byUser": "Nach Benutzer", "stats.byUser": "Nach Benutzer",
"snippets.title": "Prompt-Bausteine",
"snippets.hint": "Eigene Textbausteine. Tippe / im Nachrichtenfeld und wähle einen Baustein, um ihn einzufügen (z. B. /tests → \"Schreibe Tests zu dieser Änderung\").",
"snippets.name": "Name",
"snippets.text": "Einzufügender Text",
"snippets.add": "Baustein hinzufügen",
"snippets.insert": "Baustein",
"link.title": "Nur-Lese-Link",
"link.hint": "Wer den Link hat, kann den Gesprächstext lesen (keine Tool-Ausgaben, keine Dateien) – ohne Konto.",
"link.create": "Link erstellen",
"link.copy": "Kopieren",
"link.copied": "Link kopiert",
"link.revoke": "Widerrufen",
"link.validity": "Gültigkeit",
"link.days": "{n} Tage",
"link.until": "bis {date}",
"link.readOnly": "Schreibgeschützt geteilt",
"git.suggest": "Nachricht vorschlagen",
"git.suggestHint": "Das kleine Modell schreibt eine Commit-Nachricht für die vorgemerkten Änderungen",
"settings.weeklyDigest": "Wochenrückblick montags um 8:00 (Chats, Checks, Kosten)",
"settings.transcribeModel": "Spracherkennungs-Modell",
"settings.transcribeHint": "provider/modell eines OpenAI-kompatiblen Transkriptions-Endpunkts (z. B. openai/whisper-1). Schaltet den Mikrofon-Knopf in Browsern ohne eigene Diktierfunktion frei, etwa Firefox.",
"voice.transcribing": "Wird transkribiert …",
"offline.banner": "Keine Verbindung – gespeicherte Kopien. Geschriebene Nachrichten warten im Postausgang.",
"settings.planModel": "Planungsmodell",
"settings.planModelHint": "Der Plan-Modus nutzt immer dieses Modell (z. B. ein starkes); alle anderen Modi das im Chat gewählte (z. B. ein günstigeres). Leer = Modell des Chats.",
"settings.sameAsChat": "(Modell des Chats)",
"jobEditor.notify": "Bericht als Push-Nachricht senden, wenn ein Lauf endet",
"stats.year": "Jahr",
"stats.month": "Monat",
"stats.perMonth": "Pro Monat",
"stats.costPerMonth": "Kosten pro Monat",
"stats.tokensPerMonth": "Tokens pro Monat",
"stats.export": "Export",
"stats.exportHint": "Die Werte des gewählten Zeitraums herunterladen",
"stats.exportXlsx": "Excel (.xlsx) – alle Tabellen",
"stats.exportCsv": "CSV – {table}",
"stats.table.day": "pro Tag",
"stats.table.month": "pro Monat",
"stats.table.model": "nach Modell",
"stats.table.provider": "nach Anbieter",
"stats.table.agent": "nach Modus",
"stats.table.user": "nach Benutzer",
"stats.table.session": "teuerste Chats",
"command.clear": "Bisheriges Gespräch vergessen (der Chat bleibt sichtbar)",
"chat.cleared": "Kontext geleert – das Modell sieht die Nachrichten darüber nicht mehr",
"ssh.title": "SSH-Schlüssel",
"ssh.hint": "Schlüssel, mit denen Agenten, Terminal und Git-Panel andere Server erreichen (ssh, scp, rsync, Git über ssh). Sie werden automatisch genutzt; Hosts werden beim ersten Kontakt akzeptiert. Den öffentlichen Schlüssel trägst du in die authorized_keys des Servers ein.",
"ssh.none": "Noch keine Schlüssel.",
"ssh.name": "Name (z. B. Laptop, Build-Server)",
"ssh.generate": "Neuen Schlüssel erzeugen",
"ssh.import": "Schlüssel importieren",
"ssh.importExisting": "Vorhandenen Schlüssel importieren",
"ssh.importHint": "Nur private Schlüssel ohne Passphrase. Der Schlüssel wird für dein Konto auf dem Server gespeichert.",
"ssh.private": "Privater Schlüssel",
"ssh.public": "Öffentlicher Schlüssel",
"ssh.copyPublic": "Öffentlichen Schlüssel kopieren",
"ssh.copied": "Öffentlicher Schlüssel kopiert",
"ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren",
"ssh.added": "Schlüssel hinzugefügt",
"ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.",
"chat.deleteTitle": "Chat löschen",
"chat.deleteWithFolder": "Dieser Chat hat einen eigenen Ordner:\n{path}\n({size})\n\nSoll der Ordner mit dem Chat gelöscht werden?",
"chat.deleteOnlyChat": "Nur Chat löschen",
"chat.deleteChatAndFolder": "Chat und Ordner löschen",
"chat.folderFiles": "{n} Dateien",
"chat.folderHasGit": "Der Ordner enthält ein Git-Repository – nicht gepushte Commits und Branches gehen verloren.",
"chat.folderDeleted": "Chat und Ordner gelöscht",
"nav.factory": "Factory",
"factory.title": "Software-Factory",
"factory.tab.overview": "Übersicht",
"factory.tab.settings": "Einstellungen",
"factory.empty": "Noch kein Repository verbunden. Füge in den Einstellungen eines hinzu: Issues mit dem Factory-Label werden dann von Agenten geprüft, gebaut und abgenommen.",
"factory.addRepo": "Repository hinzufügen",
"factory.newRepo": "Neues Repository",
"factory.noArea": "Der Factory-Bereich ist nicht eingebunden (FACTORY_DIR → /factory). Repositories können nicht geklont werden.",
"factory.kpi.running": "Läuft gerade",
"factory.kpi.issues": "{n} Issues",
"factory.kpi.done": "Fertig",
"factory.kpi.failed": "{n} fehlgeschlagen oder abgelehnt",
"factory.kpi.tokens": "Tokens",
"factory.kpi.cost": "Kosten",
"factory.kpi.runs": "{n} Läufe",
"factory.kpi.time": "Ø Laufzeit",
"factory.kpi.perRun": "pro beendetem Lauf",
"factory.in": "ein",
"factory.out": "aus",
"factory.allRepos": "Alle Projekte",
"factory.repoLine": "{n} Issues · {run} laufen",
"factory.noToken": "kein Token",
"factory.pollNow": "Jetzt prüfen",
"factory.col.review": "Prüfung",
"factory.col.question": "Rückfragen",
"factory.col.approved": "Freigegeben",
"factory.col.build": "Bau",
"factory.col.qa": "Abnahme",
"factory.col.done": "Fertig",
"factory.col.failed": "Fehlgeschlagen / abgelehnt",
"factory.phase": "Phase",
"factory.phase.review": "Prüfung",
"factory.phase.build": "Bau",
"factory.phase.qa": "Abnahme",
"factory.latestRuns": "Letzte Läufe",
"factory.noRuns": "Noch keine Läufe.",
"factory.issue": "Issue",
"factory.model": "Modell",
"factory.status": "Status",
"factory.started": "Gestartet",
"factory.duration": "Dauer",
"factory.tokens": "Tokens",
"factory.cost": "Kosten",
"factory.pr": "Pull Request",
"factory.round": "Runde {n}",
"factory.openChat": "Chat öffnen",
"factory.runStatus.running": "läuft",
"factory.runStatus.done": "fertig",
"factory.runStatus.timeout": "Zeitlimit",
"factory.runStatus.error": "Fehler",
"factory.runStatus.no-result": "kein Ergebnis",
"factory.runStatus.interrupted": "unterbrochen",
"factory.settingsHint": "Specs kommen als Gitea-Issues mit dem Factory-Label (\"Spec: <Pfad>\" im Text). Jede Phase läuft als eigener Chat in der Sandbox mit eigenem Modell. Repositories werden in den separaten Factory-Bereich geklont, nicht in den Workspace; die Agenten sehen den API-Token nie.",
"factory.url": "Repository-URL",
"factory.token": "Gitea-API-Token",
"factory.tokenPlaceholder": "Token mit Rechten für Issues und Pull Requests",
"factory.tokenSet": "Ein Token ist gespeichert. Gib einen neuen ein, um ihn zu ersetzen.",
"factory.label": "Issue-Label",
"factory.parallel": "Parallele Läufe",
"factory.allowed": "Erlaubte Issue-Autoren",
"factory.allowedPlaceholder": "leer = nur der Token-Besitzer",
"factory.agent": "Agent",
"factory.mode": "Berechtigungsmodus",
"factory.timeout": "Zeitlimit (Minuten)",
"factory.extraPrompt": "Zusätzliche Anweisungen",
"factory.test": "Verbindung testen",
"factory.testOk": "Verbunden als {login} mit {repo}; {n} Labels angelegt, Repository geklont.",
"factory.deleteConfirm": "{name} aus der Factory entfernen? Issues und Chats bleiben.",
"factory.newProject": "Neues Projekt",
"factory.connections": "Verbindungen",
"factory.connectionsHint": "Deine Konten bei Gitea, GitHub oder GitLab. Jeder Nutzer richtet seine eigenen ein; Repositories nutzen den Token ihrer Verbindung, sofern sie keinen eigenen haben.",
"factory.connName": "Name",
"factory.kind": "Dienst",
"factory.serviceUrl": "Adresse des Dienstes",
"factory.defaultOwner": "Standard-Besitzer für neue Repositories",
"factory.addConn": "Verbindung hinzufügen",
"factory.testConn": "Testen",
"factory.connOk": "Angemeldet als {login}; {n} Organisationen oder Gruppen gefunden.",
"factory.connDeleteConfirm": "Verbindung {name} entfernen? Repositories funktionieren danach nur mit eigenem Token.",
"factory.tokenHint.gitea": "Scopes: write:repository, write:issue, read:user – zum Anlegen von Repositories zusätzlich write:user (oder write:organization).",
"factory.tokenHint.github": "Classic-Token mit Scope \"repo\" oder fein abgestufter Token mit Contents, Issues und Pull requests (Lesen/Schreiben).",
"factory.tokenHint.gitlab": "Persönlicher oder Projekt-Zugriffstoken mit Scope \"api\".",
"factory.defaults": "Standard für neue Projekte",
"factory.defaultsHint": "Modelle und Grenzen, mit denen ein Projekt startet; du kannst sie danach pro Projekt ändern.",
"factory.specModel": "Modell des Spec-Chats",
"factory.repos": "Repositories",
"factory.connection": "Verbindung",
"factory.noConnection": "– keine (eigener Token) –",
"factory.repoToken": "Eigener Token (optional)",
"factory.repoTokenPlaceholder": "leer = Token der Verbindung",
"factory.repoTokenHint": "Nur nötig, wenn dieses Repository einen anderen Token nutzen soll.",
"draft.hint": "Spec-Chat: Wenn die Spec fertig ist, lege sie als neues Repository ab.",
"draft.create": "Projekt anlegen",
"draft.createGo": "Repository anlegen und starten",
"draft.name": "Projektname",
"draft.slug": "Repository-Name",
"draft.description": "Kurzbeschreibung",
"draft.connection": "Verbindung",
"draft.owner": "Besitzer",
"draft.ownerPlaceholder": "leer = dein eigenes Konto",
"draft.private": "Das Repository wird privat angelegt. Die Spec wird als docs/specs/<name>.md committet und ein Issue startet den Workflow.",
"draft.specPreview": "Spec anzeigen",
"draft.noSpec": "In diesem Chat gibt es noch keine spec.md – lass sie zuerst vom Modell schreiben.",
"draft.noConnection": "Richte zuerst eine Verbindung (Gitea, GitHub oder GitLab) ein:",
"draft.created": "{repo} angelegt, Issue #{n} eröffnet – die Prüfung startet gleich.",
"factory.saved": "Gespeichert",
}; };
+173
View File
@@ -1100,6 +1100,179 @@ export const en = {
"stats.user": "User", "stats.user": "User",
"stats.allUsers": "All users", "stats.allUsers": "All users",
"stats.byUser": "By user", "stats.byUser": "By user",
"snippets.title": "Prompt snippets",
"snippets.hint": "Your own text blocks. Type / in the message field and pick a snippet to insert it (e.g. /tests → \"Add tests for this change\").",
"snippets.name": "name",
"snippets.text": "Text to insert",
"snippets.add": "Add snippet",
"snippets.insert": "Snippet",
"link.title": "Read-only link",
"link.hint": "Anyone with the link can read the conversation text (no tool output, no files) – without an account.",
"link.create": "Create link",
"link.copy": "Copy",
"link.copied": "Link copied",
"link.revoke": "Revoke",
"link.validity": "Validity",
"link.days": "{n} days",
"link.until": "until {date}",
"link.readOnly": "Shared read-only",
"git.suggest": "Suggest message",
"git.suggestHint": "Let the small model write a commit message for the staged changes",
"settings.weeklyDigest": "Weekly review on Mondays at 8:00 (chats, checks, costs)",
"settings.transcribeModel": "Speech-to-text model",
"settings.transcribeHint": "provider/model of an OpenAI-compatible transcription endpoint (e.g. openai/whisper-1). Adds the microphone button in browsers without built-in dictation, such as Firefox.",
"voice.transcribing": "Transcribing …",
"offline.banner": "No connection – showing saved copies. Messages you write wait in the outbox.",
"settings.planModel": "Planning model",
"settings.planModelHint": "Plan mode always uses this model (e.g. a strong one); all other modes use the model chosen for the chat (e.g. a cheaper one). Empty = the chat's model.",
"settings.sameAsChat": "(model of the chat)",
"jobEditor.notify": "Send the report as a push notification when a run ends",
"stats.year": "Year",
"stats.month": "Month",
"stats.perMonth": "Per month",
"stats.costPerMonth": "Cost per month",
"stats.tokensPerMonth": "Tokens per month",
"stats.export": "Export",
"stats.exportHint": "Download the figures of the selected range",
"stats.exportXlsx": "Excel (.xlsx) – all tables",
"stats.exportCsv": "CSV – {table}",
"stats.table.day": "per day",
"stats.table.month": "per month",
"stats.table.model": "by model",
"stats.table.provider": "by provider",
"stats.table.agent": "by agent",
"stats.table.user": "by user",
"stats.table.session": "top chats",
"command.clear": "Forget the conversation so far (the chat stays visible)",
"chat.cleared": "Context cleared – the model no longer sees the messages above",
"ssh.title": "SSH keys",
"ssh.hint": "Keys that agents, the terminal and the git panel use to reach other servers (ssh, scp, rsync, git over ssh). They are used automatically; hosts are trusted on first contact. Add the public key to the server's authorized_keys.",
"ssh.none": "No keys yet.",
"ssh.name": "Name (e.g. laptop, build server)",
"ssh.generate": "Generate new key",
"ssh.import": "Import key",
"ssh.importExisting": "Import an existing key",
"ssh.importHint": "Private keys without a passphrase only. The key is stored on the server for your account.",
"ssh.private": "Private key",
"ssh.public": "Public key",
"ssh.copyPublic": "Copy public key",
"ssh.copied": "Public key copied",
"ssh.copyFailed": "Could not copy – select the text manually",
"ssh.added": "Key added",
"ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.",
"chat.deleteTitle": "Delete chat",
"chat.deleteWithFolder": "This chat has a folder of its own:\n{path}\n({size})\n\nDelete the folder with the chat?",
"chat.deleteOnlyChat": "Delete chat only",
"chat.deleteChatAndFolder": "Delete chat and folder",
"chat.folderFiles": "{n} files",
"chat.folderHasGit": "The folder contains a git repository – commits and branches that were not pushed are lost.",
"chat.folderDeleted": "Chat and folder deleted",
"nav.factory": "Factory",
"factory.title": "Software factory",
"factory.tab.overview": "Overview",
"factory.tab.settings": "Settings",
"factory.empty": "No repository connected yet. Add one in the settings: issues with the factory label are then reviewed, built and checked by agents.",
"factory.addRepo": "Add repository",
"factory.newRepo": "New repository",
"factory.noArea": "The factory area is not mounted (FACTORY_DIR → /factory). Repositories cannot be cloned.",
"factory.kpi.running": "Running now",
"factory.kpi.issues": "{n} issues",
"factory.kpi.done": "Done",
"factory.kpi.failed": "{n} failed or rejected",
"factory.kpi.tokens": "Tokens",
"factory.kpi.cost": "Cost",
"factory.kpi.runs": "{n} runs",
"factory.kpi.time": "Avg. run time",
"factory.kpi.perRun": "per finished run",
"factory.in": "in",
"factory.out": "out",
"factory.allRepos": "All projects",
"factory.repoLine": "{n} issues · {run} running",
"factory.noToken": "no token",
"factory.pollNow": "Check now",
"factory.col.review": "Review",
"factory.col.question": "Questions",
"factory.col.approved": "Approved",
"factory.col.build": "Build",
"factory.col.qa": "Acceptance",
"factory.col.done": "Done",
"factory.col.failed": "Failed / rejected",
"factory.phase": "Phase",
"factory.phase.review": "Review",
"factory.phase.build": "Build",
"factory.phase.qa": "Acceptance",
"factory.latestRuns": "Latest runs",
"factory.noRuns": "No runs yet.",
"factory.issue": "Issue",
"factory.model": "Model",
"factory.status": "Status",
"factory.started": "Started",
"factory.duration": "Duration",
"factory.tokens": "Tokens",
"factory.cost": "Cost",
"factory.pr": "Pull request",
"factory.round": "round {n}",
"factory.openChat": "Open chat",
"factory.runStatus.running": "running",
"factory.runStatus.done": "done",
"factory.runStatus.timeout": "timeout",
"factory.runStatus.error": "error",
"factory.runStatus.no-result": "no result",
"factory.runStatus.interrupted": "interrupted",
"factory.settingsHint": "Specs arrive as Gitea issues with the factory label (\"Spec: <path>\" in the text). Each phase runs as its own chat in the sandbox with its own model. Repositories are cloned into the separate factory area, not into the workspace; agents never see the API token.",
"factory.url": "Repository URL",
"factory.token": "Gitea API token",
"factory.tokenPlaceholder": "Token with rights for issues and pull requests",
"factory.tokenSet": "A token is stored. Type a new one to replace it.",
"factory.label": "Issue label",
"factory.parallel": "Parallel runs",
"factory.allowed": "Allowed issue authors",
"factory.allowedPlaceholder": "empty = only the token's owner",
"factory.agent": "Agent",
"factory.mode": "Permission mode",
"factory.timeout": "Timeout (minutes)",
"factory.extraPrompt": "Extra instructions",
"factory.test": "Test connection",
"factory.testOk": "Connected as {login} to {repo}; {n} labels created, repository cloned.",
"factory.deleteConfirm": "Remove {name} from the factory? Issues and chats stay.",
"factory.newProject": "New project",
"factory.connections": "Connections",
"factory.connectionsHint": "Your accounts at Gitea, GitHub or GitLab. Each user sets up their own; repositories use the token of their connection unless they have a token of their own.",
"factory.connName": "Name",
"factory.kind": "Service",
"factory.serviceUrl": "Address of the service",
"factory.defaultOwner": "Default owner for new repositories",
"factory.addConn": "Add connection",
"factory.testConn": "Test",
"factory.connOk": "Signed in as {login}; {n} organisations or groups found.",
"factory.connDeleteConfirm": "Remove the connection {name}? Repositories keep working only with a token of their own.",
"factory.tokenHint.gitea": "Scopes: write:repository, write:issue, read:user – plus write:user (or write:organization) to create repositories.",
"factory.tokenHint.github": "Classic token with scope \"repo\", or a fine-grained token with Contents, Issues and Pull requests (read/write).",
"factory.tokenHint.gitlab": "Personal or project access token with scope \"api\".",
"factory.defaults": "Defaults for new projects",
"factory.defaultsHint": "Models and limits a project starts with; you can change them per project afterwards.",
"factory.specModel": "Model of the spec chat",
"factory.repos": "Repositories",
"factory.connection": "Connection",
"factory.noConnection": "– none (own token) –",
"factory.repoToken": "Own token (optional)",
"factory.repoTokenPlaceholder": "empty = token of the connection",
"factory.repoTokenHint": "Only needed when this repository should use a different token.",
"draft.hint": "Spec chat: when the spec is done, file it as a new repository.",
"draft.create": "Create project",
"draft.createGo": "Create repository and start",
"draft.name": "Project name",
"draft.slug": "Repository name",
"draft.description": "Short description",
"draft.connection": "Connection",
"draft.owner": "Owner",
"draft.ownerPlaceholder": "empty = your own account",
"draft.private": "The repository is created private. The spec is committed as docs/specs/<name>.md and an issue starts the workflow.",
"draft.specPreview": "Show spec",
"draft.noSpec": "There is no spec.md in this chat yet – let the model write it first.",
"draft.noConnection": "Set up a connection (Gitea, GitHub or GitLab) first:",
"draft.created": "{repo} created, issue #{n} opened – the review starts shortly.",
"factory.saved": "Saved",
}; };
export type Messages = Record<keyof typeof en, string>; export type Messages = Record<keyof typeof en, string>;
+86
View File
@@ -0,0 +1,86 @@
import type { Info, Message, Part, Session } from "./types";
/**
* Read-only copy of the app's last state for the offline case (train, tunnel): the chat
* list and the most recently opened chats. Kept small: attachments are dropped and long
* tool output is cut. Everything is best-effort – storage may be full or blocked.
*/
const KEY = "codecraft.offline.v1";
const MAX_CHATS = 6;
const MAX_MESSAGES = 80;
const MAX_BYTES = 2_500_000;
interface Cache {
info?: Info;
sessions?: Session[];
chats: Record<string, { at: number; session: Session; messages: Message[] }>;
}
function read(): Cache {
try {
const c = JSON.parse(localStorage.getItem(KEY) ?? "null");
if (c && typeof c === "object") return { chats: {}, ...c };
} catch {
/* empty or corrupt */
}
return { chats: {} };
}
function write(c: Cache) {
try {
// Oldest chats go first when the copy gets too big.
let text = JSON.stringify(c);
while (text.length > MAX_BYTES && Object.keys(c.chats).length > 0) {
const oldest = Object.entries(c.chats).sort((a, b) => a[1].at - b[1].at)[0][0];
delete c.chats[oldest];
text = JSON.stringify(c);
}
localStorage.setItem(KEY, text);
} catch {
/* storage full or unavailable */
}
}
function slim(part: Part): Part {
if (part.type === "image") return { type: "text", text: "[image]" } as Part;
if (part.type === "tool_result") return { ...part, content: part.content.slice(0, 1500), images: [], meta: undefined } as Part;
return part;
}
export function cacheInfo(info: Info) {
if (!info.user) return;
write({ ...read(), info });
}
export function cachedInfo(): Info | undefined {
return read().info;
}
export function cacheSessions(sessions: Session[]) {
write({ ...read(), sessions });
}
export function cachedSessions(): Session[] {
return read().sessions ?? [];
}
export function cacheChat(session: Session, messages: Message[]) {
const c = read();
c.chats[session.id] = {
at: Date.now(),
session,
messages: messages.slice(-MAX_MESSAGES).map((m) => ({ ...m, parts: m.parts.map(slim) })),
};
const ids = Object.entries(c.chats).sort((a, b) => b[1].at - a[1].at);
for (const [id] of ids.slice(MAX_CHATS)) delete c.chats[id];
write(c);
}
export function cachedChat(id: string) {
return read().chats[id];
}
/** A request failed because there is no connection (as opposed to an answer from the server). */
export function isOffline(e: unknown): boolean {
return e instanceof TypeError || !navigator.onLine;
}
+28 -4
View File
@@ -1,3 +1,4 @@
import { cacheChat, cacheSessions, cachedChat, cachedSessions, isOffline } from "./offline";
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { get } from "./api"; import { get } from "./api";
import type { import type {
@@ -29,6 +30,8 @@ export interface State {
/** Newer release (admins). */ /** Newer release (admins). */
update: { version: string; url: string; notes: string } | null; update: { version: string; url: string; notes: string } | null;
connected: boolean; connected: boolean;
/** Showing a saved copy: the server cannot be reached. */
offline: boolean;
sessions: Session[]; sessions: Session[];
/** Includes subagent sessions loaded on demand. */ /** Includes subagent sessions loaded on demand. */
sessionById: Record<string, Session>; sessionById: Record<string, Session>;
@@ -60,6 +63,7 @@ let state: State = {
me: null, me: null,
update: null, update: null,
connected: false, connected: false,
offline: false,
sessions: [], sessions: [],
sessionById: {}, sessionById: {},
messages: {}, messages: {},
@@ -236,7 +240,17 @@ export function dismissError(sessionId: string, id: number) {
} }
export async function loadSessions() { export async function loadSessions() {
const list = await get<Session[]>("/sessions"); let list: Session[];
try {
list = await get<Session[]>("/sessions");
cacheSessions(list);
if (getState().offline) setState(() => ({ offline: false }));
} catch (e) {
if (!isOffline(e)) throw e;
list = cachedSessions().map((x) => ({ ...x, busy: false }));
setState(() => ({ offline: true }));
if (list.length === 0) throw e;
}
setState((s) => ({ setState((s) => ({
sessions: list, sessions: list,
sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) }, sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) },
@@ -250,9 +264,19 @@ export const PAGE = 200;
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean }; type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
export async function loadSession(id: string) { export async function loadSession(id: string) {
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>( type Loaded = { session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean };
`/sessions/${id}?limit=${PAGE}`, let data: Loaded;
); try {
data = await get<Loaded>(`/sessions/${id}?limit=${PAGE}`);
cacheChat(data.session, data.messages);
if (getState().offline) setState(() => ({ offline: false }));
} catch (e) {
const copy = isOffline(e) ? cachedChat(id) : undefined;
if (!copy) throw e;
// No connection: show the saved copy; new messages wait in the outbox.
data = { session: copy.session, messages: copy.messages, busy: false, can_write: true, queue: [], has_older: true };
setState(() => ({ offline: true }));
}
setState((s) => ({ setState((s) => ({
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } }, windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
...upsertSession(s, data.session), ...upsertSession(s, data.session),
+253 -26
View File
@@ -524,28 +524,50 @@ button.session-group:hover {
position: relative; position: relative;
} }
/* Message actions: hover on desktop, faintly visible on touch. */ /* Message actions: hover on desktop, faintly visible on touch. */
.msg-menu { /* The ⋯ menu sits in the foot of the bubble (next to the time); its popup opens upwards inside
position: absolute; * the bubble and never gets wider than the bubble. Faint on touch, appears on hover on desktop. */
top: 2px; .msg-foot {
right: 2px; display: flex;
z-index: 3; align-items: center;
opacity: 0; gap: 6px;
margin-top: 4px;
}
.msg-foot .msg-time {
margin-top: 0;
flex: 1;
}
.msg-menu.menu-pop {
position: static; /* the popup is placed against the bubble */
flex-shrink: 0;
opacity: 0.45;
transition: opacity 0.12s; transition: opacity 0.12s;
} }
.msg:hover > .msg-menu, .msg:hover .msg-menu,
.msg-menu:focus-within, .msg-menu:focus-within,
.msg-menu[open] { .msg-menu[open] {
opacity: 1; opacity: 1;
} }
@media (pointer: coarse) { .msg-menu.menu-pop > summary {
.msg-menu { padding: 0 4px;
opacity: 0.5; border-radius: 6px;
}
}
.msg-menu > summary {
background: var(--bg1);
border: 1px solid var(--bg2);
line-height: 0; line-height: 0;
background: transparent;
border: none;
}
.msg-menu.menu-pop > summary:hover {
background: color-mix(in srgb, var(--fg1) 14%, transparent);
}
.msg-menu.menu-pop .menu-pop-body {
top: auto;
bottom: 30px;
right: 8px;
z-index: 30;
min-width: 0;
width: max-content;
max-width: max(calc(100% - 16px), 11rem);
}
.msg-menu.menu-pop .menu-pop-body a {
white-space: normal;
} }
/* Chat bubbles: the agent's answers on the left, your messages on the right in another colour. */ /* Chat bubbles: the agent's answers on the left, your messages on the right in another colour. */
.msg-user { .msg-user {
@@ -578,9 +600,6 @@ button.session-group:hover {
background: color-mix(in srgb, var(--tag) 38%, var(--bg1)); background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
border-color: color-mix(in srgb, var(--tag) 70%, transparent); border-color: color-mix(in srgb, var(--tag) 70%, transparent);
} }
.chat-pane[data-color] .msg.bubble.agent {
background: color-mix(in srgb, var(--tag) 7%, var(--bg1));
}
@media (max-width: 700px) { @media (max-width: 700px) {
.msg.user-msg { .msg.user-msg {
max-width: 94%; max-width: 94%;
@@ -608,7 +627,7 @@ button.session-group:hover {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
.msg-user .msg-time { .msg-user .msg-foot .msg-time {
text-align: right; text-align: right;
} }
.msg-user img { .msg-user img {
@@ -4736,15 +4755,10 @@ span.chat-tab > button {
} }
} }
.chat-pane[data-color] { /* The chat's colour marks the top bar (line), the list entry, the badge and your own bubbles –
background: color-mix(in srgb, var(--tag) 16%, var(--bg0)); * the chat background itself stays neutral. */
}
.chat-pane[data-color] .composer-wrap {
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
}
.topbar[data-color] { .topbar[data-color] {
border-bottom: 3px solid var(--tag); border-bottom: 3px solid var(--tag);
background: color-mix(in srgb, var(--tag) 28%, var(--bg0));
} }
.session-item[data-color] { .session-item[data-color] {
box-shadow: inset 4px 0 0 var(--tag); box-shadow: inset 4px 0 0 var(--tag);
@@ -4943,3 +4957,216 @@ span.chat-tab > button {
.tl-meta .check-timeout { .tl-meta .check-timeout {
color: var(--red); color: var(--red);
} }
/* Foldable statistics sections */
.fold-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.fold-caret {
width: 0;
height: 0;
border-left: 5px solid currentColor;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.15s;
opacity: 0.7;
}
.fold-toggle[aria-expanded="true"] .fold-caret {
transform: rotate(90deg);
}
.panel.fold.closed h3 {
margin-bottom: 0;
}
.fold-group td {
background: var(--bg2, color-mix(in srgb, var(--fg, #888) 6%, transparent));
font-weight: 600;
}
td.fold-indent {
padding-left: 1.6em;
}
.snippet-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.snippet-slash {
color: var(--muted);
font-family: var(--mono, monospace);
}
.public-chat { max-width: 820px; margin: 0 auto; padding: 16px; }
.public-chat header { margin-bottom: 12px; }
.public-chat h1 { font-size: 1.2rem; margin: 0 0 4px; }
.public-chat .chat-scroll { display: flex; flex-direction: column; gap: 10px; }
.share-links { margin-top: 14px; border-top: 1px solid var(--border, #8884); padding-top: 10px; }
.share-links h4 { margin: 0 0 4px; }
.share-link { margin-top: 6px; flex-wrap: wrap; }
.share-link input { flex: 1 1 200px; min-width: 0; }
.offline-bar { background: color-mix(in srgb, var(--warn, #d79921) 25%, var(--bg0, transparent)); color: var(--fg, inherit); padding: 6px 12px; font-size: 0.85rem; text-align: center; }
.ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.ssh-key .row { align-items: center; flex-wrap: wrap; }
.ssh-key input.mono { width: 100%; font-size: 12px; }
.dialog-message { overflow-wrap: anywhere; }
.dialog-choices { flex-wrap: wrap; }
/* --- software factory ------------------------------------------------------ */
.button-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
text-decoration: none;
color: var(--fg1);
}
.table-wrap {
overflow-x: auto;
}
.factory-repos {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
align-items: stretch;
}
.factory-repo {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
background: var(--bg1);
border: 1px solid var(--bg2);
border-radius: var(--radius);
padding: 8px 12px;
min-width: 150px;
}
.factory-repo.active {
border-color: var(--accent);
}
.factory-repo.off {
opacity: 0.6;
}
.factory-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(200px, 1fr);
gap: 10px;
overflow-x: auto;
padding-bottom: 6px;
}
.factory-col {
background: var(--bg1);
border-radius: var(--radius);
padding: 8px;
min-height: 120px;
display: flex;
flex-direction: column;
gap: 6px;
}
.factory-col h4 {
margin: 0 0 2px;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
}
.factory-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
background: var(--bg0);
border: 1px solid var(--bg2);
border-radius: 8px;
padding: 8px 10px;
}
.factory-card.active {
border-color: var(--accent);
}
.factory-card.running {
border-left: 3px solid var(--accent);
}
.factory-pulse {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-right: 5px;
animation: factory-pulse 1.4s ease-in-out infinite;
}
@keyframes factory-pulse {
50% {
opacity: 0.25;
}
}
.factory-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
margin: 12px 0;
}
.factory-form label,
.factory-phase label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
color: var(--fg3);
}
.factory-phases {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.factory-phase {
background: var(--bg1);
border-radius: var(--radius);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.factory-phase h4 {
margin: 0;
}
.factory-h {
margin: 22px 0 6px;
}
.draft-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg1);
border-top: 1px solid var(--bg2);
font-size: 13px;
color: var(--fg2);
}
.draft-modal {
width: min(640px, calc(100vw - 32px));
}
.draft-spec {
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
background: var(--bg1);
border-radius: 6px;
padding: 8px;
font-size: 12px;
}
+62
View File
@@ -3,6 +3,68 @@
* under "unreleased"; scripts/release.sh renames the key to the release version. * under "unreleased"; scripts/release.sh renames the key to the release version.
*/ */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = { export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00041": {
en: ["New: software factory – write a spec with a model, create the repository (Gitea, GitHub or GitLab) and let agents review it, a model per phase", "Factory overview with board, tokens, run time and links into the runs"],
de: ["Neu: Software-Factory – Spec mit einem Modell schreiben, Repository anlegen (Gitea, GitHub oder GitLab) und von Agenten prüfen lassen, ein Modell pro Phase", "Factory-Übersicht mit Board, Tokens, Laufzeit und Links in die Läufe"],
},
"202611-00040": {
en: ["Images you upload are now visible to Claude Code (they arrived empty before)"],
de: ["Hochgeladene Bilder sind für Claude Code jetzt sichtbar (sie kamen vorher leer an)"],
},
"202611-00039": {
en: ["The chat colour no longer tints the chat background", "Claude Code can ask you questions again (question card in the chat)"],
de: ["Die Chat-Farbe färbt den Hintergrund des Chats nicht mehr ein", "Claude Code kann dir wieder Fragen stellen (Fragenkarte im Chat)"],
},
"202611-00038": {
en: ["Sidebar chat menu: Delete added; colour and icon are now chosen in the chat's own menu"],
de: ["Chat-Menü in der Sidebar: Löschen hinzugefügt; Farbe und Icon wählst du jetzt im Menü des Chats"],
},
"202611-00037": {
en: ["Deleting a chat now asks whether to delete its own folder too"],
de: ["Beim Löschen eines Chats wirst du gefragt, ob sein eigener Ordner mitgelöscht werden soll"],
},
"202611-00036": {
en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"],
de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"],
},
"202611-00035": {
en: ["Creating SSH keys works in the container now (fixes \"No user exists for uid\")"],
de: ["SSH-Schlüssel lassen sich im Container jetzt erzeugen (behebt \"No user exists for uid\")"],
},
"202611-00034": {
en: ["ssh/scp/sftp now work in the container (fixes \"No user exists for uid\")"],
de: ["ssh/scp/sftp funktionieren jetzt im Container (behebt \"No user exists for uid\")"],
},
"202611-00033": {
en: ["SSH keys in Settings → Account: agents, terminal and git use them to reach other servers"],
de: ["SSH-Schlüssel unter Einstellungen → Account: Agenten, Terminal und Git nutzen sie für den Zugriff auf andere Server"],
},
"202611-00032": {
en: ["Agents now use your stored git credentials instead of searching for them (also Claude Code)"],
de: ["Agenten nutzen deine hinterlegten Git-Zugangsdaten, statt danach zu suchen (auch Claude Code)"],
},
"202611-00031": {
en: ["Images an agent reads are now shown in the chat (also with Claude Code)"],
de: ["Bilder, die ein Agent liest, werden jetzt im Chat angezeigt (auch mit Claude Code)"],
},
"202611-00030": {
en: ["/clear: the model forgets the conversation so far, the chat stays visible"],
de: ["/clear: das Modell vergisst das bisherige Gespräch, der Chat bleibt sichtbar"],
},
"202611-00029": {
en: [
"Statistics: foldable sections, years of history with a year filter, export as XLSX/CSV",
"Prompt snippets (/name), read-only links for chats, commit message suggestions, weekly review, offline copies of recent chats, planning model",
],
de: [
"Statistik: zuklappbare Abschnitte, Historie über Jahre mit Jahresfilter, Export als XLSX/CSV",
"Prompt-Bausteine (/name), Nur-Lese-Links für Chats, Commit-Vorschläge, Wochenrückblick, Offline-Kopien der letzten Chats, Planungsmodell",
],
},
"202611-00028": {
en: ["18 more icons for chats (gamepad, computer, food, …); the ⋯ menu of a message now sits inside its bubble"],
de: ["18 weitere Icons für Chats (Gamepad, Computer, Essen, …); das ⋯-Menü einer Nachricht sitzt jetzt in der Sprechblase"],
},
"202611-00027": { "202611-00027": {
en: ["Chat bubbles (yours on the right), icons for chats and more vivid, clearly different colour tags (⋯ menu of a chat)"], en: ["Chat bubbles (yours on the right), icons for chats and more vivid, clearly different colour tags (⋯ menu of a chat)"],
de: ["Sprechblasen (deine rechts), Icons für Chats und kräftigere, besser unterscheidbare Farbmarkierungen (⋯-Menü eines Chats)"], de: ["Sprechblasen (deine rechts), Icons für Chats und kräftigere, besser unterscheidbare Farbmarkierungen (⋯-Menü eines Chats)"],