Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c551171c18 | ||
|
|
4000084560 | ||
|
|
b5e2ef146c | ||
|
|
a2b9528e0b | ||
|
|
81d9df4e6e | ||
|
|
ead75e78e0 | ||
|
|
77f9d4b0e0 | ||
|
|
103132a359 | ||
|
|
5d5c0f8124 | ||
|
|
f77b101847 | ||
|
|
12a3198e77 | ||
|
|
4e6f74d477 | ||
|
|
658148ada7 | ||
|
|
b6e4500233 | ||
|
|
126c7719b9 | ||
|
|
fccaee4b94 |
No files matched your search
@@ -9,6 +9,9 @@ CODECRAFT_ADMIN_PASSWORD=
|
||||
# Directory with your projects, mounted as /workspace.
|
||||
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).
|
||||
PORT=8787
|
||||
BIND_ADDR=127.0.0.1
|
||||
|
||||
@@ -3,6 +3,85 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
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
|
||||
|
||||
+8
-1
@@ -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 \
|
||||
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
|
||||
openscad xvfb xauth \
|
||||
openssh-client rsync mc \
|
||||
openssh-client libnss-wrapper rsync mc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00033" src="https://img.shields.io/badge/version-v202611--00033-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="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">
|
||||
|
||||
@@ -34,6 +34,8 @@ services:
|
||||
- codecraft-data:/data
|
||||
# The projects the agents may work on.
|
||||
- ${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.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
- codecraft-data:/data
|
||||
# The projects the agents may work on.
|
||||
- ${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.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
Executable
+13
@@ -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")" "$@"
|
||||
+227
-3
@@ -86,6 +86,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/search", get(search_in_session))
|
||||
.route("/sessions/{id}/media", get(session_media))
|
||||
.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}/abort", post(abort))
|
||||
.route("/sessions/{id}/compact", post(compact))
|
||||
@@ -103,6 +104,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.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}/queue", get(get_queue))
|
||||
.route("/sessions/{id}/queue/flush", post(flush_queue))
|
||||
@@ -157,6 +159,14 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/images/{id}", delete(delete_image))
|
||||
.route("/images/{id}/file", get(image_file))
|
||||
.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))
|
||||
@@ -1055,8 +1065,83 @@ async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppSt
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
/// The folder a chat owns: inside the owner's folders (never one of them), not a link, and no
|
||||
/// 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.
|
||||
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
||||
runner::abort(&app, &id);
|
||||
@@ -1070,7 +1155,20 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
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,
|
||||
@@ -2494,6 +2592,132 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
|
||||
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 ------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
+18
-1
@@ -189,6 +189,20 @@ impl App {
|
||||
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
|
||||
/// without owner) the whole workspace, users their own folder plus shared folders.
|
||||
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 {
|
||||
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() {
|
||||
return vec![ws];
|
||||
return std::iter::once(ws).chain(factory).collect();
|
||||
}
|
||||
let own = ws.join(&user.username);
|
||||
let _ = std::fs::create_dir_all(&own);
|
||||
let mut roots = vec![own];
|
||||
roots.extend(factory);
|
||||
for p in self.store.folders_for_user(&user.id).unwrap_or_default() {
|
||||
let p = PathBuf::from(p);
|
||||
if p.starts_with(&ws) && p.is_dir() {
|
||||
|
||||
@@ -189,6 +189,9 @@ fn is_public(path: &str) -> bool {
|
||||
// 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 {
|
||||
|
||||
+127
-6
@@ -112,7 +112,51 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul
|
||||
// --- tool mapping -----------------------------------------------------------------------
|
||||
|
||||
/// 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) {
|
||||
// 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 rename = |v: &mut Value, from: &str, to: &str| {
|
||||
if let Some(x) = v.get(from).cloned() {
|
||||
@@ -149,6 +193,8 @@ impl ToolInfo for CcTool {
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
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 {
|
||||
matches!(self.name.as_str(), "write" | "edit")
|
||||
@@ -178,16 +224,16 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
|
||||
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()),
|
||||
"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).
|
||||
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);
|
||||
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 cfg = app.config();
|
||||
let root = app.root_session_id(&session.id);
|
||||
@@ -315,7 +361,12 @@ fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (i, p) in parts.iter().enumerate() {
|
||||
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 };
|
||||
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 path = dir.join(format!("chat-{}-{i}.{ext}", now_ms()));
|
||||
if std::fs::create_dir_all(&dir).is_ok() {
|
||||
@@ -333,7 +384,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
let session = app.session(session_id)?;
|
||||
let owner = session.owner_id.clone();
|
||||
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 {
|
||||
bail!("nothing to do: no user message");
|
||||
};
|
||||
@@ -365,7 +417,13 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
if let Some(r) = &resume {
|
||||
args.extend(["--resume".into(), r.clone()]);
|
||||
}
|
||||
// CodeCraft's own tools for the CLI (MCP, per-run token).
|
||||
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();
|
||||
}
|
||||
@@ -402,6 +460,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
let mut cur: Option<Cur> = None;
|
||||
let mut calls: 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 got_result = false;
|
||||
let mut failure: Option<String> = None;
|
||||
@@ -483,7 +543,7 @@ 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 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: result_images(&b["content"]), is_error, meta });
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
@@ -496,6 +556,30 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
"control_request" if ev["request"]["subtype"] == "can_use_tool" => {
|
||||
finalize(&app, session_id, &mut cur, &model_id);
|
||||
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;
|
||||
if allow {
|
||||
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
|
||||
@@ -878,6 +962,43 @@ pub async fn update() -> Result<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
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"}}]);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ mod config;
|
||||
mod digest;
|
||||
mod docs;
|
||||
mod export;
|
||||
mod factory;
|
||||
mod files;
|
||||
mod git;
|
||||
mod health;
|
||||
@@ -24,6 +25,7 @@ mod lsp;
|
||||
mod mentions;
|
||||
mod metrics;
|
||||
mod mcp;
|
||||
mod mcp_bridge;
|
||||
mod media;
|
||||
mod mesh3d;
|
||||
mod permission;
|
||||
@@ -77,6 +79,7 @@ async fn main() -> Result<()> {
|
||||
push::spawn_listener(app.clone());
|
||||
terminal::spawn_reaper(app.clone());
|
||||
jobs::spawn_scheduler(app.clone());
|
||||
factory::spawn(app.clone());
|
||||
update::spawn_checker(app.clone());
|
||||
health::spawn_checker(app.clone());
|
||||
digest::spawn(app.clone());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,10 @@ pub fn write_files(app: &App, user: &User, s: &SshSettings) -> Result<()> {
|
||||
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, real) in [("ssh", "/usr/bin/ssh"), ("scp", "/usr/bin/scp"), ("sftp", "/usr/bin/sftp")] {
|
||||
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)?;
|
||||
|
||||
@@ -171,6 +171,39 @@ impl Store {
|
||||
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 (
|
||||
cwd TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL
|
||||
@@ -409,6 +442,16 @@ impl Store {
|
||||
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<()> {
|
||||
// Images go to the blob table (encoded before taking the lock).
|
||||
let mut m = m.clone();
|
||||
@@ -1417,6 +1460,97 @@ impl Store {
|
||||
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<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE jobs SET last_run = ?2 WHERE id = ?1", rusqlite::params![id, ts])?;
|
||||
Ok(())
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NewSession } from "./components/NewSession";
|
||||
import { Settings } from "./components/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
import { Images } from "./components/Images";
|
||||
import { Factory } from "./components/Factory";
|
||||
import { Jobs } from "./components/Jobs";
|
||||
import { JobEditor } from "./components/JobEditor";
|
||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||
@@ -110,6 +111,7 @@ export function App() {
|
||||
);
|
||||
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 === "factory") content = <Factory onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "stats") content = <Stats 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)} />;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DraftBar, isDraftChat } from "./FactoryDraft";
|
||||
import { takePrefill } from "../share";
|
||||
import { Icon } from "./Icon";
|
||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||
@@ -24,7 +25,8 @@ import { UsageOverlay } from "./UsageOverlay";
|
||||
import { RewindDialog } from "./RewindDialog";
|
||||
import { ShellsChip } from "./ShellsPanel";
|
||||
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 { ColorSwatches, IconPicker } from "./ColorSwatches";
|
||||
import { ProjectPermissions } from "./ProjectPermissions";
|
||||
@@ -757,9 +759,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||
await del(`/sessions/${sessionId}`);
|
||||
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
||||
if (await deleteChat(session)) navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
||||
};
|
||||
|
||||
// Context usage of the last request relative to the model's window.
|
||||
@@ -1090,6 +1090,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||
{!readOnly && isDraftChat(session.cwd) && <DraftBar sessionId={sessionId} />}
|
||||
{!readOnly && <OutboxBar sessionId={sessionId} />}
|
||||
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
||||
{readOnly ? (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ColorSwatches, IconPicker } from "./ColorSwatches";
|
||||
import { deleteChat } from "../chatActions";
|
||||
import { Icon } from "./Icon";
|
||||
import { StatusLeds } from "./StatusLeds";
|
||||
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")}
|
||||
</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 }))}>
|
||||
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
||||
</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>
|
||||
@@ -481,6 +491,10 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
|
||||
<Icon name="clock" className="nav-icon" />
|
||||
{t("nav.jobs")}
|
||||
</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")}>
|
||||
<Icon name="chart" className="nav-icon" />
|
||||
{t("nav.stats")}
|
||||
|
||||
+36
-8
@@ -20,8 +20,15 @@ interface PromptOpts {
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
interface ChoiceOpts {
|
||||
title?: string;
|
||||
message: string;
|
||||
choices: { value: string; label: string; danger?: boolean }[];
|
||||
}
|
||||
|
||||
type Dialog =
|
||||
| ({ 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);
|
||||
|
||||
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> {
|
||||
const o = typeof opts === "string" ? { message: opts } : opts;
|
||||
return new Promise((resolve) => {
|
||||
@@ -108,14 +123,27 @@ function DialogView({ d }: { d: Dialog }) {
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
<div className="row dialog-actions">
|
||||
<button type="button" onClick={() => close(d, cancelValue)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
|
||||
{d.confirmLabel ?? t("common.ok")}
|
||||
</button>
|
||||
</div>
|
||||
{d.kind === "choice" ? (
|
||||
<div className="row dialog-actions dialog-choices">
|
||||
<button type="button" onClick={() => close(d, null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
{d.choices.map((c, i) => (
|
||||
<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)}>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1161,4 +1161,117 @@ export const de: Messages = {
|
||||
"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",
|
||||
};
|
||||
@@ -1160,6 +1160,119 @@ export const en = {
|
||||
"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>;
|
||||
+154
-10
@@ -600,9 +600,6 @@ button.session-group:hover {
|
||||
background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
|
||||
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) {
|
||||
.msg.user-msg {
|
||||
max-width: 94%;
|
||||
@@ -4758,15 +4755,10 @@ span.chat-tab > button {
|
||||
}
|
||||
}
|
||||
|
||||
.chat-pane[data-color] {
|
||||
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
||||
}
|
||||
.chat-pane[data-color] .composer-wrap {
|
||||
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
||||
}
|
||||
/* The chat's colour marks the top bar (line), the list entry, the badge and your own bubbles –
|
||||
* the chat background itself stays neutral. */
|
||||
.topbar[data-color] {
|
||||
border-bottom: 3px solid var(--tag);
|
||||
background: color-mix(in srgb, var(--tag) 28%, var(--bg0));
|
||||
}
|
||||
.session-item[data-color] {
|
||||
box-shadow: inset 4px 0 0 var(--tag);
|
||||
@@ -5026,3 +5018,155 @@ td.fold-indent {
|
||||
.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;
|
||||
}
|
||||
@@ -3,6 +3,38 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
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"],
|
||||
|
||||
Reference in new issue
Block a user