chore: archive AgentDock v1 implementation

This commit is contained in:
leefer
2026-08-24 17:14:08 +08:00
commit f512bd58ec
66 changed files with 10663 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.env
.env.*
console/
console-data/
data/
workspace/
docs/
proxy/
tests/
**/node_modules/
**/dist/
*.log
+40
View File
@@ -0,0 +1,40 @@
# Ubuntu host directory that contains all projects.
WORKSPACE_PATH=/vol1/1000/docker/ai-toools/workspace
AI_HOME_PATH=/vol1/1000/docker/ai-toools/data
# Use `id -u` and `id -g` on the Ubuntu host.
AI_UID=1000
AI_GID=1001
AI_CONTAINER_NAME=ai-tools
TZ=Asia/Shanghai
# Web console. Generate long random values; do not reuse passwords.
CONSOLE_HTTPS_PORT=8443
CONSOLE_DATA_PATH=/vol1/1000/docker/ai-toools/console-data
ADMIN_PASSWORD=replace-with-a-long-admin-password
RUNNER_TOKEN=replace-with-at-least-32-random-characters
CONFIG_ENCRYPTION_KEY=replace-with-at-least-32-random-characters
AI_TOOLS_IMAGE_TAG=0.2.0
CONSOLE_IMAGE_TAG=0.1.0
AI_TOOLS_MEMORY_LIMIT=6g
AI_TOOLS_CPU_LIMIT=4.0
CONSOLE_MEMORY_LIMIT=512m
CONSOLE_CPU_LIMIT=1.0
# Use `latest` for the first deployment. Pin exact versions after verification.
CODEX_VERSION=latest
CLAUDE_VERSION=latest
CODEBUDDY_VERSION=latest
KIMI_VERSION=latest
OPENCODE_VERSION=latest
QWEN_VERSION=latest
DSH_VERSION=latest
CC_SWITCH_VERSION=v5.10.2
MULTICA_VERSION=v0.1.53
# Multica daemon starts automatically after `multica setup self-host` succeeds.
MULTICA_ENABLED=true
MULTICA_DAEMON_MAX_CONCURRENT_TASKS=2
MULTICA_AGENT_RUNTIME_NAME=AgentDock Ubuntu Runtime
+7
View File
@@ -0,0 +1,7 @@
* text=auto eol=lf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.woff binary
*.woff2 binary
+13
View File
@@ -0,0 +1,13 @@
.env
.env.server
data/
workspace/
console-data/
**/node_modules/
**/dist/
*.log
*.crt
.agentdock-initial-password
.qa-data/
proxy/certs/
references/
+123
View File
@@ -0,0 +1,123 @@
FROM ubuntu:24.04
ARG DEBIAN_FRONTEND=noninteractive
ARG AI_UID=1000
ARG AI_GID=1000
ARG CODEX_VERSION=latest
ARG CLAUDE_VERSION=latest
ARG CODEBUDDY_VERSION=latest
ARG KIMI_VERSION=latest
ARG OPENCODE_VERSION=latest
ARG QWEN_VERSION=latest
ARG DSH_VERSION=latest
ARG CC_SWITCH_VERSION=v5.10.2
ARG CC_SWITCH_SHA256_AMD64=8065c5bae9eda270747c1766cefbb2091d9625655dbf409ad7764eb47c0a8635
ARG CC_SWITCH_SHA256_ARM64=b25c77f7eebbe3968c53022e1b5e703e324203e94e5c6379320bcd1bbe268e63
ARG MULTICA_VERSION=v0.1.53
ARG MULTICA_SHA256_AMD64=cfe60ff5cfee07e8147eb64fbaca46dac28162d84d03f1e07e4f87858d0fabb6
ARG MULTICA_SHA256_ARM64=afb77e15d585c1d60a67a9583c20b5ff1ef4a1bacdcdd73c8e318fc9d254ad02
ARG TARGETARCH
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash-completion \
build-essential \
ca-certificates \
curl \
git \
gnupg \
jq \
less \
locales \
nano \
openssh-client \
pipx \
procps \
python3 \
python3-pip \
python3-venv \
ripgrep \
rsync \
supervisor \
tini \
unzip \
vim \
wget \
zip \
&& rm -rf /var/lib/apt/lists/*
# Kimi Code requires Node.js 22.19.0 or later. NodeSource 22.x supplies it.
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node -e 'const [major, minor] = process.versions.node.split(".").map(Number); if (major < 22 || (major === 22 && minor < 19)) process.exit(1)'
RUN npm config set update-notifier false \
&& npm install --global \
"@openai/codex@${CODEX_VERSION}" \
"@anthropic-ai/claude-code@${CLAUDE_VERSION}" \
"@tencent-ai/codebuddy-code@${CODEBUDDY_VERSION}" \
"@moonshot-ai/kimi-code@${KIMI_VERSION}" \
"opencode-ai@${OPENCODE_VERSION}" \
"@qwen-code/qwen-code@${QWEN_VERSION}" \
"@deepseek-ai/dsh@${DSH_VERSION}" \
&& npm cache clean --force
RUN set -eux; \
case "${TARGETARCH:-amd64}" in \
amd64) cc_arch=x64; multica_arch=amd64; cc_sha="${CC_SWITCH_SHA256_AMD64}"; multica_sha="${MULTICA_SHA256_AMD64}" ;; \
arm64) cc_arch=arm64; multica_arch=arm64; cc_sha="${CC_SWITCH_SHA256_ARM64}"; multica_sha="${MULTICA_SHA256_ARM64}" ;; \
*) echo "Unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
cc_archive="/tmp/cc-switch.tar.gz"; \
multica_archive="/tmp/multica.tar.gz"; \
curl -fsSL -o "${cc_archive}" "https://github.com/SaladDay/cc-switch-cli/releases/download/${CC_SWITCH_VERSION}/cc-switch-cli-linux-${cc_arch}-musl.tar.gz"; \
echo "${cc_sha} ${cc_archive}" | sha256sum -c -; \
tar -xzf "${cc_archive}" -C /tmp cc-switch; \
install -m 0755 /tmp/cc-switch /usr/local/bin/cc-switch; \
curl -fsSL -o "${multica_archive}" "https://github.com/zimplemedia/multica-cli/releases/download/${MULTICA_VERSION}/multica_linux_${multica_arch}.tar.gz"; \
echo "${multica_sha} ${multica_archive}" | sha256sum -c -; \
tar -xzf "${multica_archive}" -C /tmp multica; \
install -m 0755 /tmp/multica /usr/local/bin/multica; \
rm -f "${cc_archive}" "${multica_archive}" /tmp/cc-switch /tmp/multica
COPY runner/package.json runner/package-lock.json /opt/runner/
RUN cd /opt/runner \
&& npm ci --omit=dev \
&& npm cache clean --force
COPY runner/*.js /opt/runner/
RUN existing_group="$(getent group "${AI_GID}" 2>/dev/null | cut -d: -f1 || true)" \
&& if [ -z "${existing_group}" ]; then groupadd --gid "${AI_GID}" ai; existing_group=ai; fi \
&& existing_user="$(getent passwd "${AI_UID}" 2>/dev/null | cut -d: -f1 || true)" \
&& if [ -n "${existing_user}" ]; then \
usermod --login ai --home /home/ai --move-home --gid "${existing_group}" --shell /bin/bash "${existing_user}"; \
else \
useradd --uid "${AI_UID}" --gid "${existing_group}" --create-home --home-dir /home/ai --shell /bin/bash ai; \
fi \
&& mkdir -p /workspace /home/ai/.ai-console/sessions \
&& chown -R "${AI_UID}:${AI_GID}" /workspace /home/ai /opt/runner
COPY --chmod=755 scripts/container-check.sh /usr/local/bin/ai-tools-check
COPY --chmod=755 scripts/multica-runtime.sh /usr/local/bin/multica-runtime
COPY --chmod=755 scripts/multica-setup.sh /usr/local/bin/multica-setup
COPY scripts/supervisord.conf /etc/supervisor/conf.d/ai-runtime.conf
ENV HOME=/home/ai \
USER=ai \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PATH=/home/ai/.local/bin:/home/ai/bin:${PATH} \
PIPX_HOME=/home/ai/.local/pipx \
PIPX_BIN_DIR=/home/ai/.local/bin
USER ai
WORKDIR /workspace
EXPOSE 4174
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/ai-runtime.conf"]
+80
View File
@@ -0,0 +1,80 @@
# Ubuntu AI CLI 工具箱
这套部署包在一台独立 Ubuntu 主机的 Docker 中统一运行以下工具:
- Codex CLI
- Claude Code
- Tencent CodeBuddy Code
- Kimi Code CLI
- OpenCode
- Qwen Code
- DeepSeek Harness (`dsh`)
- CC Switch CLI
- Multica 客户端 Runtime 与 daemon
OpenClaw 不在这个容器中安装,继续使用 Ubuntu 主机上现有的 OpenClaw。Cursor 桌面版继续运行在 Windows,通过 Remote SSH 编辑 Ubuntu 上的项目。
## AgentDock 网页控制台
本项目现在还提供一个仅限局域网使用的网页控制台:
- 保留 OpenDesign `agent-console-v4.html` 的视觉、布局和设计 Token
- 单管理员登录,无公开注册
- 浏览器内真实 PTY 终端,支持关闭浏览器后重新连接
- 实时显示 CLI 版本、运行实例、CPU、内存、会话日志和 Git Diff
- 在设置页管理加密 API Key、默认模型和 DeepSeek Harness profile
- 使用 CC Switch 管理 Claude、Codex、OpenCode 的 Provider、MCP 和 Skills
- 连接现有 Multica 自托管服务端,让 Runtime 调度容器内 CLI
- Runner 只允许 7 个登记过的 CLI,项目目录只能位于 `/workspace`
- 不挂载 Docker Socket
服务关系如下:
```text
浏览器 -> ai-proxy (HTTPS + LAN 限制) -> ai-console -> ai-tools (Runner + Multica daemon + CLI)
```
## 最终效果
Windows 上可以直接运行:
```powershell
.\scripts\remote-ai.ps1 -Server nas -Tool codex -Project my-project
```
AI CLI 实际在 Ubuntu 的 Docker 容器中运行,Windows Terminal 只负责显示和输入。项目文件位于 NAS 的 `/vol1/1000/docker/ai-toools/workspace`,在容器内对应 `/workspace`
## 文档入口
1. [部署说明](docs/DEPLOYMENT.md)
2. [日常使用](docs/USAGE.md)
3. [升级和备份](docs/MAINTENANCE.md)
4. [常见故障](docs/TROUBLESHOOTING.md)
5. [官方资料来源](docs/SOURCES.md)
6. [192.168.200.36 实际部署记录](docs/SERVER-192.168.200.36.md)
## 最短部署步骤
在 Ubuntu 主机上进入本目录,然后执行:
```bash
cp .env.example .env
chmod +x scripts/ai
mkdir -p /vol1/1000/docker/ai-toools/workspace
docker compose build
docker compose up -d
docker compose exec ai-tools ai-tools-check
```
网页控制台还需要在 `.env` 设置 `ADMIN_PASSWORD``RUNNER_TOKEN`
`CONFIG_ENCRYPTION_KEY`。完整步骤见 [网页控制台部署说明](docs/CONSOLE-DEPLOYMENT.md)。
首次使用某个工具时,需要在容器内完成它自己的登录:
```bash
./scripts/ai codex .
./scripts/ai claude .
./scripts/ai kimi .
```
登录信息保存在部署目录的 `data` 中。重启或重建容器不会清除它,请不要把这个目录公开或提交到 Git。
+142
View File
@@ -0,0 +1,142 @@
name: ai-toolbox
services:
ai-tools:
image: local/ai-toolbox:${AI_TOOLS_IMAGE_TAG:-0.2.0}
container_name: ${AI_CONTAINER_NAME:-ai-tools}
build:
context: .
args:
AI_UID: ${AI_UID:-1000}
AI_GID: ${AI_GID:-1000}
CODEX_VERSION: ${CODEX_VERSION:-latest}
CLAUDE_VERSION: ${CLAUDE_VERSION:-latest}
CODEBUDDY_VERSION: ${CODEBUDDY_VERSION:-latest}
KIMI_VERSION: ${KIMI_VERSION:-latest}
OPENCODE_VERSION: ${OPENCODE_VERSION:-latest}
QWEN_VERSION: ${QWEN_VERSION:-latest}
DSH_VERSION: ${DSH_VERSION:-latest}
CC_SWITCH_VERSION: ${CC_SWITCH_VERSION:-v5.10.2}
MULTICA_VERSION: ${MULTICA_VERSION:-v0.1.53}
tty: true
stdin_open: true
restart: unless-stopped
working_dir: /workspace
environment:
TZ: ${TZ:-Asia/Shanghai}
TERM: xterm-256color
COLORTERM: truecolor
RUNNER_PORT: 4174
RUNNER_TOKEN: ${RUNNER_TOKEN:?RUNNER_TOKEN is required}
WORKSPACE_ROOT: /workspace
SESSION_DIRECTORY: /home/ai/.ai-console/sessions
MULTICA_ENABLED: ${MULTICA_ENABLED:-true}
MULTICA_DAEMON_MAX_CONCURRENT_TASKS: ${MULTICA_DAEMON_MAX_CONCURRENT_TASKS:-2}
MULTICA_AGENT_RUNTIME_NAME: ${MULTICA_AGENT_RUNTIME_NAME:-AgentDock Ubuntu Runtime}
volumes:
- type: bind
source: ${WORKSPACE_PATH:-/srv/projects}
target: /workspace
- type: bind
source: ${AI_HOME_PATH:-./data}
target: /home/ai
networks:
- runner
- egress
security_opt:
- no-new-privileges:true
pids_limit: 2048
mem_limit: ${AI_TOOLS_MEMORY_LIMIT:-6g}
cpus: ${AI_TOOLS_CPU_LIMIT:-4.0}
stop_grace_period: 30s
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:4174/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
ai-console:
image: local/agentdock-console:${CONSOLE_IMAGE_TAG:-0.1.0}
build:
context: ./console
dockerfile: Dockerfile
restart: unless-stopped
environment:
TZ: ${TZ:-Asia/Shanghai}
CONSOLE_PORT: 4173
RUNNER_URL: http://ai-tools:4174
RUNNER_TOKEN: ${RUNNER_TOKEN:?RUNNER_TOKEN is required}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?CONFIG_ENCRYPTION_KEY is required}
CONSOLE_DATA_DIRECTORY: /data
volumes:
- type: bind
source: ${CONSOLE_DATA_PATH:-./console-data}
target: /data
networks:
- frontend
- runner
depends_on:
ai-tools:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 256
mem_limit: ${CONSOLE_MEMORY_LIMIT:-512m}
cpus: ${CONSOLE_CPU_LIMIT:-1.0}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4173/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
ai-proxy:
image: caddy:2.10.2-alpine
restart: unless-stopped
ports:
- "192.168.200.36:${CONSOLE_HTTPS_PORT:-8443}:443"
volumes:
- ./proxy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./proxy/certs:/etc/caddy/certs:ro
- caddy_data:/data
- caddy_config:/config
networks:
- frontend
- edge
depends_on:
ai-console:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
read_only: true
tmpfs:
- /tmp
- /config/caddy
mem_limit: 256m
cpus: 0.5
healthcheck:
test: ["CMD", "wget", "--no-check-certificate", "-qO-", "https://127.0.0.1/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
frontend:
internal: true
runner:
internal: true
egress:
edge:
volumes:
caddy_data:
caddy_config:
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
*.log
.env*
+26
View File
@@ -0,0 +1,26 @@
FROM node:22.22.0-alpine3.22 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22.22.0-alpine3.22 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build \
&& npm prune --omit=dev
FROM node:22.22.0-alpine3.22 AS production
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S agentdock \
&& adduser -S agentdock -u 1001 -G agentdock
COPY --from=build --chown=agentdock:agentdock /app/package.json ./package.json
COPY --from=build --chown=agentdock:agentdock /app/node_modules ./node_modules
COPY --from=build --chown=agentdock:agentdock /app/dist ./dist
COPY --from=build --chown=agentdock:agentdock /app/server ./server
USER agentdock
EXPOSE 4173
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:4173/health >/dev/null || exit 1
CMD ["node", "server/index.js"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>AgentDock - AI CLI 控制台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2754
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "agentdock-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"predev": "node scripts/extract-design-css.mjs",
"dev": "vite --host 0.0.0.0",
"dev:server": "node --watch server/index.js",
"prebuild": "node scripts/extract-design-css.mjs",
"build": "vite build --configLoader runner",
"start": "node server/index.js",
"test": "node --test"
},
"dependencies": {
"@fontsource/inter": "^5.2.6",
"@fontsource/jetbrains-mono": "^5.2.6",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"express": "^5.1.0",
"lucide-react": "^0.468.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"ws": "^8.18.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}
+11
View File
@@ -0,0 +1,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const directory = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(directory, "../src/design/agent-console-v4.html");
const targetPath = path.resolve(directory, "../src/design/prototype.css");
const source = await fs.readFile(sourcePath, "utf8");
const match = source.match(/<style>([\s\S]*?)<\/style>/i);
if (!match) throw new Error("OpenDesign v4 CSS was not found");
await fs.writeFile(targetPath, `${match[1].trim()}\n`, "utf8");
+73
View File
@@ -0,0 +1,73 @@
import crypto from "node:crypto";
const COOKIE_NAME = "agentdock_session";
const MAX_AGE_MS = 12 * 60 * 60 * 1000;
function parseCookies(header = "") {
return Object.fromEntries(header.split(";").map((part) => {
const index = part.indexOf("=");
if (index < 0) return ["", ""];
return [part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())];
}).filter(([key]) => key));
}
export class AuthService {
constructor(password, { secureCookie = true } = {}) {
this.salt = crypto.randomBytes(16);
this.passwordHash = crypto.scryptSync(password, this.salt, 64);
this.sessions = new Map();
this.attempts = new Map();
this.secureCookie = secureCookie;
}
verifyPassword(password) {
const candidate = crypto.scryptSync(String(password || ""), this.salt, 64);
return crypto.timingSafeEqual(candidate, this.passwordHash);
}
login(req, res, password) {
const remote = req.ip || "unknown";
const attempt = this.attempts.get(remote) || { count: 0, resetAt: Date.now() + 60_000 };
if (Date.now() > attempt.resetAt) {
attempt.count = 0;
attempt.resetAt = Date.now() + 60_000;
}
if (attempt.count >= 8) return { ok: false, status: 429, error: "尝试次数过多,请一分钟后重试" };
if (!this.verifyPassword(password)) {
attempt.count += 1;
this.attempts.set(remote, attempt);
return { ok: false, status: 401, error: "管理员密码不正确" };
}
this.attempts.delete(remote);
const token = crypto.randomBytes(32).toString("base64url");
this.sessions.set(token, Date.now() + MAX_AGE_MS);
const secure = this.secureCookie ? "; Secure" : "";
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; Path=/; HttpOnly${secure}; SameSite=Strict; Max-Age=${MAX_AGE_MS / 1000}`);
return { ok: true };
}
logout(req, res) {
const token = parseCookies(req.headers.cookie)[COOKIE_NAME];
if (token) this.sessions.delete(token);
const secure = this.secureCookie ? "; Secure" : "";
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly${secure}; SameSite=Strict; Max-Age=0`);
}
isAuthenticated(req) {
const token = parseCookies(req.headers.cookie)[COOKIE_NAME];
if (!token) return false;
const expiresAt = this.sessions.get(token);
if (!expiresAt || expiresAt < Date.now()) {
this.sessions.delete(token);
return false;
}
return true;
}
middleware() {
return (req, res, next) => {
if (!this.isAuthenticated(req)) return res.status(401).json({ error: "Unauthorized" });
next();
};
}
}
+291
View File
@@ -0,0 +1,291 @@
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import { WebSocketServer, WebSocket } from "ws";
import { AuthService } from "./auth.js";
import { buildLocalMulticaCallbackUrl, buildManagedMulticaLoginUrl } from "./multica-oauth.js";
import { SettingsStore } from "./settings-store.js";
const PORT = Number(process.env.CONSOLE_PORT || 4173);
const RUNNER_URL = process.env.RUNNER_URL || "http://ai-tools:4174";
const RUNNER_TOKEN = process.env.RUNNER_TOKEN || "";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
const CONFIG_ENCRYPTION_KEY = process.env.CONFIG_ENCRYPTION_KEY || "";
const DATA_DIRECTORY = process.env.CONSOLE_DATA_DIRECTORY || "/data";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
if (RUNNER_TOKEN.length < 32) throw new Error("RUNNER_TOKEN must contain at least 32 characters");
if (ADMIN_PASSWORD.length < 12) throw new Error("ADMIN_PASSWORD must contain at least 12 characters");
if (CONFIG_ENCRYPTION_KEY.length < 32) throw new Error("CONFIG_ENCRYPTION_KEY must contain at least 32 characters");
const auth = new AuthService(ADMIN_PASSWORD, { secureCookie: process.env.COOKIE_SECURE !== "false" });
const settings = new SettingsStore(path.join(DATA_DIRECTORY, "settings.enc.json"), CONFIG_ENCRYPTION_KEY);
await settings.init();
async function runnerFetch(route, options = {}) {
const response = await fetch(`${RUNNER_URL}${route}`, {
...options,
headers: {
Authorization: `Bearer ${RUNNER_TOKEN}`,
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const contentType = response.headers.get("content-type") || "";
const body = contentType.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) {
const error = new Error(body?.error || `Runner request failed (${response.status})`);
error.status = response.status;
throw error;
}
return body;
}
function requireSameSite(req, res, next) {
if (!["GET", "HEAD", "OPTIONS"].includes(req.method) && req.headers["sec-fetch-site"] === "cross-site") {
return res.status(403).json({ error: "Cross-site request blocked" });
}
next();
}
const app = express();
app.set("trust proxy", 1);
app.disable("x-powered-by");
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
res.setHeader("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' wss:; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'");
next();
});
app.use(express.json({ limit: "128kb" }));
app.use(requireSameSite);
app.get("/health", (_req, res) => res.json({ ok: true, service: "agentdock-console" }));
app.get("/api/auth/status", (req, res) => res.json({ authenticated: auth.isAuthenticated(req) }));
app.post("/api/auth/login", (req, res) => {
const result = auth.login(req, res, req.body?.password);
res.status(result.status || 200).json(result);
});
app.post("/api/auth/logout", (req, res) => {
auth.logout(req, res);
res.status(204).end();
});
app.use("/api", auth.middleware());
app.get("/api/bootstrap", async (_req, res) => {
try {
const [tools, projects, sessions, integrations] = await Promise.all([
runnerFetch("/tools"),
runnerFetch("/projects"),
runnerFetch("/sessions"),
runnerFetch("/integrations"),
]);
res.json({ ...tools, ...projects, ...sessions, ...integrations, settings: settings.publicView() });
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.get("/api/integrations", async (req, res) => {
try {
res.json(await runnerFetch(`/integrations${req.query.force === "1" ? "?force=1" : ""}`));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.get("/api/sessions/:id/output", async (req, res) => {
try {
res.type("text/plain").send(await runnerFetch(`/sessions/${encodeURIComponent(req.params.id)}/output`));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.get("/api/sessions/:id/diff", async (req, res) => {
try {
res.type("text/plain").send(await runnerFetch(`/sessions/${encodeURIComponent(req.params.id)}/diff`));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.post("/api/sessions", async (req, res) => {
try {
const body = {
tool: req.body?.tool,
purpose: req.body?.purpose,
project: req.body?.project,
profile: req.body?.profile,
model: req.body?.model,
initialInput: req.body?.initialInput,
serverUrl: req.body?.serverUrl,
appUrl: req.body?.appUrl,
cols: req.body?.cols,
rows: req.body?.rows,
environment: settings.environmentForTool(req.body?.tool),
};
res.status(201).json(await runnerFetch("/sessions", { method: "POST", body: JSON.stringify(body) }));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
for (const action of ["input", "resize", "stop"]) {
app.post(`/api/sessions/:id/${action}`, async (req, res) => {
try {
await runnerFetch(`/sessions/${encodeURIComponent(req.params.id)}/${action}`, {
method: "POST",
body: JSON.stringify(req.body || {}),
});
res.status(204).end();
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
}
app.put("/api/settings/secrets/:name", async (req, res) => {
try {
const value = typeof req.body?.value === "string" ? req.body.value.trim().slice(0, 4096) : "";
await settings.setSecret(req.params.name, value);
res.json({ settings: settings.publicView() });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.put("/api/settings/preferences", async (req, res) => {
try {
await settings.setPreferences(req.body || {});
res.json({ settings: settings.publicView() });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.post("/api/integrations/cc-switch/:app/switch", async (req, res) => {
try {
res.json(await runnerFetch(`/integrations/cc-switch/${encodeURIComponent(req.params.app)}/switch`, {
method: "POST",
body: JSON.stringify({ providerId: req.body?.providerId }),
}));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.post("/api/integrations/cc-switch/:app/import-live", async (req, res) => {
try {
res.json(await runnerFetch(`/integrations/cc-switch/${encodeURIComponent(req.params.app)}/import-live`, {
method: "POST",
body: "{}",
}));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
app.post("/api/integrations/multica/callback", async (req, res) => {
try {
res.json(await runnerFetch("/integrations/multica/callback", {
method: "POST",
body: JSON.stringify({ callbackUrl: req.body?.callbackUrl }),
}));
} catch (error) {
res.status(error.status || 502).json({ error: error.message });
}
});
async function multicaSession(id) {
const [{ sessions }, output] = await Promise.all([
runnerFetch("/sessions"),
runnerFetch(`/sessions/${encodeURIComponent(id)}/output`),
]);
const session = sessions.find((item) => item.id === id);
if (!session || session.tool !== "multica" || session.purpose !== "authorization") {
const error = new Error("Multica 初始化会话不存在");
error.status = 404;
throw error;
}
if (session.status !== "running") {
const error = new Error("Multica 初始化会话已过期,请重新开始");
error.status = 409;
throw error;
}
return { session, output };
}
app.get("/api/integrations/multica/auth-url/:sessionId", async (req, res) => {
try {
const { output } = await multicaSession(req.params.sessionId);
const publicOrigin = `${req.protocol}://${req.get("host")}`;
res.json({ authUrl: buildManagedMulticaLoginUrl(output, publicOrigin, req.params.sessionId) });
} catch (error) {
res.status(error.status || 409).json({ error: error.message });
}
});
app.get("/api/integrations/multica/oauth/callback/:sessionId", async (req, res) => {
try {
const { output } = await multicaSession(req.params.sessionId);
const callbackUrl = buildLocalMulticaCallbackUrl(output, req.query);
await runnerFetch("/integrations/multica/callback", {
method: "POST",
body: JSON.stringify({ callbackUrl }),
});
res.type("html").send("<!doctype html><html lang=zh-CN><meta charset=utf-8><meta http-equiv=refresh content='2;url=/'><title>Multica 验证成功</title><body><main><h1>Multica 验证成功</h1><p>验证结果已送回容器,页面即将返回 AgentDock。</p><p><a href='/'>立即返回</a></p></main></body></html>");
} catch (error) {
const message = String(error.message).replace(/[&<>"']/g, "");
res.status(error.status || 400).type("html").send(`<!doctype html><html lang=zh-CN><meta charset=utf-8><title>Multica 验证失败</title><body><main><h1>Multica 验证失败</h1><p>${message}</p><p><a href='/'>返回 AgentDock 重新验证</a></p></main></body></html>`);
}
});
const dist = path.resolve(__dirname, "../dist");
app.use(express.static(dist, { index: false, maxAge: "1h" }));
app.use((_req, res) => res.sendFile(path.join(dist, "index.html")));
const server = http.createServer(app);
const browserSockets = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url, "http://console.internal");
const match = url.pathname.match(/^\/api\/sessions\/([a-f0-9-]+)\/ws$/);
if (!match || !auth.isAuthenticated(request)) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return socket.destroy();
}
browserSockets.handleUpgrade(request, socket, head, (browser) => {
const runnerWsUrl = new URL(RUNNER_URL.replace(/^http/, "ws"));
runnerWsUrl.pathname = `/ws/sessions/${match[1]}`;
runnerWsUrl.searchParams.set("token", RUNNER_TOKEN);
const upstream = new WebSocket(runnerWsUrl);
const heartbeat = setInterval(() => {
if (browser.readyState === WebSocket.OPEN) browser.ping();
if (upstream.readyState === WebSocket.OPEN) upstream.ping();
}, 25_000);
heartbeat.unref();
upstream.on("open", () => {
browser.on("message", (message, isBinary) => {
if (upstream.readyState === WebSocket.OPEN) upstream.send(message, { binary: isBinary });
});
upstream.on("message", (message, isBinary) => {
if (browser.readyState === WebSocket.OPEN) browser.send(message, { binary: isBinary });
});
});
upstream.on("close", () => browser.close());
upstream.on("error", () => browser.close(1011, "Runner connection failed"));
browser.on("close", () => upstream.close());
const clearHeartbeat = () => clearInterval(heartbeat);
upstream.once("close", clearHeartbeat);
browser.once("close", clearHeartbeat);
});
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`agentdock console listening on ${PORT}`);
});
+36
View File
@@ -0,0 +1,36 @@
const LOGIN_URL_PATTERN = /https?:\/\/[^\s]+\/login\?[^\s]+/g;
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
export function parseMulticaLoginOutput(output) {
const matches = String(output || "").match(LOGIN_URL_PATTERN) || [];
const loginUrl = new URL(matches.at(-1) || "");
const callbackUrl = new URL(loginUrl.searchParams.get("cli_callback") || "");
const state = loginUrl.searchParams.get("cli_state") || "";
const port = Number(callbackUrl.port);
if (callbackUrl.protocol !== "http:" || !LOOPBACK_HOSTS.has(callbackUrl.hostname) || callbackUrl.pathname !== "/callback") {
throw new Error("Multica 尚未生成有效的本机回调地址");
}
if (!Number.isInteger(port) || port < 1024 || port > 65535 || !state) {
throw new Error("Multica 登录链接缺少端口或 state");
}
return { loginUrl, callbackUrl, state };
}
export function buildManagedMulticaLoginUrl(output, publicOrigin, sessionId) {
const { loginUrl } = parseMulticaLoginOutput(output);
const managedCallback = new URL(`/api/integrations/multica/oauth/callback/${encodeURIComponent(sessionId)}`, publicOrigin);
loginUrl.searchParams.set("cli_callback", managedCallback.toString());
return loginUrl.toString();
}
export function buildLocalMulticaCallbackUrl(output, query) {
const { callbackUrl, state } = parseMulticaLoginOutput(output);
const returnedState = typeof query?.state === "string" ? query.state : "";
const token = typeof query?.token === "string" ? query.token : "";
if (!token || !returnedState || returnedState !== state) throw new Error("Multica 回调校验失败,请重新发起验证");
callbackUrl.search = "";
callbackUrl.searchParams.set("token", token);
callbackUrl.searchParams.set("state", returnedState);
return callbackUrl.toString();
}
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildLocalMulticaCallbackUrl, buildManagedMulticaLoginUrl, parseMulticaLoginOutput } from "./multica-oauth.js";
const output = `Waiting for authentication...\nhttps://mul.example/login?cli_callback=http%3A%2F%2Flocalhost%3A43239%2Fcallback&cli_state=nonce`;
test("parses Multica login output", () => {
const parsed = parseMulticaLoginOutput(output);
assert.equal(parsed.callbackUrl.port, "43239");
assert.equal(parsed.state, "nonce");
});
test("rewrites Multica login to the managed callback endpoint", () => {
const loginUrl = new URL(buildManagedMulticaLoginUrl(output, "https://agent.example", "session-id"));
assert.equal(loginUrl.searchParams.get("cli_callback"), "https://agent.example/api/integrations/multica/oauth/callback/session-id");
});
test("builds the local callback only when state matches", () => {
assert.equal(
buildLocalMulticaCallbackUrl(output, { token: "secret", state: "nonce" }),
"http://localhost:43239/callback?token=secret&state=nonce",
);
assert.throws(() => buildLocalMulticaCallbackUrl(output, { token: "secret", state: "wrong" }), /校验失败/);
});
+116
View File
@@ -0,0 +1,116 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
export const SECRET_DEFINITIONS = Object.freeze([
{ name: "OPENAI_API_KEY", label: "OpenAI API Key", tools: ["codex", "opencode"] },
{ name: "ANTHROPIC_API_KEY", label: "Anthropic API Key", tools: ["claude", "opencode"] },
{ name: "CODEBUDDY_API_KEY", label: "CodeBuddy API Key", tools: ["codebuddy"] },
{ name: "MOONSHOT_API_KEY", label: "Moonshot API Key", tools: ["kimi"] },
{ name: "DASHSCOPE_API_KEY", label: "DashScope API Key", tools: ["qwen"] },
{ name: "DEEPSEEK_API_KEY", label: "DeepSeek API Key", tools: ["dsh", "opencode"] },
{ name: "GOOGLE_GENERATIVE_AI_API_KEY", label: "Google AI API Key", tools: ["opencode"] },
]);
const DEFINITION_BY_NAME = new Map(SECRET_DEFINITIONS.map((item) => [item.name, item]));
function mask(value) {
if (!value) return null;
if (value.length < 9) return "********";
return `${value.slice(0, 3)}...${value.slice(-4)}`;
}
export class SettingsStore {
constructor(file, encryptionSecret) {
this.file = file;
this.key = crypto.createHash("sha256").update(encryptionSecret).digest();
this.data = { secrets: {}, preferences: { models: {}, dshProfile: "headless", ccSwitchManaged: false } };
}
async init() {
await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
try {
const payload = JSON.parse(await fs.readFile(this.file, "utf8"));
const iv = Buffer.from(payload.iv, "base64");
const tag = Buffer.from(payload.tag, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", this.key, iv);
decipher.setAuthTag(tag);
const plain = Buffer.concat([
decipher.update(Buffer.from(payload.data, "base64")),
decipher.final(),
]);
const parsed = JSON.parse(plain.toString("utf8"));
this.data = {
secrets: parsed.secrets || {},
preferences: { models: {}, dshProfile: "headless", ccSwitchManaged: false, ...(parsed.preferences || {}) },
};
} catch (error) {
if (error.code !== "ENOENT") throw new Error(`Cannot decrypt console settings: ${error.message}`);
}
}
publicView() {
return {
secrets: SECRET_DEFINITIONS.map((definition) => ({
...definition,
configured: Boolean(this.data.secrets[definition.name]),
masked: mask(this.data.secrets[definition.name]),
})),
preferences: this.data.preferences,
};
}
environmentForTool(toolId) {
const environment = {};
if (this.data.preferences.ccSwitchManaged && ["claude", "codex", "opencode"].includes(toolId)) {
return environment;
}
for (const definition of SECRET_DEFINITIONS) {
if (definition.tools.includes(toolId) && this.data.secrets[definition.name]) {
environment[definition.name] = this.data.secrets[definition.name];
}
}
return environment;
}
async setSecret(name, value) {
if (!DEFINITION_BY_NAME.has(name)) throw new Error("Unsupported secret name");
if (value) this.data.secrets[name] = value;
else delete this.data.secrets[name];
await this.save();
}
async setPreferences(preferences) {
const models = typeof preferences.models === "object" && preferences.models
? preferences.models
: this.data.preferences.models;
const cleanModels = Object.fromEntries(Object.entries(models).map(([key, value]) => [
String(key).slice(0, 32),
String(value).slice(0, 128),
]));
const dshProfile = "headless";
const ccSwitchManaged = typeof preferences.ccSwitchManaged === "boolean"
? preferences.ccSwitchManaged
: this.data.preferences.ccSwitchManaged;
this.data.preferences = { models: cleanModels, dshProfile, ccSwitchManaged };
await this.save();
}
async save() {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", this.key, iv);
const encrypted = Buffer.concat([
cipher.update(JSON.stringify(this.data), "utf8"),
cipher.final(),
]);
const payload = `${JSON.stringify({
version: 1,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
data: encrypted.toString("base64"),
}, null, 2)}\n`;
const temp = `${this.file}.tmp`;
await fs.writeFile(temp, payload, { mode: 0o600 });
await fs.rename(temp, this.file);
}
}
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { SettingsStore } from "./settings-store.js";
test("settings encrypt secrets and never expose full values", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-settings-"));
const file = path.join(directory, "settings.enc.json");
const store = new SettingsStore(file, "a secure test encryption key with 32 chars");
await store.init();
await store.setSecret("OPENAI_API_KEY", "sk-test-1234567890");
const raw = await fs.readFile(file, "utf8");
assert.equal(raw.includes("sk-test-1234567890"), false);
assert.equal(store.publicView().secrets.find((item) => item.name === "OPENAI_API_KEY").masked, "sk-...7890");
});
test("CC Switch ownership suppresses competing provider environment variables", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-settings-"));
const store = new SettingsStore(path.join(directory, "settings.enc.json"), "a secure test encryption key with 32 chars");
await store.init();
await store.setSecret("OPENAI_API_KEY", "sk-test-1234567890");
assert.equal(store.environmentForTool("codex").OPENAI_API_KEY, "sk-test-1234567890");
await store.setPreferences({ models: {}, ccSwitchManaged: true });
assert.deepEqual(store.environmentForTool("codex"), {});
assert.equal(store.environmentForTool("dsh").DEEPSEEK_API_KEY, undefined);
});
test("partial preference updates preserve CC Switch ownership", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-settings-"));
const store = new SettingsStore(path.join(directory, "settings.enc.json"), "a secure test encryption key with 32 chars");
await store.init();
await store.setPreferences({ models: { codex: "gpt-test" }, ccSwitchManaged: true });
await store.setPreferences({ models: { claude: "sonnet-test" } });
assert.equal(store.publicView().preferences.ccSwitchManaged, true);
assert.deepEqual(store.publicView().preferences.models, { claude: "sonnet-test" });
});
+555
View File
@@ -0,0 +1,555 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
Command,
Grid2X2,
HelpCircle,
LayoutDashboard,
ListTree,
Maximize2,
Search,
Send,
Settings,
Square,
TerminalSquare,
X,
} from "lucide-react";
import { api } from "./api.js";
import { AuthorizationTerminal } from "./components/AuthorizationTerminal.jsx";
import { LoginPage } from "./components/LoginPage.jsx";
import { ChatConversation } from "./components/ChatConversation.jsx";
import { SettingsView } from "./components/SettingsView.jsx";
import { TerminalPane } from "./components/TerminalPane.jsx";
import { formatBytes, formatDuration, formatTime, statusFor, TOOL_DETAILS } from "./tooling.js";
const VIEWS = ["overview", "workbench", "logs", "settings"];
const VIEW_LABELS = {
overview: ["总览", "agentdock / 工具状态"],
workbench: ["工作台", "agentdock / 焦点会话"],
logs: ["日志", "agentdock / 会话时间轴"],
settings: ["设置", "agentdock / 集成与运行时"],
};
const QUICK_TASKS = ["检查项目并运行测试", "分析当前改动并给出建议", "阅读项目并总结结构", "检查依赖和安全风险"];
function useStoredState(key, initial) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(`agentdock.console.${key}`);
return stored === null ? initial : JSON.parse(stored);
} catch {
return initial;
}
});
useEffect(() => {
try { localStorage.setItem(`agentdock.console.${key}`, JSON.stringify(value)); } catch {}
}, [key, value]);
return [value, setValue];
}
function Dot({ status, still = false }) {
return <span className={`dot ${statusFor(status).dot}`} style={still ? { animation: "none", boxShadow: "none" } : undefined} />;
}
function AppLoading({ message = "正在连接 Runner" }) {
return <main className="login-shell"><div className="login-panel loading-panel"><span className="dot run" /><span>{message}</span></div></main>;
}
function DiffPanel({ value, loading }) {
if (loading) return <div className="diff-panel empty-panel">正在读取 Git Diff</div>;
if (!value) return <div className="diff-panel empty-panel">当前会话对应的项目没有未提交 Diff</div>;
const lines = value.split("\n");
return (
<div className="diff-panel">
<div className="diff-file"><span>git diff</span><span className="meta">实时读取</span></div>
{lines.map((line, index) => {
const type = line.startsWith("+") && !line.startsWith("+++") ? "add" : line.startsWith("-") && !line.startsWith("---") ? "del" : line.startsWith("@@") ? "hunk" : "";
return <div className={`dl ${type}`} key={`${index}-${line.slice(0, 10)}`}><span className="ln">{index + 1}</span><span className="lc">{line || " "}</span></div>;
})}
</div>
);
}
function CommandPalette({ open, query, setQuery, items, onClose }) {
const filtered = items.filter((item) => `${item.label} ${item.meta}`.toLowerCase().includes(query.toLowerCase()));
if (!open) return null;
return (
<>
<button className="veil open" type="button" onClick={onClose} aria-label="关闭命令面板" />
<div className="palette open" role="dialog" aria-modal="true" aria-label="命令面板">
<input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索 CLI 或命令..." aria-label="搜索 CLI 或命令" />
<div className="pal-list" role="listbox">
{filtered.length ? filtered.map((item) => (
<button className="pal-item" type="button" key={item.label} onClick={() => { item.run(); onClose(); }}>
<span>{item.label}</span><span className="meta">{item.meta}</span><span className="kind">{item.kind}</span>
</button>
)) : <div className="pal-empty">没有匹配的 CLI 或命令</div>}
</div>
</div>
</>
);
}
export default function App() {
const [authState, setAuthState] = useState("checking");
const [data, setData] = useState(null);
const [error, setError] = useState("");
const [view, setView] = useStoredState("view", "workbench");
const [selectedToolId, setSelectedToolId] = useStoredState("tool", "codex");
const [selectedProject, setSelectedProject] = useStoredState("project", ".");
const [workbenchTab, setWorkbenchTab] = useStoredState("tab", "term");
const [mode, setMode] = useStoredState("mode", "focus");
const [follow, setFollow] = useStoredState("follow", true);
const [task, setTask] = useState("");
const [busy, setBusy] = useState(false);
const [diff, setDiff] = useState("");
const [diffLoading, setDiffLoading] = useState(false);
const [selectedLogId, setSelectedLogId] = useState(null);
const [logRaw, setLogRaw] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [paletteQuery, setPaletteQuery] = useState("");
const [helpOpen, setHelpOpen] = useState(false);
const [toast, setToast] = useState("");
const [terminalSize, setTerminalSize] = useState({ cols: 120, rows: 32 });
const [authorizationSessionId, setAuthorizationSessionId] = useState(null);
const [mobileLogOpen, setMobileLogOpen] = useState(false);
const refresh = useCallback(async () => {
try {
const bootstrap = await api.bootstrap();
setData(bootstrap);
setError("");
if (!bootstrap.projects.includes(selectedProject) && selectedProject !== ".") setSelectedProject(".");
} catch (requestError) {
if (requestError.status === 401) setAuthState("anonymous");
else setError(requestError.message);
}
}, [selectedProject, setSelectedProject]);
const refreshIntegrations = useCallback(async () => {
try {
const response = await api.integrations({ force: true });
setData((current) => current ? ({ ...current, integrations: response.integrations }) : current);
setError("");
} catch (requestError) {
if (requestError.status === 401) setAuthState("anonymous");
else setError(requestError.message);
}
}, []);
useEffect(() => {
api.authStatus().then(({ authenticated }) => {
setAuthState(authenticated ? "authenticated" : "anonymous");
if (authenticated) refresh();
}).catch(() => setAuthState("anonymous"));
}, []);
useEffect(() => {
document.documentElement.classList.add("booted");
return () => document.documentElement.classList.remove("booted");
}, []);
useEffect(() => {
if (authState !== "authenticated") return undefined;
const timer = setInterval(refresh, 15000);
return () => clearInterval(timer);
}, [authState, refresh]);
useEffect(() => {
if (!toast) return undefined;
const timer = setTimeout(() => setToast(""), 4200);
return () => clearTimeout(timer);
}, [toast]);
const tools = useMemo(() => (data?.tools || []).map((tool) => ({ ...TOOL_DETAILS[tool.id], ...tool })), [data]);
const sessions = data?.sessions || [];
const selectedTool = tools.find((tool) => tool.id === selectedToolId) || tools[0];
const taskSessions = sessions.filter((session) => session.purpose !== "authorization");
const selectedToolSessions = taskSessions.filter((session) => (
session.tool === selectedTool?.id
&& session.project === selectedProject
&& session.interactive === false
&& session.initialInput?.trim()
));
const selectedSession = selectedToolSessions.find((session) => session.status === "running")
|| selectedToolSessions[0]
|| null;
const authorizationSession = sessions.find((session) => session.id === authorizationSessionId) || null;
const selectedLog = sessions.find((session) => session.id === selectedLogId) || sessions[0] || null;
const runningCount = sessions.filter((session) => session.status === "running").length;
const waitingSessions = taskSessions.filter((session) => session.status === "waiting");
useEffect(() => {
if (tools.length && !tools.some((tool) => tool.id === selectedToolId)) setSelectedToolId(tools[0].id);
}, [tools, selectedToolId, setSelectedToolId]);
useEffect(() => {
if (sessions.length && !selectedLogId) setSelectedLogId(sessions[0].id);
}, [sessions, selectedLogId]);
useEffect(() => {
document.querySelector(`[data-tool-tab="${selectedToolId}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
}, [selectedToolId]);
useEffect(() => {
if (workbenchTab !== "diff" || !selectedSession) return;
setDiffLoading(true);
api.diff(selectedSession.id).then(setDiff).catch((requestError) => {
setDiff("");
setToast(requestError.message);
}).finally(() => setDiffLoading(false));
}, [workbenchTab, selectedSession?.id]);
useEffect(() => {
function onKey(event) {
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || "");
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault(); setPaletteOpen((current) => !current); return;
}
if (event.key === "Escape") { setPaletteOpen(false); setHelpOpen(false); return; }
if (typing) return;
if (event.key === "?") { event.preventDefault(); setHelpOpen(true); }
if (/^[1-4]$/.test(event.key)) setView(VIEWS[Number(event.key) - 1]);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [setView]);
function openTool(toolId, nextView = "workbench") {
setSelectedToolId(toolId);
setView(nextView);
}
async function startSession(toolId = selectedTool?.id, initialInput = "", purpose = "task") {
if (!toolId || busy) return null;
if (toolId === "dsh" && !initialInput.trim()) {
setToast("DeepSeek Harness 必须先输入任务,再点击发送任务");
return null;
}
setBusy(true);
try {
const response = await api.createSession({
tool: toolId,
purpose,
project: selectedProject,
profile: toolId === "dsh" ? "headless" : undefined,
model: data.settings.preferences.models?.[toolId] || undefined,
initialInput,
...terminalSize,
});
await refresh();
if (purpose === "task") {
setSelectedToolId(toolId);
setView("workbench");
setWorkbenchTab("term");
}
setToast(`${tools.find((tool) => tool.id === toolId)?.name || toolId} 会话已启动`);
return response.session;
} catch (requestError) {
setToast(requestError.message);
return null;
} finally {
setBusy(false);
}
}
async function submitTask(event) {
event?.preventDefault();
const text = task.trim();
if (!text || busy || !selectedTool) return;
setTask("");
if (selectedSession?.status === "running") {
setTask(text);
setToast("当前任务仍在运行,请等待完成或先停止");
return;
}
await startSession(selectedTool.id, text);
}
async function stopSession() {
if (!selectedSession || selectedSession.status !== "running") return;
setBusy(true);
try {
await api.sessionAction(selectedSession.id, "stop");
setToast("停止信号已发送");
await refresh();
} catch (requestError) {
setToast(requestError.message);
} finally {
setBusy(false);
}
}
async function saveSecret(name, value) {
try {
const response = await api.saveSecret(name, value);
setData((current) => ({ ...current, settings: response.settings }));
setToast(value ? "API Key 已加密保存" : "API Key 已删除");
} catch (requestError) {
setToast(requestError.message);
}
}
async function savePreferences(preferences) {
try {
const response = await api.savePreferences(preferences);
setData((current) => ({ ...current, settings: response.settings }));
setToast("偏好设置已保存");
} catch (requestError) {
setToast(requestError.message);
throw requestError;
}
}
async function authorize(toolId) {
if (toolId === "dsh") {
openTool(toolId);
setToast("DeepSeek Harness 使用设置中的 API Key,输入任务后直接发送即可");
return;
}
openTool(toolId);
const running = sessions.find((session) => session.tool === toolId && session.purpose === "authorization" && session.status === "running");
const session = running || await startSession(toolId, "", "authorization");
if (session) setAuthorizationSessionId(session.id);
}
async function switchProvider(app, providerId) {
try {
const response = await api.switchCcProvider(app, providerId);
setData((current) => ({ ...current, integrations: response.integrations }));
setToast("Provider 已切换,新启动的 CLI 会话将使用此配置");
} catch (requestError) {
setToast(requestError.message);
}
}
async function importProvider(app) {
try {
const response = await api.importCcLive(app);
setData((current) => ({ ...current, integrations: response.integrations }));
setToast("已导入当前 CLI 配置");
} catch (requestError) {
setToast(requestError.message);
}
}
async function openIntegration(toolId, options = {}) {
const running = sessions.find((session) => session.tool === toolId && session.purpose === "authorization" && session.status === "running");
if (running) {
setAuthorizationSessionId(running.id);
return;
}
try {
const response = await api.createSession({
tool: toolId,
purpose: "authorization",
project: ".",
serverUrl: options.serverUrl,
appUrl: options.appUrl,
...terminalSize,
});
await refresh();
setAuthorizationSessionId(response.session.id);
setToast(toolId === "multica" ? "Multica 初始化终端已打开" : "CC Switch TUI 已打开");
} catch (requestError) {
setToast(requestError.message);
}
}
async function stopAuthorization() {
if (!authorizationSession) return;
await api.sessionAction(authorizationSession.id, "stop");
await refresh();
}
async function relayMulticaCallback(callbackUrl) {
try {
await api.relayMulticaCallback(callbackUrl);
setToast("Multica 验证结果已送回 CLI");
await refresh();
} catch (requestError) {
setToast(requestError.message);
throw requestError;
}
}
async function logout() {
await api.logout();
setData(null);
setAuthState("anonymous");
}
const paletteItems = [
...tools.map((tool) => ({ label: tool.name, meta: `${tool.command} / ${statusFor(tool.status).label}`, kind: "CLI", run: () => openTool(tool.id) })),
...VIEWS.map((item) => ({ label: `前往${VIEW_LABELS[item][0]}`, meta: VIEW_LABELS[item][1], kind: "视图", run: () => setView(item) })),
];
if (authState === "checking") return <AppLoading message="正在检查登录状态" />;
if (authState === "anonymous") return <LoginPage onLogin={() => { setAuthState("authenticated"); refresh(); }} />;
if (!data && !error) return <AppLoading />;
if (!data) return <AppLoading message={`连接失败: ${error}`} />;
const activeStatus = selectedSession?.status || selectedTool?.status || "stopped";
const currentView = VIEWS.includes(view) ? view : "overview";
return (
<div className="app" id="app">
<nav className="rail" aria-label="主导航">
<span className="logo" aria-hidden="true"><TerminalSquare size={16} strokeWidth={1.8} /></span>
<RailButton icon={LayoutDashboard} label="总览" active={currentView === "overview"} onClick={() => setView("overview")} />
<RailButton icon={TerminalSquare} label="工作台" badge={waitingSessions.length} active={currentView === "workbench"} onClick={() => setView("workbench")} />
<RailButton icon={ListTree} label="日志" active={currentView === "logs"} onClick={() => setView("logs")} />
<RailButton icon={Settings} label="设置" active={currentView === "settings"} onClick={() => setView("settings")} />
<span className="spacer" />
<RailButton icon={HelpCircle} label="快捷键 (?)" active={false} onClick={() => setHelpOpen(true)} />
</nav>
<div className="main">
<header className="topbar">
<h1>{VIEW_LABELS[currentView][0]}</h1>
<span className="crumb">{VIEW_LABELS[currentView][1]}</span>
<span className="env-sel">
<select value={selectedProject} onChange={(event) => setSelectedProject(event.target.value)} aria-label="选择项目">
<option value=".">/workspace</option>
{data.projects.map((project) => <option value={project} key={project}>/workspace/{project}</option>)}
</select>
</span>
{waitingSessions.length ? <button className="attn-pill" type="button" onClick={() => openTool(waitingSessions[0].tool)}><span className="dot wait" />{waitingSessions.length} 个等待输入</button> : null}
<button className="kbtn" type="button" onClick={() => setPaletteOpen(true)}><Search size={13} />搜索 / 命令 <kbd>Ctrl K</kbd></button>
</header>
<main className="views">
<section className={`view ${currentView === "overview" ? "active entering" : ""}`}>
<div className="ov-scroll">
<div className="stat-strip">
<Stat value={tools.length} label="已安装 CLI" sub={`${tools.filter((tool) => tool.installed).length} 个可用`} />
<Stat value={runningCount} label="运行中实例" sub="浏览器关闭后继续运行" />
<Stat value={sessions.length} label="历史会话" sub="输出保存在数据目录" />
<Stat value={data.projects.length} label="项目目录" sub="仅限 /workspace" />
</div>
<div className="ov-grid">
{tools.map((tool) => {
const session = sessions.find((item) => item.tool === tool.id && item.status === "running");
const status = session?.status || tool.status;
return (
<article className="card stag-in" style={{ "--si": tools.indexOf(tool) }} key={tool.id} tabIndex={0} role="button" onClick={() => openTool(tool.id)} onKeyDown={(event) => { if (event.key === "Enter") openTool(tool.id); }}>
<div className="card-head">
<span className="mono-badge">{tool.mono}</span>
<span><strong>{tool.name}</strong><span className="meta">{tool.vendor}</span></span>
<span className="card-status"><Dot status={status} />{statusFor(status).label}</span>
</div>
<dl className="kv">
<dt>命令</dt><dd>{tool.command}</dd>
<dt>版本</dt><dd title={tool.version || ""}>{tool.version || "未检测到"}</dd>
<dt>资源</dt><dd>{session ? `CPU ${tool.resources?.cpu ?? "-"}% / ${formatBytes(tool.resources?.memoryBytes)}` : "无运行实例"}</dd>
</dl>
</article>
);
})}
</div>
</div>
</section>
<section className={`view ${currentView === "workbench" ? "active entering" : ""}`}>
<div className="sess-tabs" role="tablist" aria-label="CLI 会话">
{tools.map((tool) => {
const session = taskSessions.find((item) => item.tool === tool.id && item.status === "running");
const status = session?.status || (tool.status === "error" ? "error" : "stopped");
return (
<button data-tool-tab={tool.id} className={`sess-tab ${status === "waiting" ? "waiting" : ""} ${status === "failed" ? "shake" : ""}`} type="button" role="tab" key={tool.id} aria-current={tool.id === selectedTool?.id} onClick={() => setSelectedToolId(tool.id)}>
<Dot status={status} />
<span className="st-main"><span className="st-name">{tool.name}</span><span className="st-sub">ai-tools / {session ? session.project : statusFor(status).label}</span></span>
{status === "waiting" ? <span className="st-badge">等待</span> : null}
</button>
);
})}
</div>
<div className="wb-bar" role="tablist" aria-label="工作台面板">
{[["term", "会话"], ["raw", "原始输出"], ["diff", "Diff"], ["info", "信息"]].map(([id, label]) => <button className="wb-tab" type="button" role="tab" key={id} aria-current={workbenchTab === id} onClick={() => { setMode("focus"); setWorkbenchTab(id); }}>{label}</button>)}
<span className="seg" role="group" aria-label="视图模式">
<button type="button" aria-current={mode === "focus"} onClick={() => setMode("focus")}><Maximize2 size={12} />焦点</button>
<button type="button" aria-current={mode === "grid"} onClick={() => setMode("grid")}><Grid2X2 size={12} />网格 2x2</button>
</span>
</div>
<div className="wb-body">
{mode === "grid" ? (
<div className="wb-grid active">
{tools.slice(0, 4).map((tool) => {
const session = taskSessions.find((item) => item.tool === tool.id && item.status === "running");
return <button className="mini" type="button" key={tool.id} onClick={() => { setSelectedToolId(tool.id); setMode("focus"); }}><span className="term-head"><span className="l"><Dot status={session?.status || tool.status} /><span>{tool.name}</span></span><span className="r">{session?.project || "未运行"}</span></span><span className="mini-summary mono">{session ? `PID ${session.pid}\n运行 ${formatDuration(session.createdAt)}\n点击查看实时终端` : "当前没有 CLI 实例\n点击进入焦点视图"}</span></button>;
})}
</div>
) : (
<>
<div className={`pane ${workbenchTab === "term" ? "active" : ""}`}>
<div className="term-panel">
<div className="term-head"><span className="l"><Dot status={activeStatus} /><span>{selectedTool?.name || "CLI"} / {selectedSession?.project || selectedProject}</span></span><span className="r">{selectedSession ? `PID ${selectedSession.pid}` : selectedTool?.version}</span></div>
<ChatConversation sessions={selectedToolSessions} onExit={refresh} follow={follow} />
</div>
<div className="chips">{QUICK_TASKS.map((text) => <button type="button" className="chip" key={text} onClick={() => setTask(text)}>{text}</button>)}</div>
<form className="composer" onSubmit={submitTask}>
<span className="sel"><select className="input" value={selectedTool?.id === "dsh" ? "headless" : data.settings.preferences.models?.[selectedTool?.id] || ""} disabled={selectedTool?.id === "dsh"} onChange={(event) => savePreferences({ ...data.settings.preferences, models: { ...data.settings.preferences.models, [selectedTool.id]: event.target.value } })} aria-label={selectedTool?.id === "dsh" ? "DeepSeek Harness 配置" : "默认模型"}>{selectedTool?.id === "dsh" ? <option value="headless">headless</option> : <><option value="">CLI 默认模型</option>{data.settings.preferences.models?.[selectedTool?.id] ? <option value={data.settings.preferences.models[selectedTool.id]}>{data.settings.preferences.models[selectedTool.id]}</option> : null}</>}</select></span>
<textarea className="input chat-input" rows="1" value={task} onChange={(event) => setTask(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } }} aria-label="任务指令" placeholder={selectedSession?.status === "running" ? `${selectedTool?.name} 正在处理当前任务` : `${selectedTool?.name} 发送消息`} />
<button className="btn btn-primary composer-send" type="submit" aria-label="发送任务" title="发送任务" disabled={busy || selectedSession?.status === "running" || !task.trim()}><Send size={15} /><span>发送</span></button>
{selectedSession?.status === "running" ? <button className="btn btn-danger composer-stop" type="button" disabled={busy} onClick={stopSession}><Square size={14} />停止</button> : null}
</form>
</div>
<div className={`pane ${workbenchTab === "raw" ? "active" : ""}`}>
<div className="term-panel">
<div className="term-head"><span className="l"><Dot status={activeStatus} /><span>{selectedTool?.name || "CLI"} / 原始输出</span></span><span className="r">{selectedSession ? `PID ${selectedSession.pid}` : "发送消息后显示"}</span></div>
<TerminalPane session={selectedSession} onExit={refresh} onResizeReady={setTerminalSize} follow={follow} />
</div>
</div>
<div className={`pane ${workbenchTab === "diff" ? "active" : ""}`}><DiffPanel value={diff} loading={diffLoading} /></div>
<div className={`pane ${workbenchTab === "info" ? "active" : ""}`}>
<div className="info-scroll"><div className="info-grid">
<section className="info-card"><h3>CLI 信息</h3><dl className="kv"><dt>命令</dt><dd>{selectedTool?.command}</dd><dt>版本</dt><dd>{selectedTool?.version || "未检测到"}</dd><dt>容器</dt><dd>ai-tools</dd><dt>状态</dt><dd>{statusFor(activeStatus).label}</dd><dt>资源</dt><dd>CPU {selectedTool?.resources?.cpu ?? "-"}% / {formatBytes(selectedTool?.resources?.memoryBytes)}</dd></dl></section>
<section className="info-card"><h3>当前会话</h3>{selectedSession ? <dl className="kv"><dt>项目</dt><dd>/workspace/{selectedSession.project === "." ? "" : selectedSession.project}</dd><dt>PID</dt><dd>{selectedSession.pid || "-"}</dd><dt>创建</dt><dd>{new Date(selectedSession.createdAt).toLocaleString("zh-CN")}</dd><dt>时长</dt><dd>{formatDuration(selectedSession.createdAt, selectedSession.endedAt)}</dd></dl> : <span className="meta">当前 CLI 没有会话</span>}</section>
<section className="info-card"><h3>能力</h3><div className="tag-list">{selectedTool?.tags?.map((tag) => <span className="tag" key={tag}>{tag}</span>)}</div><p className="info-description">{selectedTool?.description}</p></section>
<section className="info-card"><h3>运行边界</h3><dl className="kv"><dt>工作区</dt><dd>/workspace</dd><dt>网络</dt><dd>Runner 内部网络</dd><dt>Socket</dt><dd>未挂载 Docker Socket</dd></dl></section>
</div></div>
</div>
</>
)}
</div>
</section>
<section className={`view ${currentView === "logs" ? "active entering" : ""}`}>
<div className="logs-layout">
<div className="log-list" role="listbox" aria-label="会话时间轴">
{sessions.length ? sessions.map((session) => (
<button className="log-row stag-in" style={{ "--si": sessions.indexOf(session) }} type="button" key={session.id} aria-current={selectedLog?.id === session.id} onClick={() => { setSelectedLogId(session.id); setMobileLogOpen(true); }}>
<span className="t">{formatTime(session.createdAt)}</span><Dot status={session.status} still={session.status !== "running"} />
<span><span className="task">{session.toolName} / {session.project}</span><span className="who">{statusFor(session.status).label} / {formatDuration(session.createdAt, session.endedAt)}</span></span>
</button>
)) : <div className="empty-list">还没有 CLI 会话</div>}
</div>
<aside className={`log-detail ${mobileLogOpen ? "open" : ""}`}>
{selectedLog ? <><div className="ld-head"><button className="log-back" type="button" onClick={() => setMobileLogOpen(false)}><ArrowLeft size={15} />返回日志</button><h2>{selectedLog.toolName} / {selectedLog.project}</h2><div className="ld-meta meta num"><span>{new Date(selectedLog.createdAt).toLocaleString("zh-CN")}</span><span>PID {selectedLog.pid || "-"}</span><span>{statusFor(selectedLog.status).label}</span></div><div className="ld-tabs"><button className="ld-tab" type="button" aria-current={!logRaw} onClick={() => setLogRaw(false)}>摘要</button><button className="ld-tab" type="button" aria-current={logRaw} onClick={() => setLogRaw(true)}>原始 JSON</button></div></div><div className="ld-body">{logRaw ? <pre className="json-pre">{JSON.stringify(selectedLog, null, 2)}</pre> : <><div className="bubble user">{selectedLog.initialInput || `启动 ${selectedLog.toolName}`}</div><div className="bubble bot">工作目录/workspace/{selectedLog.project === "." ? "" : selectedLog.project}<ul className="steps"><li>状态 <b>{statusFor(selectedLog.status).label}</b></li><li>时长 <b>{formatDuration(selectedLog.createdAt, selectedLog.endedAt)}</b></li><li>退出码 <b>{selectedLog.exitCode ?? "-"}</b></li></ul></div></>}</div></> : <div className="empty-panel">选择一条会话记录</div>}
</aside>
</div>
</section>
<section className={`view ${currentView === "settings" ? "active entering" : ""}`}>
<SettingsView settings={data.settings} tools={tools} integrations={data.integrations} onSaveSecret={saveSecret} onSavePreferences={savePreferences} onAuthorize={authorize} onLogout={logout} onRefreshIntegrations={refreshIntegrations} onSwitchProvider={switchProvider} onImportProvider={importProvider} onOpenIntegration={openIntegration} follow={follow} setFollow={setFollow} />
</section>
</main>
<div className="scanline" aria-hidden="true" />
</div>
<footer className="statusbar"><span className="grp"><span className={`dot ${error ? "err" : "run"}`} /><span>{error || "Runner 已连接"}</span></span><span className="grp num">{runningCount}/7 运行</span><span className="right"><span><kbd>Ctrl K</kbd> 命令</span><span><kbd>?</kbd> 快捷键</span></span></footer>
<CommandPalette open={paletteOpen} query={paletteQuery} setQuery={setPaletteQuery} items={paletteItems} onClose={() => setPaletteOpen(false)} />
{helpOpen ? <><button className="veil open" type="button" onClick={() => setHelpOpen(false)} aria-label="关闭快捷键" /><div className="modal open" role="dialog" aria-modal="true"><button className="modal-close icon-btn" type="button" onClick={() => setHelpOpen(false)} aria-label="关闭"><X size={15} /></button><h2>快捷键速查</h2><table className="kbd-table"><tbody><tr><td>命令面板</td><td><kbd>Ctrl K</kbd></td></tr><tr><td>切换视图</td><td><kbd>1</kbd> - <kbd>4</kbd></td></tr><tr><td>本面板</td><td><kbd>?</kbd></td></tr><tr><td>关闭浮层</td><td><kbd>Esc</kbd></td></tr></tbody></table></div></> : null}
{toast ? <div className="toasts" aria-live="polite"><button className="toast in" type="button" onClick={() => setToast("")}><span className="dot wait" /><span>{toast}</span></button></div> : null}
<AuthorizationTerminal session={authorizationSession} tool={tools.find((tool) => tool.id === authorizationSession?.tool) || ({ ccswitch: { name: "CC Switch" }, multica: { name: "Multica Runtime" } }[authorizationSession?.tool])} onClose={() => setAuthorizationSessionId(null)} onStop={stopAuthorization} onExit={refresh} onMulticaCallback={relayMulticaCallback} />
</div>
);
}
function RailButton({ icon: Icon, label, badge = 0, active, onClick }) {
return <button className="rail-btn" type="button" data-tip={label} aria-label={label} aria-current={active} onClick={onClick}><Icon size={18} strokeWidth={1.7} />{badge ? <span className="rail-badge">{badge}</span> : null}</button>;
}
function Stat({ value, label, sub }) {
return <div className="stat-tile"><div className="v num">{value}</div><div className="l">{label}</div><div className="sub">{sub}</div></div>;
}
+36
View File
@@ -0,0 +1,36 @@
async function request(path, options = {}) {
const response = await fetch(path, {
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
if (response.status === 204) return null;
const contentType = response.headers.get("content-type") || "";
const body = contentType.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) {
const error = new Error(body?.error || `请求失败 (${response.status})`);
error.status = response.status;
throw error;
}
return body;
}
export const api = {
authStatus: () => request("/api/auth/status"),
login: (password) => request("/api/auth/login", { method: "POST", body: JSON.stringify({ password }) }),
logout: () => request("/api/auth/logout", { method: "POST" }),
bootstrap: () => request("/api/bootstrap"),
integrations: ({ force = false } = {}) => request(`/api/integrations${force ? "?force=1" : ""}`),
createSession: (body) => request("/api/sessions", { method: "POST", body: JSON.stringify(body) }),
sessionAction: (id, action, body = {}) => request(`/api/sessions/${id}/${action}`, { method: "POST", body: JSON.stringify(body) }),
output: (id) => request(`/api/sessions/${id}/output`),
diff: (id) => request(`/api/sessions/${id}/diff`),
saveSecret: (name, value) => request(`/api/settings/secrets/${encodeURIComponent(name)}`, { method: "PUT", body: JSON.stringify({ value }) }),
savePreferences: (preferences) => request("/api/settings/preferences", { method: "PUT", body: JSON.stringify(preferences) }),
switchCcProvider: (app, providerId) => request(`/api/integrations/cc-switch/${encodeURIComponent(app)}/switch`, { method: "POST", body: JSON.stringify({ providerId }) }),
importCcLive: (app) => request(`/api/integrations/cc-switch/${encodeURIComponent(app)}/import-live`, { method: "POST", body: "{}" }),
relayMulticaCallback: (callbackUrl) => request("/api/integrations/multica/callback", { method: "POST", body: JSON.stringify({ callbackUrl }) }),
multicaAuthUrl: (sessionId) => request(`/api/integrations/multica/auth-url/${encodeURIComponent(sessionId)}`),
};
+339
View File
@@ -0,0 +1,339 @@
#root { height: 100%; }
.app { height: 100dvh; }
button:disabled { cursor: not-allowed; }
.login-shell {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(60% 80% at 50% 0%, color-mix(in oklch, var(--accent) 6%, transparent), transparent 75%),
var(--bg);
}
.login-shell::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgb(255 255 255 / .028) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / .028) 1px, transparent 1px);
background-size: 30px 30px;
mask-image: linear-gradient(180deg, black, transparent 72%);
}
.login-panel {
position: relative;
z-index: 1;
width: min(380px, 100%);
display: grid;
gap: 18px;
padding: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-pop), var(--top-hi);
}
.login-panel h1 { font-size: 19px; font-weight: 600; }
.login-panel p { margin: 2px 0 0; color: var(--muted); font-size: 13px; }
.login-logo { margin: 0; }
.login-field { display: grid; gap: 7px; color: var(--muted); font-size: 12px; }
.login-input-wrap { position: relative; display: flex; align-items: center; }
.login-input-wrap svg { position: absolute; left: 12px; color: var(--muted); pointer-events: none; }
.login-input-wrap input {
width: 100%;
min-height: 42px;
padding: 9px 12px 9px 38px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--fg);
font: inherit;
}
.login-input-wrap input:focus { outline: none; border-color: color-mix(in oklch, var(--accent) 55%, var(--border)); }
.login-meta { text-align: center; }
.form-error { color: color-mix(in oklch, var(--err) 70%, var(--fg)) !important; margin: -8px 0 0 !important; }
.loading-panel { width: auto; grid-auto-flow: column; align-items: center; padding: 14px 18px; color: var(--muted); }
.xterm-host { padding: 10px 12px; }
.xterm-host .xterm { height: 100%; }
.xterm-host .xterm-viewport { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.transcript-wrap {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.transcript {
scroll-behavior: smooth;
}
.transcript .tl.user {
margin: 0 0 14px;
padding: 10px 13px;
border: 1px solid color-mix(in oklch, var(--accent) 22%, transparent);
border-radius: var(--radius);
background: var(--accent-faint);
}
.auth-terminal {
position: fixed;
z-index: 52;
inset: max(24px, 5vh) max(24px, 5vw);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--term-bg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-pop), var(--top-hi);
animation: auth-terminal-in var(--dur-med) var(--ease-in) both;
}
@keyframes auth-terminal-in {
from { opacity: 0; transform: translateY(8px) scale(.99); }
}
.auth-terminal .xterm-host { min-height: 0; }
.auth-actions { display: inline-flex; align-items: center; gap: 8px; }
.multica-callback { display: grid; gap: 7px; padding: 10px 12px 12px; border-top: 1px solid var(--border); background: var(--surface); }
.multica-callback label { color: var(--muted); font-size: 11px; }
.multica-callback > span { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.multica-callback .input { min-width: 0; }
.multica-managed-auth { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.multica-managed-auth > span { min-width: 0; }
.multica-managed-auth strong, .multica-managed-auth small { display: block; }
.multica-managed-auth strong { color: var(--fg); font-size: 12px; }
.multica-managed-auth small { margin-top: 2px; color: var(--muted); font-size: 10.5px; }
.palette.open,
.modal.open {
animation: overlay-mount var(--dur-med) var(--ease-in) both;
}
.toast.in {
animation: toast-mount var(--dur-slow) var(--ease-spring) both;
}
@keyframes overlay-mount {
from { opacity: 0; transform: translateY(8px); }
}
@keyframes toast-mount {
from { opacity: 0; transform: translateY(10px) scale(.98); }
}
.empty-panel,
.empty-list {
display: grid;
place-items: center;
min-height: 180px;
padding: 24px;
color: var(--muted);
font-family: var(--font-mono);
font-size: 12px;
text-align: center;
}
.mini-summary {
flex: 1;
display: grid;
place-items: center;
padding: 20px;
color: var(--muted);
font-size: 11.5px;
line-height: 1.8;
text-align: center;
white-space: pre-line;
}
.tag-list { display: flex; flex-wrap: wrap; gap: 7px; }
.info-description { margin: 12px 0 0; color: var(--muted); font-size: 12.5px; }
.console-settings-grid { max-width: 980px; }
.credentials-card { grid-column: span 2; }
.secret-row { padding: 10px 0; border-bottom: 1px solid var(--border); }
.secret-row > label { display: block; margin-bottom: 7px; font-size: 13px; }
.secret-row .meta { display: block; margin-top: 1px; }
.secret-controls { display: flex; gap: 7px; align-items: center; }
.secret-input-wrap { position: relative; flex: 1; min-width: 0; }
.secret-input-wrap .input { width: 100%; padding-right: 38px; }
.secret-input-wrap .icon-btn { position: absolute; top: 50%; right: 4px; translate: 0 -50%; }
.icon-btn {
width: 32px;
height: 32px;
flex: none;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--overlay);
color: var(--muted);
transition: background var(--dur-fast) var(--ease-move), color var(--dur-fast) var(--ease-move);
}
.icon-btn:hover:not(:disabled) { background: var(--hover-strong); color: var(--fg); }
.icon-btn:disabled { opacity: .4; }
.danger-icon { color: color-mix(in oklch, var(--err) 70%, var(--fg)); }
.security-note { margin: 11px 0 0; }
.model-setting-input { width: min(260px, 48%); }
.modal-close { position: absolute; top: 12px; right: 12px; }
.palette .pal-item:hover { background: var(--hover-strong); }
.veil { border-radius: 0; }
.topbar .env-sel select { max-width: 280px; }
.log-detail .empty-panel { height: 100%; }
/* Settings extends the existing OpenDesign shell with denser operations UI. */
.settings-shell { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.settings-tabs { flex: none; display: flex; gap: 3px; min-height: 44px; padding: 8px 16px 0; overflow-x: auto; scrollbar-width: none; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 84%, transparent); }
.settings-tabs::-webkit-scrollbar { display: none; }
.settings-tabs button { position: relative; flex: none; min-height: 35px; padding: 7px 12px 9px; border-radius: 7px 7px 0 0; color: var(--muted); font-size: 12.5px; white-space: nowrap; transition: color var(--dur-fast) var(--ease-move), background var(--dur-fast) var(--ease-move); }
.settings-tabs button:hover { color: var(--fg); background: var(--hover); }
.settings-tabs button[aria-selected="true"] { color: var(--fg); background: var(--overlay); }
.settings-tabs button[aria-selected="true"]::after { content: ""; position: absolute; right: 10px; bottom: -1px; left: 10px; height: 2px; border-radius: 2px; background: var(--accent); }
.settings-tab-scroll { padding-top: 18px; }
.settings-card-title { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; color: var(--muted); }
.settings-card-title h3 { margin: 0; }
.settings-empty { min-height: 260px; display: grid; place-items: center; align-content: center; gap: 8px; padding: 28px; color: var(--muted); text-align: center; }
.settings-empty.compact { min-height: 104px; padding: 18px; }
.settings-empty p { margin: 0; font-size: 12px; }
.integration-stack { width: min(1120px, 100%); display: grid; gap: 12px; }
.integration-toolbar { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.integration-toolbar h2 { margin: 0; font-size: 15px; font-weight: 600; }
.integration-toolbar p { margin: 2px 0 0; color: var(--muted); font-size: 11.5px; }
.integration-actions { display: inline-flex; align-items: center; gap: 7px; flex: none; }
.integration-ownership { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 11px 13px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.integration-ownership strong, .integration-ownership small { display: block; }
.integration-ownership strong { font-size: 12.5px; }
.integration-ownership small { margin-top: 2px; color: var(--muted); font-size: 10.5px; }
.integration-ownership .switch:disabled { opacity: .48; }
.integration-notice { width: min(720px, 100%); display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 13px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.integration-notice.error { border-color: color-mix(in oklch, var(--err) 35%, var(--border)); color: color-mix(in oklch, var(--err) 70%, var(--fg)); }
.integration-notice strong, .integration-notice small { display: block; }
.integration-notice small { margin-top: 2px; color: var(--muted); }
.integration-skeleton { width: min(1120px, 100%); display: grid; gap: 9px; padding: 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); }
.skeleton-line { display: block; width: 68%; height: 34px; border-radius: 6px; background: var(--overlay); opacity: .7; }
.skeleton-line.wide { width: 34%; height: 14px; margin-bottom: 5px; }
.integration-skeleton .skeleton-line:nth-child(odd) { width: 84%; }
.integration-empty { width: min(720px, 100%); min-height: 280px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); text-align: center; }
.integration-empty.compact { min-height: 132px; }
.integration-empty-icon { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--accent); }
.integration-empty strong { font-size: 13.5px; }
.integration-empty p { max-width: 440px; margin: 0 0 3px; color: var(--muted); font-size: 12px; line-height: 1.6; }
.provider-workspace { min-height: 430px; display: grid; grid-template-columns: minmax(180px, .78fr) minmax(220px, 1fr) minmax(250px, 1.18fr); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--top-hi); }
.provider-tools, .provider-list, .provider-detail { min-width: 0; }
.provider-tools, .provider-list { border-right: 1px solid var(--border); }
.provider-column-head { min-height: 41px; display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; border-bottom: 1px solid var(--border); color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
.provider-tools > button, .provider-list > button { width: 100%; min-height: 56px; display: flex; align-items: center; gap: 10px; padding: 9px 11px; border-bottom: 1px solid color-mix(in srgb, var(--border) 74%, transparent); text-align: left; transition: background var(--dur-fast) var(--ease-move), color var(--dur-fast) var(--ease-move); }
.provider-tools > button:hover, .provider-list > button:hover { background: var(--hover); }
.provider-tools > button[aria-current="true"], .provider-list > button[aria-current="true"] { background: var(--accent-faint); }
.provider-tools > button[aria-current="true"] { box-shadow: inset 2px 0 var(--accent); }
.provider-tools button > span:last-child, .provider-list button > span:last-child, .provider-detail-heading > span:last-child, .runtime-tool-grid div > span:nth-child(2) { min-width: 0; }
.provider-tools strong, .provider-tools small, .provider-list strong, .provider-list small, .provider-detail-heading strong, .provider-detail-heading small, .runtime-tool-grid strong, .runtime-tool-grid small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.provider-tools strong, .provider-list strong, .provider-detail-heading strong, .runtime-tool-grid strong { font-size: 12.5px; font-weight: 550; }
.provider-tools small, .provider-list small, .provider-detail-heading small, .runtime-tool-grid small { margin-top: 2px; color: var(--muted); font-size: 10.5px; }
.integration-state-mark { width: 18px; height: 18px; flex: none; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--accent-ink); }
.integration-state-mark.active { border-color: var(--accent); background: var(--accent); }
.integration-state-mark.inactive { background: var(--bg); }
.icon-btn.compact { width: 26px; height: 26px; }
.provider-empty-list, .provider-detail-empty { min-height: 190px; display: grid; place-items: center; align-content: center; gap: 7px; padding: 18px; color: var(--muted); font-size: 11.5px; text-align: center; }
.provider-empty-list button { color: var(--accent); font-size: 11.5px; }
.provider-detail { background: color-mix(in srgb, var(--overlay) 38%, var(--surface)); }
.provider-detail-body { display: flex; min-height: calc(100% - 41px); flex-direction: column; padding: 18px; }
.provider-detail-heading { display: flex; align-items: center; gap: 10px; padding-bottom: 17px; }
.integration-kv { display: grid; gap: 0; margin: 0; }
.integration-kv > div { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 12px; padding: 10px 0; border-top: 1px solid var(--border); }
.integration-kv dt { color: var(--muted); font-size: 11px; }
.integration-kv dd { min-width: 0; margin: 0; overflow: hidden; color: var(--fg); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
.provider-switch { width: 100%; margin-top: auto; }
.unsupported-tools { display: flex; align-items: center; gap: 12px; padding: 9px 12px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11.5px; }
.unsupported-tools > div { display: flex; flex-wrap: wrap; gap: 6px; }
.capability-rows { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--top-hi); }
.capability-rows section { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding: 17px 18px; }
.capability-rows section + section { border-top: 1px solid var(--border); }
.capability-icon { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--accent); }
.capability-rows h3 { margin: 0; font-size: 13px; }
.capability-rows p { margin: 2px 0 0; color: var(--muted); font-size: 11.5px; }
.integration-badge { flex: none; padding: 2px 7px; border: 1px solid var(--border); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; }
.integration-badge.ok { border-color: color-mix(in oklch, var(--ok) 38%, var(--border)); color: color-mix(in oklch, var(--ok) 78%, var(--fg)); }
.integration-badge.muted { color: var(--muted); }
.integration-footnote { margin: 0; }
.runtime-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--border); }
.runtime-summary section { min-width: 0; display: flex; align-items: center; gap: 12px; padding: 14px 15px; background: var(--surface); box-shadow: var(--top-hi); }
.runtime-icon { width: 36px; height: 36px; flex: none; display: grid; place-items: center; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--muted); }
.runtime-icon.online { color: var(--ok); }
.runtime-icon.offline { color: var(--err); }
.runtime-summary section > div { min-width: 0; }
.runtime-summary strong, .runtime-summary small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.runtime-summary strong { margin-top: 1px; font-size: 14px; }
.runtime-summary small { margin-top: 1px; color: var(--muted); font-size: 10.5px; }
.runtime-content { display: grid; grid-template-columns: minmax(280px, .76fr) minmax(360px, 1.24fr); gap: 12px; }
.runtime-config { display: grid; align-content: start; gap: 11px; }
.runtime-config h3 { margin-bottom: 0; }
.runtime-config label { display: grid; gap: 5px; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; }
.runtime-config .input { width: 100%; min-height: 36px; font-size: 11.5px; }
.runtime-config .btn { width: 100%; margin-top: 2px; }
.runtime-config p { margin: 0; line-height: 1.55; }
.runtime-tools h3 { margin-bottom: 8px; }
.runtime-tool-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--border); }
.runtime-tool-grid > div { min-width: 0; display: flex; align-items: center; gap: 9px; padding: 10px; background: var(--bg); }
.runtime-tool-grid > div.missing { opacity: .62; }
.runtime-tool-grid .integration-badge { margin-left: auto; }
@media (prefers-reduced-motion: no-preference) {
.settings-tab-scroll > * { animation: settings-panel-in var(--dur-med) var(--ease-in) both; }
.integration-skeleton .skeleton-line { animation: skeleton-pulse 1.5s var(--ease-move) infinite; }
}
@keyframes settings-panel-in { from { opacity: 0; transform: translateY(4px); } }
@keyframes skeleton-pulse { 50% { opacity: .38; } }
@media (max-width: 840px) {
.credentials-card { grid-column: span 1; }
.provider-workspace { grid-template-columns: minmax(150px, .7fr) minmax(200px, 1fr); }
.provider-detail { grid-column: 1 / -1; border-top: 1px solid var(--border); }
.provider-detail-body { min-height: 230px; }
.runtime-content { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.auth-terminal { inset: 0 0 var(--status-h); border-radius: 0; }
.auth-terminal .term-head { padding-left: 12px; }
.topbar .env-sel { max-width: 42vw; }
.topbar .env-sel select { width: 100%; overflow: hidden; text-overflow: ellipsis; }
.kbtn { width: 36px; height: 32px; justify-content: center; font-size: 0; }
.kbtn svg { margin: 0; }
.secret-controls { align-items: stretch; }
.credentials-card { grid-column: auto; }
.set-row { align-items: flex-start; }
.model-row { flex-direction: column; }
.model-setting-input { width: 100%; }
.log-detail.open { top: var(--top-h); left: var(--rail-w); }
.settings-tabs { padding-inline: 10px; }
.settings-tab-scroll { padding: 14px 12px 28px; }
.integration-toolbar { align-items: flex-start; }
.provider-workspace { grid-template-columns: minmax(128px, .68fr) minmax(180px, 1fr); overflow-x: auto; }
.provider-tools .mono-badge { display: none; }
.provider-detail { min-width: 308px; }
.runtime-summary { grid-template-columns: 1fr; }
.runtime-tool-grid { grid-template-columns: 1fr; }
.integration-notice { grid-template-columns: auto minmax(0, 1fr); }
.integration-notice .btn { grid-column: 1 / -1; }
.capability-rows section { grid-template-columns: auto minmax(0, 1fr); }
.capability-rows .integration-badge { grid-column: 2; justify-self: start; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; }
.settings-tab-scroll > *, .integration-skeleton .skeleton-line { animation: none !important; }
}
@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { ExternalLink, Send, Square, X } from "lucide-react";
import { api } from "../api.js";
import { TerminalPane } from "./TerminalPane.jsx";
export function AuthorizationTerminal({ session, tool, onClose, onStop, onExit, onMulticaCallback }) {
const [callbackUrl, setCallbackUrl] = useState("");
const [callbackBusy, setCallbackBusy] = useState(false);
const [multicaAuthUrl, setMulticaAuthUrl] = useState("");
const [multicaAuthError, setMulticaAuthError] = useState("");
useEffect(() => {
setCallbackUrl("");
setMulticaAuthUrl("");
setMulticaAuthError("");
if (!session?.id || session.tool !== "multica" || session.status !== "running") return undefined;
let stopped = false;
let timer;
async function poll() {
try {
const response = await api.multicaAuthUrl(session.id);
if (!stopped) {
setMulticaAuthUrl(response.authUrl);
setMulticaAuthError("");
}
} catch (error) {
if (!stopped) {
setMulticaAuthError(error.status === 409 ? "等待终端生成验证地址" : error.message);
timer = window.setTimeout(poll, 1_500);
}
}
}
poll();
return () => { stopped = true; window.clearTimeout(timer); };
}, [session?.id, session?.status, session?.tool]);
if (!session) return null;
async function submitCallback(event) {
event.preventDefault();
if (!callbackUrl.trim() || !onMulticaCallback) return;
setCallbackBusy(true);
try {
await onMulticaCallback(callbackUrl.trim());
setCallbackUrl("");
} finally {
setCallbackBusy(false);
}
}
return (
<>
<button className="veil open" type="button" onClick={onClose} aria-label="关闭授权终端" />
<section className="auth-terminal open" role="dialog" aria-modal="true" aria-label={`${tool?.name || "CLI"} 授权终端`}>
<header className="term-head">
<span className="l"><span className="dot run" /><span>{tool?.name} / 初始化与授权</span></span>
<span className="auth-actions">
{session.status === "running" ? <button className="btn btn-danger btn-sm" type="button" onClick={onStop}><Square size={13} />结束进程</button> : null}
<button className="icon-btn" type="button" onClick={onClose} aria-label="关闭"><X size={15} /></button>
</span>
</header>
<TerminalPane session={session} onExit={onExit} />
{session.tool === "multica" && session.status === "running" ? (
<form className="multica-callback" onSubmit={submitCallback}>
<div className="multica-managed-auth">
<span><strong>网页回调通道</strong><small>{multicaAuthUrl ? "验证结果会自动转交给容器,请在 5 分钟内完成" : multicaAuthError || "正在准备验证地址"}</small></span>
{multicaAuthUrl ? <a className="btn btn-primary" href={multicaAuthUrl} target="_blank" rel="noreferrer"><ExternalLink size={14} />打开 Multica 验证</a> : <button className="btn btn-primary" type="button" disabled>正在准备</button>}
</div>
<label htmlFor="multica-callback-url">备用方式将验证后的 localhost 完整链接粘贴到这里</label>
<span>
<input id="multica-callback-url" className="input mono" type="password" value={callbackUrl} onChange={(event) => setCallbackUrl(event.target.value)} placeholder="http://localhost:端口/callback?..." autoComplete="off" />
<button className="btn btn-primary" type="submit" disabled={callbackBusy || !callbackUrl.trim()}><Send size={14} />{callbackBusy ? "正在提交" : "完成验证"}</button>
</span>
</form>
) : null}
</section>
</>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import { api } from "../api.js";
import "../visual-polish.css";
const MAX_OUTPUT_LENGTH = 2_000_000;
const OSC_PATTERN = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
const DCS_PATTERN = /\u001B[P^_].*?(?:\u001B\\|\u0007)/gs;
const CSI_PATTERN = /(?:\u001B\[|\u009B)[?=>!]*[\d;:<?]*[ -/]*[@-~]/g;
const ESC_PATTERN = /\u001B[@-_]/g;
const CONTROL_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
function applyBackspaces(value) {
const characters = [];
for (const character of value) {
if (character === "\u0008") characters.pop();
else characters.push(character);
}
return characters.join("");
}
export function cleanConversationOutput(value) {
const cleaned = String(value || "")
.replace(OSC_PATTERN, "")
.replace(DCS_PATTERN, "")
.replace(CSI_PATTERN, "")
.replace(ESC_PATTERN, "")
.replace(/\uFFFD/g, "")
.replace(/[\u2800-\u28FF]/g, "")
.replace(/[\u2500-\u257F]/g, " ");
return cleaned.split("\n").map((line) => {
const latestCarriageReturn = line.split("\r").at(-1) || "";
return applyBackspaces(latestCarriageReturn).replace(CONTROL_PATTERN, "").replace(/[ \t]+$/g, "");
}).join("\n").replace(/\n{4,}/g, "\n\n\n").trim();
}
function statusFor(session, connectionState) {
if (["failed", "error"].includes(session.status)) return { kind: "error", label: "运行失败" };
if (["interrupted", "stopped"].includes(session.status)) return { kind: "stopped", label: "已停止" };
if (["exited", "completed", "done"].includes(session.status)) return { kind: "done", label: "已完成" };
if (connectionState === "error") return { kind: "error", label: "连接异常" };
return { kind: "working", label: "正在运行" };
}
export function ChatConversation({ sessions = [], onExit, follow = true }) {
const [outputs, setOutputs] = useState({});
const [connections, setConnections] = useState({});
const [unread, setUnread] = useState(0);
const viewportRef = useRef(null);
const pinnedRef = useRef(true);
const renderFrameRef = useRef(0);
const followRef = useRef(follow);
const onExitRef = useRef(onExit);
useEffect(() => { followRef.current = follow; }, [follow]);
useEffect(() => { onExitRef.current = onExit; }, [onExit]);
const orderedSessions = useMemo(
() => [...sessions].sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
[sessions],
);
const sessionIds = orderedSessions.map((session) => session.id).join(",");
useEffect(() => {
let cancelled = false;
Promise.all(orderedSessions.map(async (session) => [
session.id,
cleanConversationOutput(await api.output(session.id)).slice(-MAX_OUTPUT_LENGTH),
])).then((records) => {
if (!cancelled) setOutputs((current) => ({ ...current, ...Object.fromEntries(records) }));
}).catch(() => {});
return () => { cancelled = true; };
}, [sessionIds]);
useEffect(() => {
const connectionsBySession = orderedSessions.filter((session) => session.status === "running").map((session) => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socketUrl = `${protocol}//${window.location.host}/api/sessions/${session.id}/ws`;
const connection = { socket: null, reconnectTimer: null, heartbeatTimer: null, stopped: false, attempt: 0 };
function connect() {
if (connection.stopped) return;
const socket = new WebSocket(socketUrl);
connection.socket = socket;
setConnections((current) => ({ ...current, [session.id]: connection.attempt ? "reconnecting" : "connecting" }));
socket.addEventListener("open", () => {
connection.attempt = 0;
setConnections((current) => ({ ...current, [session.id]: "connected" }));
window.clearInterval(connection.heartbeatTimer);
connection.heartbeatTimer = window.setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "heartbeat" }));
}, 20_000);
});
socket.addEventListener("message", (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "snapshot") {
setOutputs((current) => ({ ...current, [session.id]: cleanConversationOutput(message.data) }));
}
if (message.type === "output") {
const chunk = String(message.data || "");
setOutputs((current) => ({
...current,
[session.id]: cleanConversationOutput(`${current[session.id] || ""}${chunk}`).slice(-MAX_OUTPUT_LENGTH),
}));
if (!followRef.current || !pinnedRef.current) setUnread((current) => current + 1);
}
if (message.type === "exit") {
setConnections((current) => ({ ...current, [session.id]: "closed" }));
onExitRef.current?.(message);
}
} catch {
setConnections((current) => ({ ...current, [session.id]: "error" }));
}
});
socket.addEventListener("close", () => {
window.clearInterval(connection.heartbeatTimer);
if (connection.stopped) return;
setConnections((current) => ({ ...current, [session.id]: "reconnecting" }));
const delay = Math.min(10_000, 1_000 * (2 ** connection.attempt));
connection.attempt += 1;
connection.reconnectTimer = window.setTimeout(connect, delay);
});
}
connect();
return connection;
});
return () => connectionsBySession.forEach((connection) => {
connection.stopped = true;
window.clearTimeout(connection.reconnectTimer);
window.clearInterval(connection.heartbeatTimer);
connection.socket?.close();
});
}, [sessionIds]);
useEffect(() => {
if (!follow || !pinnedRef.current || !viewportRef.current) return undefined;
cancelAnimationFrame(renderFrameRef.current);
renderFrameRef.current = requestAnimationFrame(() => {
if (!viewportRef.current) return;
viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
setUnread(0);
});
return () => cancelAnimationFrame(renderFrameRef.current);
}, [outputs, sessionIds, follow]);
const updateScrollPosition = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) return;
pinnedRef.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 32;
if (pinnedRef.current) setUnread(0);
}, []);
const scrollToLatest = useCallback(() => {
if (!viewportRef.current) return;
pinnedRef.current = true;
viewportRef.current.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
setUnread(0);
}, []);
if (!orderedSessions.length) {
return (
<div className="chat-conversation chat-empty">
<div className="chat-empty-mark" aria-hidden="true">AD</div>
<strong>开始一个新任务</strong>
<span>选择项目并发送消息后CLI 的文字回复会显示在这里</span>
</div>
);
}
return (
<div className="chat-conversation">
<div ref={viewportRef} className="chat-scroll" role="log" aria-label="会话消息" onScroll={updateScrollPosition}>
<div className="chat-thread">
{orderedSessions.map((session) => {
const status = statusFor(session, connections[session.id]);
const output = outputs[session.id];
const assistantText = output || (status.kind === "working" ? "正在等待模型响应" : "本次任务没有返回可显示的文本。");
return (
<div className="chat-turn" key={session.id}>
<article className="chat-message chat-message-user">
<div className="chat-user-bubble">{session.initialInput}</div>
</article>
<article className="chat-message chat-message-assistant">
<header className="chat-assistant-head">
<span className="chat-assistant-mark" aria-hidden="true">AI</span>
<span className="chat-assistant-name">{session.toolName || session.tool}</span>
<span className={`chat-run-state is-${status.kind}`}>
<span className="chat-state-dot" aria-hidden="true" />{status.label}
</span>
</header>
<pre className={`chat-response ${output ? "" : "is-placeholder"} ${status.kind === "working" ? "is-working" : ""}`}>{assistantText}</pre>
</article>
</div>
);
})}
</div>
</div>
<button className={`chat-jump ${unread ? "show" : ""}`} type="button" onClick={scrollToLatest} aria-hidden={!unread} tabIndex={unread ? 0 : -1}>
<ArrowDown size={13} strokeWidth={1.8} />{unread} 条新消息
</button>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowRightLeft, Boxes, Check, ExternalLink, Import, MonitorCog, PlugZap, RefreshCw, ServerCog, TerminalSquare, TriangleAlert } from "lucide-react";
function IntegrationError({ title, detail, onRefresh }) {
return <div className="integration-notice error" role="alert"><TriangleAlert size={17} /><span><strong>{title}</strong><small>{detail}</small></span>{onRefresh ? <button className="btn btn-secondary btn-sm" type="button" onClick={onRefresh}><RefreshCw size={14} />重试</button> : null}</div>;
}
function IntegrationSkeleton({ rows = 4 }) {
return <div className="integration-skeleton" aria-label="正在加载集成状态" aria-busy="true"><span className="skeleton-line wide" />{Array.from({ length: rows }, (_, index) => <span className="skeleton-line" key={index} />)}</div>;
}
function StateMark({ active }) {
return <span className={`integration-state-mark ${active ? "active" : "inactive"}`} aria-hidden="true">{active ? <Check size={11} /> : null}</span>;
}
function ProvidersPanel({ integrations, tools, settings, onSavePreferences, onRefresh, onSwitchProvider, onImportProvider, onOpenIntegration }) {
const ccSwitch = integrations?.ccSwitch;
const apps = ccSwitch?.apps || [];
const [selectedAppId, setSelectedAppId] = useState("");
const [selectedProviderId, setSelectedProviderId] = useState("");
const [busyAction, setBusyAction] = useState("");
useEffect(() => {
if (!apps.some((app) => app.id === selectedAppId)) setSelectedAppId(apps[0]?.id || "");
}, [apps, selectedAppId]);
const selectedApp = apps.find((app) => app.id === selectedAppId) || apps[0];
const providers = selectedApp?.providers || [];
useEffect(() => {
if (!providers.some((provider) => provider.id === selectedProviderId)) setSelectedProviderId(selectedApp?.currentProviderId || providers[0]?.id || "");
}, [providers, selectedApp?.currentProviderId, selectedProviderId]);
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId) || providers.find((provider) => provider.active) || providers[0];
const configuredApps = apps.filter((app) => app.currentProviderId || app.providers?.some((provider) => provider.active)).length;
const ccSwitchManaged = settings?.preferences?.ccSwitchManaged === true;
const canEnableManagement = Boolean(ccSwitch?.initialized && apps.length && configuredApps === apps.length);
async function run(actionId, action) {
if (!action) return;
setBusyAction(actionId);
try { await action(); } catch {} finally { setBusyAction(""); }
}
async function toggleManagement() {
if (!onSavePreferences || (!ccSwitchManaged && !canEnableManagement)) return;
if (!ccSwitchManaged && !window.confirm("启用后,AgentDock 将停止向 Claude、Codex 和 OpenCode 注入旧 API Key。确定由 CC Switch 接管吗?")) return;
await run("ownership", () => onSavePreferences({
...(settings?.preferences || {}),
ccSwitchManaged: !ccSwitchManaged,
}));
}
if (!integrations) return <IntegrationSkeleton rows={7} />;
if (ccSwitch?.error) return <IntegrationError title="CC Switch 状态读取失败" detail={ccSwitch.error} onRefresh={onRefresh} />;
if (!ccSwitch?.installed) return <div className="integration-empty"><span className="integration-empty-icon"><ArrowRightLeft size={20} /></span><strong>CC Switch CLI 尚未安装</strong><p>安装完成后这里会显示可切换的 Provider 与当前生效配置</p><button className="btn btn-secondary" type="button" disabled={!onRefresh} onClick={onRefresh}><RefreshCw size={14} />重新检测</button></div>;
if (!ccSwitch.initialized) return <div className="integration-empty"><span className="integration-empty-icon"><TerminalSquare size={20} /></span><strong>需要初始化 CC Switch</strong><p>打开完整 TUI完成 Provider 导入和首次配置</p><button className="btn btn-primary" type="button" disabled={!onOpenIntegration} onClick={() => onOpenIntegration?.("ccswitch")}><TerminalSquare size={14} />打开 CC Switch TUI</button></div>;
return (
<div className="integration-stack">
<header className="integration-toolbar">
<div><h2>Provider 管理</h2><p>CC Switch {ccSwitch.version || "版本未知"}切换后新启动的 CLI 会话生效</p></div>
<div className="integration-actions"><button className="icon-btn" type="button" disabled={!onRefresh || busyAction === "refresh"} onClick={() => run("refresh", onRefresh)} title="刷新状态" aria-label="刷新集成状态"><RefreshCw size={15} /></button><button className="btn btn-secondary btn-sm" type="button" disabled={!onOpenIntegration} onClick={() => onOpenIntegration?.("ccswitch")}><TerminalSquare size={14} />完整 TUI</button></div>
</header>
<div className="integration-ownership">
<span><strong>CC Switch 配置接管</strong><small>{canEnableManagement || ccSwitchManaged ? "由 CC Switch 管理 Claude、Codex 和 OpenCode 的 Provider" : `先为 ${apps.length - configuredApps} 个 CLI 配置 Provider`}</small></span>
<button className="switch" type="button" role="switch" aria-checked={ccSwitchManaged} disabled={busyAction === "ownership" || (!ccSwitchManaged && !canEnableManagement)} onClick={toggleManagement} aria-label="CC Switch 配置接管" />
</div>
{apps.length ? (
<div className="provider-workspace">
<nav className="provider-tools" aria-label="CC Switch CLI">
<div className="provider-column-head">CLI</div>
{apps.map((app) => <button key={app.id} type="button" aria-current={selectedApp?.id === app.id} onClick={() => setSelectedAppId(app.id)}><span className="mono-badge">{app.name.slice(0, 2).toUpperCase()}</span><span><strong>{app.name}</strong><small>{app.providers?.find((provider) => provider.id === app.currentProviderId)?.name || "未选择 Provider"}</small></span></button>)}
</nav>
<section className="provider-list" aria-label={`${selectedApp?.name || "CLI"} Provider`}>
<div className="provider-column-head"><span>Provider</span><button className="icon-btn compact" type="button" disabled={!selectedApp || !onImportProvider || busyAction === "import"} onClick={() => run("import", () => onImportProvider?.(selectedApp.id))} title="导入当前配置" aria-label="导入当前配置"><Import size={14} /></button></div>
{providers.length ? providers.map((provider) => <button key={provider.id} type="button" aria-current={selectedProvider?.id === provider.id} onClick={() => setSelectedProviderId(provider.id)}><StateMark active={provider.active} /><span><strong>{provider.name}</strong><small>{provider.active ? "当前生效" : provider.endpoint || "使用默认地址"}</small></span></button>) : <div className="provider-empty-list"><PlugZap size={17} /><span>还没有 Provider</span><button type="button" disabled={!onImportProvider} onClick={() => onImportProvider?.(selectedApp.id)}>导入当前配置</button></div>}
</section>
<aside className="provider-detail">
<div className="provider-column-head">配置详情</div>
{selectedProvider ? <div className="provider-detail-body"><div className="provider-detail-heading"><StateMark active={selectedProvider.active} /><span><strong>{selectedProvider.name}</strong><small>{selectedApp?.name}</small></span></div><dl className="integration-kv"><div><dt>状态</dt><dd>{selectedProvider.active ? "当前生效" : "可切换"}</dd></div><div><dt>API 地址</dt><dd className="mono">{selectedProvider.endpoint || "CLI 默认地址"}</dd></div><div><dt>配置 ID</dt><dd className="mono">{selectedProvider.id}</dd></div></dl><button className="btn btn-primary provider-switch" type="button" disabled={selectedProvider.active || !onSwitchProvider || busyAction === "switch"} onClick={() => run("switch", () => onSwitchProvider?.(selectedApp.id, selectedProvider.id))}>{selectedProvider.active ? <><Check size={14} />正在使用</> : <><ArrowRightLeft size={14} />{busyAction === "switch" ? "正在切换" : "切换到此 Provider"}</>}</button></div> : <div className="provider-detail-empty">选择一个 Provider 查看详情</div>}
</aside>
</div>
) : <div className="integration-empty compact"><strong>CC Switch 中没有可管理的 CLI</strong><p>打开完整 TUI 导入 Provider或刷新检测结果</p></div>}
{ccSwitch.unsupportedToolIds?.length ? <div className="unsupported-tools"><span> AgentDock 管理</span><div>{ccSwitch.unsupportedToolIds.map((toolId) => <span className="tag" key={toolId}>{tools.find((item) => item.id === toolId)?.name || toolId}</span>)}</div></div> : null}
</div>
);
}
function CapabilitiesPanel({ integrations, onRefresh, onOpenIntegration }) {
const ccSwitch = integrations?.ccSwitch;
if (!integrations) return <IntegrationSkeleton rows={4} />;
if (ccSwitch?.error) return <IntegrationError title="无法读取 MCP 与 Skills" detail={ccSwitch.error} onRefresh={onRefresh} />;
return <div className="integration-stack capabilities-panel"><header className="integration-toolbar"><div><h2>MCP Skills</h2><p> CC Switch 统一维护安装源和各 CLI 的启用状态</p></div><button className="btn btn-secondary btn-sm" type="button" disabled={!ccSwitch?.installed || !onOpenIntegration} onClick={() => onOpenIntegration?.("ccswitch")}><ExternalLink size={14} />打开 CC Switch</button></header><div className="capability-rows"><section><span className="capability-icon"><PlugZap size={18} /></span><div><h3>MCP 服务</h3><p>{ccSwitch?.initialized ? "在 CC Switch TUI 中安装、启用和同步 MCP 服务。" : "完成 CC Switch 初始化后可管理 MCP 服务。"}</p></div><span className={`integration-badge ${ccSwitch?.initialized ? "ok" : "muted"}`}>{ccSwitch?.initialized ? "可管理" : "未初始化"}</span></section><section><span className="capability-icon"><Boxes size={18} /></span><div><h3>Skills</h3><p>{ccSwitch?.initialized ? "集中查看并同步已安装的 Agent Skills。" : "当前没有可读取的 Skills 状态。"}</p></div><span className={`integration-badge ${ccSwitch?.initialized ? "ok" : "muted"}`}>{ccSwitch?.initialized ? "可管理" : "未初始化"}</span></section></div><p className="meta integration-footnote">完整编辑功能当前在 CC Switch TUI 中提供AgentDock 只展示连接状态和快捷入口</p></div>;
}
function RuntimePanel({ integrations, tools, onRefresh, onOpenIntegration }) {
const multica = integrations?.multica;
const [serverUrl, setServerUrl] = useState("");
const [appUrl, setAppUrl] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { setServerUrl(multica?.config?.serverUrl || ""); setAppUrl(multica?.config?.appUrl || ""); }, [multica?.config?.serverUrl, multica?.config?.appUrl]);
const daemonOnline = ["running", "online", "healthy"].includes(multica?.daemon?.status);
const detectedTools = useMemo(() => {
const supported = new Set(multica?.supportedToolIds || []);
return tools.map((tool) => ({ ...tool, installed: tool.installed !== false && Boolean(tool.version), supported: supported.has(tool.id) }));
}, [multica?.supportedToolIds, tools]);
const installedCount = detectedTools.filter((tool) => tool.installed).length;
async function configure() {
if (!onOpenIntegration || !serverUrl.trim() || !appUrl.trim()) return;
setBusy(true);
try { await onOpenIntegration("multica", { serverUrl: serverUrl.trim(), appUrl: appUrl.trim() }); } finally { setBusy(false); }
}
if (!integrations) return <IntegrationSkeleton rows={6} />;
if (multica?.error) return <IntegrationError title="Multica Runtime 状态读取失败" detail={multica.error} onRefresh={onRefresh} />;
if (!multica?.installed) return <div className="integration-empty"><span className="integration-empty-icon"><MonitorCog size={20} /></span><strong>Multica daemon 尚未安装</strong><p>安装客户端运行时后Multica 才能检测容器中的 Agent CLI</p><button className="btn btn-secondary" type="button" disabled={!onRefresh} onClick={onRefresh}><RefreshCw size={14} />重新检测</button></div>;
return (
<div className="integration-stack runtime-panel">
<header className="integration-toolbar"><div><h2>Multica Runtime</h2><p>daemon {multica.version || "版本未知"}连接自托管服务端并调度容器内 CLI</p></div><button className="icon-btn" type="button" disabled={!onRefresh} onClick={onRefresh} title="刷新状态" aria-label="刷新 Multica 状态"><RefreshCw size={15} /></button></header>
<div className="runtime-summary"><section><span className={`runtime-icon ${daemonOnline ? "online" : "offline"}`}><ServerCog size={18} /></span><div><span className="meta">DAEMON</span><strong>{daemonOnline ? "在线" : "离线"}</strong><small>{multica.daemon?.supervision?.mode || (typeof multica.daemon?.supervision === "string" ? multica.daemon.supervision : "容器进程管理")}</small></div></section><section><span className="runtime-icon"><TerminalSquare size={18} /></span><div><span className="meta">容器内 CLI</span><strong className="num">{installedCount}</strong><small>其中 {detectedTools.filter((tool) => tool.installed && tool.supported).length} 个已声明支持</small></div></section><section><span className="runtime-icon"><MonitorCog size={18} /></span><div><span className="meta">WORKSPACE</span><strong>{multica.config?.workspaceId || "未绑定"}</strong><small>自托管工作区</small></div></section></div>
<div className="runtime-content">
<section className="runtime-config info-card"><h3>服务端连接</h3><label>Server URL<input className="input mono" type="url" value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} placeholder="https://multica-api.example.com" autoComplete="url" /></label><label>App URL<input className="input mono" type="url" value={appUrl} onChange={(event) => setAppUrl(event.target.value)} placeholder="https://multica.example.com" autoComplete="url" /></label><button className="btn btn-primary" type="button" disabled={busy || !onOpenIntegration || !serverUrl.trim() || !appUrl.trim()} onClick={configure}><TerminalSquare size={14} />{busy ? "正在打开" : multica.config?.configured ? "重新授权" : "初始化 Runtime"}</button><p className="meta">授权过程会在独立终端中完成密钥不会显示在网页</p></section>
<section className="runtime-tools info-card"><h3>CLI 检测结果</h3>{detectedTools.length ? <div className="runtime-tool-grid">{detectedTools.map((tool) => <div key={tool.id} className={tool.installed ? "installed" : "missing"}><StateMark active={tool.installed} /><span><strong>{tool.name}</strong><small>{tool.installed ? (tool.supported ? "已安装,可调用" : "已安装,待验证支持") : "未检测到"}</small></span>{tool.installed && !tool.supported ? <span className="integration-badge muted">待验证</span> : null}</div>)}</div> : <div className="settings-empty compact">daemon 尚未返回 CLI 检测结果</div>}</section>
</div>
</div>
);
}
export function IntegrationsView({ view, integrations, tools = [], settings, onSavePreferences, onRefresh, onSwitchProvider, onImportProvider, onOpenIntegration }) {
if (view === "providers") return <ProvidersPanel integrations={integrations} tools={tools} settings={settings} onSavePreferences={onSavePreferences} onRefresh={onRefresh} onSwitchProvider={onSwitchProvider} onImportProvider={onImportProvider} onOpenIntegration={onOpenIntegration} />;
if (view === "capabilities") return <CapabilitiesPanel integrations={integrations} onRefresh={onRefresh} onOpenIntegration={onOpenIntegration} />;
return <RuntimePanel integrations={integrations} tools={tools} onRefresh={onRefresh} onOpenIntegration={onOpenIntegration} />;
}
+54
View File
@@ -0,0 +1,54 @@
import { useState } from "react";
import { LockKeyhole, TerminalSquare } from "lucide-react";
import { api } from "../api.js";
export function LoginPage({ onLogin }) {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event) {
event.preventDefault();
setBusy(true);
setError("");
try {
await api.login(password);
onLogin();
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
return (
<main className="login-shell">
<form className="login-panel" onSubmit={submit}>
<span className="logo login-logo" aria-hidden="true"><TerminalSquare size={18} strokeWidth={1.8} /></span>
<div>
<h1>AgentDock</h1>
<p>AI CLI 管理控制台</p>
</div>
<label className="login-field">
<span>管理员密码</span>
<span className="login-input-wrap">
<LockKeyhole size={15} aria-hidden="true" />
<input
autoFocus
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入管理员密码"
/>
</span>
</label>
{error ? <p className="form-error" role="alert">{error}</p> : null}
<button className="btn btn-primary" type="submit" disabled={busy || !password}>
{busy ? "正在验证" : "登录"}
</button>
<span className="meta login-meta">仅允许局域网内访问</span>
</form>
</main>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useMemo, useState } from "react";
import { Eye, EyeOff, KeyRound, LogOut, Save, ShieldCheck, TerminalSquare, Trash2 } from "lucide-react";
import { IntegrationsView } from "./IntegrationsView.jsx";
const SETTINGS_TABS = [
["providers", "Provider"],
["accounts", "CLI 账号"],
["capabilities", "MCP 与 Skills"],
["runtime", "Runtime"],
["security", "安全"],
];
function SecretRow({ secret, onSave }) {
const [value, setValue] = useState("");
const [visible, setVisible] = useState(false);
const [busy, setBusy] = useState(false);
async function save(nextValue) {
setBusy(true);
try {
await onSave(secret.name, nextValue);
setValue("");
} finally {
setBusy(false);
}
}
return (
<div className="secret-row">
<label htmlFor={`secret-${secret.name}`}>
{secret.label}
<span className="meta">{secret.configured ? `已配置 ${secret.masked}` : `用于 ${secret.tools.join(", ")}`}</span>
</label>
<div className="secret-controls">
<span className="secret-input-wrap">
<input id={`secret-${secret.name}`} className="input" type={visible ? "text" : "password"} value={value} onChange={(event) => setValue(event.target.value)} placeholder={secret.configured ? "输入新值以替换" : "输入 API Key"} autoComplete="off" />
<button type="button" className="icon-btn" onClick={() => setVisible((current) => !current)} aria-label={visible ? "隐藏 Key" : "显示 Key"} title={visible ? "隐藏 Key" : "显示 Key"}>{visible ? <EyeOff size={15} /> : <Eye size={15} />}</button>
</span>
<button type="button" className="icon-btn" disabled={busy || !value.trim()} onClick={() => save(value.trim())} aria-label="保存 Key" title="保存 Key"><Save size={15} /></button>
<button type="button" className="icon-btn danger-icon" disabled={busy || !secret.configured} onClick={() => save("")} aria-label="删除 Key" title="删除 Key"><Trash2 size={15} /></button>
</div>
</div>
);
}
function AccountsPanel({ settings, tools, configuredByTool, onSaveSecret, onSavePreferences, onAuthorize }) {
async function changeModel(toolId, value) {
await onSavePreferences({
...(settings.preferences || {}),
models: { ...(settings.preferences?.models || {}), [toolId]: value.trim() },
dshProfile: "headless",
});
}
return (
<div className="set-grid console-settings-grid">
<section className="info-card credentials-card">
<div className="settings-card-title"><KeyRound size={15} /><h3>API Key</h3></div>
{settings.secrets.length ? settings.secrets.map((secret) => <SecretRow key={secret.name} secret={secret} onSave={onSaveSecret} />) : <div className="settings-empty compact">当前没有可配置的 API Key</div>}
<p className="meta security-note">Key 使用 AES-256-GCM 加密保存页面只显示掩码</p>
</section>
<section className="info-card">
<h3>CLI 授权</h3>
{tools.length ? tools.map((tool) => (
<div className="set-row" key={tool.id}>
<span>{tool.name}<span className="meta">{configuredByTool[tool.id] ? "API Key 已配置" : "可在真实终端完成设备授权"}</span></span>
<button className="btn btn-secondary btn-sm" type="button" onClick={() => onAuthorize(tool.id)}><TerminalSquare size={14} />{tool.id === "dsh" ? "查看运行方式" : "打开终端"}</button>
</div>
)) : <div className="settings-empty compact">没有检测到 CLI</div>}
</section>
<section className="info-card">
<h3>DeepSeek Harness</h3>
<div className="set-row"><span>运行配置<span className="meta">输入任务后启动完成后自动退出</span></span><span className="tag">headless</span></div>
<p className="meta security-note">当前官方 npm 包没有可安装的 TUI profileWeb profile 不适合作为 AgentDock 会话使用</p>
</section>
<section className="info-card credentials-card">
<h3>默认模型</h3>
{tools.filter((tool) => tool.id !== "dsh").map((tool) => (
<div className="set-row model-row" key={tool.id}>
<span>{tool.name}<span className="meta">留空时使用 CLI 自己的默认模型</span></span>
<input className="input model-setting-input" key={`${tool.id}-${settings.preferences?.models?.[tool.id] || ""}`} defaultValue={settings.preferences?.models?.[tool.id] || ""} onBlur={(event) => changeModel(tool.id, event.target.value)} placeholder="CLI 默认模型" aria-label={`${tool.name} 默认模型`} />
</div>
))}
</section>
</div>
);
}
function SecurityPanel({ follow, setFollow, onLogout }) {
return (
<div className="set-grid console-settings-grid">
<section className="info-card">
<div className="settings-card-title"><ShieldCheck size={15} /><h3>安全边界</h3></div>
<div className="set-row"><span>网络<span className="meta">HTTPS 192.168.200.0/24</span></span><span className="meta">LAN</span></div>
<div className="set-row"><span>项目目录<span className="meta">Runner 拒绝 /workspace 之外的路径</span></span><span className="meta mono">/workspace</span></div>
<div className="set-row"><span>Docker Socket<span className="meta">控制台与 Runner 均未挂载</span></span><span className="meta">未挂载</span></div>
</section>
<section className="info-card">
<h3>终端行为</h3>
<div className="set-row"><span>自动跟随钉底<span className="meta">新输出到达时保持滚动到底部</span></span><button className="switch" type="button" role="switch" aria-checked={follow} onClick={() => setFollow(!follow)} aria-label="自动跟随钉底" /></div>
</section>
<section className="info-card">
<h3>账户</h3>
<div className="set-row"><span>管理员<span className="meta">单管理员模式无公开注册</span></span><button type="button" className="btn btn-secondary btn-sm" onClick={onLogout}><LogOut size={14} />退出登录</button></div>
</section>
</div>
);
}
export function SettingsView({ settings, tools, integrations, onSaveSecret, onSavePreferences, onAuthorize, onLogout, onRefreshIntegrations, onSwitchProvider, onImportProvider, onOpenIntegration, follow, setFollow }) {
const [activeTab, setActiveTab] = useState("providers");
const configuredByTool = useMemo(() => Object.fromEntries(tools.map((tool) => [tool.id, settings.secrets.some((secret) => secret.configured && secret.tools.includes(tool.id))])), [settings.secrets, tools]);
return (
<div className="settings-shell">
<div className="settings-tabs" role="tablist" aria-label="设置分类">
{SETTINGS_TABS.map(([id, label]) => <button key={id} type="button" role="tab" aria-selected={activeTab === id} aria-controls={`settings-panel-${id}`} onClick={() => setActiveTab(id)}>{label}</button>)}
</div>
<div className="set-scroll settings-tab-scroll" id={`settings-panel-${activeTab}`} role="tabpanel">
{activeTab === "providers" || activeTab === "capabilities" || activeTab === "runtime" ? <IntegrationsView view={activeTab} integrations={integrations} tools={tools} settings={settings} onSavePreferences={onSavePreferences} onRefresh={onRefreshIntegrations} onSwitchProvider={onSwitchProvider} onImportProvider={onImportProvider} onOpenIntegration={onOpenIntegration} /> : null}
{activeTab === "accounts" ? <AccountsPanel settings={settings} tools={tools} configuredByTool={configuredByTool} onSaveSecret={onSaveSecret} onSavePreferences={onSavePreferences} onAuthorize={onAuthorize} /> : null}
{activeTab === "security" ? <SecurityPanel follow={follow} setFollow={setFollow} onLogout={onLogout} /> : null}
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
export function TerminalPane({ session, onExit, onResizeReady, follow = true }) {
const hostRef = useRef(null);
const followRef = useRef(follow);
const onExitRef = useRef(onExit);
const onResizeReadyRef = useRef(onResizeReady);
useEffect(() => { followRef.current = follow; }, [follow]);
useEffect(() => { onExitRef.current = onExit; }, [onExit]);
useEffect(() => { onResizeReadyRef.current = onResizeReady; }, [onResizeReady]);
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
const terminal = new Terminal({
allowProposedApi: false,
convertEol: true,
cursorBlink: true,
fontFamily: '"JetBrains Mono", "SF Mono", ui-monospace, monospace',
fontSize: 12.5,
lineHeight: 1.35,
scrollback: 5000,
theme: {
background: "#0a0e14",
foreground: "#e2e8f2",
cursor: "#69d5dc",
selectionBackground: "#29545d",
black: "#0d1117",
brightBlack: "#8b95a9",
red: "#df6d74",
green: "#65cf93",
yellow: "#d7bb68",
blue: "#73a8e8",
magenta: "#bc8be8",
cyan: "#69d5dc",
white: "#e2e8f2",
},
});
const fit = new FitAddon();
terminal.loadAddon(fit);
terminal.open(host);
fit.fit();
if (!session) {
terminal.writeln("\x1b[90m选择项目并发送任务后,将在这里启动真实 CLI 会话。\x1b[0m");
terminal.writeln("\x1b[90m关闭浏览器不会结束已启动的会话。\x1b[0m");
return () => terminal.dispose();
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socketUrl = `${protocol}//${window.location.host}/api/sessions/${session.id}/ws`;
let socket;
let reconnectTimer;
let heartbeatTimer;
let stopped = false;
let reconnectAttempt = 0;
const clearHeartbeat = () => {
window.clearInterval(heartbeatTimer);
heartbeatTimer = undefined;
};
const sendResize = () => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows }));
}
};
const resize = () => {
fit.fit();
sendResize();
};
const observer = new ResizeObserver(resize);
observer.observe(host);
onResizeReadyRef.current?.({ cols: terminal.cols, rows: terminal.rows });
terminal.onData((data) => {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "input", data }));
});
function connect() {
if (stopped || socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return;
socket = new WebSocket(socketUrl);
socket.addEventListener("open", () => {
reconnectAttempt = 0;
sendResize();
clearHeartbeat();
heartbeatTimer = window.setInterval(() => {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "heartbeat" }));
}, 20_000);
});
socket.addEventListener("message", (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "snapshot") {
terminal.reset();
if (message.data) terminal.write(message.data);
}
if (message.type === "output") {
terminal.write(message.data);
if (followRef.current) terminal.scrollToBottom();
}
if (message.type === "exit") onExitRef.current?.(message);
} catch {
terminal.writeln("\r\n\x1b[31m终端消息解析失败\x1b[0m");
}
});
socket.addEventListener("close", () => {
clearHeartbeat();
if (stopped) return;
const delay = Math.min(10_000, 1_000 * (2 ** reconnectAttempt));
reconnectAttempt += 1;
terminal.writeln("\r\n\x1b[90m[连接中断,正在自动重连]\x1b[0m");
reconnectTimer = window.setTimeout(connect, delay);
});
}
connect();
return () => {
stopped = true;
observer.disconnect();
window.clearTimeout(reconnectTimer);
clearHeartbeat();
socket?.close();
terminal.dispose();
};
}, [session?.id]);
return <div ref={hostRef} className="term xterm-host" aria-label="终端输出" />;
}
File diff suppressed because it is too large Load Diff
+401
View File
@@ -0,0 +1,401 @@
/* ─── tokens ────────────────────────────────────────────────────── */
:root {
color-scheme: dark;
/* 三层表面明度递进(Tokyo Night 低饱和蓝黑,不用纯黑) */
--bg: #0d1117; /* 底 */
--surface: #141a24; /* 面板 */
--overlay: #1b2330; /* 浮层 */
--fg: #e2e8f2;
--muted: #8b95a9;
--border: rgb(255 255 255 / .06); /* hairline */
--accent: oklch(0.8 0.13 195); /* 电光青:唯一品牌强调色 */
/* 状态语义色:只用于 ~10px 圆点与呼吸边框 */
--ok: oklch(0.76 0.15 150);
--warn: oklch(0.8 0.13 85);
--err: oklch(0.7 0.17 25);
/* 派生 */
--accent-ink: oklch(0.16 0.02 240);
--accent-soft: color-mix(in oklch, var(--accent) 26%, transparent);
--accent-faint: color-mix(in oklch, var(--accent) 10%, transparent);
--hover: rgb(255 255 255 / .05);
--hover-strong: rgb(255 255 255 / .09);
--term-bg: #0a0e14;
--glass: color-mix(in srgb, var(--overlay) 78%, transparent);
/* 光影分层:柔和多层投影 + 顶部 1px 高光描边(材质厚度感) */
--shadow-pop: 0 1px 2px rgb(0 0 0 / .32), 0 10px 28px rgb(0 0 0 / .42);
--top-hi: inset 0 1px 0 rgb(255 255 255 / .04);
--font-display: "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--font-body: "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "SF Mono", ui-monospace, Menlo, monospace;
/* 动效 token:三档时长 + 缓动(spring 只给低频仪式时刻) */
--dur-fast: 100ms; /* hover */
--dur-med: 200ms; /* 面板 / Tab / 视图切换 */
--dur-slow: 400ms; /* 高亮淡出 / 数字跳变 / toast 入场 */
--ease-in: cubic-bezier(0, 0, 0.2, 1);
--ease-out: cubic-bezier(0.4, 0, 1, 1);
--ease-move: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(.34, 1.32, .42, 1); /* overshoot 克制 */
--rail-w: 48px; --status-h: 28px; --top-h: 44px;
--radius: 8px; --radius-lg: 12px;
}
/* ─── reset & base ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font-family: var(--font-body); font-size: 14px; line-height: 1.55;
text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased;
overflow: hidden;
}
svg { display: block; }
button { font: inherit; cursor: pointer; color: inherit; background: none; border: 0; padding: 0; }
[hidden] { display: none !important; }
h1, h2, h3 { font-family: var(--font-display); margin: 0; text-wrap: balance; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
::selection { background: var(--accent-soft); }
.num, .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.meta { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
/* 键盘触发的动作零动画(选中高亮瞬时切换) */
html.kbd *, html.kbd *::before, html.kbd *::after { transition-duration: 0s !important; animation-duration: 0s !important; }
/* ─── app shell:活动栏 / 主区 + 底部状态栏(v4 无侧栏)───────── */
.app { display: grid; height: 100vh; grid-template-rows: 1fr var(--status-h); grid-template-columns: var(--rail-w) 1fr; }
.statusbar { grid-column: 1 / -1; }
/* 活动栏 */
.rail { background: var(--bg); border-right: 1px solid var(--border); display: flex; flex-direction: column; align-items: center; padding: 8px 0; gap: 4px; z-index: 5; }
.rail .logo { width: 30px; height: 30px; margin-bottom: 10px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); box-shadow: var(--top-hi); color: var(--fg); }
.rail-btn { position: relative; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 8px; color: var(--muted); transition: background var(--dur-fast) var(--ease-move), color var(--dur-fast) var(--ease-move); }
.rail-btn:hover { background: var(--hover); color: var(--fg); }
.rail-btn[aria-current="true"] { color: var(--fg); background: var(--hover); }
.rail-btn[aria-current="true"]::before { content: ''; position: absolute; left: -6px; top: 8px; bottom: 8px; width: 2px; border-radius: 2px; background: var(--accent); }
.rail-btn::after {
content: attr(data-tip); position: absolute; left: calc(100% + 12px); top: 50%; translate: 0 -50%;
background: var(--glass); backdrop-filter: blur(12px); border: 1px solid var(--border); box-shadow: var(--shadow-pop);
color: var(--fg); font-size: 12px; white-space: nowrap; padding: 5px 10px; border-radius: 6px;
opacity: 0; pointer-events: none; transition: opacity var(--dur-fast) var(--ease-in); z-index: 60;
}
.rail-btn:hover::after { opacity: 1; }
.rail .spacer { flex: 1; }
/* 「等待输入」注意力角标:挂在工作台图标上 */
.rail-badge { position: absolute; top: 1px; right: -1px; min-width: 15px; height: 15px; padding: 0 4px; border-radius: 999px; background: var(--warn); color: var(--accent-ink); font-family: var(--font-mono); font-size: 9.5px; font-weight: 600; display: grid; place-items: center; }
/* 状态点:颜色 + 形状双编码;running 呼吸点带辉光(特效三处之一) */
.dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--muted); }
.dot.run { background: var(--ok); animation: breathe-dot 2s cubic-bezier(.4, 0, .6, 1) infinite; }
.dot.wait { background: var(--warn); border-radius: 2px; }
.dot.err { background: var(--err); border-radius: 2px; transform: rotate(45deg); }
.dot.off { background: transparent; border: 1.5px solid var(--muted); }
@keyframes breathe-dot {
0%, 100% { box-shadow: 0 0 8px color-mix(in oklch, var(--ok) 45%, transparent), 0 0 0 0 color-mix(in oklch, var(--ok) 40%, transparent); opacity: 1; }
50% { box-shadow: 0 0 4px color-mix(in oklch, var(--ok) 28%, transparent), 0 0 0 4px transparent; opacity: .72; }
}
/* waiting:唯一脉冲元素 = claude 会话 Tab 的 1px 呼吸边框;其余等待者降级为常亮琥珀徽标 */
.sess-tab.waiting { border-color: color-mix(in oklch, var(--warn) 45%, transparent); animation: breathe-border 2s cubic-bezier(.4, 0, .6, 1) infinite; }
@keyframes breathe-border { 0%, 100% { border-color: color-mix(in oklch, var(--warn) 45%, transparent); } 50% { border-color: color-mix(in oklch, var(--warn) 12%, transparent); } }
.sess-tab.shake { animation: shake .4s var(--ease-move) 1; }
@keyframes shake { 25% { transform: translateX(-3px); } 50% { transform: translateX(3px); } 75% { transform: translateX(-2px); } }
/* 主区:环境纹理(网格 ≤4%)+ 顶部单道青色环境光晕(全页唯一大面积光效) */
.main { position: relative; min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--bg); }
.main::before { content: ''; position: absolute; inset: 0 0 auto; height: 320px; pointer-events: none; background: radial-gradient(60% 100% at 50% 0%, color-mix(in oklch, var(--accent) 6%, transparent), transparent 75%); }
.main::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background-image: linear-gradient(rgb(255 255 255 / .028) 1px, transparent 1px), linear-gradient(90deg, rgb(255 255 255 / .028) 1px, transparent 1px);
background-size: 30px 30px;
mask-image: linear-gradient(180deg, black, transparent 62%);
}
/* 顶部上下文栏 */
.topbar { position: relative; height: var(--top-h); flex: none; display: flex; align-items: center; gap: 14px; padding: 0 16px; border-bottom: 1px solid var(--border); background: var(--surface); box-shadow: var(--top-hi); z-index: 4; }
.topbar h1 { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; }
.topbar .crumb { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); }
.env-sel { position: relative; margin-left: auto; }
.env-sel select { appearance: none; background: var(--overlay); color: var(--fg); border: 1px solid var(--border); border-radius: 7px; font-family: var(--font-mono); font-size: 12px; padding: 5px 28px 5px 10px; transition: border-color var(--dur-fast) var(--ease-move); }
.env-sel select:hover { border-color: var(--hover-strong); }
.env-sel::after { content: '▾'; position: absolute; right: 10px; top: 50%; translate: 0 -50%; color: var(--muted); font-size: 11px; pointer-events: none; }
/* 注意力药丸:点击跳到第一个等待中的智能体 */
.attn-pill { display: inline-flex; align-items: center; gap: 7px; padding: 5px 12px; border-radius: 999px; border: 1px solid color-mix(in oklch, var(--warn) 42%, transparent); background: var(--glass); backdrop-filter: blur(12px); color: var(--warn); font-family: var(--font-mono); font-size: 12px; transition: background var(--dur-fast) var(--ease-move), transform var(--dur-fast) var(--ease-move); }
.attn-pill:hover { background: color-mix(in oklch, var(--warn) 10%, transparent); transform: translateY(-1px); }
.kbtn { display: inline-flex; align-items: center; gap: 8px; background: var(--overlay); border: 1px solid var(--border); border-radius: 7px; padding: 5px 10px; color: var(--muted); font-size: 12.5px; transition: background var(--dur-fast) var(--ease-move); }
.kbtn:hover { background: var(--hover); color: var(--fg); }
kbd { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; background: var(--bg); }
/* 视图容器(叠在环境纹理之上) */
.views { position: relative; z-index: 1; flex: 1; min-height: 0; }
.view { display: none; height: 100%; min-height: 0; }
.view.active { display: flex; flex-direction: column; }
/* 入场 fade + 6px 上移,瞬态类:稳态下视图不带 animation,任何截图时机都能捕获到内容 */
.view.entering { animation: view-in var(--dur-med) var(--ease-in); }
@keyframes view-in { from { opacity: 0; transform: translateY(6px); } }
/* 列表/表格行入场:交错淡入上移(瞬态类,只首次进入视图时挂) */
.stag-in { animation: stag-in var(--dur-slow) var(--ease-in) both; animation-delay: calc(var(--si, 0) * 36ms); }
@keyframes stag-in { from { opacity: 0; transform: translateY(8px); } }
/* ─── 总览 ──────────────────────────────────────────────────────── */
.ov-scroll { flex: 1; overflow-y: auto; padding: 22px 24px 40px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.stat-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
.stat-tile { background: var(--surface); box-shadow: var(--top-hi); padding: 15px 18px 13px; }
.stat-tile .v { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 25px; font-weight: 600; line-height: 1.15; letter-spacing: -0.02em; }
/* 数字入场:400ms easeOut 轻微上浮(booted 后触发,防截图捕获空白首帧) */
html.booted .stat-tile .v { animation: num-in var(--dur-slow) var(--ease-out) both; }
@keyframes num-in { from { opacity: .3; transform: translateY(4px); } }
.stat-tile .l { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
.stat-tile .sub { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 3px; opacity: .85; }
.ov-grid { display: grid; gap: 12px; margin-top: 16px; grid-template-columns: repeat(auto-fill, minmax(min(288px, 100%), 1fr)); }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--top-hi); padding: 15px 16px; cursor: pointer; display: flex; flex-direction: column; gap: 11px; transition: background var(--dur-fast) var(--ease-move), border-color var(--dur-fast) var(--ease-move), transform var(--dur-fast) var(--ease-move); }
.card:hover { background: var(--overlay); border-color: var(--hover-strong); transform: translateY(-2px); }
.card:active { transform: scale(.97); }
.card-head { display: flex; align-items: center; gap: 10px; }
.mono-badge { width: 32px; height: 32px; flex: none; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--bg); font-family: var(--font-mono); font-size: 11px; font-weight: 600; }
.card-head strong { font-size: 14px; display: block; line-height: 1.25; }
.card-head .meta { font-size: 11px; }
.card-status { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.st-badge { flex: none; font-size: 10.5px; font-family: var(--font-mono); color: var(--warn); border: 1px solid color-mix(in oklch, var(--warn) 40%, transparent); border-radius: 999px; padding: 1px 7px; }
.kv { display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 4px 12px; margin: 0; }
.kv dt { color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; padding-top: 1.5px; }
.kv dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 12px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ─── 工作台 ────────────────────────────────────────────────────── */
/* 会话 Tab 条:编辑器式并行会话隐喻,横向滚动,毛玻璃粘在顶部 */
.sess-tabs { flex: none; display: flex; align-items: stretch; gap: 4px; padding: 8px 12px 0; overflow-x: auto; scrollbar-width: none; background: var(--glass); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); }
.sess-tabs::-webkit-scrollbar { display: none; }
.sess-tab { position: relative; display: flex; align-items: center; gap: 8px; padding: 7px 12px; margin-bottom: -1px; min-width: 0; flex: none; border: 1px solid transparent; border-radius: 9px 9px 0 0; color: var(--muted); transition: background var(--dur-fast) var(--ease-move), border-color var(--dur-fast) var(--ease-move), color var(--dur-fast) var(--ease-move); }
.sess-tab:hover { background: var(--hover); color: var(--fg); }
/* 当前会话 Tab:高亮 + 辉光(特效三处之二,blur 12px / opacity ≤.5 */
.sess-tab[aria-current="true"] { background: var(--overlay); border-color: var(--border); border-bottom-color: var(--overlay); box-shadow: var(--top-hi), 0 0 12px color-mix(in oklch, var(--accent) 22%, transparent); color: var(--fg); }
.st-main { min-width: 0; text-align: left; }
.st-name { display: block; font-size: 12.5px; font-weight: 500; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-sub { display: block; font-family: var(--font-mono); font-size: 10px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ⌘B:紧凑 / 舒适密度 */
.sess-tabs.compact { padding-top: 5px; }
.sess-tabs.compact .sess-tab { padding: 5px 10px; gap: 6px; }
.sess-tabs.compact .st-sub { display: none; }
.wb-bar { flex: none; display: flex; align-items: center; gap: 6px; padding: 10px 16px 0; }
.wb-tab { padding: 7px 13px; border-radius: 7px; font-size: 13px; color: var(--muted); border: 1px solid transparent; transition: background var(--dur-fast) var(--ease-move), color var(--dur-fast) var(--ease-move); }
.wb-tab:hover { background: var(--hover); color: var(--fg); }
.wb-tab[aria-current="true"] { color: var(--fg); background: var(--overlay); border-color: var(--border); box-shadow: var(--top-hi); font-weight: 500; }
.seg { margin-left: auto; display: inline-flex; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 2px; }
.seg button { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 6px; font-size: 12.5px; color: var(--muted); transition: background var(--dur-fast) var(--ease-move), color var(--dur-fast) var(--ease-move); }
.seg button:hover { color: var(--fg); }
.seg button[aria-current="true"] { background: var(--overlay); color: var(--fg); box-shadow: var(--top-hi); }
.wb-body { flex: 1; min-height: 0; padding: 12px 16px 16px; display: flex; flex-direction: column; }
.pane { display: none; flex: 1; min-height: 0; flex-direction: column; }
.pane.active { display: flex; }
/* 终端面板:浮层材质 = 多层投影 + 顶部高光 */
.term-panel { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; background: var(--term-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-pop), var(--top-hi); overflow: hidden; }
.term-head { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); background: var(--surface); box-shadow: var(--top-hi); }
.term-head .l { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.term-head .l span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.term-head .r { color: var(--fg); flex: none; }
.term { flex: 1; overflow-y: auto; padding: 14px 16px 16px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.tl { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.75; white-space: pre-wrap; word-break: break-word; color: var(--fg); }
.tl.dim { color: var(--muted); }
.tl.ev { color: var(--muted); } .tl.ev .c { color: var(--fg); }
.tl.ok { color: color-mix(in oklch, var(--ok) 70%, var(--fg)); }
.tl.err { color: color-mix(in oklch, var(--err) 70%, var(--fg)); }
.tl.user { font-weight: 600; } .tl.user .mark { color: var(--ok); margin-right: 8px; }
/* 事件级变化:单次 1.8s yellow-fade 背景高亮淡出(不循环,只给最新一行) */
.tl.flash { animation: flash 1.8s var(--ease-out) 1; }
@keyframes flash { 0% { background: color-mix(in oklch, var(--warn) 15%, transparent); } 100% { background: transparent; } }
.caret { display: inline-block; width: 8px; height: 14px; vertical-align: -2px; background: var(--accent); animation: blink 1.1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.wait-box { margin: 6px 0; padding: 10px 14px; border: 1px solid color-mix(in oklch, var(--warn) 40%, transparent); border-radius: var(--radius); background: color-mix(in oklch, var(--warn) 7%, transparent); font-family: var(--font-mono); font-size: 12.5px; color: var(--fg); }
.wait-box .meta { display: block; margin-top: 3px; }
/* 「↓ N 条新输出」毛玻璃药丸 */
.new-pill { position: absolute; right: 16px; bottom: 14px; display: inline-flex; align-items: center; gap: 7px; padding: 7px 13px; border-radius: 999px; border: 1px solid var(--border); background: var(--glass); backdrop-filter: blur(12px); box-shadow: var(--shadow-pop); color: var(--fg); font-family: var(--font-mono); font-size: 12px; opacity: 0; pointer-events: none; transform: translateY(6px); transition: opacity var(--dur-med) var(--ease-in), transform var(--dur-med) var(--ease-in); }
.new-pill.show { opacity: 1; pointer-events: auto; transform: none; }
.new-pill:hover { background: var(--overlay); }
/* composer */
.chips { flex: none; display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0 8px; }
.chip { border: 1px solid var(--border); border-radius: 999px; padding: 6px 13px; font-size: 12px; color: var(--muted); background: var(--surface); transition: background var(--dur-fast) var(--ease-move), color var(--dur-fast) var(--ease-move); }
.chip:hover { background: var(--hover); color: var(--fg); }
.composer { flex: none; display: flex; gap: 10px; align-items: stretch; }
.input { min-width: 0; min-height: 40px; padding: 9px 13px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--fg); font: inherit; font-size: 13.5px; transition: border-color var(--dur-fast) var(--ease-move); }
.input::placeholder { color: var(--muted); }
.input:focus { outline: none; border-color: color-mix(in oklch, var(--accent) 55%, var(--border)); }
.composer .input[type="text"] { flex: 1; }
.sel { position: relative; flex: none; }
.sel::after { content: '▾'; position: absolute; right: 11px; top: 50%; translate: 0 -50%; pointer-events: none; color: var(--muted); font-size: 11px; }
.sel select { appearance: none; padding-right: 30px; height: 100%; font-family: var(--font-mono); font-size: 12px; }
.sel select option { background: var(--overlay); color: var(--fg); }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 9px 16px; min-height: 40px; border-radius: var(--radius); border: 1px solid transparent; font-size: 13.5px; font-weight: 500; transition: transform var(--dur-fast) var(--ease-move), background var(--dur-fast) var(--ease-move), border-color var(--dur-fast) var(--ease-move); }
.btn:active { transform: scale(.97); }
.btn[disabled] { opacity: .5; cursor: not-allowed; }
/* 主按钮辉光(特效三处之三) */
.btn-primary { background: var(--accent); color: var(--accent-ink); box-shadow: 0 0 12px color-mix(in oklch, var(--accent) 35%, transparent); }
.btn-primary:hover { background: color-mix(in oklch, var(--accent) 88%, white); }
.btn-secondary { background: var(--overlay); color: var(--fg); border-color: var(--border); box-shadow: var(--top-hi); }
.btn-secondary:hover { background: var(--hover-strong); }
.btn-danger { background: transparent; color: color-mix(in oklch, var(--err) 70%, var(--fg)); border-color: color-mix(in oklch, var(--err) 40%, var(--border)); }
.btn-danger:hover { background: color-mix(in oklch, var(--err) 10%, transparent); }
.btn-sm { padding: 6px 12px; min-height: 32px; font-size: 12.5px; }
/* Diff 视图:绿/红只给 diff 行内小面积 */
.diff-panel { flex: 1; min-height: 0; overflow-y: auto; background: var(--term-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-pop), var(--top-hi); scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.diff-file { padding: 9px 16px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 12px; color: var(--fg); background: var(--surface); box-shadow: var(--top-hi); display: flex; justify-content: space-between; }
.dl { display: flex; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 12px; line-height: 1.7; white-space: pre; }
.dl .ln { flex: none; width: 76px; text-align: right; padding-right: 14px; color: var(--muted); opacity: .6; user-select: none; }
.dl .lc { flex: 1; padding-right: 16px; overflow: hidden; text-overflow: ellipsis; }
.dl.add { background: color-mix(in oklch, var(--ok) 8%, transparent); } .dl.add .lc { color: color-mix(in oklch, var(--ok) 65%, var(--fg)); }
.dl.del { background: color-mix(in oklch, var(--err) 8%, transparent); } .dl.del .lc { color: color-mix(in oklch, var(--err) 65%, var(--fg)); }
.dl.hunk .lc { color: var(--muted); }
.dl.add .ln, .dl.del .ln { opacity: .9; }
/* 信息视图 */
.info-scroll { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding-bottom: 8px; }
.info-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--top-hi); padding: 15px 16px; }
.info-card h3 { font-size: 11px; font-family: var(--font-mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-bottom: 10px; font-weight: 600; }
.tag { display: inline-flex; align-items: center; padding: 3px 10px; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; font-size: 11.5px; font-family: var(--font-mono); }
.d-session { display: flex; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 12.5px; align-items: baseline; }
.d-session:last-child { border-bottom: 0; }
/* 网格监控模式 2×2 */
.wb-grid { flex: 1; min-height: 0; display: none; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 12px; }
.wb-grid.active { display: grid; }
.mini { position: relative; min-height: 0; display: flex; flex-direction: column; background: var(--term-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--top-hi); overflow: hidden; cursor: pointer; transition: border-color var(--dur-fast) var(--ease-move); }
.mini:hover { border-color: var(--hover-strong); }
.mini .term-head { padding: 7px 12px; }
.mini .term { padding: 10px 12px; overflow: hidden; }
.mini .tl { font-size: 11px; line-height: 1.65; }
/* ─── 日志视图:左时间轴 + 右详情抽屉 ───────────────────────────── */
.logs-layout { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(340px, 5fr) minmax(380px, 6fr); }
.log-list { overflow-y: auto; border-right: 1px solid var(--border); padding: 8px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.log-row { width: 100%; display: grid; grid-template-columns: 52px 9px 1fr; gap: 10px; align-items: baseline; padding: 10px 12px; border-radius: var(--radius); border: 1px solid transparent; text-align: left; transition: background var(--dur-fast) var(--ease-move), border-color var(--dur-fast) var(--ease-move); }
.log-row:hover { background: var(--hover); border-color: var(--border); }
.log-row[aria-current="true"] { background: var(--overlay); border-color: var(--border); box-shadow: var(--top-hi); }
.log-row .t { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 11px; color: var(--muted); }
.log-row .task { display: block; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.log-row .who { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 1px; }
.log-detail { min-width: 0; display: flex; flex-direction: column; background: var(--surface); box-shadow: var(--top-hi); }
.ld-head { flex: none; padding: 14px 18px 0; border-bottom: 1px solid var(--border); }
.ld-head h2 { font-size: 15px; font-weight: 600; line-height: 1.3; }
.ld-meta { display: flex; gap: 14px; flex-wrap: wrap; margin: 6px 0 10px; }
.ld-tabs { display: flex; gap: 4px; }
.ld-tab { padding: 7px 12px; font-size: 12.5px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color var(--dur-fast) var(--ease-move); }
.ld-tab:hover { color: var(--fg); }
.ld-tab[aria-current="true"] { color: var(--fg); border-color: var(--accent); font-weight: 500; }
.ld-body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 18px 24px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.bubble { max-width: 88%; padding: 10px 14px; border-radius: 12px; font-size: 13px; line-height: 1.6; margin-bottom: 12px; }
.bubble.user { margin-left: auto; background: var(--accent-faint); border: 1px solid color-mix(in oklch, var(--accent) 22%, transparent); border-bottom-right-radius: 4px; }
.bubble.bot { background: var(--overlay); border: 1px solid var(--border); box-shadow: var(--top-hi); border-bottom-left-radius: 4px; }
.bubble .steps { margin: 8px 0 0; padding: 0; list-style: none; }
.bubble .steps li { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); padding: 2px 0; }
.bubble .steps li b { color: var(--fg); font-weight: 500; }
.json-pre { margin: 0; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.7; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
/* ─── 设置视图 ──────────────────────────────────────────────────── */
.set-scroll { flex: 1; overflow-y: auto; padding: 22px 24px 40px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.set-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); gap: 12px; max-width: 900px; }
.set-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.set-row:last-child { border-bottom: 0; }
.set-row .meta { font-size: 11px; display: block; margin-top: 1px; }
.switch { position: relative; width: 34px; height: 20px; flex: none; border-radius: 999px; background: var(--overlay); border: 1px solid var(--border); transition: background var(--dur-fast) var(--ease-move); }
.switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--muted); transition: transform var(--dur-med) var(--ease-move), background var(--dur-fast) var(--ease-move); }
.switch[aria-checked="true"] { background: var(--accent-faint); border-color: color-mix(in oklch, var(--accent) 40%, transparent); }
.switch[aria-checked="true"]::after { transform: translateX(14px); background: var(--accent); }
.kbd-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.kbd-table td { padding: 7px 0; border-bottom: 1px solid var(--border); }
.kbd-table tr:last-child td { border-bottom: 0; }
.kbd-table td:last-child { text-align: right; }
/* ─── 底部状态栏 ────────────────────────────────────────────────── */
.statusbar { display: flex; align-items: center; gap: 16px; padding: 0 14px; background: var(--surface); border-top: 1px solid var(--border); box-shadow: var(--top-hi); font-family: var(--font-mono); font-size: 11px; color: var(--muted); z-index: 5; }
.statusbar .grp { display: inline-flex; align-items: center; gap: 6px; }
.statusbar .right { margin-left: auto; display: inline-flex; gap: 14px; }
.statusbar .right span { display: inline-flex; align-items: center; gap: 5px; }
/* ─── 浮层:命令面板 / 快捷键 / Toast(毛玻璃)─────────────────── */
.veil { position: fixed; inset: 0; z-index: 40; background: color-mix(in srgb, var(--bg) 60%, transparent); opacity: 0; pointer-events: none; transition: opacity var(--dur-med) var(--ease-out); }
.veil.open { opacity: 1; pointer-events: auto; transition-timing-function: var(--ease-in); }
.palette { position: fixed; z-index: 50; left: 50%; top: 16vh; translate: -50% 0; width: min(560px, calc(100vw - 48px)); background: var(--glass); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-pop), var(--top-hi); overflow: hidden; opacity: 0; pointer-events: none; transform: translateY(8px); transition: opacity var(--dur-med) var(--ease-in), transform var(--dur-med) var(--ease-in); }
.palette.open { opacity: 1; pointer-events: auto; transform: none; }
.palette input { width: 100%; background: transparent; border: 0; border-bottom: 1px solid var(--border); color: var(--fg); font: inherit; font-size: 14.5px; padding: 14px 16px; }
.palette input:focus { outline: none; }
.palette input::placeholder { color: var(--muted); }
.pal-list { max-height: 320px; overflow-y: auto; padding: 6px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.pal-item { width: 100%; display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: var(--radius); text-align: left; }
.pal-item .meta { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pal-item .kind { flex: none; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px; }
.pal-item.active { background: var(--hover-strong); }
.pal-empty { padding: 20px 16px; color: var(--muted); font-size: 13px; text-align: center; }
.modal { position: fixed; z-index: 50; left: 50%; top: 50%; translate: -50% -50%; width: min(440px, calc(100vw - 48px)); background: var(--glass); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-pop), var(--top-hi); padding: 18px 20px; opacity: 0; pointer-events: none; transform: translateY(8px); transition: opacity var(--dur-med) var(--ease-in), transform var(--dur-med) var(--ease-in); }
.modal.open { opacity: 1; pointer-events: auto; transform: none; }
.modal h2 { font-size: 14px; font-weight: 600; margin-bottom: 10px; }
.toasts { position: fixed; z-index: 55; right: 16px; bottom: calc(var(--status-h) + 12px); display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
/* toast:低频仪式时刻,位移+淡入用克制的 spring 曲线 */
.toast { display: flex; align-items: center; gap: 10px; background: var(--glass); backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-pop), var(--top-hi); padding: 10px 14px; font-size: 12.5px; cursor: pointer; opacity: 0; transform: translateY(10px); transition: opacity var(--dur-slow) var(--ease-in), transform var(--dur-slow) var(--ease-spring); }
.toast.in { opacity: 1; transform: none; }
.toast.out { opacity: 0; transform: translateY(6px); transition-duration: 320ms; transition-timing-function: var(--ease-out); }
/* ─── 彩蛋:极弱扫描线(设置页开关,默认关)────────────────────── */
.scanline { position: absolute; inset: 0; z-index: 2; pointer-events: none; display: none; background: repeating-linear-gradient(0deg, rgb(255 255 255 / .022) 0 1px, transparent 1px 3px); }
.scanline.on { display: block; }
.scanline.on::after { content: ''; position: absolute; left: 0; right: 0; height: 120px; background: linear-gradient(180deg, transparent, color-mix(in oklch, var(--accent) 5%, transparent), transparent); animation: scan-drift 9s linear infinite; }
@keyframes scan-drift { from { top: -120px; } to { top: 100%; } }
/* ─── reduced motion:脉冲改常亮、位移改淡入、纹理静止 ─────────── */
@media (prefers-reduced-motion: reduce) {
.dot.run, .sess-tab.waiting { animation: none; }
.dot.run { box-shadow: 0 0 8px color-mix(in oklch, var(--ok) 30%, transparent), 0 0 0 3px color-mix(in oklch, var(--ok) 25%, transparent); }
.sess-tab.waiting { border-color: color-mix(in oklch, var(--warn) 45%, transparent); }
.caret { animation: none; }
.toast, .palette, .modal, .new-pill { transform: none !important; transition-property: opacity !important; }
.tl.flash, .stag-in, .view.entering, html.booted .stat-tile .v, .scanline.on::after, .log-detail.open { animation: none; }
.card:hover, .attn-pill:hover { transform: none; }
}
/* ─── 响应式:移动端专用控件桌面端隐藏 ─────────────────────────── */
.log-back { display: none; }
/* ─── 平板(≤1080px):触控目标 ≥44px、会话 Tab 条横滚 ────────── */
@media (max-width: 1080px) {
.rail-btn { width: 44px; height: 44px; } /* 触摸目标 ≥44px */
.rail-btn::after { display: none; } /* 触屏无 hover,关掉 tooltip */
.sess-tab { min-height: 44px; }
}
/* ─── 手机(≤720px):单栏布局 + 日志详情全屏覆盖 ────────────── */
@media (max-width: 720px) {
.topbar { padding: 0 10px; gap: 8px; }
.topbar .crumb { display: none; }
.attn-pill { padding: 5px 9px; }
.kbtn { padding: 5px 8px; }
.kbtn kbd { display: none; }
.ov-scroll, .set-scroll { padding: 16px 16px 32px; }
.stat-strip { grid-template-columns: 1fr 1fr; }
.sess-tabs { padding: 6px 8px 0; }
.wb-bar { flex-wrap: wrap; row-gap: 8px; padding: 8px 12px 0; }
.wb-body { padding: 10px 12px 12px; }
.wb-grid { grid-template-columns: 1fr; grid-template-rows: repeat(4, 240px); overflow-y: auto; }
.composer { flex-wrap: wrap; }
.composer .sel { max-width: 148px; }
.composer .sel select { width: 100%; }
.composer .input[type="text"] { flex: 1 1 140px; }
.diff-panel { overflow-x: auto; } /* .dl 为 pre,行内容横向滚动兜底 */
.logs-layout { grid-template-columns: 1fr; }
.log-list { border-right: 0; }
.log-detail { display: none; }
.log-detail.open { display: flex; position: fixed; inset: 0 0 var(--status-h) 0; z-index: 25; animation: view-in var(--dur-med) var(--ease-in); }
.log-back { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; margin: -6px 0 4px -12px; padding: 0 12px; border-radius: 8px; color: var(--muted); font-size: 13px; }
.log-back:hover { background: var(--hover); color: var(--fg); }
.statusbar { gap: 12px; padding: 0 12px; }
.statusbar .right { display: none; } /* 隐藏快捷键提示,保留连接状态与计数 */
.toasts { right: 12px; left: 12px; align-items: stretch; }
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fontsource/inter/latin-400.css";
import "@fontsource/inter/latin-500.css";
import "@fontsource/inter/latin-600.css";
import "@fontsource/jetbrains-mono/latin-400.css";
import "@fontsource/jetbrains-mono/latin-600.css";
import "@xterm/xterm/css/xterm.css";
import App from "./App.jsx";
import "./design/prototype.css";
import "./app.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode><App /></React.StrictMode>,
);
+64
View File
@@ -0,0 +1,64 @@
export const TOOL_DETAILS = Object.freeze({
codex: {
description: "OpenAI 官方编码智能体,适合仓库分析、修改、测试和代码审查。",
tags: ["沙箱执行", "代码修改", "任务编排"],
},
claude: {
description: "Anthropic 官方 CLI,适合复杂工程任务、长链路分析和工具调用。",
tags: ["复杂任务", "MCP", "长上下文"],
},
codebuddy: {
description: "腾讯 CodeBuddy CLI,面向中文代码任务和工程协作。",
tags: ["中文任务", "工程协作", "代码分析"],
},
kimi: {
description: "Moonshot AI 的终端编码工具,适合大上下文仓库阅读与文档任务。",
tags: ["长上下文", "仓库阅读", "中文任务"],
},
opencode: {
description: "可切换多家模型提供方的开源终端编码智能体。",
tags: ["多模型", "开源", "TUI"],
},
qwen: {
description: "基于 Qwen Coder 的终端智能体,适合中文代码和仓库级任务。",
tags: ["中文代码", "仓库级任务", "智能体模式"],
},
dsh: {
description: "DeepSeek Harness 以 headless 模式执行单次任务,完成后自动退出。",
tags: ["DeepSeek", "headless", "批量任务"],
},
});
export const STATUS = Object.freeze({
waiting: { dot: "wait", label: "等待输入" },
running: { dot: "run", label: "运行中" },
exited: { dot: "off", label: "已结束" },
stopped: { dot: "off", label: "未运行" },
interrupted: { dot: "err", label: "已中断" },
failed: { dot: "err", label: "错误" },
error: { dot: "err", label: "不可用" },
});
export function statusFor(value) {
return STATUS[value] || STATUS.stopped;
}
export function formatTime(value) {
if (!value) return "-";
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
}
export function formatDuration(start, end = Date.now()) {
if (!start) return "-";
const seconds = Math.max(0, Math.floor((new Date(end).getTime() - new Date(start).getTime()) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
export function formatBytes(value) {
if (!Number.isFinite(value) || value < 0) return "-";
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
return `${(value / (1024 * 1024)).toFixed(value >= 100 * 1024 * 1024 ? 0 : 1)} MB`;
}
+329
View File
@@ -0,0 +1,329 @@
.chat-conversation {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--term-bg);
}
.chat-scroll {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.chat-thread {
width: min(860px, 100%);
min-height: 100%;
margin: 0 auto;
padding: 28px 28px 44px;
}
.chat-message {
animation: chat-message-in var(--dur-med) var(--ease-in) both;
}
.chat-message-user {
display: flex;
justify-content: flex-end;
margin-bottom: 30px;
}
.chat-user-bubble {
width: fit-content;
max-width: min(78%, 680px);
padding: 10px 14px;
border: 1px solid color-mix(in oklch, var(--accent) 18%, var(--border));
border-radius: var(--radius-lg) var(--radius-lg) 4px var(--radius-lg);
background: var(--accent-faint);
color: var(--fg);
font-size: 13.5px;
line-height: 1.65;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.chat-message-assistant {
min-width: 0;
animation-delay: 36ms;
}
.chat-turn + .chat-turn {
margin-top: 34px;
padding-top: 30px;
border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
}
.chat-assistant-head {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
margin-bottom: 10px;
}
.chat-assistant-mark,
.chat-empty-mark {
width: 24px;
height: 24px;
flex: none;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--surface);
box-shadow: var(--top-hi);
color: var(--accent);
font-family: var(--font-mono);
font-size: 8.5px;
font-weight: 600;
}
.chat-assistant-name {
min-width: 0;
overflow: hidden;
color: var(--fg);
font-size: 12.5px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-run-state {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
color: var(--muted);
font-family: var(--font-mono);
font-size: 10.5px;
white-space: nowrap;
}
.chat-state-dot {
width: 7px;
height: 7px;
flex: none;
border: 1px solid var(--muted);
border-radius: 50%;
}
.chat-run-state.is-working .chat-state-dot {
border-color: transparent;
background: var(--ok);
animation: chat-status-breathe 2s cubic-bezier(.4, 0, .6, 1) infinite;
}
.chat-run-state.is-done .chat-state-dot {
border-color: transparent;
background: var(--ok);
}
.chat-run-state.is-error {
color: color-mix(in oklch, var(--err) 70%, var(--fg));
}
.chat-run-state.is-error .chat-state-dot {
border-color: transparent;
border-radius: 2px;
background: var(--err);
transform: rotate(45deg);
}
.chat-run-state.is-stopped .chat-state-dot {
border-radius: 2px;
}
.chat-response {
width: 100%;
min-height: 24px;
margin: 0;
color: color-mix(in srgb, var(--fg) 92%, var(--muted));
font-family: var(--font-body);
font-size: 13.5px;
line-height: 1.72;
overflow-wrap: anywhere;
tab-size: 2;
white-space: pre-wrap;
}
.chat-response.is-placeholder {
color: var(--muted);
}
.chat-response.is-placeholder.is-working::after {
content: "...";
}
.chat-jump {
position: absolute;
right: 18px;
bottom: 16px;
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
padding: 6px 11px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--glass);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-pop), var(--top-hi);
color: var(--fg);
font-family: var(--font-mono);
font-size: 11px;
opacity: 0;
pointer-events: none;
transform: translateY(6px);
transition:
opacity var(--dur-med) var(--ease-in),
transform var(--dur-med) var(--ease-in),
background var(--dur-fast) var(--ease-move);
}
.chat-jump.show {
opacity: 1;
pointer-events: auto;
transform: none;
}
.chat-jump:hover {
background: var(--overlay);
}
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px;
text-align: center;
}
.chat-empty-mark {
width: 32px;
height: 32px;
margin-bottom: 5px;
font-size: 10px;
}
.chat-empty strong {
color: var(--fg);
font-size: 13px;
font-weight: 600;
}
.chat-empty > span {
max-width: 360px;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
/* The existing composer keeps its DOM and controls, but reads as one input surface. */
.app .composer {
gap: 6px;
padding: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--top-hi), 0 8px 24px rgb(0 0 0 / .18);
transition: border-color var(--dur-fast) var(--ease-move), box-shadow var(--dur-fast) var(--ease-move);
}
.app .composer:focus-within {
border-color: color-mix(in oklch, var(--accent) 34%, var(--border));
box-shadow: var(--top-hi), 0 8px 24px rgb(0 0 0 / .22);
}
.app .composer > .chat-input {
flex: 1 1 240px;
min-height: 36px;
max-height: 112px;
padding-block: 8px;
border-color: transparent;
background: transparent;
line-height: 1.5;
resize: none;
}
.app .composer > .chat-input:focus {
border-color: transparent;
}
.app .composer .sel select,
.app .composer .btn {
min-height: 36px;
}
@keyframes chat-message-in {
from {
opacity: 0;
transform: translateY(6px);
}
}
@keyframes chat-status-breathe {
0%, 100% {
box-shadow: 0 0 6px color-mix(in oklch, var(--ok) 38%, transparent);
opacity: 1;
}
50% {
box-shadow: 0 0 3px color-mix(in oklch, var(--ok) 22%, transparent);
opacity: .66;
}
}
@media (max-width: 720px) {
.chat-thread {
padding: 20px 16px 36px;
}
.chat-message-user {
margin-bottom: 24px;
}
.chat-user-bubble {
max-width: 92%;
}
.chat-run-state {
max-width: 42%;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-jump {
right: 12px;
bottom: 12px;
}
.app .composer {
gap: 5px;
}
.app .composer > .chat-input {
order: -1;
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-message,
.chat-run-state.is-working .chat-state-dot {
animation: none;
}
.chat-jump {
transform: none;
transition-property: opacity, background;
}
.chat-scroll {
scroll-behavior: auto;
}
}
+80
View File
@@ -0,0 +1,80 @@
import crypto from "node:crypto";
import http from "node:http";
import express from "express";
import { WebSocketServer } from "ws";
const token = process.env.RUNNER_TOKEN || "test-runner-token-that-is-at-least-32-characters";
const tools = [
["codex", "Codex", "OpenAI", "CX", "codex", "0.149.0"],
["claude", "Claude Code", "Anthropic", "CC", "claude", "2.1.241"],
["codebuddy", "CodeBuddy", "Tencent", "CB", "codebuddy", "2.137.1"],
["kimi", "Kimi CLI", "Moonshot AI", "KM", "kimi", "0.38.0"],
["opencode", "OpenCode", "SST / Open source", "OC", "opencode", "1.18.21"],
["qwen", "Qwen Code", "Alibaba", "QW", "qwen", "0.22.0"],
["dsh", "DeepSeek Harness", "DeepSeek", "DS", "dsh", "0.1.1-rc.2"],
].map(([id, name, vendor, mono, command, version]) => ({ id, name, vendor, mono, command, version, installed: true, status: "stopped", profiles: id === "dsh" ? ["headless"] : [] }));
const sessions = [];
const integrations = {
ccSwitch: {
installed: true,
version: "cc-switch 5.10.2",
initialized: true,
apps: [
{ id: "claude", toolId: "claude", name: "Claude Code", currentProviderId: "anthropic", providers: [{ id: "anthropic", name: "Anthropic", endpoint: "https://api.anthropic.com", active: true }, { id: "relay", name: "Team Relay", endpoint: "https://relay.example", active: false }] },
{ id: "codex", toolId: "codex", name: "Codex", currentProviderId: "codex-official", providers: [{ id: "codex-official", name: "OpenAI Official", endpoint: null, active: true }] },
{ id: "open-code", toolId: "opencode", name: "OpenCode", currentProviderId: "openrouter", providers: [{ id: "openrouter", name: "OpenRouter", endpoint: "https://openrouter.ai", active: true }] },
],
unsupportedToolIds: ["codebuddy", "kimi", "qwen", "dsh"],
},
multica: {
installed: true,
version: "multica 0.1.53",
daemon: { status: "stopped", supervision: { mode: "unsupervised" } },
config: { configured: false, serverUrl: null, appUrl: null, workspaceId: null },
supportedToolIds: ["claude", "codex", "codebuddy", "kimi", "opencode"],
},
};
const app = express();
app.use(express.json());
app.use((req, res, next) => req.headers.authorization === `Bearer ${token}` ? next() : res.status(401).json({ error: "Unauthorized" }));
app.get("/health", (_req, res) => res.json({ ok: true }));
app.get("/tools", (_req, res) => res.json({ tools: tools.map((tool) => ({ ...tool, status: sessions.some((session) => session.tool === tool.id && session.status === "running") ? "running" : "stopped" })) }));
app.get("/projects", (_req, res) => res.json({ projects: ["demo-app", "website"] }));
app.get("/sessions", (_req, res) => res.json({ sessions }));
app.get("/integrations", (_req, res) => res.json({ integrations }));
app.get("/sessions/:id/output", (_req, res) => res.type("text/plain").send("AgentDock local QA session\r\n"));
app.get("/sessions/:id/diff", (_req, res) => res.type("text/plain").send("diff --git a/src/app.js b/src/app.js\n--- a/src/app.js\n+++ b/src/app.js\n@@ -1 +1 @@\n-old\n+new\n"));
app.post("/sessions", (req, res) => {
const tool = tools.find((item) => item.id === req.body.tool);
const session = { id: crypto.randomUUID(), tool: tool.id, toolName: tool.name, project: req.body.project || ".", purpose: req.body.purpose || "task", initialInput: req.body.initialInput || "", profile: req.body.profile || null, model: req.body.model || null, status: "running", pid: 4242, createdAt: new Date().toISOString(), endedAt: null, exitCode: null, signal: null };
sessions.unshift(session);
res.status(201).json({ session });
});
app.post("/sessions/:id/input", (_req, res) => res.status(204).end());
app.post("/sessions/:id/resize", (_req, res) => res.status(204).end());
app.post("/sessions/:id/stop", (req, res) => { const session = sessions.find((item) => item.id === req.params.id); if (session) { session.status = "stopped"; session.endedAt = new Date().toISOString(); } res.status(204).end(); });
app.post("/integrations/cc-switch/:app/switch", (req, res) => {
const appConfig = integrations.ccSwitch.apps.find((item) => item.id === req.params.app);
if (!appConfig) return res.status(400).json({ error: "Unsupported CC Switch application" });
appConfig.currentProviderId = req.body.providerId;
appConfig.providers.forEach((provider) => { provider.active = provider.id === req.body.providerId; });
res.json({ integrations });
});
app.post("/integrations/cc-switch/:app/import-live", (_req, res) => res.json({ integrations }));
const server = http.createServer(app);
const sockets = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url, "http://localhost");
const match = url.pathname.match(/^\/ws\/sessions\/([a-f0-9-]+)$/);
if (!match || url.searchParams.get("token") !== token) return socket.destroy();
sockets.handleUpgrade(request, socket, head, (client) => {
client.send(JSON.stringify({ type: "snapshot", data: "\u001b[36mAgentDock local QA session\u001b[0m\r\n$ CLI ready\r\n" }));
client.on("message", (raw) => {
const message = JSON.parse(raw.toString());
if (message.type === "input" && message.data.trim()) client.send(JSON.stringify({ type: "output", data: `\r\nreceived: ${message.data}` }));
});
});
});
server.listen(4174, "127.0.0.1", () => console.log("mock runner listening on 4174"));
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:4173",
},
},
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("@xterm")) return "terminal";
if (id.includes("react") || id.includes("lucide-react")) return "ui-vendor";
},
},
},
},
});
+117
View File
@@ -0,0 +1,117 @@
# AgentDock 网页控制台部署说明
## 访问范围
控制台固定绑定服务器地址 `192.168.200.36`,默认端口为 `8443`
```text
https://192.168.200.36:8443
```
Caddy 同时检查来源 IP,只接受 `192.168.200.0/24`。Compose 不映射
Runner 和 Console 的内部端口,局域网只能接触 HTTPS 代理。
## 准备 `.env`
在服务器执行:
```bash
cd /vol1/1000/docker/ai-toools
cp -n .env.example .env
chmod 600 .env
```
必须设置三个独立的机密值:
```dotenv
ADMIN_PASSWORD=一个至少12位的管理员密码
RUNNER_TOKEN=至少32位随机字符串
CONFIG_ENCRYPTION_KEY=至少32位随机字符串
```
可以用 OpenSSL 生成随机值:
```bash
openssl rand -base64 36
```
不要把三个值写进 `compose.yaml`、Dockerfile 或 Git。`CONFIG_ENCRYPTION_KEY`
丢失后,设置页中保存的 API Key 将无法解密。
## 构建和启动
```bash
cd /vol1/1000/docker/ai-toools
mkdir -p workspace data console-data
chmod +x scripts/generate-console-certificate.sh
./scripts/generate-console-certificate.sh /vol1/1000/docker/ai-toools
docker compose build
docker compose up -d
docker compose ps
docker compose logs --tail=100 ai-tools ai-console ai-proxy
```
健康状态应为 `healthy`。检查内部服务:
```bash
docker compose exec ai-tools curl -fsS http://127.0.0.1:4174/health
docker compose exec ai-console wget -qO- http://127.0.0.1:4173/health
```
## 信任局域网证书
Caddy 使用部署目录中为服务器 IP 生成的自签名证书。把证书复制到 Windows:
```bash
cd /vol1/1000/docker/ai-toools
scp nas:/vol1/1000/docker/ai-toools/proxy/certs/agentdock.crt .
```
复制到 Windows 后,在当前用户的受信任根证书存储中安装:
```powershell
certutil -user -addstore Root .\agentdock.crt
```
只应信任从自己的 `192.168.200.36` 服务器导出的证书。安装后重新打开浏览器,
访问 `https://192.168.200.36:8443`
## 第一次使用
1. 使用 `.env` 中的 `ADMIN_PASSWORD` 登录。
2. 在顶部选择 `/workspace` 下的项目。
3. 在设置页填写需要的 API Key,或点击“打开终端”进入独立授权终端完成设备授权。
4. 回到工作台,选择 CLI 后直接发送消息。
5. DeepSeek Harness 必须先填写任务再发送,使用 `headless` 执行一次后自动退出。
工作台的“会话”页使用非交互 CLI 输出和单一多行输入框。每条消息对应一次任务,
回复按时间连续显示。按 Enter 发送,按 Shift+Enter 换行。初始化授权使用独立的
可交互终端;“原始输出”页只用于查看本次非交互任务的底层输出,不接收键盘输入。
当前 `@deepseek-ai/dsh@0.1.1-rc.2` 没有发布可直接安装的 TUI profile bundle。
CLI 帮助中的 `tui` 只是自定义 profile 示例,不能直接执行。AgentDock 因此只开放
官方内置并适合本控制台的 `headless` profile,不开放 Harness 自带 Web 服务。
DeepSeek Harness 不使用其他 CLI 的网页登录状态,必须在设置页配置
`DEEPSEEK_API_KEY` 才能运行。
API Key 使用 AES-256-GCM 加密后存放于 `console-data/settings.enc.json`,接口只返回
掩码。原有 CLI 自己的登录状态仍保存在 `data` 目录。
## 会话生命周期
- 浏览器关闭:CLI 进程继续运行,重新登录后可继续连接。
- `ai-console` 重启:CLI 进程不受影响,但管理员需要重新登录。
- `ai-tools` 重启:其中的所有 CLI 进程会停止;历史输出仍保留。
- `docker compose down`:所有 CLI 进程停止,`data``console-data` 不删除。
## 安全检查
```bash
# 不应出现 docker.sock
docker inspect ai-tools --format '{{json .Mounts}}' | grep docker.sock
# 主机只应监听 HTTPS 端口,不应监听 4173/4174
ss -lnt | grep -E '(:8443|:4173|:4174)'
```
第一条命令没有输出是正确结果。第二条只应看到 `192.168.200.36:8443`
+161
View File
@@ -0,0 +1,161 @@
# 部署说明
## 1. 部署前提
Ubuntu 主机需要满足:
- Ubuntu 22.04 或 24.04x86_64 或 ARM64
- 已安装 Docker Engine 和 Docker Compose Plugin
- 能访问各 CLI 的官网、npm Registry 和模型接口
- 当前用户有运行 Docker 的权限
- 建议至少 4 核 CPU、8 GB 内存和 15 GB 可用磁盘
这些 CLI 通常调用云端模型,本容器不在本机加载大模型权重,因此一般不需要 GPU。
## 2. 放置部署文件
建议把本目录放到 Ubuntu
```text
/opt/ai-toolbox
```
项目统一放到:
```text
/srv/projects
```
创建项目目录:
```bash
sudo mkdir -p /srv/projects
sudo chown -R "$(id -u):$(id -g)" /srv/projects
```
## 3. 准备配置
```bash
cd /opt/ai-toolbox
cp .env.example .env
chmod +x scripts/ai
id -u
id -g
```
`id -u``id -g` 的结果填写进 `.env`
```dotenv
WORKSPACE_PATH=/srv/projects
AI_HOME_PATH=/opt/ai-toolbox/data
AI_UID=1000
AI_GID=1000
AI_CONTAINER_NAME=ai-tools
TZ=Asia/Shanghai
```
不要把 API Key 写进 Dockerfile。`.env` 也不要提交到 Git。
## 4. 构建并启动
```bash
docker compose build
docker compose up -d
docker compose ps
docker compose exec ai-tools ai-tools-check
```
检查结果中以下命令都应显示 `OK`
```text
codex claude codebuddy kimi opencode qwen dsh cc-switch multica
```
## 5. 首次登录
每个工具需要分别登录一次:
```bash
./scripts/ai codex .
./scripts/ai claude .
./scripts/ai codebuddy .
./scripts/ai kimi .
./scripts/ai opencode .
./scripts/ai qwen .
```
按照终端显示的链接或验证码,在 Windows 浏览器中完成登录。Kimi 进入界面后使用 `/login`
远程服务器上,浏览器自动打开通常不会成功,这是正常的。优先使用工具提供的设备验证码登录;如果某个工具只支持浏览器回调,可以改用它支持的 API Key,或者为回调端口建立 SSH 隧道。
登录信息和会话文件保存在 `.env``AI_HOME_PATH` 目录中,不会因为重新构建镜像而丢失。
## 6. 初始化 CC Switch
打开网页的“设置 -> Provider”,点击“打开 CC Switch TUI”。先为 Claude、Codex 和 OpenCode 导入或创建 Provider,并确认三个 CLI 都有当前 Provider。
也可以在 SSH 中操作:
```bash
ai cc-switch
ai cc-switch --app codex provider list
```
配置完成后,在网页启用“CC Switch 配置接管”。启用后,AgentDock 不再向 Claude、Codex 和 OpenCode 会话注入网页账号页保存的旧 API Key,避免环境变量覆盖 CC Switch 配置。CodeBuddy、Kimi、Qwen 和 DeepSeek 仍由 AgentDock 管理。
## 7. 初始化 Multica Runtime
打开网页的“设置 -> Runtime”,填写现有自托管 Multica 的 Server URL 和 App URL,然后点击“初始化 Runtime”。授权只在独立终端中进行,完成后 Supervisor 会自动启动前台 daemon。
也可以在 SSH 中检查:
```bash
ai multica config show
ai multica daemon status --output json
docker compose logs --tail=100 ai-tools
```
Multica 当前公开支持 Claude、Codex、CodeBuddy、Kimi 和 OpenCode。Qwen 与 DeepSeek 会显示为“已安装,待验证支持”。本部署仅安装 Multica 客户端 Runtime,不包含也不替换用户现有的自托管服务端。
如果网页终端中的 OAuth 回调无法从 Windows 返回容器,可在 NAS SSH 中运行一次临时初始化。将两个示例 URL 替换为实际地址:
```bash
docker run --rm -it \
--network host \
--user 1000:1001 \
-e HOME=/home/ai \
-v /vol1/1000/docker/ai-toools/data:/home/ai \
local/ai-toolbox:0.2.0 \
multica-setup \
--server-url https://multica-api.example.com \
--app-url https://multica.example.com \
--callback-host 192.168.200.36
```
该容器只在授权期间使用宿主机网络,完成后自动退出。正式 Multica daemon 仍由 `ai-tools` 内的 Supervisor 管理,不使用 host 网络。
## 8. DeepSeek Harness
DeepSeek Harness 的命令是 `dsh`,当前属于 developer preview,官方提示未来可能出现不兼容更新。
无界面执行一次任务:
```bash
./scripts/ai dsh my-project --profile headless "检查项目并运行测试"
```
查看当前版本支持的参数:
```bash
docker compose exec ai-tools dsh --help
```
当前 npm 包只提供 `headless``web` profile,没有可安装的原生 TUI。AgentDock 使用 `headless`,每次提交一个任务,完成后进程自动退出。`web` profile 不纳入 Compose,也不额外暴露端口。
## 9. OpenClaw 和 Cursor
- 不修改 Ubuntu 主机现有的 OpenClaw。
- 不占用 OpenClaw 的 Gateway 端口。
- Cursor 桌面版保留在 Windows。
- Cursor 使用 Remote SSH 打开 `/srv/projects/项目名`
- AI 容器通过 `/workspace/项目名` 访问同一批文件。
+72
View File
@@ -0,0 +1,72 @@
# 升级和备份
## 升级 CLI
默认版本是 `latest`。升级时重新构建镜像:
```bash
cd /opt/ai-toolbox
docker compose build --pull --no-cache
docker compose up -d
docker compose exec ai-tools ai-tools-check
```
为了避免上游突然更新造成故障,首次部署成功后建议记录版本:
```bash
docker compose exec ai-tools npm list -g --depth=0
```
然后把 `.env` 中的 `latest` 改成确定的版本号,再重新构建。例如:
```dotenv
CODEX_VERSION=具体版本号
CLAUDE_VERSION=具体版本号
```
DeepSeek Harness 目前是 developer preview,尤其建议锁定版本。
CC Switch 与 Multica 使用固定原生二进制版本和 SHA256 校验。升级时同时修改 Dockerfile 中的版本号和对应架构校验值,不要只修改 `.env` 版本而保留旧校验值。
## 备份账号和会话
所有 CLI 的用户配置位于 `.env``AI_HOME_PATH`。默认示例为:
```text
/opt/ai-toolbox/data
```
这个目录也包含 `~/.cc-switch``~/.multica`、各 CLI 登录信息以及 Multica Runtime 身份,必须整体备份。
创建备份:
```bash
mkdir -p "$HOME/ai-toolbox-backups"
docker run --rm \
-v /opt/ai-toolbox/data:/source:ro \
-v "$HOME/ai-toolbox-backups:/backup" \
ubuntu:24.04 \
tar -C /source -czf /backup/ai-toolbox-home.tar.gz .
```
备份中包含登录凭证,应当像密码一样保护。
网页控制台还需要备份:
```text
/vol1/1000/docker/ai-toools/console-data
/vol1/1000/docker/ai-toools/.env
```
`.env` 内的 `CONFIG_ENCRYPTION_KEY` 必须与 `console-data` 成对保存,否则无法解密
网页中保存的 API Key。备份中还包含管理员密码,也应按密码文件保护。
## 不要执行的命令
停止使用:
```bash
docker compose down
```
`docker compose down` 不会删除 `AI_HOME_PATH` 中的配置。不要手动删除该目录,除非明确要清除全部登录状态和会话。
+124
View File
@@ -0,0 +1,124 @@
# 192.168.200.36 实际部署记录
部署时间:2026-08-23
## 已部署状态
- SSH 主机:`192.168.200.36:22`
- 实际可用账户:`leefer`
- Windows SSH 别名:`nas`
- 部署目录:`/vol1/1000/docker/ai-toools`
- 项目目录:`/vol1/1000/docker/ai-toools/workspace`
- CLI 账号数据:`/vol1/1000/docker/ai-toools/data`
- 容器名称:`ai-tools`
- 镜像:`local/ai-toolbox:latest`
## 网页控制台
- 局域网 URL`https://192.168.200.36:8443`
- 局域网域名:`https://agent.solsum.cn`
- 代理:`ai-proxy`
- 控制服务:`ai-console`
- CLI Runner`ai-tools:4174`,仅 Docker 内部网络可达
- RuntimeRunner 与 Multica daemon 同处 `ai-tools`,共享 `/home/ai``/workspace` 和 CLI PATH
- 控制台数据:`/vol1/1000/docker/ai-toools/console-data`
- 允许来源:`192.168.200.0/24`
- Docker Socket:未挂载
注意:本机 SSH 配置把直接使用 IP `192.168.200.36` 指向了 Gitea 的 `222` 端口。日常系统 SSH 应使用已有别名 `nas`
```powershell
ssh nas
```
`root` 账户没有接受本机现有私钥,因此本次部署使用已验证且属于 Docker 组的 `leefer` 账户。没有修改服务器的 root SSH 设置。
## 已锁定版本
```text
Codex CLI 0.149.0
Claude Code 2.1.241
CodeBuddy Code 2.137.1
Kimi Code CLI 0.38.0
OpenCode 1.18.21
Qwen Code 0.22.0
DeepSeek Harness 0.1.1-rc.2
CC Switch CLI 5.10.2
Multica CLI 0.1.53
Node.js 22.23.2
```
## 第一次登录
从 Windows 进入服务器:
```powershell
ssh nas
```
然后逐个启动并完成登录:
```bash
ai codex .
ai claude .
ai codebuddy .
ai kimi .
ai opencode .
ai qwen .
ai cc-switch
```
Kimi 进入界面后输入 `/login`。网页登录在 Windows 浏览器完成即可。DeepSeek Harness 按实际模型提供方配置使用,不需要作为常驻服务启动。
## 日常调用
在 Ubuntu SSH 终端调用:
```bash
ai codex 项目名
ai claude 项目名
ai kimi 项目名
ai qwen 项目名
ai dsh 项目名 --profile headless "运行测试"
ai multica daemon status --output json
```
这里的 `项目名` 是项目目录相对于 `workspace` 的路径。例如:
```text
/vol1/1000/docker/ai-toools/workspace/my-app
```
对应:
```bash
ai codex my-app
```
从 Windows 直接调用:
```powershell
.\scripts\remote-ai.ps1 -Server nas -Tool codex -Project my-app
.\scripts\remote-ai.ps1 -Server nas -Tool claude -Project my-app
.\scripts\remote-ai.ps1 -Server nas -Tool kimi -Project my-app
```
## 管理命令
```bash
cd /vol1/1000/docker/ai-toools
docker compose ps
docker compose exec ai-tools ai-tools-check
docker compose logs --tail=100 ai-tools
docker compose restart
docker compose down
docker compose up -d
```
`docker compose down` 不会删除登录数据。不要手动删除 `data` 目录。
## Cursor 和 OpenClaw
- Windows Cursor 使用 Remote SSH 的 `nas` 连接,打开 `workspace` 下的项目。
- OpenClaw 没有安装进 `ai-tools`,现有 OpenClaw 配置和端口均未修改。
+17
View File
@@ -0,0 +1,17 @@
# 官方资料来源
以下资料在 2026-08-23 核对。上游安装方式可能继续变化。
- Codex CLI[官方 CLI 文档](https://developers.openai.com/codex/cli/);官方当前提供 Linux 安装方式,并支持 ChatGPT 或 API Key 登录。
- Codex 登录:[官方认证文档](https://learn.chatgpt.com/docs/auth);凭证可能保存在 `~/.codex/auth.json` 或系统凭证存储中。
- Claude Code[官方安装文档](https://code.claude.com/docs/en/setup);支持 Ubuntu 20.04+,官方推荐原生安装,也保留 npm 安装方式。
- Kimi Code CLI[官方入门文档](https://www.kimi.com/code/docs/en/kimi-code-cli/guides/getting-started.html)npm 包为 `@moonshot-ai/kimi-code`,要求 Node.js 22.19.0+。
- DeepSeek Harness[官方仓库](https://github.com/deepseek-ai/deepseek-harness)npm 包为 `@deepseek-ai/dsh`,命令为 `dsh`,当前处于 developer preview。
- DeepSeek Harness CLI[官方 CLI 说明](https://github.com/deepseek-ai/deepseek-harness/blob/master/apps/cli/README.md);支持 `web``headless` profile。
- CodeBuddy Code[npm 包](https://www.npmjs.com/package/@tencent-ai/codebuddy-code)
- OpenCode[官方站点](https://opencode.ai/)
- Qwen Code[官方仓库](https://github.com/QwenLM/qwen-code)
- CC Switch CLI[社区 CLI 仓库](https://github.com/SaladDay/cc-switch-cli);本部署锁定 `v5.10.2`,管理 Claude、Codex 和 OpenCode。
- Multica CLI[公开二进制分发仓库](https://github.com/zimplemedia/multica-cli);本部署锁定 `v0.1.53`,仅运行连接现有自托管服务端的客户端 daemon。
本部署选择 npm 统一安装,是为了让所有工具共用同一套 Node.js 运行环境并简化 Docker 镜像维护。对于提供原生安装器的工具,这不代表 npm 是其唯一安装方式。
+101
View File
@@ -0,0 +1,101 @@
# 常见故障
## 1. 命令不存在
```bash
docker compose exec ai-tools ai-tools-check
docker compose build --no-cache
docker compose up -d
```
如果只缺少一个命令,查看构建日志中对应 npm 包的安装错误。
## 2. 项目目录没有权限
在 Ubuntu 主机执行:
```bash
grep -E '^(AI_UID|AI_GID)=' .env
id -u
id -g
sudo chown -R "$(id -u):$(id -g)" /srv/projects
```
`.env` 中的 UID/GID 应与操作项目文件的 Ubuntu 用户一致。修改后重新构建容器。
## 3. 登录完成后重建容器又要求登录
检查持久化目录:
```bash
docker compose config
grep '^AI_HOME_PATH=' .env
ls -ld "$(grep '^AI_HOME_PATH=' .env | cut -d= -f2-)"
```
确认 Compose 仍把 `AI_HOME_PATH` 挂载到了 `/home/ai`,并且目录没有被手动删除。
## 4. Windows 运行脚本后界面显示异常
使用 Windows Terminal,并确保 SSH 分配终端:
```powershell
ssh -tt mobai@192.168.1.100 "docker exec -it ai-tools codex"
```
本部署包的 `remote-ai.ps1` 已经使用 `ssh -tt`
## 5. 登录链接跳回 localhost 后失败
原因是网页在 Windows 打开,但登录回调服务在 Ubuntu 容器里。优先选择设备验证码登录或 API Key。只有在工具明确显示回调端口时,才建立对应的 SSH 端口转发。
不要直接把随机回调端口开放到公网。
## 6. 无法访问模型接口或 npm
在容器内检查 DNS 和 HTTPS
```bash
docker compose exec ai-tools getent hosts registry.npmjs.org
docker compose exec ai-tools curl -I https://registry.npmjs.org/
```
如果 Ubuntu 主机需要代理,应把代理地址配置到 Docker daemon 或 Compose 环境中。代理凭证不要写入会提交的文件。
## 7. DeepSeek Harness 更新后参数变化
它目前是 developer preview。先查看当前帮助:
```bash
docker compose exec ai-tools dsh --help
docker compose exec ai-tools dsh web --help
```
确认工作后,把 `.env``DSH_VERSION``latest` 改为当前版本号并重新构建。
## 8. OpenClaw 受到影响
本 Compose 文件没有安装 OpenClaw、没有映射 OpenClaw 端口,也没有挂载 OpenClaw 配置目录。若现有 OpenClaw 出现问题,应单独检查它原来的服务,不要删除 AI 工具箱的 `data` 目录试图修复。
## 9. CC Switch 切换后没有生效
先检查冲突环境变量和当前 Provider:
```bash
ai cc-switch env check --app codex
ai cc-switch --app codex provider current
```
确认网页“设置 -> Provider”中的“CC Switch 配置接管”已启用,并重新创建 CLI 会话。已经运行中的会话不会自动切换 Provider。
## 10. Multica daemon 离线
```bash
ai multica config show
ai multica daemon status --output json
docker compose logs --tail=150 ai-tools
```
`workspace_id` 未设置,回到网页 Runtime 页面重新初始化。若配置完整但 daemon 未上线,执行 `docker compose restart ai-tools`Supervisor 会重新拉起它。不要在容器内额外运行后台 daemon,否则会与 Supervisor 管理的前台进程重复。
若授权页面最终跳回无法访问的本地回调地址,使用部署文档“初始化 Multica Runtime”中的一次性 host 网络命令。它只负责写入 `/home/ai/.multica`,不会改变正式 Compose 的网络边界。
+113
View File
@@ -0,0 +1,113 @@
# 日常使用
## 使用网页控制台
局域网浏览器访问:
```text
https://192.168.200.36:8443
```
登录后可以选择项目、发送消息、查看文字回复、Git Diff 和历史会话。
关闭浏览器不会停止 CLI;停止 `ai-tools` 容器才会停止其中全部 CLI 进程。
日常任务使用“会话”页的单一多行输入框;Enter 发送,Shift+Enter 换行。首次登录时从“设置 -> CLI 账号”打开独立终端。
“原始输出”只用于故障排查。日常任务使用非交互模式,不会弹出原生确认提示。
DeepSeek Harness 没有空会话,必须先输入完整任务再发送。
Provider 切换在“设置 -> Provider”完成,只影响切换后新启动的 CLI 会话。MCP 与 Skills 的完整编辑仍在 CC Switch TUI 中完成。Multica 的连接、Workspace 和 daemon 状态在“设置 -> Runtime”查看。
网页中的“运行中实例”表示当前真实 CLI 进程数量,不是已安装工具数量。
## 在 Ubuntu 上使用
先登录 Ubuntu
```powershell
ssh nas
```
进入部署目录后调用工具:
```bash
ai codex my-project
ai claude my-project
ai codebuddy my-project
ai kimi my-project
ai opencode my-project
ai qwen my-project
ai cc-switch
ai multica daemon status --output json
```
`my-project` 对应 NAS 的 `/vol1/1000/docker/ai-toools/workspace/my-project``cc-switch``multica` 是管理命令,不接项目参数。
如果不使用辅助脚本,原始命令是:
```bash
docker compose exec --workdir /workspace/my-project ai-tools codex
```
## 从 Windows 一条命令调用
在包含本部署包的 Windows 目录执行:
```powershell
.\scripts\remote-ai.ps1 `
-Server mobai@192.168.1.100 `
-Tool codex `
-Project my-project
```
调用其他工具只需要修改 `-Tool`
```powershell
.\scripts\remote-ai.ps1 -Server mobai@192.168.1.100 -Tool claude -Project my-project
.\scripts\remote-ai.ps1 -Server mobai@192.168.1.100 -Tool kimi -Project my-project
.\scripts\remote-ai.ps1 -Server mobai@192.168.1.100 -Tool qwen -Project my-project
```
传递额外参数:
```powershell
.\scripts\remote-ai.ps1 `
-Server mobai@192.168.1.100 `
-Tool kimi `
-Project my-project `
-ToolArguments @("-p", "解释这个项目")
```
DeepSeek Harness 无界面任务:
```powershell
.\scripts\remote-ai.ps1 `
-Server mobai@192.168.1.100 `
-Tool dsh `
-Project my-project `
-ToolArguments @("--profile", "headless", "运行测试")
```
## 推荐的工作方式
1. Windows Cursor 使用 Remote SSH 打开 Ubuntu 的 `/srv/projects/my-project`
2. Windows Terminal 使用 `remote-ai.ps1` 启动需要的 CLI。
3. Cursor 和容器看到的是同一份 Ubuntu 项目文件。
4. OpenClaw继续按现有方式运行,与本容器互不影响。
## 查看容器状态
```bash
cd /opt/ai-toolbox
docker compose ps
docker compose logs --tail=100 ai-tools
docker compose exec ai-tools ai-tools-check
```
## 停止和启动
```bash
docker compose stop
docker compose start
```
Codex、Claude 等任务 CLI 只在会话期间运行。容器常驻 Runner、Supervisor 和 Multica daemon;停止 `ai-tools` 容器会同时停止 daemon 和全部会话。配置保存在宿主机 `data` 目录,重新启动或重建容器后仍在。
+14
View File
@@ -0,0 +1,14 @@
:443 {
tls /etc/caddy/certs/agentdock.crt /etc/caddy/certs/agentdock.key
@outside_lan not remote_ip 192.168.200.0/24 127.0.0.1/32
respond @outside_lan "Forbidden" 403
encode zstd gzip
reverse_proxy ai-console:4173
header {
Strict-Transport-Security "max-age=31536000"
-Server
}
}
+365
View File
@@ -0,0 +1,365 @@
import crypto from "node:crypto";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import express from "express";
import pty from "node-pty";
import { WebSocketServer, WebSocket } from "ws";
import { buildCommand, SECRET_ENV_ALLOWLIST, TOOLS, toolById } from "./registry.js";
import { importCcLiveConfig, integrationSnapshot, switchCcProvider } from "./integrations.js";
import { relayMulticaCallback } from "./multica-callback.js";
import { listProjects, resolveWorkspacePath } from "./path-policy.js";
import { SessionStore } from "./session-store.js";
const execFileAsync = promisify(execFile);
const PORT = Number(process.env.RUNNER_PORT || 4174);
const TOKEN = process.env.RUNNER_TOKEN || "";
const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT || "/workspace";
const SESSION_DIRECTORY = process.env.SESSION_DIRECTORY || "/home/ai/.ai-console/sessions";
if (TOKEN.length < 32) throw new Error("RUNNER_TOKEN must contain at least 32 characters");
const store = new SessionStore(SESSION_DIRECTORY);
await store.init();
function tokenMatches(candidate = "") {
const left = Buffer.from(candidate);
const right = Buffer.from(TOKEN);
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
function requireRunnerToken(req, res, next) {
const candidate = req.headers.authorization?.replace(/^Bearer\s+/i, "") || "";
if (!tokenMatches(candidate)) return res.status(401).json({ error: "Unauthorized" });
next();
}
function jsonError(res, error, status = 400) {
const message = error instanceof Error ? error.message : "Request failed";
return res.status(status).json({ error: message });
}
async function commandVersion(command) {
try {
const { stdout, stderr } = await execFileAsync(command, ["--version"], {
timeout: 8000,
maxBuffer: 256 * 1024,
env: process.env,
});
return { installed: true, version: (stdout || stderr).trim().split(/\r?\n/)[0] || "installed" };
} catch (error) {
if (error.code === "ENOENT") return { installed: false, version: null };
return { installed: true, version: "unknown", error: error.message };
}
}
async function processResources(pid) {
if (!pid) return { cpu: null, memoryBytes: null };
try {
const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "%cpu=,rss="], {
timeout: 3000,
maxBuffer: 64 * 1024,
});
const [cpu, rssKb] = stdout.trim().split(/\s+/);
return {
cpu: Number.isFinite(Number(cpu)) ? Number(cpu) : null,
memoryBytes: Number.isFinite(Number(rssKb)) ? Number(rssKb) * 1024 : null,
};
} catch {
return { cpu: null, memoryBytes: null };
}
}
function activeSessionForTool(toolId) {
return store.list().find((session) => session.tool === toolId && session.status === "running");
}
function broadcast(session, message) {
const payload = JSON.stringify(message);
for (const client of session.clients) {
if (client.readyState === WebSocket.OPEN) client.send(payload);
}
}
function appendOutput(session, data) {
if (!data) return;
store.append(session, data);
broadcast(session, { type: "output", data });
}
const app = express();
app.disable("x-powered-by");
app.use(express.json({ limit: "128kb" }));
app.get("/health", (_req, res) => res.json({ ok: true, service: "agentdock-runner" }));
app.use(requireRunnerToken);
app.get("/tools", async (_req, res) => {
const records = await Promise.all(Object.values(TOOLS).filter((tool) => tool.visible !== false).map(async (tool) => {
const version = await commandVersion(tool.command);
const active = activeSessionForTool(tool.id);
const resources = active ? await processResources(active.pid) : { cpu: null, memoryBytes: null };
return {
id: tool.id,
name: tool.name,
vendor: tool.vendor,
mono: tool.mono,
command: tool.command,
profiles: tool.profiles || [],
...version,
status: active ? "running" : version.installed ? "stopped" : "error",
sessionId: active?.id || null,
project: active?.project || null,
resources,
};
}));
res.json({ tools: records });
});
app.get("/projects", async (_req, res) => {
try {
res.json({ projects: await listProjects(WORKSPACE_ROOT) });
} catch (error) {
jsonError(res, error, 500);
}
});
app.get("/sessions", (_req, res) => res.json({ sessions: store.list() }));
app.get("/integrations", async (req, res) => {
try {
res.json({ integrations: await integrationSnapshot({ force: req.query.force === "1" }) });
} catch (error) {
jsonError(res, error, 500);
}
});
app.post("/integrations/cc-switch/:app/switch", async (req, res) => {
try {
await switchCcProvider(req.params.app, req.body?.providerId);
res.json({ integrations: await integrationSnapshot({ force: true }) });
} catch (error) {
jsonError(res, error);
}
});
app.post("/integrations/cc-switch/:app/import-live", async (req, res) => {
try {
await importCcLiveConfig(req.params.app);
res.json({ integrations: await integrationSnapshot({ force: true }) });
} catch (error) {
jsonError(res, error);
}
});
app.post("/integrations/multica/callback", async (req, res) => {
try {
res.json(await relayMulticaCallback(req.body?.callbackUrl));
} catch (error) {
jsonError(res, error);
}
});
app.get("/sessions/:id/output", async (req, res) => {
const session = store.get(req.params.id);
if (!session) return res.status(404).json({ error: "Session not found" });
res.type("text/plain").send(await store.readOutput(session));
});
app.get("/sessions/:id/diff", async (req, res) => {
const session = store.get(req.params.id);
if (!session) return res.status(404).json({ error: "Session not found" });
try {
const cwd = await resolveWorkspacePath(WORKSPACE_ROOT, session.project);
const { stdout } = await execFileAsync("git", ["diff", "--no-ext-diff", "--no-color"], {
cwd,
timeout: 10000,
maxBuffer: 2 * 1024 * 1024,
});
res.type("text/plain").send(stdout || "");
} catch (error) {
if (error.code === 1 && typeof error.stdout === "string") return res.type("text/plain").send(error.stdout);
jsonError(res, error, 500);
}
});
app.post("/sessions", async (req, res) => {
const tool = toolById(req.body?.tool);
if (!tool) return res.status(400).json({ error: "Unsupported CLI tool" });
try {
const cwd = await resolveWorkspacePath(WORKSPACE_ROOT, req.body.project || ".");
const request = {
purpose: req.body.purpose === "authorization" ? "authorization" : "task",
profile: req.body.profile,
model: req.body.model,
initialInput: typeof req.body.initialInput === "string" ? req.body.initialInput.slice(0, 32000) : "",
serverUrl: typeof req.body.serverUrl === "string" ? req.body.serverUrl.trim().slice(0, 2048) : "",
appUrl: typeof req.body.appUrl === "string" ? req.body.appUrl.trim().slice(0, 2048) : "",
};
const built = buildCommand(tool, request);
const environment = {};
for (const [name, value] of Object.entries(req.body.environment || {})) {
if (SECRET_ENV_ALLOWLIST.has(name) && tool.secretNames.includes(name) && typeof value === "string") {
environment[name] = value;
}
}
const id = crypto.randomUUID();
const processEnvironment = {
...process.env,
...environment,
TERM: built.interactive ? "xterm-256color" : "dumb",
COLORTERM: built.interactive ? "truecolor" : "",
NO_COLOR: built.interactive ? (process.env.NO_COLOR || "") : "1",
CI: built.interactive ? (process.env.CI || "") : "1",
};
const baseRecord = {
id,
tool: tool.id,
toolName: tool.name,
project: path.relative(await fs.realpath(WORKSPACE_ROOT), cwd) || ".",
purpose: request.purpose,
initialInput: request.initialInput,
profile: request.profile || null,
model: typeof req.body.model === "string" ? req.body.model.slice(0, 128) : null,
status: "running",
createdAt: new Date().toISOString(),
endedAt: null,
exitCode: null,
signal: null,
interactive: built.interactive,
environment,
};
let session;
if (built.interactive) {
const processHandle = pty.spawn(built.command, built.args, {
name: "xterm-256color",
cols: Math.max(40, Math.min(300, Number(req.body.cols) || 120)),
rows: Math.max(12, Math.min(120, Number(req.body.rows) || 32)),
cwd,
env: processEnvironment,
});
session = store.add({ ...baseRecord, pid: processHandle.pid, pty: processHandle });
processHandle.onData((data) => appendOutput(session, data));
processHandle.onExit(({ exitCode, signal }) => {
session.processExited = true;
const status = session.status === "stopped" ? "stopped" : exitCode === 0 ? "exited" : "failed";
store.finish(session, status, exitCode, signal);
broadcast(session, { type: "exit", exitCode, signal, status });
});
} else {
const childProcess = spawn(built.command, built.args, {
cwd,
env: processEnvironment,
stdio: ["ignore", "pipe", "pipe"],
});
session = store.add({ ...baseRecord, pid: childProcess.pid, childProcess });
childProcess.stdout.setEncoding("utf8");
childProcess.stderr.setEncoding("utf8");
childProcess.stdout.on("data", (data) => appendOutput(session, data));
childProcess.stderr.on("data", (data) => {
session.errorBuffer = `${session.errorBuffer}${data}`.slice(-256 * 1024);
broadcast(session, { type: "activity", label: "CLI 正在处理任务" });
});
childProcess.on("error", (error) => {
session.errorBuffer = error.message;
});
childProcess.on("close", (exitCode, signal) => {
session.processExited = true;
const status = session.status === "stopped" ? "stopped" : exitCode === 0 ? "exited" : "failed";
if (!session.buffer.trim()) {
const fallback = status === "failed"
? session.errorBuffer.trim() || `CLI 退出,代码 ${exitCode ?? "unknown"}`
: "任务已完成,但 CLI 没有返回文本。";
appendOutput(session, fallback);
}
store.finish(session, status, exitCode, signal);
broadcast(session, { type: "exit", exitCode, signal, status });
});
}
res.status(201).json({ session: store.publicRecord(session) });
} catch (error) {
jsonError(res, error);
}
});
app.post("/sessions/:id/input", (req, res) => {
const session = store.get(req.params.id);
if (!session) return res.status(404).json({ error: "Session not found" });
if (session.status !== "running" || !session.pty || !session.interactive) return res.status(409).json({ error: "Only interactive sessions accept terminal input" });
const data = typeof req.body?.data === "string" ? req.body.data.slice(0, 65536) : "";
session.pty.write(data);
res.status(204).end();
});
app.post("/sessions/:id/resize", (req, res) => {
const session = store.get(req.params.id);
if (!session) return res.status(404).json({ error: "Session not found" });
if (session.status !== "running" || !session.pty || !session.interactive) return res.status(409).json({ error: "Only interactive sessions can be resized" });
const cols = Math.max(40, Math.min(300, Number(req.body?.cols) || 120));
const rows = Math.max(12, Math.min(120, Number(req.body?.rows) || 32));
session.pty.resize(cols, rows);
res.status(204).end();
});
app.post("/sessions/:id/stop", (req, res) => {
const session = store.get(req.params.id);
if (!session) return res.status(404).json({ error: "Session not found" });
if (session.status === "running" && (session.pty || session.childProcess)) {
store.finish(session, "stopped");
const handle = session.pty || session.childProcess;
handle.kill("SIGTERM");
setTimeout(() => {
if (!session.processExited) handle.kill("SIGKILL");
}, 5000).unref();
}
res.status(204).end();
});
const server = http.createServer(app);
const sockets = new WebSocketServer({ noServer: true });
const socketHeartbeat = setInterval(() => {
for (const client of sockets.clients) {
if (client.readyState === WebSocket.OPEN) client.ping();
}
}, 25_000);
socketHeartbeat.unref();
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url, "http://runner.internal");
const match = url.pathname.match(/^\/ws\/sessions\/([a-f0-9-]+)$/);
if (!match || !tokenMatches(url.searchParams.get("token") || "")) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return socket.destroy();
}
const session = store.get(match[1]);
if (!session) {
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
return socket.destroy();
}
sockets.handleUpgrade(request, socket, head, async (client) => {
session.clients.add(client);
client.send(JSON.stringify({ type: "snapshot", data: await store.readOutput(session), session: store.publicRecord(session) }));
client.on("message", (raw) => {
if (session.status !== "running" || !session.pty || !session.interactive) return;
try {
const message = JSON.parse(raw.toString());
if (message.type === "input" && typeof message.data === "string") session.pty.write(message.data.slice(0, 65536));
if (message.type === "resize") {
const cols = Math.max(40, Math.min(300, Number(message.cols) || 120));
const rows = Math.max(12, Math.min(120, Number(message.rows) || 32));
session.pty.resize(cols, rows);
}
} catch {
client.send(JSON.stringify({ type: "error", error: "Invalid WebSocket message" }));
}
});
client.on("close", () => session.clients.delete(client));
});
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`agentdock runner listening on ${PORT}`);
});
+178
View File
@@ -0,0 +1,178 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const HOME = process.env.HOME || "/home/ai";
const CC_SWITCH_DATABASE = path.join(HOME, ".cc-switch", "cc-switch.db");
const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
const SAFE_ID = /^[a-zA-Z0-9._-]{1,128}$/;
export const CC_SWITCH_APPS = Object.freeze([
{ id: "claude", toolId: "claude", name: "Claude Code" },
{ id: "codex", toolId: "codex", name: "Codex" },
{ id: "open-code", toolId: "opencode", name: "OpenCode" },
]);
export const MULTICA_TOOL_IDS = Object.freeze(["claude", "codex", "codebuddy", "kimi", "opencode"]);
function clean(value) {
return String(value || "").replace(ANSI, "").trim();
}
function publicEndpoint(value) {
const candidate = clean(value);
if (!candidate || candidate === "N/A") return null;
try {
const url = new URL(candidate);
return url.origin;
} catch {
return "configured";
}
}
export function parseCcProviderList(output) {
const providers = [];
let currentProviderId = null;
for (const rawLine of clean(output).split(/\r?\n/)) {
const line = rawLine.trim();
const current = line.match(/^→\s*Current:\s*(\S+)/);
if (current) currentProviderId = current[1];
if (!line.startsWith("│") || !line.includes("┆")) continue;
const columns = line.slice(1, line.endsWith("│") ? -1 : undefined).split("┆").map(clean);
if (columns.length < 4 || columns[1] === "ID") continue;
const [marker, id, name, endpoint] = columns;
if (!SAFE_ID.test(id)) continue;
providers.push({
id,
name: name || id,
endpoint: publicEndpoint(endpoint),
active: marker.includes("✓"),
});
}
if (!currentProviderId) currentProviderId = providers.find((provider) => provider.active)?.id || null;
return { currentProviderId, providers };
}
export function parseMulticaConfig(output) {
const values = {};
for (const line of clean(output).split(/\r?\n/)) {
const match = line.match(/^([a-z_]+):\s*(.*)$/);
if (match) values[match[1]] = match[2] === "(not set)" ? null : match[2];
}
return {
serverUrl: values.server_url || null,
appUrl: values.app_url || null,
workspaceId: values.workspace_id || null,
configured: Boolean(values.server_url && values.workspace_id),
};
}
async function run(command, args, options = {}) {
const { stdout, stderr } = await execFileAsync(command, args, {
timeout: options.timeout || 10000,
maxBuffer: options.maxBuffer || 1024 * 1024,
env: { ...process.env, NO_COLOR: "1", TERM: "dumb" },
});
return clean(stdout || stderr);
}
async function versionOf(command, args = ["--version"]) {
try {
const output = await run(command, args, { timeout: 8000 });
return { installed: true, version: output.split(/\r?\n/)[0] || "installed" };
} catch (error) {
if (error.code === "ENOENT") return { installed: false, version: null };
return { installed: true, version: "unknown", error: error.message };
}
}
async function ccSwitchInitialized() {
try {
await fs.access(CC_SWITCH_DATABASE);
return true;
} catch {
return false;
}
}
async function ccSwitchSnapshot() {
const availability = await versionOf("cc-switch");
const initialized = availability.installed && await ccSwitchInitialized();
const apps = await Promise.all(CC_SWITCH_APPS.map(async (app) => {
if (!initialized) return { ...app, currentProviderId: null, providers: [] };
try {
return { ...app, ...parseCcProviderList(await run("cc-switch", ["--app", app.id, "provider", "list"])) };
} catch (error) {
return { ...app, currentProviderId: null, providers: [], error: error.message };
}
}));
return {
...availability,
initialized,
apps,
unsupportedToolIds: ["codebuddy", "kimi", "qwen", "dsh"],
};
}
async function multicaSnapshot() {
const availability = await versionOf("multica", ["version"]);
if (!availability.installed) {
return { ...availability, daemon: { status: "unavailable" }, config: { configured: false }, supportedToolIds: MULTICA_TOOL_IDS };
}
let daemon = { status: "unknown" };
let config = { configured: false, serverUrl: null, appUrl: null, workspaceId: null };
try {
daemon = JSON.parse(await run("multica", ["daemon", "status", "--output", "json"]));
} catch (error) {
daemon = { status: "unknown", error: error.message };
}
try {
config = parseMulticaConfig(await run("multica", ["config", "show"]));
} catch (error) {
config = { ...config, error: error.message };
}
return { ...availability, daemon, config, supportedToolIds: MULTICA_TOOL_IDS };
}
let cachedSnapshot = null;
let cachedAt = 0;
export async function integrationSnapshot({ force = false } = {}) {
if (!force && cachedSnapshot && Date.now() - cachedAt < 5000) return cachedSnapshot;
const [ccSwitch, multica] = await Promise.all([ccSwitchSnapshot(), multicaSnapshot()]);
cachedSnapshot = { ccSwitch, multica };
cachedAt = Date.now();
return cachedSnapshot;
}
function validateCcSwitchApp(appId) {
if (!CC_SWITCH_APPS.some((app) => app.id === appId)) throw new Error("Unsupported CC Switch application");
}
let mutationQueue = Promise.resolve();
function serializeMutation(operation) {
const result = mutationQueue.then(operation, operation);
mutationQueue = result.catch(() => {});
return result;
}
export async function switchCcProvider(appId, providerId) {
validateCcSwitchApp(appId);
if (!SAFE_ID.test(providerId || "")) throw new Error("Invalid provider ID");
return serializeMutation(async () => {
const output = await run("cc-switch", ["--app", appId, "provider", "switch", providerId], { timeout: 30000 });
cachedSnapshot = null;
return { ok: true, output: output.split(/\r?\n/).slice(0, 4).join("\n") };
});
}
export async function importCcLiveConfig(appId) {
validateCcSwitchApp(appId);
return serializeMutation(async () => {
const output = await run("cc-switch", ["--app", appId, "provider", "import-live"], { timeout: 30000 });
cachedSnapshot = null;
return { ok: true, output: output.split(/\r?\n/).slice(0, 6).join("\n") };
});
}
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseCcProviderList, parseMulticaConfig } from "./integrations.js";
test("parses CC Switch provider table without exposing URL paths", () => {
const result = parseCcProviderList(`
┌───┬────────────────┬─────────────────┬────────────────────────────┐
│ ┆ ID ┆ Name ┆ API URL │
╞═══╪════════════════╪═════════════════╪════════════════════════════╡
│ ✓ ┆ codex-official ┆ OpenAI Official ┆ N/A │
│ ┆ relay-one ┆ Relay One ┆ https://api.example/a/key │
└───┴────────────────┴─────────────────┴────────────────────────────┘
→ Current: codex-official
`);
assert.equal(result.currentProviderId, "codex-official");
assert.deepEqual(result.providers, [
{ id: "codex-official", name: "OpenAI Official", endpoint: null, active: true },
{ id: "relay-one", name: "Relay One", endpoint: "https://api.example", active: false },
]);
});
test("parses Multica config and marks an authenticated workspace configured", () => {
const result = parseMulticaConfig(`
Config file: /home/ai/.multica/config.json
server_url: https://multica-api.example
app_url: https://multica.example
workspace_id: ws-123
`);
assert.deepEqual(result, {
serverUrl: "https://multica-api.example",
appUrl: "https://multica.example",
workspaceId: "ws-123",
configured: true,
});
});
+37
View File
@@ -0,0 +1,37 @@
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
export function parseMulticaCallbackUrl(value) {
if (typeof value !== "string" || !value || value.length > 12_000) {
throw new Error("请粘贴完整的 Multica 回调链接");
}
let url;
try {
url = new URL(value);
} catch {
throw new Error("回调链接格式不正确");
}
const port = Number(url.port);
if (url.protocol !== "http:" || !LOOPBACK_HOSTS.has(url.hostname) || url.pathname !== "/callback") {
throw new Error("只接受 Multica 生成的 localhost 回调链接");
}
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
throw new Error("回调链接缺少有效端口");
}
if (!url.searchParams.get("token") || !url.searchParams.get("state")) {
throw new Error("回调链接缺少 token 或 state");
}
return url;
}
export async function relayMulticaCallback(value, fetchImpl = fetch) {
const url = parseMulticaCallbackUrl(value);
const response = await fetchImpl(url, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) throw new Error(`Multica 回调未被接受 (${response.status})`);
return { ok: true };
}
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseMulticaCallbackUrl, relayMulticaCallback } from "./multica-callback.js";
test("accepts a complete Multica loopback callback", () => {
const url = parseMulticaCallbackUrl("http://localhost:42957/callback?token=secret&state=nonce");
assert.equal(url.port, "42957");
});
test("rejects non-loopback callback targets", () => {
assert.throws(
() => parseMulticaCallbackUrl("https://example.com/callback?token=secret&state=nonce"),
/localhost/,
);
});
test("rejects callbacks without OAuth parameters", () => {
assert.throws(() => parseMulticaCallbackUrl("http://localhost:42957/callback"), /token/);
});
test("relays the callback without exposing its response", async () => {
let receivedUrl;
const result = await relayMulticaCallback(
"http://127.0.0.1:42957/callback?token=secret&state=nonce",
async (url) => {
receivedUrl = url;
return { ok: true, status: 200 };
},
);
assert.equal(receivedUrl.hostname, "127.0.0.1");
assert.deepEqual(result, { ok: true });
});
+924
View File
@@ -0,0 +1,924 @@
{
"name": "agentdock-runner",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agentdock-runner",
"version": "0.1.0",
"dependencies": {
"express": "^5.1.0",
"node-pty": "^1.1.0",
"ws": "^8.18.3"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"license": "MIT",
"dependencies": {
"content-type": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "agentdock-runner",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node index.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.1.0",
"node-pty": "^1.1.0",
"ws": "^8.18.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
import fs from "node:fs/promises";
import path from "node:path";
export async function resolveWorkspacePath(root, project = ".") {
if (typeof project !== "string" || project.includes("\0")) {
throw new Error("Invalid project path");
}
const rootReal = await fs.realpath(root);
const candidate = path.resolve(rootReal, project || ".");
const candidateReal = await fs.realpath(candidate);
if (candidateReal !== rootReal && !candidateReal.startsWith(`${rootReal}${path.sep}`)) {
throw new Error("Project must be below /workspace");
}
const stat = await fs.stat(candidateReal);
if (!stat.isDirectory()) throw new Error("Project is not a directory");
return candidateReal;
}
export async function listProjects(root) {
const rootReal = await fs.realpath(root);
const entries = await fs.readdir(rootReal, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
}
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { listProjects, resolveWorkspacePath } from "./path-policy.js";
test("workspace policy accepts child directories and rejects traversal", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-workspace-"));
await fs.mkdir(path.join(root, "demo"));
assert.equal(await resolveWorkspacePath(root, "demo"), path.join(root, "demo"));
await assert.rejects(() => resolveWorkspacePath(root, ".."));
});
test("project listing hides dot directories", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "agentdock-projects-"));
await fs.mkdir(path.join(root, "zeta"));
await fs.mkdir(path.join(root, "alpha"));
await fs.mkdir(path.join(root, ".cache"));
assert.deepEqual(await listProjects(root), ["alpha", "zeta"]);
});
+168
View File
@@ -0,0 +1,168 @@
export const TOOLS = Object.freeze({
codex: {
id: "codex",
command: "codex",
name: "Codex",
vendor: "OpenAI",
mono: "CX",
modelFlag: "--model",
secretNames: ["OPENAI_API_KEY"],
},
claude: {
id: "claude",
command: "claude",
name: "Claude Code",
vendor: "Anthropic",
mono: "CC",
modelFlag: "--model",
secretNames: ["ANTHROPIC_API_KEY"],
},
codebuddy: {
id: "codebuddy",
command: "codebuddy",
name: "CodeBuddy",
vendor: "Tencent",
mono: "CB",
modelFlag: "--model",
secretNames: ["CODEBUDDY_API_KEY"],
},
kimi: {
id: "kimi",
command: "kimi",
name: "Kimi CLI",
vendor: "Moonshot AI",
mono: "KM",
modelFlag: "--model",
secretNames: ["MOONSHOT_API_KEY"],
},
opencode: {
id: "opencode",
command: "opencode",
name: "OpenCode",
vendor: "SST / Open source",
mono: "OC",
modelFlag: "--model",
secretNames: [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DEEPSEEK_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
],
},
qwen: {
id: "qwen",
command: "qwen",
name: "Qwen Code",
vendor: "Alibaba",
mono: "QW",
modelFlag: "--model",
secretNames: ["DASHSCOPE_API_KEY"],
},
dsh: {
id: "dsh",
command: "dsh",
name: "DeepSeek Harness",
vendor: "DeepSeek",
mono: "DS",
secretNames: ["DEEPSEEK_API_KEY"],
profiles: ["headless"],
},
ccswitch: {
id: "ccswitch",
command: "cc-switch",
name: "CC Switch",
vendor: "Community CLI",
mono: "CS",
secretNames: [],
visible: false,
integration: true,
},
multica: {
id: "multica",
command: "multica-setup",
name: "Multica Runtime",
vendor: "Multica",
mono: "MU",
secretNames: [],
visible: false,
integration: true,
},
});
export const SECRET_ENV_ALLOWLIST = new Set(
Object.values(TOOLS).flatMap((tool) => tool.secretNames),
);
export function toolById(id) {
return typeof id === "string" ? TOOLS[id] : undefined;
}
export function buildCommand(tool, request = {}) {
const args = [];
const input = request.initialInput?.trim() || "";
if (tool.integration) {
if (request.purpose !== "authorization") throw new Error("Integration tools only support setup sessions");
if (tool.id === "multica") {
for (const [flag, value] of [["--server-url", request.serverUrl], ["--app-url", request.appUrl]]) {
if (!value) continue;
let url;
try { url = new URL(value); } catch { throw new Error(`${flag} must be a valid URL`); }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error(`${flag} must be an HTTP(S) URL without embedded credentials`);
args.push(flag, url.toString().replace(/\/$/, ""));
}
}
return { command: tool.command, args, interactive: true };
}
if (request.purpose === "task" && !input) {
throw new Error("A task message is required");
}
if (request.purpose === "task") {
switch (tool.id) {
case "codex":
args.push("exec", "--color", "never", "--skip-git-repo-check");
break;
case "claude":
case "codebuddy":
args.push("--print", "--output-format", "text");
break;
case "kimi":
args.push("--prompt", input, "--output-format", "text");
break;
case "opencode":
args.push("run", "--format", "default");
break;
case "qwen":
args.push("--prompt", input, "--output-format", "text");
break;
case "dsh":
args.push("--profile", "headless", input);
return { command: tool.command, args, interactive: false };
default:
throw new Error("Unsupported CLI task mode");
}
if (tool.modelFlag && typeof request.model === "string" && request.model.trim()) {
args.push(tool.modelFlag, request.model.trim().slice(0, 128));
}
if (!["kimi", "qwen"].includes(tool.id)) args.push(input);
return { command: tool.command, args, interactive: false };
}
if (tool.id === "dsh") {
const profile = request.profile || "headless";
if (!tool.profiles.includes(profile)) {
throw new Error("DeepSeek Harness profile must be headless or web");
}
if (profile === "headless" && !request.initialInput?.trim()) {
throw new Error("DeepSeek Harness requires a task before starting");
}
args.push("--profile", profile);
if (profile === "headless" && request.initialInput) args.push(request.initialInput);
} else if (tool.modelFlag && typeof request.model === "string" && request.model.trim()) {
args.push(tool.modelFlag, request.model.trim().slice(0, 128));
}
return { command: tool.command, args, interactive: true };
}
+74
View File
@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildCommand, toolById } from "./registry.js";
test("DeepSeek Harness requires a headless task", () => {
const tool = toolById("dsh");
assert.throws(() => buildCommand(tool, { profile: "headless", initialInput: "" }), /requires a task/);
assert.deepEqual(
buildCommand(tool, { profile: "headless", initialInput: "run tests" }),
{ command: "dsh", args: ["--profile", "headless", "run tests"], interactive: true },
);
});
test("DeepSeek Harness rejects unavailable profiles", () => {
assert.throws(() => buildCommand(toolById("dsh"), { profile: "tui", initialInput: "run tests" }), /must be headless/);
});
test("task commands use non-interactive CLI modes", () => {
const task = { purpose: "task", initialInput: "review this", model: "test-model" };
assert.deepEqual(buildCommand(toolById("codex"), task), {
command: "codex",
args: ["exec", "--color", "never", "--skip-git-repo-check", "--model", "test-model", "review this"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("claude"), task), {
command: "claude",
args: ["--print", "--output-format", "text", "--model", "test-model", "review this"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("kimi"), task), {
command: "kimi",
args: ["--prompt", "review this", "--output-format", "text", "--model", "test-model"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("opencode"), task), {
command: "opencode",
args: ["run", "--format", "default", "--model", "test-model", "review this"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("qwen"), task), {
command: "qwen",
args: ["--prompt", "review this", "--output-format", "text", "--model", "test-model"],
interactive: false,
});
assert.deepEqual(buildCommand(toolById("dsh"), task), {
command: "dsh",
args: ["--profile", "headless", "review this"],
interactive: false,
});
});
test("task commands require a message", () => {
assert.throws(
() => buildCommand(toolById("codex"), { purpose: "task", initialInput: "" }),
/task message is required/i,
);
});
test("Multica setup accepts only HTTP URLs and stays interactive", () => {
const built = buildCommand(toolById("multica"), {
purpose: "authorization",
serverUrl: "https://api.example.test/",
appUrl: "https://app.example.test/",
});
assert.deepEqual(built, {
command: "multica-setup",
args: ["--server-url", "https://api.example.test", "--app-url", "https://app.example.test"],
interactive: true,
});
assert.throws(() => buildCommand(toolById("multica"), {
purpose: "authorization",
serverUrl: "file:///etc/passwd",
}), /HTTP\(S\)/);
});
+103
View File
@@ -0,0 +1,103 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
export class SessionStore {
constructor(directory) {
this.directory = directory;
this.sessions = new Map();
}
async init() {
await fsp.mkdir(this.directory, { recursive: true, mode: 0o700 });
const files = await fsp.readdir(this.directory);
for (const file of files.filter((name) => name.endsWith(".json"))) {
try {
const record = JSON.parse(await fsp.readFile(path.join(this.directory, file), "utf8"));
if (record.status === "running") {
record.status = "interrupted";
record.endedAt = new Date().toISOString();
}
this.sessions.set(record.id, { ...record, archived: true, clients: new Set(), buffer: "" });
} catch {
// A partial metadata file must not prevent the runner from starting.
}
}
}
add(record) {
const session = { ...record, clients: new Set(), buffer: "", errorBuffer: "", archived: false };
this.sessions.set(session.id, session);
this.persist(session);
return session;
}
get(id) {
return this.sessions.get(id);
}
publicRecord(session) {
const {
clients,
pty,
childProcess,
buffer,
errorBuffer,
logStream,
environment,
archived,
...record
} = session;
return { ...record, connectedClients: clients?.size || 0, archived: Boolean(archived) };
}
list() {
return [...this.sessions.values()]
.map((session) => this.publicRecord(session))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
append(session, data) {
session.buffer += data;
if (Buffer.byteLength(session.buffer) > MAX_BUFFER_BYTES) {
session.buffer = session.buffer.slice(-MAX_BUFFER_BYTES);
}
if (!session.logStream) {
session.logStream = fs.createWriteStream(path.join(this.directory, `${session.id}.log`), {
flags: "a",
mode: 0o600,
});
}
session.logStream.write(data);
}
async readOutput(session) {
if (session.buffer) return session.buffer;
try {
return await fsp.readFile(path.join(this.directory, `${session.id}.log`), "utf8");
} catch {
return "";
}
}
finish(session, status, exitCode = null, signal = null) {
session.status = status;
session.exitCode = exitCode;
session.signal = signal;
session.endedAt = new Date().toISOString();
session.logStream?.end();
session.logStream = undefined;
this.persist(session);
}
persist(session) {
const target = path.join(this.directory, `${session.id}.json`);
const temp = `${target}.tmp`;
const body = `${JSON.stringify(this.publicRecord(session), null, 2)}\n`;
fsp.writeFile(temp, body, { mode: 0o600 })
.then(() => fsp.rename(temp, target))
.catch(() => {});
}
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
script_path="$(readlink -f -- "${BASH_SOURCE[0]}")"
script_dir="$(cd -- "$(dirname -- "${script_path}")" && pwd)"
deploy_dir="$(cd -- "${script_dir}/.." && pwd)"
usage() {
cat <<'EOF'
Usage:
./scripts/ai <tool> [project] [tool arguments...]
Tools:
codex, claude, codebuddy, kimi, opencode, qwen, dsh
cc-switch, multica (management commands; no project argument)
Examples:
./scripts/ai codex my-project
./scripts/ai claude my-project
./scripts/ai kimi my-project -p "explain this project"
./scripts/ai dsh my-project --profile headless "run the tests"
./scripts/ai cc-switch
./scripts/ai multica daemon status
EOF
}
if [[ $# -lt 1 ]]; then
usage
exit 2
fi
tool="$1"
shift
case "${tool}" in
codex|claude|codebuddy|kimi|opencode|qwen|dsh|cc-switch|multica) ;;
*)
printf 'Unsupported tool: %s\n\n' "${tool}" >&2
usage >&2
exit 2
;;
esac
if [[ "${tool}" == "cc-switch" || "${tool}" == "multica" ]]; then
exec docker compose --project-directory "${deploy_dir}" exec ai-tools "${tool}" "$@"
fi
project="."
if [[ $# -gt 0 && "$1" != -* ]]; then
project="$1"
shift
fi
if [[ "${project}" == /* || "${project}" == *".."* ]]; then
printf 'Project must be a path below /workspace.\n' >&2
exit 2
fi
exec docker compose --project-directory "${deploy_dir}" exec \
--workdir "/workspace/${project}" ai-tools "${tool}" "$@"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -uo pipefail
tools=(codex claude codebuddy kimi opencode qwen dsh cc-switch multica)
failed=0
printf '%-12s %s\n' "TOOL" "STATUS"
printf '%-12s %s\n' "------------" "------------------------------"
for tool in "${tools[@]}"; do
if command -v "${tool}" >/dev/null 2>&1; then
printf '%-12s %s\n' "${tool}" "OK: $(command -v "${tool}")"
else
printf '%-12s %s\n' "${tool}" "MISSING"
failed=1
fi
done
printf '\nNode.js: %s\n' "$(node --version 2>/dev/null || printf 'MISSING')"
printf 'npm: %s\n' "$(npm --version 2>/dev/null || printf 'MISSING')"
printf 'Git: %s\n' "$(git --version 2>/dev/null || printf 'MISSING')"
exit "${failed}"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
deploy_dir="${1:-$(pwd)}"
certificate_dir="${deploy_dir}/proxy/certs"
certificate_file="${certificate_dir}/agentdock.crt"
key_file="${certificate_dir}/agentdock.key"
mkdir -p "${certificate_dir}"
chmod 755 "${certificate_dir}"
if [[ -f "${certificate_file}" && -f "${key_file}" ]]; then
printf 'Existing AgentDock certificate kept: %s\n' "${certificate_file}"
exit 0
fi
openssl req -x509 -nodes -newkey rsa:2048 -sha256 -days 3650 \
-keyout "${key_file}" \
-out "${certificate_file}" \
-subj "/CN=192.168.200.36/O=AgentDock LAN" \
-addext "subjectAltName=IP:192.168.200.36" \
-addext "keyUsage=digitalSignature,keyEncipherment" \
-addext "extendedKeyUsage=serverAuth"
chmod 644 "${key_file}"
chmod 644 "${certificate_file}"
printf 'AgentDock certificate created: %s\n' "${certificate_file}"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${MULTICA_ENABLED:-true}" != "true" ]]; then
echo "multica runtime disabled by MULTICA_ENABLED"
exec sleep infinity
fi
config_file="${HOME}/.multica/config.json"
while ! jq -e '.server_url and .workspace_id' "${config_file}" >/dev/null 2>&1; do
sleep 10
done
exec multica daemon start \
--foreground \
--no-auto-update \
--max-concurrent-tasks "${MULTICA_DAEMON_MAX_CONCURRENT_TASKS:-2}" \
--runtime-name "${MULTICA_AGENT_RUNTIME_NAME:-AgentDock Ubuntu Runtime}"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
multica setup self-host "$@"
# setup starts a detached daemon. Supervisor will immediately replace it with
# the foreground process used for container lifecycle and clean shutdown.
multica daemon stop >/dev/null 2>&1 || true
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
deploy_dir="${1:-$(pwd)}"
env_file="${deploy_dir}/.env"
password_file="${deploy_dir}/.agentdock-initial-password"
if [[ ! -f "${env_file}" ]]; then
cp "${deploy_dir}/.env.example" "${env_file}"
fi
chmod 600 "${env_file}"
has_key() {
grep -q "^${1}=" "${env_file}"
}
append_value() {
local key="$1"
local value="$2"
if ! has_key "${key}"; then
printf '\n%s=%s\n' "${key}" "${value}" >> "${env_file}"
fi
}
if ! has_key "ADMIN_PASSWORD"; then
admin_password="AgentDock-$(openssl rand -hex 12)"
append_value "ADMIN_PASSWORD" "${admin_password}"
printf '%s\n' "${admin_password}" > "${password_file}"
chmod 600 "${password_file}"
fi
append_value "RUNNER_TOKEN" "$(openssl rand -base64 48 | tr -d '\n')"
append_value "CONFIG_ENCRYPTION_KEY" "$(openssl rand -base64 48 | tr -d '\n')"
append_value "CONSOLE_HTTPS_PORT" "8443"
append_value "CONSOLE_DATA_PATH" "${deploy_dir}/console-data"
append_value "AI_TOOLS_IMAGE_TAG" "0.2.0"
append_value "CONSOLE_IMAGE_TAG" "0.1.0"
append_value "AI_TOOLS_MEMORY_LIMIT" "6g"
append_value "AI_TOOLS_CPU_LIMIT" "4.0"
append_value "CONSOLE_MEMORY_LIMIT" "512m"
append_value "CONSOLE_CPU_LIMIT" "1.0"
mkdir -p "${deploy_dir}/console-data"
chmod 700 "${deploy_dir}/console-data"
printf 'AgentDock console environment is ready.\n'
if [[ -f "${password_file}" ]]; then
printf 'Initial password file: %s\n' "${password_file}"
fi
+34
View File
@@ -0,0 +1,34 @@
param(
[Parameter(Mandatory = $true)]
[string]$Server,
[Parameter(Mandatory = $true)]
[ValidateSet("codex", "claude", "codebuddy", "kimi", "opencode", "qwen", "dsh")]
[string]$Tool,
[string]$Project = ".",
[string]$Container = "ai-tools",
[string[]]$ToolArguments
)
function ConvertTo-PosixArgument {
param([string]$Value)
return "'" + $Value.Replace("'", "'`"'`"'") + "'"
}
if ($Project.StartsWith("/") -or $Project -match "(^|[\\/])\.\.([\\/]|$)") {
throw "Project must be a path below /workspace."
}
$remoteArguments = @(
"docker", "exec", "-it",
"--workdir", "/workspace/$Project",
$Container,
$Tool
) + $ToolArguments
$remoteCommand = ($remoteArguments | ForEach-Object { ConvertTo-PosixArgument $_ }) -join " "
& ssh -tt $Server $remoteCommand
exit $LASTEXITCODE
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
base_url="http://127.0.0.1:${RUNNER_PORT:-4174}"
authorization="Authorization: Bearer ${RUNNER_TOKEN:?RUNNER_TOKEN is required}"
response="$(curl -fsS \
-H "${authorization}" \
-H "Content-Type: application/json" \
-d '{"tool":"codex","project":".","cols":100,"rows":30}' \
"${base_url}/sessions")"
session_id="$(printf '%s' "${response}" | jq -r '.session.id')"
test -n "${session_id}"
cleanup() {
curl -fsS -X POST \
-H "${authorization}" \
-H "Content-Type: application/json" \
-d '{}' \
"${base_url}/sessions/${session_id}/stop" >/dev/null || true
}
trap cleanup EXIT
sleep 3
curl -fsS -H "${authorization}" "${base_url}/sessions/${session_id}/output" | tail -c 500
printf '\nSESSION=%s PTY_OK\n' "${session_id}"
+31
View File
@@ -0,0 +1,31 @@
[supervisord]
nodaemon=true
pidfile=/tmp/supervisord.pid
logfile=/dev/null
logfile_maxbytes=0
[program:runner]
command=node /opt/runner/index.js
priority=10
autostart=true
autorestart=true
startsecs=2
stopasgroup=true
killasgroup=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
[program:multica]
command=/usr/local/bin/multica-runtime
priority=20
autostart=true
autorestart=true
startsecs=2
stopasgroup=true
killasgroup=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
+9
View File
@@ -0,0 +1,9 @@
# Add user-installed commands to interactive non-login shells.
if [ -d "$HOME/bin" ]; then
case ":$PATH:" in
*":$HOME/bin:"*) ;;
*) PATH="$HOME/bin:$PATH" ;;
esac
fi
export PATH
+11
View File
@@ -0,0 +1,11 @@
# Add user-installed commands to login shells.
if [ -d "$HOME/bin" ]; then
PATH="$HOME/bin:$PATH"
fi
export PATH
if [ -f "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi