console: React 生产级重写(左列表 + 中对话 + 右详情)

旧单页 console.html 换成 Vite + React + TypeScript 单文件构建产物
(309525 字节,sha 90345ec8…,重建字节一致),源码在
cloud/console-react/,改页面只改 src 再 npm run build:console,
产物不手改。布局:左侧列表(模块导航 + 任务列表)、中间标准对话
(实时链路时间线 + 继续会话聊天框,共用 Chat 组件)、右侧详情
(元数据、操作、评审、审批、协调子任务、子代理、线程)。
API 与后端语义未改,key 只存浏览器 localStorage。
6 个 console 标记单测随实现改名更新(意图不变:壳确实调了
这些接口、turn 信息仍在详情里、旧 goal 输入仍不存在);
P1 标记单测未动且通过。
This commit is contained in:
host committed 2026-10-29 06:32:38 +08:00
1 parent 3f4e849a93
commit ebc5624893
57 files changed
+4789 -1746

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
tsconfig.tsbuildinfo
+138
View File
@@ -0,0 +1,138 @@
# Codex 命令台 · React 版
生产级 React 重写,替代 `cloud/console.html` 的旧单页脚本。视觉沿用 v3
瓷墨印(白底圆角卡、松绿 `#14544a`、朱印 `#be2a1e` 只作点缀),布局是
三栏:左侧列表、中间对话、右侧详情。
## 怎么改(只改 src,不改产物)
- 改一个模块:只动 `src/sections/<模块>.tsx`,需要新数据时先在
`src/api/types.ts` 加类型、再在对应 `src/hooks/use*.ts` 里加 loader,
不要在组件里拼 URL 或各写一份请求。
- 改通用样式/组件:`src/tokens.css`(唯一 token 源)或
`src/components/` 下的共享组件(`Card`、`DataList`、`StatusPill`、
`ChatList`/`ChatBubble`/`ChatInput`、`LeftNav`)。
- 改 API:只动 `src/api/paths.ts`(全部路径集中在这里)和
`src/api/client.ts`(`api()`、错误文案、key 存取)。组件里不许出现
手拼的 `/tasks/...` 字符串。
- 构建产物(`dist/`、`console-react.html`)**不许手改**。以后改页面
只改 `src` 再构建,产物永远由构建命令生成。
## 构建(一键,自动)
```bash
npm install
npm run build:console
```
`npm run build:console` = `tsc -b && vite build`(vite-plugin-singlefile
把 JS/CSS 全部内联成一个 `dist/index.html`),再由
`scripts/copy-build.mjs` **原样**拷到
`../console-redesign/console-react.html`(审阅用),并打印字节数与
sha256。脚本不改动产物内容。
## 自动部署链路(不留手工步骤)
```
改 src → npm run build:console → 产物拷到 cloud/console.html → 提交 → 部署
```
具体到仓库里的一步(由主 agent 在审阅后执行,本项目不部署生产):
```bash
cp console-redesign/console-react.html <仓库>/cloud/console.html
# 或在仓库分支里直接用构建产物覆盖 cloud/console.html
git add cloud/console.html && git commit -m "console: React 版"
# 随后走仓库已有的 PR → CI → 合并 → deploy.py git 模式部署
```
服务端无需改动:`cloud/server.py` 的 `GET /console` 本来就是每次请求
现读 `cloud/console.html` 这一个文件,无静态资源路由、无需重启。
产物是单文件、无外部 `/assets/*`、无外部字体/图片引用(唯一的
`url(...)` 是内联 SVG 下拉箭头),交给主 agent 就能直接部署到现有
`/console` 页面。
## 怎么验证
```bash
npm run build:console # 必须 0 退出,tsc 无错误
ls -l ../console-redesign/console-react.html
sha256sum ../console-redesign/console-react.html
grep -E 'src="/assets|href="/assets' ../console-redesign/console-react.html # 应无输出
```
部署后另行核对(主 agent 执行):公网 `/console` 的字节数/sha 与
构建产物一致、`/health` 正常、4 个服务 active。不要用真实 API key
做验证调用;本地构建验证不需要 key。
## 布局(三栏联动)
- 左侧 `LeftNav` + `TaskListSection`(约 300px):模块导航(任务、
项目、环境、Workers、技能与 MCP、自动化、分诊、管理区、API Key)
在上,紧凑任务列表(状态/执行器筛选 + 搜索 + 状态行)在下。
- 中间 `ConversationSection`(弹性宽,内容居中收敛 800px):任务视图
时是对话主角——实时链路时间线在上、继续会话聊天框在下;非任务
模块视图时是该模块的标准内容。
- 右侧(约 340px):任务视图时是 `TaskDetailPanel`(元数据、操作、
结果/错误、评审、审批、协调子任务、子代理、线程、成本);模块视图
时是该模块的上下文(项目→成员、环境→仓库绑定、技能→MCP 操作、
Workers/管理区/API Key→健康与配额、自动化↔分诊互为上下文)。
- 联动:左点任务 → `showDetail(id)` → 中间对话与右侧详情同时切到
该任务;左点模块 → 中间与右侧同时切到该模块。平板(≤1240px)
右侧整行落到下方;手机(≤900px)单列,依次是列表、对话、详情,
聊天输入条 `position: sticky; bottom: 0`,在手机上也固定在底部可用。
## 对话与详情的分工(什么进对话、什么进详情)
- 进对话(`src/components/chat/` 共享组件):任务 prompt(用户气泡)、
trace 的 message 条目(助手气泡)、exec/file/tool/call(工具气泡)、
plan/reasoning/agent-start/agent-done(居中状态提示)、继续/后续
消息(用户气泡)。流式追加、底部自动跟随、往上翻暂停跟随。
- 进详情(右侧,不塞进气泡):状态/模型/强度/执行器/项目/turn/
主机/PR/更新、结果/错误文本、fork/重试/取消/合并/提 PR/查成本、
评审、审批、协调子任务、子代理、线程操作、自动化、分诊。
- 聊天发送只用已有接口,不造新接口:终态任务 →
`POST /tasks/:id/continue`(只发 message,其余选项留空即沿用任务原值,
与 live 继续表单全留空等价);运行中/排队中 →
`POST /tasks/:id/followups`(`mode: "direct"`);merged/merge-failed
不可发送。trace 映射只按 live 的 kind 分角色,trace 条目本身没有
时间戳与发送方字段,所以不编造时间顺序:followup 气泡排在 trace
助手消息之后,而不是假装交错排序。
## 目录与职责
```
src/
api/ client.ts(api()、key、错误文案、工具函数)
paths.ts(全部 API 路径,唯一来源)
types.ts(全部实体与表单类型)
hooks/ useCatalog.ts(key/配额/健康/项目/worker/环境/成员/技能/模型/仓库/管理区)
useTaskList.ts(任务列表、筛选、派发)
useTaskDetail.ts(任务详情、trace SSE、聊天发送、全部详情子列表)
useProjectTools.ts(自动化、分诊)
useConsoleStore.ts(胶水:把上面合成 ConsoleCtx + 自动刷新策略)
useAutoRefresh.ts(10s/60s 定时器、select 焦点保护)
components/ Card.tsx、lists.tsx(DataList/StatusPill)、notes.tsx、
controls.tsx、LeftNav.tsx、chat/(Chat.tsx、mapTrace.ts)
sections/ 一个模块一个文件:ApiKey、Admin、Projects、MembersPanel、
Env、EnvContextPanel、Workers、Skills、SkillsToolsPanel、
Automations、Triage、TaskList(左栏紧凑列表)、
Conversation(中间对话组合)、TaskDetailPanel(右侧详情)、
Dispatch(派发,挂在对话上方作为新会话入口)
detail/(TracePanel、ContinuePanel、DetailActions、
ReviewPanel、DetailLists、ProjectToolsPanels)
context.ts ConsoleCtx 类型与 React context(不含业务逻辑)
App.tsx 只做布局与组合:三栏 + Provider + 定时器接入
tokens.css 唯一 token 源:颜色、圆角、间距、40px 控件、断点
```
## 已知边界(诚实版)
- 左栏任务列表是紧凑行(id/状态/prompt/执行器),live 的 9 列
表格(turn、root turn、主机、PR、更新)不在左栏,改在右侧详情
里展示——这是 300px 左栏放不下表格的取舍,不是功能删除。
- 继续聊天只发 message:live 继续表单里的模型/强度/权限/子任务
覆盖项在聊天框里没有入口,发送即沿用任务原值;需要覆盖时仍
是后端原有语义,未改动。
- 本地构建验证未接真实后端(无 key 调用),上线前主 agent 需
独立用真实页面验一遍。
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "codex-console-react",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"preview": "vite preview",
"build:console": "npm run build && node scripts/copy-build.mjs"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vite-plugin-singlefile": "^2.2.0"
}
}
@@ -0,0 +1,17 @@
// build:console 的第二步:把 vite-plugin-singlefile 产出的单文件
// 原样拷到可部署位置。绝不改动产物内容——以后改页面只改 src 再构建。
// 在本施工目录,目标是 ../console-redesign/console-react.html(主 agent
// 审阅用);进仓库后,主 agent 把同一文件拷到 cloud/console.html。
import { copyFileSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const src = join(root, "dist", "index.html");
const dest = join(root, "..", "console-redesign", "console-react.html");
copyFileSync(src, dest);
const buf = readFileSync(dest);
console.log(`console-react.html ${buf.length} bytes sha256=${createHash("sha256").update(buf).digest("hex")}`);
console.log(`built: ${src} -> ${dest}`);
+86
View File
@@ -0,0 +1,86 @@
import { useState } from "react";
import { ConsoleContext } from "./context";
import { useConsoleStore } from "./hooks/useConsoleStore";
import { useAutoRefresh, useIntervalMs } from "./hooks/useAutoRefresh";
import LeftNav, { type View } from "./components/LeftNav";
import ApiKeySection from "./sections/ApiKeySection";
import AdminSection from "./sections/AdminSection";
import ProjectsSection from "./sections/ProjectsSection";
import MembersPanel from "./sections/MembersPanel";
import EnvSection from "./sections/EnvSection";
import EnvContextPanel from "./sections/EnvContextPanel";
import WorkersSection from "./sections/WorkersSection";
import SkillsSection from "./sections/SkillsSection";
import SkillsToolsPanel from "./sections/SkillsToolsPanel";
import AutomationsSection from "./sections/AutomationsSection";
import TriageSection from "./sections/TriageSection";
import TaskListSection from "./sections/TaskListSection";
import ConversationSection from "./sections/ConversationSection";
import TaskDetailPanel from "./sections/TaskDetailPanel";
import HealthPanel from "./sections/HealthPanel";
// Layout only: three columns, ChatGPT/Claude style.
// Left (~300px): module nav + task list. Centre (elastic, content
// centred to ~800px): conversation, or the selected module's content.
// Right (~340px): detail/context for whatever the centre shows.
// Selecting a task (left) switches the centre conversation and the
// right detail to that task together; selecting a module switches the
// centre to that module and the right to its context.
export default function App() {
const store = useConsoleStore();
const [view, setView] = useState<View>("task");
useAutoRefresh((selectFocused) => store.refreshAll(selectFocused), 10000);
useIntervalMs(() => store.refreshSlow(), 60000);
const centre = (() => {
switch (view) {
case "projects": return <ProjectsSection />;
case "env": return <EnvSection />;
case "workers": return <WorkersSection />;
case "skills": return <SkillsSection />;
case "automations": return <AutomationsSection />;
case "triage": return <TriageSection />;
case "admin": return <AdminSection />;
case "key": return <ApiKeySection />;
default: return <ConversationSection />;
}
})();
const right = (() => {
switch (view) {
case "projects": return <MembersPanel />;
case "env": return <EnvContextPanel />;
case "skills": return <SkillsToolsPanel />;
case "workers": case "admin": case "key": return <HealthPanel />;
case "automations": return <TriageSection />;
case "triage": return <AutomationsSection />;
default: return <TaskDetailPanel />;
}
})();
return (
<ConsoleContext.Provider value={store}>
<div className="site-header">
<div className="topbar">
<span className="seal-mark">令</span>
<div>
<h1>Codex 命令台</h1>
<p className="subtitle">瓷 · 墨 · 印</p>
</div>
<span className="version">key 只存本浏览器</span>
<span className="muted" style={{ marginLeft: "auto" }}>{store.healthText} | 自动刷新 · 每 10 秒 · 已刷新 {store.refreshStamp}</span>
</div>
</div>
<div className="shell">
<aside className="rail">
<LeftNav view={view} onSelect={setView} />
<TaskListSection />
</aside>
<main className="center-col">
<div className="center-inner">{centre}</div>
</main>
<aside className="detail-col">{right}</aside>
</div>
</ConsoleContext.Provider>
);
}
+129
View File
@@ -0,0 +1,129 @@
// API client: a faithful port of the live console's api().
// Same-origin absolute paths via paths.ts, X-API-Key from localStorage
// only, never hardcoded, never sent anywhere else. Error text matches
// the live file, including the 401 / 429 / 402 distinctions.
import { paths } from "./paths";
import { TRACE_KIND } from "./types";
import type { TraceEntry } from "./types";
const KEY_STORE = "codex-console-key";
export function getKey(): string {
try { return localStorage.getItem(KEY_STORE) || ""; } catch { return ""; }
}
export function saveKey(v: string): void { localStorage.setItem(KEY_STORE, v); }
export function clearKey(): void { localStorage.removeItem(KEY_STORE); }
export function shortId(id?: string | null): string { return id ? id.slice(0, 8) : ""; }
export function fmt(ts?: number | null): string {
if (!ts) return "";
return new Date(ts * 1000).toLocaleString();
}
export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
export async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
const key = getKey();
if (key) headers["X-API-Key"] = key;
const opt: RequestInit = { method, headers };
if (body !== undefined) opt.body = JSON.stringify(body);
const r = await fetch(path, opt);
let data: unknown = null;
try { data = await r.json(); } catch { data = null; }
if (!r.ok) {
const d = data as { error?: string; unpriced_token_cap?: unknown } | null;
let msg = d && d.error ? d.error : "HTTP " + r.status;
if (r.status === 401) msg += "(key 未填或无效)";
if (r.status === 429) msg += "(触发限流,稍后再试)";
if (r.status === 402) {
if (d && d.unpriced_token_cap !== undefined) msg += "(无价 token 已满,联系管理员调整)";
else if (d && d.error === "coordinator_quota_reserve_exceeded") msg = "协调员分工预留不足:剩余美元配额不够覆盖全部分工,子任务费用记父任务创建人账,整个父任务已被挡下(未建任何子任务)";
else msg += "(美元配额已满,联系管理员调整)";
}
throw new Error(msg);
}
return data as T;
}
// Uploads cannot reuse api(): multipart boundary must be browser-generated.
export async function uploadAttachments(items: Array<{ path: string; file: File }>): Promise<string> {
const fd = new FormData();
fd.append("paths", JSON.stringify(items.map((x) => x.path)));
items.forEach((x, i) => fd.append("file" + i, x.file));
const r = await fetch(paths.uploads, { method: "POST", headers: { "X-API-Key": getKey() }, body: fd });
const d = await r.json().catch(() => ({} as { error?: string; upload_id?: string }));
if (!r.ok) throw new Error((d && d.error) || "HTTP " + r.status);
return d.upload_id as string;
}
export function gatherAttachments(files: FileList | null, dir: FileList | null, imgs: FileList | null): Array<{ path: string; file: File }> {
const out: Array<{ path: string; file: File }> = [];
const seen: Record<string, boolean> = {};
const add = (list: FileList | null, pathOf: (f: File) => string) => {
if (!list) return;
for (let i = 0; i < list.length; i++) {
const p = pathOf(list[i]);
if (!p || seen[p]) continue;
seen[p] = true;
out.push({ path: p, file: list[i] });
}
};
add(files, (f) => f.name);
add(dir, (f) => (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name);
add(imgs, (f) => f.name);
return out;
}
export function workerAgeText(w: { last_seen?: number; last_seen_seconds_ago?: number }): { text: string; age: number | null } {
const now = Date.now() / 1000;
let age: number | null = null;
if (typeof w.last_seen === "number") age = now - w.last_seen;
else if (typeof w.last_seen_seconds_ago === "number") age = w.last_seen_seconds_ago;
if (age === null || age < 0) age = null;
if (age === null) return { text: "未知", age: null };
const s = Math.floor(age);
if (s < 60) return { text: s + " 秒前", age };
if (s < 3600) return { text: Math.floor(s / 60) + " 分钟前", age };
return { text: Math.floor(s / 3600) + " 小时前", age };
}
export function memberErr(e: Error): string {
const m = e.message || "";
if (m.includes("forbidden")) return "没有权限:只有项目 owner 或全局管理员可以管理成员";
if (m.includes("already_member")) return "该用户已是成员,改角色请用成员行的下拉框";
if (m.includes("cannot demote the only owner")) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner";
if (m.includes("user not found")) return "找不到该用户:请核对 user_id 或用户名是否正确";
if (m.includes("member not found")) return "该用户不是本项目成员";
if (m.includes("unknown role")) return "角色无效:只能是 viewer / contributor / maintainer / owner";
if (m.includes("project not found")) return "项目不存在(可能已被删除)";
return "成员操作失败:" + m;
}
export function adminErr(e: Error): string {
const m = e.message || "";
if (m.includes("forbidden")) return "无全局管理员权限:服务端返回 403(CODEX_GLOBAL_ADMINS 未包含当前用户),请联系管理员";
if (m.includes("user not found")) return "找不到该用户:请核对用户 id 是否正确";
return "管理操作失败:" + m;
}
export function fmtUsd(micros?: number | null): string {
if (micros === null || micros === undefined) return "不限额";
return "$" + (micros / 1000000).toFixed(2);
}
export function traceLineText(e: TraceEntry): string {
const k = TRACE_KIND[e.kind || ""] || e.kind || "?";
let body = "";
if (e.kind === "exec") body = (e.command || "") + "(exit=" + (e.exitCode ?? "") + "," + (e.durationMs ?? "") + "ms,cwd=" + (e.cwd || "") + ")";
else if (e.kind === "file") body = (e.path || "") + " " + (e.change || "") + " " + (e.status || "");
else if (e.kind === "message") body = (e.phase ? "[" + e.phase + "] " : "") + (e.text || "");
else if (e.kind === "reasoning") body = e.summary || "";
else if (e.kind === "tool") body = (e.server || "") + "/" + (e.tool || "") + " " + (e.status || "");
else if (e.kind === "call") body = e.name || "";
else if (e.kind === "plan") body = e.text || "";
else if (e.kind === "agent-start") body = "开始:" + (e.name || "") + (e.prompt ? "(" + e.prompt + ")" : "");
else if (e.kind === "agent-done") body = "结束:" + (e.name || "") + " " + (e.status || "");
else body = JSON.stringify(e).slice(0, 300);
return "[" + k + "] " + body;
}
+52
View File
@@ -0,0 +1,52 @@
// Every API path in the app, in one place. No section or hook builds a
// URL string by hand; they all go through this table. Paths are
// same-origin absolute paths, exactly as in the live console.
const enc = encodeURIComponent;
export const paths = {
health: "/health",
relayModels: "/relay/models",
quotaMe: "/users/me/quota",
githubRepos: "/github/repos",
projects: "/projects",
project: (id: string) => "/projects/" + enc(id),
projectMembers: (pid: string) => "/projects/" + enc(pid) + "/members",
projectMember: (pid: string, uid: string) => "/projects/" + enc(pid) + "/members/" + enc(uid),
projectSkills: (pid: string) => "/projects/" + enc(pid) + "/skills",
projectApps: (pid: string) => "/projects/" + enc(pid) + "/apps",
projectMcp: (pid: string) => "/projects/" + enc(pid) + "/mcp-servers",
projectMcpReload: (pid: string) => "/projects/" + enc(pid) + "/mcp-servers/reload",
projectMcpOauth: (pid: string, name: string) => "/projects/" + enc(pid) + "/mcp-servers/" + enc(name) + "/oauth-login",
projectAutomations: (pid: string) => "/projects/" + enc(pid) + "/automations",
projectAutomationCancel: (pid: string, id: string) => "/projects/" + enc(pid) + "/automations/" + enc(id) + "/cancel",
projectTriage: (pid: string) => "/projects/" + enc(pid) + "/triage",
workers: "/workers",
environments: (pid: string) => "/environments?project_id=" + enc(pid),
environmentsCreate: "/environments",
environmentArchive: (id: string) => "/environments/" + enc(id) + "/archive",
tasks: (pid: string) => "/tasks?project_id=" + enc(pid),
tasksByParent: (pid: string, parent: string) => "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent),
tasksCreate: "/tasks",
task: (id: string) => "/tasks/" + enc(id),
taskAction: (id: string, kind: string) => "/tasks/" + enc(id) + "/" + kind,
taskFork: (id: string) => "/tasks/" + enc(id) + "/fork",
taskContinue: (id: string) => "/tasks/" + enc(id) + "/continue",
taskTrace: (id: string) => "/tasks/" + enc(id) + "/trace",
taskTraceStream: (id: string) => "/tasks/" + enc(id) + "/trace/stream",
taskAgents: (id: string) => "/tasks/" + enc(id) + "/agents",
taskReviews: (id: string) => "/tasks/" + enc(id) + "/reviews",
taskReviewFindings: (id: string, rid: string) => "/tasks/" + enc(id) + "/reviews/" + enc(rid) + "/findings",
taskApprovals: (id: string) => "/tasks/" + enc(id) + "/approvals",
taskApprovalDecision: (id: string, aid: string, verb: string) => "/tasks/" + enc(id) + "/approvals/" + enc(aid) + "/" + verb,
taskFollowups: (id: string) => "/tasks/" + enc(id) + "/followups",
taskMessages: (id: string) => "/tasks/" + enc(id) + "/messages",
taskMessagesTo: (target: string) => "/tasks/" + enc(target) + "/messages",
taskThread: (id: string) => "/tasks/" + enc(id) + "/thread",
taskThreads: (id: string) => "/tasks/" + enc(id) + "/threads",
taskThreadAction: (id: string, kind: string) => "/tasks/" + enc(id) + "/thread/" + kind,
taskCost: (id: string) => "/tasks/" + enc(id) + "/cost",
uploads: "/uploads",
adminQuotas: "/admin/quotas",
adminQuotaUser: (uid: string) => "/admin/quotas?user_id=" + enc(uid),
adminSweep: "/admin/sweep",
adminBackup: "/admin/backup",
};
+67
View File
@@ -0,0 +1,67 @@
// Types mirror the live console's JSON shapes. All fields optional except
// where the server always returns them, so a missing field renders as "" /
// "?" exactly like the original esc() fallbacks instead of crashing.
export interface Project { id: string; name: string; repo_url?: string; my_role?: string | null }
export interface Worker { worker_id?: string; id?: string; executor?: string; running?: number | null; capacity?: number | null; alive?: boolean | null; last_seen?: number; last_seen_seconds_ago?: number }
export interface Env { id: string; name: string; branch?: string; executor?: string; status?: string; error_text?: string }
export interface Task {
id: string; project_id?: string; prompt?: string; status: string; executor?: string;
worktree_host?: string; pr_url?: string; pr_number?: number | string; updated_at?: number;
turn_id?: string; root_turn_id?: string; plan?: number | boolean; forked_from?: string;
coordinator_parts?: Array<{ name: string; prompt?: string }> | null; parent_task_id?: string;
coordinator_part?: string; goal_mode?: number | boolean; verify_command?: string;
environment_id?: string; attachments?: string[]; model?: string; effort?: string;
child_model?: string; child_effort?: string; approval?: string;
result_text?: string; error_text?: string;
}
export interface Review { id: string; target_type?: string; branch?: string; sha?: string; instructions?: string; status?: string; created_at?: number; findings?: string }
export interface Approval { id: string; kind?: string; status?: string; summary?: string; decision?: string; decided_by?: string; decided_at?: number }
export interface Followup { mode?: string; status?: string; sender_task_id?: string; body?: string }
export interface PeerMessage { task_id?: string; mode?: string; status?: string; body?: string }
export interface Automation { id: string; name?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number }
export interface Member { user_id: string; username?: string; role?: string }
export interface SkillItem { name?: string; id?: string; description?: string; detail?: string }
export interface McpServer { name?: string; id?: string; status?: string; detail?: string; details?: string; state?: string }
export interface TraceEntry { id?: string; kind?: string; command?: string; exitCode?: number | string; durationMs?: number | string; cwd?: string; path?: string; change?: string; status?: string; phase?: string; text?: string; summary?: string; server?: string; tool?: string; name?: string; prompt?: string }
export interface Agent { kind?: string; name?: string; status?: string; prompt?: string; updated_ts?: number; calls?: TraceEntry[] }
export interface ThreadInfo { id?: string; name?: string; thread_name?: string; archived?: boolean | null; cwd?: string; turns?: number; turn_count?: number; turns_count?: number }
export interface Triage {
counts?: { automations_due?: number; failed_tasks?: number; pending_followups?: number; recent_runs?: number };
automations_due?: Array<{ name?: string; interval_seconds?: number }>;
failed_tasks?: Array<{ id?: string; status?: string; prompt?: string }>;
pending_followups?: Array<{ task_id?: string; body?: string }>;
recent_runs?: Array<{ automation_id?: string; status?: string; task_id?: string; task_status?: string }>;
}
export const TRACE_KIND: Record<string, string> = { exec: "命令", file: "文件", call: "调用", tool: "工具", message: "消息", reasoning: "思考", plan: "计划", "agent-start": "子代理", "agent-done": "子代理" };
export const TRACE_LIMIT = 1000;
export const TRACE_TERMINAL = ["completed", "failed", "interrupted", "merged", "merge-failed"];
export const THREAD_MUTATION_STATUSES = ["completed", "failed", "interrupted"];
export const STATUS_EXPLAIN: Record<string, string> = {
queued: "排队中,尚未被 worker 认领", running: "运行中,模型回合进行中",
completed: "模型回合结束、改动在 worktree 里尚未合并", failed: "模型回合失败,可重试",
interrupted: "已被取消/中断,可重试", merged: "控制面已标记 merged,不代表 git 主分支已合入",
"merge-failed": "合并落盘失败,可重试",
};
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
export const ROLES = ["viewer", "contributor", "maintainer", "owner"];
// Form bodies sent to the server. No section builds these from loose any.
export interface DispatchBody {
project_id: string; prompt: string; environment_id?: string; executor?: string;
model?: string; effort?: string; child_model?: string; child_effort?: string;
approval?: string; plan?: number; goal_mode?: number; upload_id?: string;
coordinator_parts?: Array<{ name: string; prompt?: string }>;
}
export interface ContinueBody {
message: string; model?: string; effort?: string; child_model?: string;
child_effort?: string; approval?: string; plan?: number; goal_mode?: number;
}
export interface ReviewBody { target_type: string; branch?: string; sha?: string; instructions?: string }
export interface Health { backend?: string; db_ok?: boolean; worker_ok?: boolean; commit?: string }
export interface Quota {
used_usd_micros?: number; quota_usd_micros?: number | null; remaining_usd_micros?: number | null;
unpriced_token_cap?: number; unpriced_tokens_used?: number;
}
export interface GithubRepo { clone_url: string; repo_full_name: string }
export interface CostResponse { [k: string]: unknown }
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
export function Card({ id, className, title, children }: { id?: string; className?: string; title?: ReactNode; children: ReactNode }) {
return (
<section id={id} className={"card" + (className ? " " + className : "")}>
{title ? <h2>{title}</h2> : null}
{children}
</section>
);
}
export function Bar({ children }: { children: ReactNode }) {
return <div className="bar">{children}</div>;
}
@@ -0,0 +1,17 @@
export type View = "task" | "projects" | "env" | "workers" | "skills" | "automations" | "triage" | "admin" | "key";
const ITEMS: Array<[View, string]> = [
["task", "任务"], ["projects", "项目"], ["env", "环境"], ["workers", "Workers"],
["skills", "技能与 MCP"], ["automations", "自动化"], ["triage", "分诊"],
["admin", "管理区"], ["key", "API Key"],
];
export default function LeftNav({ view, onSelect }: { view: View; onSelect: (v: View) => void }) {
return (
<nav className="left-nav" aria-label="模块导航">
{ITEMS.map(([v, label]) => (
<button key={v} className={view === v ? "active" : ""} onClick={() => onSelect(v)}>{label}</button>
))}
</nav>
);
}
@@ -0,0 +1,72 @@
import { useEffect, useRef, useState } from "react";
import type { ChatMessage } from "./mapTrace";
export function useAutoFollow(dep: unknown) {
const ref = useRef<HTMLDivElement>(null);
const [following, setFollowing] = useState(true);
const followingRef = useRef(true);
const onScroll = () => {
const el = ref.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
followingRef.current = nearBottom;
setFollowing(nearBottom);
};
useEffect(() => {
if (followingRef.current && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [dep]);
return { ref, following, onScroll };
}
export function ChatBubble({ msg }: { msg: ChatMessage }) {
if (msg.role === "status") {
return <div className="chat-status">{msg.text}</div>;
}
return (
<div className={"chat-row " + msg.role}>
<div className={"chat-bubble " + msg.role}>
{msg.label ? <div className="chat-label">{msg.label}</div> : null}
<div className="chat-text">{msg.text}</div>
</div>
</div>
);
}
export function ChatList({ messages, empty }: { messages: ChatMessage[]; empty?: string }) {
const { ref, following, onScroll } = useAutoFollow(messages.length);
return (
<div className="chat-list" ref={ref} onScroll={onScroll}>
{messages.length === 0 ? <div className="muted">{empty || "(暂无消息)"}</div> : messages.map((m) => <ChatBubble key={m.id} msg={m} />)}
{!following && messages.length > 0 ? (
<button className="chat-follow" onClick={() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }}>回到最新 ↓</button>
) : null}
</div>
);
}
export function ChatInput({ onSend, disabled, placeholder }: {
onSend: (text: string) => void; disabled?: boolean; placeholder?: string;
}) {
const [text, setText] = useState("");
const send = () => {
const v = text.trim();
if (!v || disabled) return;
onSend(v);
setText("");
};
return (
<div className="chat-input-bar">
<textarea
aria-label="会话输入"
className="chat-input"
rows={1}
placeholder={placeholder || "输入消息,回车发送,Shift+回车换行"}
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
/>
<button className="primary chat-send" disabled={disabled || !text.trim()} onClick={send}>发送</button>
</div>
);
}
@@ -0,0 +1,27 @@
import type { TraceEntry } from "../../api/types";
import { traceLineText } from "../../api/client";
export type ChatRole = "user" | "assistant" | "tool" | "status";
export interface ChatMessage { id: string; role: ChatRole; text: string; label?: string }
// Mapping is by live trace kind ONLY. Trace entries carry no timestamp
// and no sender role in the live file, so no role is invented from
// nothing: message entries are the agent's replies, exec/file/tool/call
// are tool activity, and plan/reasoning/agent-start/agent-done are
// status timeline items. The task prompt itself is the user message
// (it comes from the task record, not from a trace entry).
export function traceEntryToChatMessage(e: TraceEntry, i: number): ChatMessage {
const id = e.id || "trace-" + i;
if (e.kind === "message") {
return { id, role: "assistant", text: (e.phase ? "[" + e.phase + "] " : "") + (e.text || ""), label: "助手" };
}
if (e.kind === "exec" || e.kind === "file" || e.kind === "tool" || e.kind === "call") {
const label = e.kind === "exec" ? "命令" : e.kind === "file" ? "文件" : e.kind === "tool" ? "工具" : "调用";
return { id, role: "tool", text: traceLineText(e), label };
}
if (e.kind === "plan") return { id, role: "status", text: e.text || "" };
if (e.kind === "reasoning") return { id, role: "status", text: "思考:" + (e.summary || "") };
if (e.kind === "agent-start") return { id, role: "status", text: "子代理开始:" + (e.name || "") + (e.prompt ? "(" + e.prompt + ")" : "") };
if (e.kind === "agent-done") return { id, role: "status", text: "子代理结束:" + (e.name || "") + " " + (e.status || "") };
return { id, role: "status", text: traceLineText(e) };
}
@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
export function Select({ value, onChange, children, disabled, ariaLabel }: {
value: string; onChange: (v: string) => void; children: ReactNode; disabled?: boolean; ariaLabel?: string;
}) {
return <select aria-label={ariaLabel} value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)}>{children}</select>;
}
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
// One shared list style for every former <br>-joined list in the live file.
export function DataList({ items, empty }: { items: ReactNode[]; empty?: string }) {
if (!items.length) return <div className="muted">{empty || "(暂无)"}</div>;
return <ul className="data-list">{items.map((it, i) => <li key={i}>{it}</li>)}</ul>;
}
export function DataListRaw({ children }: { children: ReactNode }) {
return <ul className="data-list">{children}</ul>;
}
export function StatusPill({ status }: { status?: string }) {
return <span className={"st-" + (status || "")}>{status || ""}</span>;
}
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
export function ErrorNote({ children }: { children: ReactNode }) {
if (!children) return null;
return <span className="err">{children}</span>;
}
export function OkNote({ children }: { children: ReactNode }) {
if (!children) return null;
return <span className="ok">{children}</span>;
}
export function EmptyState({ children }: { children: ReactNode }) {
return <div className="muted">{children}</div>;
}
@@ -0,0 +1,6 @@
// Barrel: sections import shared components from here. The components
// themselves live in one file each (Card, lists, notes, controls).
export { Card, Bar } from "./Card";
export { DataList, DataListRaw, StatusPill } from "./lists";
export { ErrorNote, OkNote, EmptyState } from "./notes";
export { Select } from "./controls";
+48
View File
@@ -0,0 +1,48 @@
import { createContext, useContext } from "react";
import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry } from "./api/types";
export interface ConsoleCtx {
keySaved: boolean; keyState: string;
saveKeyInput: (v: string) => void; clearKeyInput: () => void;
quotaText: string;
healthText: string; refreshHealth: () => void;
adminGate: string; adminBody: boolean; adminResult: string; adminUserId: string; setAdminUserId: (v: string) => void;
adminQuota: () => void; adminSweep: () => void; adminBackup: () => void;
projects: Project[]; projectId: string; projectMsg: string;
selectProject: (id: string) => void; refreshProjects: () => void;
createProject: (name: string, repo: string) => Promise<void>;
githubRepos: Array<{ clone_url: string; repo_full_name: string }>;
workers: Worker[] | null; workersSummary: string; workersError: string; refreshWorkers: () => void;
members: Member[]; memberMsg: string; memberError: string; refreshMembers: () => void; inviteMember: (raw: string, role: string) => Promise<void>; changeMemberRole: (uid: string, role: string) => Promise<void>;
skills: SkillItem[]; apps: SkillItem[]; mcpServers: McpServer[]; skillMsg: string; skillError: string; mcpMsg: string; mcpOauthMsg: string; mcpOauthBox: string;
refreshSkills: () => void; reloadMcp: () => void; mcpOauth: (name: string) => Promise<void>;
envs: Env[]; envCount: string; envMsg: string; envRepoLine: string; envRepoBar: boolean; envRepoMsg: string; envSelect: string; setEnvSelect: (v: string) => void;
refreshEnvs: () => void; createEnv: (name: string, branch: string, executor: string) => Promise<void>; archiveEnv: (id: string) => Promise<void>; saveEnvRepo: (repo: string) => Promise<void>;
models: string[]; modelCustomVisible: boolean;
tasks: Task[]; taskCountText: string; taskError: string; refreshTasks: () => void;
statusFilter: string; setStatusFilter: (v: string) => void; executorFilter: string; setExecutorFilter: (v: string) => void; searchText: string; setSearchText: (v: string) => void;
filteredTasks: Task[];
currentTask: Task | null; detailBody: string; actionMsg: string; actionReasons: string;
showDetail: (id: string) => void; doAction: (kind: string) => void; taskCost: () => void; costBox: string;
forkVisible: boolean; setForkVisible: (v: boolean) => void; forkTask: (msg: string) => Promise<void>; forkMsg: string;
// Continue input is ContinuePanel (ChatInput); its send path is sendChatMessage below.
coordChildren: Task[]; coordText: string; agents: Agent[]; agentsNote: string;
reviews: Review[]; reviewMsg: string; reviewError: string; refreshReviews: () => void; postReview: (body: ReviewBody) => Promise<void>; submitFindings: (id: string) => void;
traceEntries: TraceEntry[]; traceNote: string; traceDone: string; traceKind: string; setTraceKind: (v: string) => void; downloadTrace: () => void; traceMsg: string;
sendChatMessage: (text: string) => Promise<void>; chatMsg: string;
followups: Followup[];
approvals: Approval[]; approvalMsg: string; refreshApprovals: () => void; decideApproval: (id: string, decision: string) => void;
peerMessages: PeerMessage[]; peerMsg: string; postPeer: (mode: string, target: string, text: string) => Promise<void>;
automations: Automation[]; autoMsg: string; refreshAutomations: () => void; createAutomation: (name: string, prompt: string, interval: string, repo: string) => Promise<void>; cancelAutomation: (id: string) => void;
triage: Triage | null; triageError: string; refreshTriage: () => void;
threadText: string; threadGateNote: string; threadMsg: string; threadList: ThreadInfo[]; threadAction: (kind: string, name: string) => void;
dispatch: (body: DispatchBody, atts: Array<{ path: string; file: File }>) => Promise<string>; dispatchMsg: string;
refreshAll: (selectFocused: boolean) => void; refreshSlow: () => void; refreshStamp: string;
}
export const ConsoleContext = createContext<ConsoleCtx | null>(null);
export function useConsole(): ConsoleCtx {
const c = useContext(ConsoleContext);
if (!c) throw new Error("ConsoleContext missing");
return c;
}
@@ -0,0 +1,32 @@
import { useEffect, useRef } from "react";
// Auto-refresh driver. The callback receives whether a <select> currently
// has focus; callers must skip select-rebuilding loads in that case, so a
// refresh never yanks a dropdown the user is choosing from. Form state
// lives in React state and is never overwritten by a refresh, so a form
// being typed is never cleared.
export function useAutoRefresh(tick: (selectFocused: boolean) => void, ms: number) {
const cb = useRef(tick);
cb.current = tick;
useEffect(() => {
const id = setInterval(() => {
const ae = document.activeElement;
cb.current(ae ? ae.tagName === "SELECT" : false);
}, ms);
return () => clearInterval(id);
}, [ms]);
}
export function useIntervalMs(cb: () => void, ms: number) {
const ref = useRef(cb);
ref.current = cb;
useEffect(() => {
const id = setInterval(() => ref.current(), ms);
return () => clearInterval(id);
}, [ms]);
}
export function formatRefreshTime(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
}
+319
View File
@@ -0,0 +1,319 @@
import { useCallback, useRef, useState } from "react";
import { api, getKey, saveKey, clearKey, memberErr, adminErr, fmtUsd } from "../api/client";
import { paths } from "../api/paths";
import type { Env, GithubRepo, Health, Member, Project, Quota, SkillItem, McpServer, Worker } from "../api/types";
// Project-scoped catalogue data: key/quota/health, projects, workers,
// environments, members, skills/MCP, relay models, GitHub repos, admin.
// No task-detail state lives here; see useTaskDetail.
export function useCatalog() {
const [keySaved, setKeySaved] = useState(!!getKey());
const [keyState, setKeyState] = useState(getKey() ? "已保存到本浏览器(只用于调本站 API)" : "未填 key,数据接口会返回 401");
const [quotaText, setQuotaText] = useState("未加载");
const [healthText, setHealthText] = useState("未加载");
const [adminGate, setAdminGate] = useState("填 key 后判断权限…");
const [adminBody, setAdminBody] = useState(false);
const [adminResult, setAdminResult] = useState("");
const [adminUserId, setAdminUserId] = useState("");
const [projects, setProjects] = useState<Project[]>([]);
const [projectId, setProjectId] = useState("");
const [projectMsg, setProjectMsg] = useState("");
const [githubRepos, setGithubRepos] = useState<GithubRepo[]>([]);
const [workers, setWorkers] = useState<Worker[] | null>(null);
const [workersSummary, setWorkersSummary] = useState("未加载");
const [workersError, setWorkersError] = useState("");
const [members, setMembers] = useState<Member[]>([]);
const [memberMsg, setMemberMsg] = useState("");
const [memberError, setMemberError] = useState("");
const [skills, setSkills] = useState<SkillItem[]>([]);
const [apps, setApps] = useState<SkillItem[]>([]);
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [skillMsg, setSkillMsg] = useState("先选项目");
const [skillError, setSkillError] = useState("");
const [mcpMsg, setMcpMsg] = useState("");
const [mcpOauthMsg, setMcpOauthMsg] = useState("");
const [mcpOauthBox, setMcpOauthBox] = useState("");
const [envs, setEnvs] = useState<Env[]>([]);
const [envCount, setEnvCount] = useState("");
const [envMsg, setEnvMsg] = useState("");
const [envRepoLine, setEnvRepoLine] = useState("");
const [envRepoBar, setEnvRepoBar] = useState(false);
const [envRepoMsg, setEnvRepoMsg] = useState("");
const [envSelect, setEnvSelect] = useState("");
const [models, setModels] = useState<string[]>([]);
const [modelCustomVisible, setModelCustomVisible] = useState(false);
const projectIdRef = useRef("");
projectIdRef.current = projectId;
const envSelectRef = useRef("");
envSelectRef.current = envSelect;
const refreshHealth = useCallback(async () => {
try {
const h = await api<Health>(paths.health);
setHealthText("backend=" + h.backend + " db_ok=" + h.db_ok + " worker_ok=" + h.worker_ok + " commit=" + String(h.commit || "").slice(0, 7));
} catch (e: any) { setHealthText("加载失败:" + e.message); }
}, []);
const loadQuota = useCallback(async () => {
if (!getKey()) { setQuotaText("未填 key,配额不显示"); return; }
try {
const d = await api<Quota>(paths.quotaMe);
const used = ((d.used_usd_micros || 0) / 1000000).toFixed(2);
const usdLine = d.quota_usd_micros === null || d.quota_usd_micros === undefined
? "美元:本月用量 $" + used + "(不限额)"
: "美元:本月用量 $" + used + " / $" + ((d.quota_usd_micros || 0) / 1000000).toFixed(2) + "(剩余 $" + ((d.remaining_usd_micros || 0) / 1000000).toFixed(2) + ")";
const tokLine = d.unpriced_token_cap === undefined || d.unpriced_tokens_used === undefined
? "无价 token:未返回(旧服务端)"
: "无价 token:" + d.unpriced_tokens_used + " / " + d.unpriced_token_cap + " tokens";
setQuotaText(usdLine + " | " + tokLine);
} catch (e: any) { setQuotaText("配额加载失败:" + e.message); }
}, []);
const probeAdmin = useCallback(async () => {
if (!getKey()) { setAdminGate("未填 key,管理区不可用"); setAdminBody(false); return; }
try {
const r = await fetch(paths.adminQuotas, { headers: { "X-API-Key": getKey() } });
if (r.status === 400) { setAdminGate("已确认为全局管理员,管理操作可用"); setAdminBody(true); }
else if (r.status === 403) { setAdminGate("管理区仅全局管理员可用:当前 key 无全局管理员权限(服务端返回 403)"); setAdminBody(false); }
else if (r.status === 401) { setAdminGate("key 无效,管理区不可用(服务端返回 401)"); setAdminBody(false); }
else { setAdminGate("管理权限判断失败(HTTP " + r.status + "),管理操作暂不可用"); setAdminBody(false); }
} catch (e: any) { setAdminGate("管理权限判断失败:" + e.message); setAdminBody(false); }
}, []);
const refreshProjects = useCallback(async () => {
try {
const d = await api<{ projects: Project[] }>(paths.projects);
const list = d.projects || [];
setProjects(list);
setProjectMsg(list.length + " 个项目");
if (projectIdRef.current && !list.some((p) => p.id === projectIdRef.current)) {
// Selection is preserved when still present; a deleted project
// simply leaves the select empty on next render via value.
}
} catch (e: any) { setProjectMsg("加载失败:" + e.message); }
}, []);
const loadGithubRepos = useCallback(async () => {
try {
const d = await api<{ repos: GithubRepo[] }>(paths.githubRepos);
setGithubRepos(d.repos || []);
} catch { /* 未登录或无安装记录时只留手动输入,不报错 */ }
}, []);
const loadRelayModels = useCallback(async () => {
try {
const d = await api<{ models: string[] }>(paths.relayModels);
setModels(d.models || []);
setModelCustomVisible(false);
} catch { setModelCustomVisible(true); }
}, []);
const refreshWorkers = useCallback(async () => {
try {
const d = await api<{ workers?: Worker[]; workers_total?: number; workers_online?: number; workers_offline?: number } | Worker[]>(paths.workers);
if (!Array.isArray(d) && d.workers === undefined && d.workers_total !== undefined) {
setWorkers(null);
setWorkersSummary(!d.workers_total ? "无 worker 注册" : "在线 " + d.workers_online + " / 离线 " + d.workers_offline + "(共 " + d.workers_total + ";完整 worker 清单仅全局管理员可见)");
setWorkersError("");
return;
}
const list = Array.isArray(d) ? d : (d.workers || []);
setWorkers(list);
setWorkersSummary(list.length ? "" : "无 worker 注册");
setWorkersError("");
} catch (e: any) { setWorkersError("加载失败:" + e.message); }
}, []);
const refreshEnvs = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : projectIdRef.current;
const cur = envSelectRef.current;
if (!id) { setEnvs([]); setEnvCount(""); setEnvSelect(""); return; }
try {
const d = await api<{ environments: Env[] }>(paths.environments(id));
const list = d.environments || [];
setEnvs(list);
setEnvCount(list.length + " 个环境");
if (cur && list.some((e) => e.id === cur && e.status === "ready")) setEnvSelect(cur);
else if (cur && list.some((e) => e.id === cur)) setEnvSelect(cur);
const proj = projects.find((p) => p.id === id);
if (proj && proj.repo_url) { setEnvRepoLine("仓库:" + proj.repo_url); setEnvRepoBar(false); }
else if (proj) { setEnvRepoLine("该项目未绑定仓库,环境无法创建——先选一个仓库保存"); setEnvRepoBar(true); }
else { setEnvRepoLine(""); setEnvRepoBar(false); }
} catch (e: any) { setEnvMsg("加载失败:" + e.message); }
}, [projects]);
const refreshMembers = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : projectIdRef.current;
if (!id) { setMembers([]); return; }
try {
const d = await api<{ members: Member[] }>(paths.projectMembers(id));
setMembers(d.members || []);
setMemberError("");
} catch (e: any) { setMemberError("成员加载失败:" + memberErr(e)); }
}, []);
const refreshSkills = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : projectIdRef.current;
if (!id) { setSkills([]); setApps([]); setMcpServers([]); setSkillMsg("先选项目"); return; }
setSkillMsg("");
try {
const s = await api<{ skills: SkillItem[] }>(paths.projectSkills(id));
setSkills(s.skills || []);
} catch (e: any) { setSkillError("技能加载失败:" + e.message); }
try {
const a = await api<{ apps: SkillItem[] }>(paths.projectApps(id));
setApps(a.apps || []);
} catch (e: any) { setSkillError("应用加载失败:" + e.message); }
try {
const m = await api<{ servers?: McpServer[]; mcp_servers?: McpServer[] }>(paths.projectMcp(id));
setMcpServers(m.servers || m.mcp_servers || []);
} catch (e: any) { setSkillError("MCP 服务加载失败:" + e.message); }
}, []);
const saveKeyInput = useCallback((v: string) => {
if (!v.trim()) { setKeyState("key 为空,未保存"); return; }
saveKey(v.trim());
setKeySaved(true);
setKeyState("已保存到本浏览器(只用于调本站 API)");
}, []);
const clearKeyInput = useCallback(() => {
clearKey();
setKeySaved(false);
setKeyState("未填 key,数据接口会返回 401");
void probeAdmin();
}, [probeAdmin]);
const createProject = useCallback(async (name: string, repo: string) => {
if (!name.trim()) { setProjectMsg("项目名必填"); return; }
const body: { name: string; repo_url?: string } = { name: name.trim() };
if (repo.trim()) body.repo_url = repo.trim();
try {
await api(paths.projects, "POST", body);
setProjectMsg("项目已建");
await refreshProjects();
} catch (e: any) { setProjectMsg("新建失败:" + e.message); }
}, [refreshProjects]);
const createEnv = useCallback(async (name: string, branch: string, executor: string) => {
const pid = projectIdRef.current;
if (!pid) { setEnvMsg("先选项目"); return; }
if (!name.trim()) { setEnvMsg("环境名必填"); return; }
const body: { project_id: string; name: string; branch?: string; executor?: string } = { project_id: pid, name: name.trim() };
if (branch.trim()) body.branch = branch.trim();
if (executor) body.executor = executor;
try {
await api(paths.environmentsCreate, "POST", body);
setEnvMsg("环境已建,worker 准备中(creating → ready,失败会在列表里写原因)");
await refreshEnvs(pid);
} catch (e: any) { setEnvMsg("新建失败:" + e.message); }
}, [refreshEnvs]);
const archiveEnv = useCallback(async (id: string) => {
if (!window.confirm("确定归档该环境吗?归档后新任务不能再选它,进行中的任务不受影响(有进行中任务在用它时归档会被拒绝)。")) return;
try { await api(paths.environmentArchive(id), "POST", {}); await refreshEnvs(); }
catch (e: any) { setEnvMsg("归档失败:" + e.message); }
}, [refreshEnvs]);
const saveEnvRepo = useCallback(async (repo: string) => {
const pid = projectIdRef.current;
if (!pid) { setEnvRepoMsg("先选项目"); return; }
if (!repo) { setEnvRepoMsg("先选一个仓库"); return; }
try {
await api(paths.project(pid), "PATCH", { repo_url: repo });
setEnvRepoMsg("仓库已保存,现在可以建环境了");
await refreshProjects();
await refreshEnvs(pid);
} catch (e: any) { setEnvRepoMsg("保存失败:" + e.message); }
}, [refreshProjects, refreshEnvs]);
const inviteMember = useCallback(async (raw: string, role: string) => {
const pid = projectIdRef.current;
if (!pid) { setMemberMsg("先选项目"); return; }
if (!raw.trim()) { setMemberMsg("请填成员 user_id 或用户名"); return; }
try {
await api(paths.projectMembers(pid), "POST", { user_id: raw.trim(), role });
} catch (e: any) {
if (e.message.includes("user not found")) {
try { await api(paths.projectMembers(pid), "POST", { username: raw.trim(), role }); }
catch (e2: any) { setMemberMsg(memberErr(e2)); return; }
} else { setMemberMsg(memberErr(e)); return; }
}
setMemberMsg("已邀请成员");
await refreshMembers(pid);
}, [refreshMembers]);
const changeMemberRole = useCallback(async (uid: string, role: string) => {
const pid = projectIdRef.current;
if (!pid) return;
try {
await api(paths.projectMember(pid, uid), "PATCH", { role });
setMembers((prev) => prev.map((m) => (m.user_id === uid ? { ...m, role } : m)));
setMemberMsg("角色已改为 " + role);
} catch (e: any) { setMemberMsg(memberErr(e)); }
}, []);
const reloadMcp = useCallback(async () => {
const pid = projectIdRef.current;
if (!pid) { setMcpMsg("先选项目"); return; }
try {
await api(paths.projectMcpReload(pid), "POST", {});
setMcpMsg("配置已重载");
await refreshSkills(pid);
} catch (e: any) { setMcpMsg("重载失败:" + e.message); }
}, [refreshSkills]);
const mcpOauth = useCallback(async (name: string) => {
const pid = projectIdRef.current;
if (!pid) { setMcpOauthMsg("先选项目"); return; }
if (!name.trim()) { setMcpOauthMsg("服务名不能为空"); return; }
try {
const d = await api<{ url?: string; auth_url?: string; login_url?: string; authorization_url?: string; steps?: string | string[]; instructions?: string | string[] } & Record<string, unknown>>(paths.projectMcpOauth(pid, name.trim()), "POST", {});
const url = d.url || d.auth_url || d.login_url || d.authorization_url || "";
const steps = d.steps || d.instructions || "";
let box = "登录尚未完成:请在浏览器打开下面的链接或按步骤完成,完成后回到本页刷新 MCP 状态。\n";
if (url) box += url + "\n";
if (steps) box += (Array.isArray(steps) ? steps.join("\n") : String(steps)) + "\n";
box += JSON.stringify(d, null, 2).slice(0, 4000);
setMcpOauthBox(box);
setMcpOauthMsg("已返回登录信息,见下方");
} catch (e: any) { setMcpOauthMsg("发起失败:" + e.message); }
}, []);
const adminQuota = useCallback(async () => {
const uid = adminUserId.trim();
if (!uid) { setAdminResult("请填要查配额的用户 id"); return; }
try {
const d = await api<{ used_usd_micros?: number; quota_usd_micros?: number | null; remaining_usd_micros?: number | null; month?: string }>(paths.adminQuotaUser(uid));
setAdminResult("用户 " + uid.slice(0, 8) + ":本月用量 " + fmtUsd(d.used_usd_micros) + " / 配额 " + fmtUsd(d.quota_usd_micros) + (d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + fmtUsd(d.remaining_usd_micros) + ")" : "") + " | 月份 " + (d.month || ""));
} catch (e: any) { setAdminResult(adminErr(e)); }
}, [adminUserId]);
const adminSweep = useCallback(async () => {
if (!window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
try {
const d = await api<{ swept?: string[] }>(paths.adminSweep, "POST", {});
const swept = d.swept || [];
setAdminResult("sweep 完成,清理 " + swept.length + " 个任务" + (swept.length ? ":" + swept.join("、") : ""));
} catch (e: any) { setAdminResult(adminErr(e)); }
}, []);
const adminBackup = useCallback(async () => {
try {
const d = await api<{ backend?: string; path?: string; size_bytes?: number; task_count?: number }>(paths.adminBackup, "POST", {});
setAdminResult("备份完成:backend=" + d.backend + " | 路径 " + d.path + " | 大小 " + d.size_bytes + " 字节 | 任务 " + d.task_count + " 行");
} catch (e: any) { setAdminResult(adminErr(e)); }
}, []);
return {
keySaved, keyState, quotaText, healthText, adminGate, adminBody, adminResult, adminUserId, setAdminUserId,
projects, projectId, setProjectId, projectMsg, githubRepos,
workers, workersSummary, workersError, members, memberMsg, memberError,
skills, apps, mcpServers, skillMsg, skillError, mcpMsg, mcpOauthMsg, mcpOauthBox,
envs, envCount, envMsg, envRepoLine, envRepoBar, envRepoMsg, envSelect, setEnvSelect,
models, modelCustomVisible,
refreshHealth, loadQuota, probeAdmin, refreshProjects, loadGithubRepos, loadRelayModels, refreshWorkers,
refreshEnvs, refreshMembers, refreshSkills, saveKeyInput, clearKeyInput, createProject, createEnv,
archiveEnv, saveEnvRepo, inviteMember, changeMemberRole, reloadMcp, mcpOauth, adminQuota, adminSweep, adminBackup,
};
}
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getKey } from "../api/client";
import { useCatalog } from "./useCatalog";
import { useTaskList } from "./useTaskList";
import { useTaskDetail } from "./useTaskDetail";
import { useProjectTools } from "./useProjectTools";
import { formatRefreshTime } from "./useAutoRefresh";
import type { ConsoleCtx } from "../context";
// Glue only: composes the domain hooks into one ConsoleCtx and owns
// the auto-refresh policy. No API calls and no rendering live here.
export function useConsoleStore(): ConsoleCtx {
const catalog = useCatalog();
const taskList = useTaskList();
const projectIdRef = useRef("");
projectIdRef.current = catalog.projectId;
const getProjectId = useCallback(() => projectIdRef.current, []);
const tools = useProjectTools(getProjectId);
const getRole = useCallback((pid?: string) => {
const p = catalog.projects.find((x) => x.id === pid);
return p ? (p.my_role || null) : null;
}, [catalog.projects]);
const onTasksReload = useCallback(() => { void taskList.refreshTasks(undefined, projectIdRef.current); }, [taskList]);
const detail = useTaskDetail(getRole, onTasksReload);
const [refreshStamp, setRefreshStamp] = useState("--:--:--");
const selectProject = useCallback((id: string) => {
catalog.setProjectId(id);
projectIdRef.current = id;
void taskList.refreshTasks(id);
void catalog.refreshEnvs(id);
void catalog.refreshMembers(id);
void catalog.refreshSkills(id);
void tools.refreshAutomations(id);
void tools.refreshTriage(id);
}, [catalog, taskList, tools]);
const saveKeyInput = useCallback((v: string) => {
catalog.saveKeyInput(v);
if (!v.trim()) return;
// Same reload set as the live saveKey handler.
void catalog.refreshHealth();
void catalog.refreshProjects();
void catalog.refreshWorkers();
if (projectIdRef.current) {
void taskList.refreshTasks(projectIdRef.current);
void catalog.refreshEnvs(projectIdRef.current);
void catalog.refreshMembers(projectIdRef.current);
}
void catalog.loadRelayModels();
void catalog.loadQuota();
void catalog.loadGithubRepos();
void catalog.probeAdmin();
}, [catalog, taskList]);
const refreshAll = useCallback((selectFocused: boolean) => {
setRefreshStamp(formatRefreshTime(new Date()));
if (!getKey()) { void catalog.refreshHealth(); return; }
void taskList.refreshTasks(undefined, projectIdRef.current);
void catalog.refreshHealth();
void catalog.loadQuota();
void catalog.refreshWorkers();
void catalog.refreshMembers();
void catalog.refreshSkills();
void tools.refreshAutomations();
void tools.refreshTriage();
void detail.refreshDetailLists();
if (!selectFocused) {
void catalog.refreshProjects();
void catalog.refreshEnvs();
}
}, [catalog, taskList, tools, detail]);
const refreshSlow = useCallback(() => {
if (!getKey()) return;
void catalog.loadRelayModels();
void catalog.loadGithubRepos();
}, [catalog]);
// Initial load, mirroring the live file's boot sequence.
useEffect(() => {
void catalog.refreshHealth();
void catalog.refreshProjects();
void catalog.refreshWorkers();
void catalog.loadQuota();
void catalog.loadGithubRepos();
if (getKey()) void catalog.loadRelayModels();
void catalog.probeAdmin();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
keySaved: catalog.keySaved, keyState: catalog.keyState,
saveKeyInput, clearKeyInput: catalog.clearKeyInput,
quotaText: catalog.quotaText,
healthText: catalog.healthText, refreshHealth: catalog.refreshHealth,
adminGate: catalog.adminGate, adminBody: catalog.adminBody, adminResult: catalog.adminResult,
adminUserId: catalog.adminUserId, setAdminUserId: catalog.setAdminUserId,
adminQuota: catalog.adminQuota, adminSweep: catalog.adminSweep, adminBackup: catalog.adminBackup,
projects: catalog.projects, projectId: catalog.projectId, projectMsg: catalog.projectMsg,
selectProject, refreshProjects: catalog.refreshProjects,
createProject: catalog.createProject,
githubRepos: catalog.githubRepos,
workers: catalog.workers, workersSummary: catalog.workersSummary, workersError: catalog.workersError, refreshWorkers: catalog.refreshWorkers,
members: catalog.members, memberMsg: catalog.memberMsg, memberError: catalog.memberError,
refreshMembers: () => void catalog.refreshMembers(), inviteMember: catalog.inviteMember, changeMemberRole: catalog.changeMemberRole,
skills: catalog.skills, apps: catalog.apps, mcpServers: catalog.mcpServers, skillMsg: catalog.skillMsg, skillError: catalog.skillError,
mcpMsg: catalog.mcpMsg, mcpOauthMsg: catalog.mcpOauthMsg, mcpOauthBox: catalog.mcpOauthBox,
refreshSkills: () => void catalog.refreshSkills(), reloadMcp: catalog.reloadMcp, mcpOauth: catalog.mcpOauth,
envs: catalog.envs, envCount: catalog.envCount, envMsg: catalog.envMsg, envRepoLine: catalog.envRepoLine, envRepoBar: catalog.envRepoBar, envRepoMsg: catalog.envRepoMsg, envSelect: catalog.envSelect, setEnvSelect: catalog.setEnvSelect,
refreshEnvs: () => void catalog.refreshEnvs(), createEnv: catalog.createEnv, archiveEnv: catalog.archiveEnv, saveEnvRepo: catalog.saveEnvRepo,
models: catalog.models, modelCustomVisible: catalog.modelCustomVisible,
tasks: taskList.tasks, taskCountText: taskList.taskCountText, taskError: taskList.taskError,
refreshTasks: () => void taskList.refreshTasks(undefined, projectIdRef.current),
statusFilter: taskList.statusFilter, setStatusFilter: taskList.setStatusFilter,
executorFilter: taskList.executorFilter, setExecutorFilter: taskList.setExecutorFilter,
searchText: taskList.searchText, setSearchText: taskList.setSearchText,
filteredTasks: taskList.filteredTasks,
currentTask: detail.currentTask, detailBody: detail.detailBody, actionMsg: detail.actionMsg, actionReasons: detail.actionReasons,
showDetail: (id: string) => void detail.showDetail(id),
doAction: detail.doAction, taskCost: detail.taskCost, costBox: detail.costBox,
forkVisible: detail.forkVisible, setForkVisible: detail.setForkVisible, forkTask: detail.forkTask, forkMsg: detail.forkMsg,
coordChildren: detail.coordChildren, coordText: "", agents: detail.agents, agentsNote: detail.agentsNote,
reviews: detail.reviews, reviewMsg: detail.reviewMsg, reviewError: detail.reviewError,
refreshReviews: detail.refreshReviews, postReview: detail.postReview, submitFindings: detail.submitFindings,
traceEntries: detail.traceEntries, traceNote: detail.traceNote, traceDone: detail.traceDone,
traceKind: detail.traceKind, setTraceKind: detail.setTraceKind, downloadTrace: detail.downloadTrace, traceMsg: detail.traceMsg,
sendChatMessage: detail.sendChatMessage, chatMsg: detail.chatMsg,
followups: detail.followups,
approvals: detail.approvals, approvalMsg: detail.approvalMsg, refreshApprovals: detail.refreshApprovals, decideApproval: detail.decideApproval,
peerMessages: detail.peerMessages, peerMsg: detail.peerMsg, postPeer: detail.postPeer,
automations: tools.automations, autoMsg: tools.autoMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, cancelAutomation: tools.cancelAutomation,
triage: tools.triage, triageError: tools.triageError, refreshTriage: () => void tools.refreshTriage(),
threadText: detail.threadText, threadGateNote: detail.threadGateNote, threadMsg: detail.threadMsg, threadList: detail.threadList, threadAction: detail.threadAction,
dispatch: async (body, atts) => {
const msg = await taskList.dispatch(body, atts);
void taskList.refreshTasks(projectIdRef.current);
return msg;
},
dispatchMsg: "",
refreshAll, refreshSlow, refreshStamp,
};
}
@@ -0,0 +1,57 @@
import { useCallback, useState } from "react";
import { api } from "../api/client";
import { paths } from "../api/paths";
import type { Automation, Triage } from "../api/types";
// Project-scoped tools shown in the centre for their own views:
// automations (create/list/cancel) and the triage inbox (4 groups).
export function useProjectTools(getProjectId: () => string) {
const [automations, setAutomations] = useState<Automation[]>([]);
const [autoMsg, setAutoMsg] = useState("");
const [triage, setTriage] = useState<Triage | null>(null);
const [triageError, setTriageError] = useState("");
const refreshAutomations = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : getProjectId();
if (!id) { setAutomations([]); return; }
try {
const d = await api<{ automations: Automation[] }>(paths.projectAutomations(id));
setAutomations(d.automations || []);
} catch (e: any) { setAutoMsg("自动化加载失败:" + e.message); }
}, [getProjectId]);
const createAutomation = useCallback(async (name: string, prompt: string, interval: string, repo: string) => {
const pid = getProjectId();
if (!pid) { setAutoMsg("先选项目"); return; }
if (!name.trim()) { setAutoMsg("自动化名称必填"); return; }
if (!prompt.trim()) { setAutoMsg("定时任务内容必填"); return; }
const body: { name: string; prompt: string; interval_seconds?: number; repo_url?: string } = { name: name.trim(), prompt: prompt.trim() };
if (interval.trim()) body.interval_seconds = parseInt(interval.trim(), 10);
if (repo.trim()) body.repo_url = repo.trim();
try {
await api(paths.projectAutomations(pid), "POST", body);
setAutoMsg("自动化已创建(登记定义,到期后由执行器认领)");
await refreshAutomations(pid);
} catch (e: any) { setAutoMsg("创建失败:" + e.message); }
}, [getProjectId, refreshAutomations]);
const cancelAutomation = useCallback((id: string) => {
const pid = getProjectId();
if (!pid) return;
void api(paths.projectAutomationCancel(pid, id), "POST", {})
.then(() => { setAutoMsg("自动化已取消"); void refreshAutomations(pid); })
.catch((e: any) => setAutoMsg("取消失败:" + e.message));
}, [getProjectId, refreshAutomations]);
const refreshTriage = useCallback(async (pid?: string) => {
const id = pid !== undefined ? pid : getProjectId();
if (!id) { setTriage(null); return; }
try {
const d = await api<Triage>(paths.projectTriage(id));
setTriage(d);
setTriageError("");
} catch (e: any) { setTriageError("分诊加载失败:" + e.message); }
}, [getProjectId]);
return { automations, autoMsg, triage, triageError, refreshAutomations, createAutomation, cancelAutomation, refreshTriage };
}
@@ -0,0 +1,407 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api, getKey } from "../api/client";
import { paths } from "../api/paths";
import { TRACE_TERMINAL } from "../api/types";
import type { Agent, Approval, Followup, PeerMessage, Review, Task, ThreadInfo, TraceEntry } from "../api/types";
// Task detail state: the selected task, its trace SSE/one-shot stream,
// and every structured sub-list. Chat sending lives here too:
// terminal -> /continue, queued/running -> /followups (direct).
export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksReload: () => void) {
const [currentTask, setCurrentTask] = useState<Task | null>(null);
const [detailBody, setDetailBody] = useState("");
const [actionMsg, setActionMsg] = useState("");
const [actionReasons, setActionReasons] = useState("");
const [costBox, setCostBox] = useState("");
const [forkVisible, setForkVisible] = useState(false);
const [forkMsg, setForkMsg] = useState("");
const [coordChildren, setCoordChildren] = useState<Task[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsNote, setAgentsNote] = useState("");
const [reviews, setReviews] = useState<Review[]>([]);
const [reviewMsg, setReviewMsg] = useState("");
const [reviewError, setReviewError] = useState("");
const [traceEntries, setTraceEntries] = useState<TraceEntry[]>([]);
const [traceNote, setTraceNote] = useState("");
const [traceDone, setTraceDone] = useState("");
const [traceKind, setTraceKind] = useState("");
const [traceMsg, setTraceMsg] = useState("");
const [followups, setFollowups] = useState<Followup[]>([]);
const [approvals, setApprovals] = useState<Approval[]>([]);
const [approvalMsg, setApprovalMsg] = useState("");
const [peerMessages, setPeerMessages] = useState<PeerMessage[]>([]);
const [peerMsg, setPeerMsg] = useState("");
const [threadText, setThreadText] = useState("");
const [threadGateNote, setThreadGateNote] = useState("");
const [threadMsg, setThreadMsg] = useState("");
const [threadList, setThreadList] = useState<ThreadInfo[]>([]);
const [chatMsg, setChatMsg] = useState("");
const traceAbortRef = useRef<AbortController | null>(null);
const agentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTaskRef = useRef<Task | null>(null);
currentTaskRef.current = currentTask;
const stopTrace = useCallback(() => {
if (traceAbortRef.current) { try { traceAbortRef.current.abort(); } catch { /* noop */ } traceAbortRef.current = null; }
if (agentTimerRef.current) { clearTimeout(agentTimerRef.current); agentTimerRef.current = null; }
}, []);
const loadAgents = useCallback(async (taskId: string) => {
try {
const d = await api<{ agents?: Agent[]; note?: string }>(paths.taskAgents(taskId));
setAgents(d.agents || []);
setAgentsNote(d.note || "");
} catch (e: any) { setAgents([]); setAgentsNote("子代理链路加载失败:" + e.message); }
}, []);
const scheduleAgents = useCallback((taskId: string) => {
if (agentTimerRef.current) clearTimeout(agentTimerRef.current);
agentTimerRef.current = setTimeout(() => void loadAgents(taskId), 1000);
}, [loadAgents]);
const loadCoordChildren = useCallback(async (task: Task) => {
if (!task.coordinator_parts) { setCoordChildren([]); return; }
try {
const d = await api<{ tasks: Task[] }>(paths.tasksByParent(task.project_id || "", task.id));
setCoordChildren(d.tasks || []);
} catch { setCoordChildren([]); }
}, []);
const loadFollowups = useCallback(async (taskId: string) => {
try { const d = await api<{ followups: Followup[] }>(paths.taskFollowups(taskId)); setFollowups(d.followups || []); }
catch { setFollowups([]); }
}, []);
const loadReviews = useCallback(async (taskId?: string) => {
const id = taskId || currentTaskRef.current?.id;
if (!id) return;
try {
const d = await api<{ reviews: Review[] }>(paths.taskReviews(id));
setReviews(d.reviews || []);
setReviewError("");
} catch (e: any) { setReviewError("评审队列加载失败:" + e.message); }
}, []);
const loadApprovals = useCallback(async (taskId?: string) => {
const id = taskId || currentTaskRef.current?.id;
if (!id) return;
try {
const d = await api<{ approvals: Approval[] }>(paths.taskApprovals(id));
setApprovals(d.approvals || []);
} catch (e: any) { setApprovalMsg("审批队列加载失败:" + e.message); }
}, []);
const loadPeerMessages = useCallback(async (taskId: string) => {
try { const d = await api<{ messages: PeerMessage[] }>(paths.taskMessages(taskId)); setPeerMessages(d.messages || []); }
catch { setPeerMessages([]); }
}, []);
const loadThread = useCallback(async (taskId: string) => {
try {
const d = await api<{ thread?: ThreadInfo } & ThreadInfo>(paths.taskThread(taskId));
const th = d.thread || d;
let turns = th.turns;
if (turns === undefined || turns === null) turns = th.turn_count;
if (turns === undefined || turns === null) turns = th.turns_count;
setThreadText("id " + (th.id || taskId) + " | 名字 " + (th.name || th.thread_name || "") + " | 归档 " + (th.archived === undefined || th.archived === null ? "" : (th.archived ? "是" : "否")) + " | cwd " + (th.cwd || "") + (turns !== undefined && turns !== null ? " | turn 数 " + turns : ""));
} catch (e: any) { setThreadText("线程加载失败:" + e.message); }
}, []);
const loadThreads = useCallback(async (taskId: string) => {
try { const d = await api<{ threads: ThreadInfo[] }>(paths.taskThreads(taskId)); setThreadList(d.threads || []); }
catch { setThreadList([]); }
}, []);
const setThreadGate = useCallback((status: string) => {
const ok = ["completed", "failed", "interrupted"].includes(status);
setThreadGateNote(ok ? "终态任务,线程操作可用" : "仅终态任务可操作线程,当前状态 " + status + ",按钮已置灰");
}, []);
const startTrace = useCallback(async (taskId: string, status: string) => {
stopTrace();
setTraceEntries([]);
setTraceNote("");
setTraceDone("");
if (TRACE_TERMINAL.includes(status)) {
try {
const d = await api<{ entries: TraceEntry[]; note?: string; status?: string }>(paths.taskTrace(taskId));
setTraceEntries(d.entries || []);
setTraceNote(d.note || "");
setTraceDone(d.status || "");
} catch (e: any) { setTraceNote("链路加载失败:" + e.message); }
return;
}
const ctrl = new AbortController();
traceAbortRef.current = ctrl;
let entries: TraceEntry[] = [];
const index: Record<string, number> = {};
let note = "";
let done = false;
let doneStatus = "";
try {
const headers: Record<string, string> = {};
const key = getKey();
if (key) headers["X-API-Key"] = key;
const r = await fetch(paths.taskTraceStream(taskId), { headers, signal: ctrl.signal });
if (!r.ok) {
let msg = "HTTP " + r.status;
try { const dj = await r.json(); if (dj && dj.error) msg = dj.error; } catch { /* noop */ }
if (r.status === 401) msg += "(key 未填或无效)";
throw new Error(msg);
}
const reader = r.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() || "";
for (const part of parts) {
const line = part.split("\n").filter((l) => l.indexOf("data: ") === 0).map((l) => l.slice(6)).join("\n");
if (!line) continue;
try {
const m = JSON.parse(line);
if (m.type === "snapshot") {
entries = m.entries || [];
Object.keys(index).forEach((k) => delete index[k]);
entries.forEach((e: TraceEntry, i: number) => { if (e.id) index[e.id] = i; });
note = m.note || "";
setTraceEntries([...entries]);
setTraceNote(note);
scheduleAgents(taskId);
} else if (m.type === "entry") {
const e = m.entry as TraceEntry;
if (e.id && e.id in index) entries[index[e.id]] = e;
else { if (e.id) index[e.id] = entries.length; entries.push(e); }
setTraceEntries([...entries]);
scheduleAgents(taskId);
} else if (m.type === "done") {
done = true;
doneStatus = m.status;
setTraceDone(doneStatus);
}
} catch { /* ignore malformed SSE frame */ }
}
if (done) break;
}
} catch (e: any) {
if (e && e.name === "AbortError") return;
setTraceNote("链路流中断:" + e.message);
return;
}
if (done) { onTasksReload(); }
}, [stopTrace, scheduleAgents, onTasksReload]);
const showDetail = useCallback(async (taskId: string) => {
stopTrace();
try {
const t = await api<Task>(paths.task(taskId));
setCurrentTask(t);
let body = "";
if (t.result_text) body += "=== result ===\n" + t.result_text.slice(0, 8000) + "\n";
if (t.error_text) body += "=== error ===\n" + t.error_text.slice(0, 8000) + "\n";
if (!body) body = "(暂无 result/error 输出)";
setDetailBody(body);
setCostBox("");
setForkVisible(false);
setForkMsg("");
setPeerMsg("");
setThreadMsg("");
setThreadGate(t.status);
// Button gating by role: maintainer/owner for retry/merge/pr,
// contributor+ for cancel; the right panel disables with reasons.
const role = getRole(t.project_id);
const isMaintainer = role === "maintainer" || role === "owner";
const canCreate = role === "contributor" || isMaintainer;
const terminal = TRACE_TERMINAL.includes(t.status);
const reasons: string[] = [];
if (!((["failed", "interrupted", "merge-failed"].includes(t.status)) && isMaintainer)) reasons.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试" + (isMaintainer ? "" : ",且仅 maintainer 可重试"));
if (!(!terminal && canCreate)) reasons.push("取消:不可用——" + (terminal ? "终态任务不可取消" : "仅项目成员(contributor 及以上)可取消"));
if (!(t.status === "completed" && isMaintainer)) reasons.push("合并:不可用——仅 completed 可合并" + (isMaintainer ? "" : ",且仅 maintainer 可合并"));
if (!(t.status === "merged" && isMaintainer)) reasons.push("提 PR:不可用——仅 merged 可提 PR" + (isMaintainer ? "" : ",且仅 maintainer 可提 PR"));
setActionReasons(reasons.join(";"));
setActionMsg("");
void startTrace(taskId, t.status);
void loadAgents(taskId);
void loadCoordChildren(t);
void loadFollowups(taskId);
void loadPeerMessages(taskId);
void loadThread(taskId);
void loadThreads(taskId);
void loadReviews(taskId);
void loadApprovals(taskId);
} catch (e: any) {
setCurrentTask(null);
setDetailBody("加载失败:" + e.message);
}
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, getRole]);
const refreshDetailLists = useCallback(async () => {
const t = currentTaskRef.current;
if (!t) return;
try {
const fresh = await api<Task>(paths.task(t.id));
setCurrentTask(fresh);
let body = "";
if (fresh.result_text) body += "=== result ===\n" + fresh.result_text.slice(0, 8000) + "\n";
if (fresh.error_text) body += "=== error ===\n" + fresh.error_text.slice(0, 8000) + "\n";
setDetailBody(body || "(暂无 result/error 输出)");
} catch { /* light header refresh must not clear the detail */ }
void loadFollowups(t.id);
void loadReviews(t.id);
void loadApprovals(t.id);
void loadCoordChildren(t);
void loadAgents(t.id);
void loadPeerMessages(t.id);
void loadThread(t.id);
void loadThreads(t.id);
}, [loadFollowups, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads]);
const doAction = useCallback(async (kind: string) => {
const t = currentTaskRef.current;
if (!t) return;
const confirmNeeded: Record<string, string> = {
cancel: "确定取消该任务吗?", merge: "确定把 completed 任务标为 merged 吗?", pr: "确定为该任务提 PR 吗?(会在仓库真实建 PR)",
};
if (confirmNeeded[kind] && !window.confirm(confirmNeeded[kind])) return;
try {
const r = await api<unknown>(paths.taskAction(t.id, kind), "POST", {});
setActionMsg(kind + " 已提交:" + JSON.stringify(r).slice(0, 300));
onTasksReload();
await showDetail(t.id);
} catch (e: any) { setActionMsg(kind + " 失败:" + e.message); }
}, [showDetail, onTasksReload]);
const taskCost = useCallback(async () => {
const t = currentTaskRef.current;
if (!t) return;
try {
const c = await api<unknown>(paths.taskCost(t.id));
setCostBox(JSON.stringify(c, null, 2).slice(0, 4000));
} catch (e: any) { setCostBox("查成本失败:" + e.message); }
}, []);
const forkTask = useCallback(async (msg: string) => {
const t = currentTaskRef.current;
if (!t) return;
if (!msg.trim()) { setForkMsg("分支消息必填"); return; }
if (!TRACE_TERMINAL.includes(t.status)) { setForkMsg("仅终态任务可分支"); return; }
try {
const nt = await api<Task>(paths.taskFork(t.id), "POST", { message: msg.trim() });
setForkMsg("分支任务已建:" + nt.id.slice(0, 8));
onTasksReload();
} catch (e: any) { setForkMsg("分支失败:" + e.message); }
}, [onTasksReload]);
const postReview = useCallback(async (body: { target_type: string; branch?: string; sha?: string; instructions?: string }) => {
const t = currentTaskRef.current;
if (!t) return;
try {
await api(paths.taskReviews(t.id), "POST", body);
setReviewMsg("评审已登记(requested)");
await loadReviews(t.id);
} catch (e: any) { setReviewMsg("登记评审需求失败:" + e.message); }
}, [loadReviews]);
const submitFindings = useCallback((reviewId: string) => {
const t = currentTaskRef.current;
if (!t) return;
const text = window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");
if (text === null) return;
void api(paths.taskReviewFindings(t.id, reviewId), "POST", { findings: text })
.then(() => void loadReviews(t.id))
.catch((e: any) => setReviewMsg("写 findings 失败:" + e.message));
}, [loadReviews]);
const decideApproval = useCallback((approvalId: string, decision: string) => {
const t = currentTaskRef.current;
if (!t) return;
const verb = decision === "approved" ? "approve" : "deny";
void api(paths.taskApprovalDecision(t.id, approvalId, verb), "POST", {})
.then(() => { setApprovalMsg("已记录" + (decision === "approved" ? "允许" : "拒绝") + "(当前版本只留回执,不拦截实时行为)"); void loadApprovals(t.id); })
.catch((e: any) => setApprovalMsg("决定失败:" + e.message));
}, [loadApprovals]);
const postPeer = useCallback(async (mode: string, target: string, text: string) => {
const t = currentTaskRef.current;
if (!t) return;
if (!target.trim()) { setPeerMsg("目标任务 id 不能为空"); return; }
if (!text.trim()) { setPeerMsg("消息正文不能为空"); return; }
try {
const d = await api<{ delivery?: string }>(paths.taskMessagesTo(target.trim()), "POST", { from_task_id: t.id, body: text.trim(), mode });
setPeerMsg("已发送(" + d.delivery + ")");
await loadPeerMessages(t.id);
} catch (e: any) { setPeerMsg("发送失败:" + e.message); }
}, [loadPeerMessages]);
const threadAction = useCallback((kind: string, name: string) => {
const t = currentTaskRef.current;
if (!t) return;
if (!["completed", "failed", "interrupted"].includes(t.status)) {
setThreadMsg("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");
return;
}
const body: { name?: string } = {};
if (kind === "rename") {
if (!name.trim()) { setThreadMsg("新线程名不能为空"); return; }
body.name = name.trim();
}
void api(paths.taskThreadAction(t.id, kind), "POST", body)
.then((r: unknown) => { setThreadMsg(kind + " 已提交:" + JSON.stringify(r).slice(0, 300)); void loadThread(t.id); void loadThreads(t.id); })
.catch((e: any) => setThreadMsg(kind + " 失败:" + e.message));
}, [loadThread, loadThreads]);
const sendChatMessage = useCallback(async (text: string) => {
const t = currentTaskRef.current;
if (!t) return;
const v = text.trim();
if (!v) { setChatMsg("消息不能为空"); return; }
try {
if (TRACE_TERMINAL.includes(t.status) && t.status !== "merged") {
// Message-only continue: all optional fields blank, so the
// server keeps the task's original model/effort/approval.
await api(paths.taskContinue(t.id), "POST", { message: v });
setChatMsg("已重新排队,同一会话继续");
onTasksReload();
await showDetail(t.id);
} else if (["queued", "running"].includes(t.status)) {
await api(paths.taskFollowups(t.id), "POST", { body: v, mode: "direct" });
setChatMsg("已直接发送");
await loadFollowups(t.id);
} else {
setChatMsg("merged/merge-failed 任务不可继续,也不可发后续消息");
}
} catch (e: any) { setChatMsg("发送失败:" + e.message); }
}, [showDetail, onTasksReload, loadFollowups]);
const downloadTrace = useCallback(async () => {
const t = currentTaskRef.current;
if (!t) { setTraceMsg("先选一个任务"); return; }
try {
const d = await api<{ entries: TraceEntry[]; note?: string; status?: string }>(paths.taskTrace(t.id));
const blob = new Blob([JSON.stringify({ task_id: t.id, status: d.status, note: d.note || "", count: (d.entries || []).length, entries: d.entries || [] }, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "trace-" + t.id.slice(0, 8) + ".json";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
setTraceMsg("已下载完整链路 " + (d.entries || []).length + " 条");
} catch (e: any) { setTraceMsg("下载失败:" + e.message); }
}, []);
useEffect(() => () => stopTrace(), [stopTrace]);
return {
currentTask, detailBody, actionMsg, actionReasons, costBox, forkVisible, setForkVisible, forkMsg,
coordChildren, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone,
traceKind, setTraceKind, traceMsg, followups, approvals, approvalMsg, peerMessages, peerMsg,
threadText, threadGateNote, threadMsg, threadList, chatMsg,
showDetail, refreshDetailLists, doAction, taskCost, forkTask, postReview, submitFindings,
decideApproval, postPeer, threadAction, sendChatMessage, downloadTrace,
refreshReviews: () => void loadReviews(), refreshApprovals: () => void loadApprovals(),
};
}
@@ -0,0 +1,45 @@
import { useCallback, useMemo, useState } from "react";
import { api, shortId, uploadAttachments } from "../api/client";
import { paths } from "../api/paths";
import type { DispatchBody, Task } from "../api/types";
export function useTaskList() {
const [tasks, setTasks] = useState<Task[]>([]);
const [taskError, setTaskError] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [executorFilter, setExecutorFilter] = useState("");
const [searchText, setSearchText] = useState("");
const refreshTasks = useCallback(async (pid?: string, currentPid?: string) => {
const id = pid !== undefined ? pid : currentPid;
if (!id) { setTasks([]); return; }
try {
const d = await api<{ tasks: Task[] }>(paths.tasks(id));
setTasks(d.tasks || []);
setTaskError("");
} catch (e: any) { setTaskError("加载失败:" + e.message); }
}, []);
const filteredTasks = useMemo(() => {
const q = searchText.trim().toLowerCase();
return tasks.filter((t) => {
if (statusFilter && t.status !== statusFilter) return false;
if (executorFilter && (t.executor || "") !== executorFilter) return false;
if (q && !((t.id || "") + " " + (t.prompt || "")).toLowerCase().includes(q)) return false;
return true;
});
}, [tasks, statusFilter, executorFilter, searchText]);
const taskCountText = filteredTasks.length + " 个任务(共 " + tasks.length + " 个)";
const dispatch = useCallback(async (body: DispatchBody, atts: Array<{ path: string; file: File }>): Promise<string> => {
const payload: DispatchBody = { ...body };
if (atts.length) {
payload.upload_id = await uploadAttachments(atts);
}
const t = await api<Task>(paths.tasksCreate, "POST", payload);
return "任务已建:" + shortId(t.id);
}, []);
return { tasks, taskError, statusFilter, setStatusFilter, executorFilter, setExecutorFilter, searchText, setSearchText, filteredTasks, taskCountText, refreshTasks, dispatch };
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./tokens.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
@@ -0,0 +1,24 @@
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
export default function AdminSection() {
const ctx = useConsole();
return (
<Card id="adminCard" title={<>管理区 <span className="muted">(仅全局管理员)</span></>}>
<div className="muted">{ctx.adminGate}</div>
{ctx.adminBody ? (
<>
<Bar>
<input type="text" placeholder="要查配额的用户 id" value={ctx.adminUserId} onChange={(e) => ctx.setAdminUserId(e.target.value)} />
<button onClick={ctx.adminQuota}>查该用户配额</button>
</Bar>
<Bar>
<button onClick={ctx.adminSweep}>手动触发 sweep(清理陈旧 running 任务)</button>
<button onClick={ctx.adminBackup}>手动触发备份</button>
</Bar>
<div className="muted">{ctx.adminResult}</div>
</>
) : null}
</Card>
);
}
@@ -0,0 +1,19 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
export default function ApiKeySection() {
const ctx = useConsole();
const [input, setInput] = useState("");
return (
<Card id="nav-key" title="API Key">
<Bar>
<input type="password" placeholder="粘贴你的 X-API-Key(与调 API 用的同一个)" autoComplete="off" value={input} onChange={(e) => setInput(e.target.value)} />
<button className="primary" onClick={() => { ctx.saveKeyInput(input); setInput(""); }}>保存到本浏览器</button>
<button onClick={ctx.clearKeyInput}>清除</button>
<span className="muted">{ctx.keyState}</span>
</Bar>
<div><span className="muted">{ctx.quotaText}</span></div>
</Card>
);
}
@@ -0,0 +1,10 @@
import { AutomationPanel } from "./detail/ProjectToolsPanels";
import { Card } from "../components/ui";
export default function AutomationsSection() {
return (
<Card id="nav-automations" title="自动化">
<AutomationPanel />
</Card>
);
}
@@ -0,0 +1,26 @@
import { useConsole } from "../context";
import TracePanel from "./detail/TracePanel";
import ContinuePanel from "./detail/ContinuePanel";
import DispatchSection from "./DispatchSection";
// Centre column for the task view: the conversation is the protagonist.
// Dispatch is kept here as the way a new conversation starts; it is not
// a stack of work cards. Trace timeline is above, continue input below.
export default function ConversationSection() {
const ctx = useConsole();
if (!ctx.currentTask) {
return (
<div className="conversation">
<DispatchSection />
<div className="muted">左侧点一条任务,对话就切到该任务;也可以先在上面派发一个新任务。</div>
</div>
);
}
return (
<div className="conversation">
<TracePanel />
<h3>继续会话</h3>
<ContinuePanel />
</div>
);
}
@@ -0,0 +1,121 @@
import { useRef, useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { gatherAttachments } from "../api/client";
import { EFFORTS } from "../api/types";
export default function DispatchSection() {
const ctx = useConsole();
const [prompt, setPrompt] = useState("");
const [coordParts, setCoordParts] = useState("");
const [goalMode, setGoalMode] = useState(false);
const [planMode, setPlanMode] = useState(false);
const [executor, setExecutor] = useState("docker");
const [model, setModel] = useState("");
const [modelCustom, setModelCustom] = useState("");
const [effort, setEffort] = useState("");
const [approval, setApproval] = useState("");
const [childModel, setChildModel] = useState("");
const [childModelCustom, setChildModelCustom] = useState("");
const [childEffort, setChildEffort] = useState("");
const filesRef = useRef<HTMLInputElement>(null);
const dirRef = useRef<HTMLInputElement>(null);
const imgRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState("");
async function submit() {
setMsg("");
if (!ctx.projectId) { setMsg("先选项目"); return; }
if (!prompt.trim()) { setMsg("prompt 必填"); return; }
const body: any = { project_id: ctx.projectId, prompt: prompt.trim() };
if (ctx.envSelect) body.environment_id = ctx.envSelect;
if (executor) body.executor = executor;
const m = modelCustom.trim() || model;
if (m) body.model = m;
if (effort) body.effort = effort;
const cm = childModelCustom.trim() || childModel;
if (cm) body.child_model = cm;
if (childEffort) body.child_effort = childEffort;
if (approval) {
if (approval === "full" && !window.confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认提交吗?")) return;
body.approval = approval;
}
if (planMode) {
if (approval === "full") { setMsg("计划模式不能与完全访问同用"); return; }
if (goalMode) { setMsg("计划模式不能与 goal 模式同用"); return; }
body.plan = 1;
}
if (goalMode) body.goal_mode = 1;
const coordRaw = coordParts.trim();
if (coordRaw) {
try { body.coordinator_parts = JSON.parse(coordRaw); } catch { setMsg("协调员子任务 JSON 解析失败"); return; }
if (!Array.isArray(body.coordinator_parts)) { setMsg("协调员子任务必须是数组"); return; }
if (goalMode || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
}
const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
try {
const result = await ctx.dispatch(body, atts);
setMsg(result);
setPrompt(""); setCoordParts("");
if (filesRef.current) filesRef.current.value = "";
if (dirRef.current) dirRef.current.value = "";
if (imgRef.current) imgRef.current.value = "";
} catch (e: any) { setMsg("提交失败:" + (e?.message || String(e))); }
}
return (
<Card id="nav-newtask" className="composer" title="新建任务">
<Bar>
<select aria-label="环境" value={ctx.envSelect} onChange={(e) => ctx.setEnvSelect(e.target.value)}>
<option value="">环境:不指定(走项目仓库)</option>
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
</select>
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
</select>
<label><input type="checkbox" checked={goalMode} onChange={(e) => setGoalMode(e.target.checked)} /> goal 模式</label>
<label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label>
</Bar>
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
<Bar>
<select aria-label="模型" value={model} onChange={(e) => setModel(e.target.value)}>
<option value="">模型:默认(跟随配置)</option>
{ctx.models.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
{ctx.modelCustomVisible ? <input type="text" placeholder="手动输入模型(中转列表取不到时用)" value={modelCustom} onChange={(e) => setModelCustom(e.target.value)} /> : null}
<select aria-label="推理强度" value={effort} onChange={(e) => setEffort(e.target.value)}>
<option value="">推理强度:默认(跟随配置)</option>
{EFFORTS.map((x) => <option key={x} value={x}>{x}</option>)}
</select>
<select aria-label="权限" value={approval} onChange={(e) => setApproval(e.target.value)}>
<option value="">权限:默认(跟随配置)</option><option value="ask">请求批准</option><option value="auto">帮我批准</option><option value="full">完全访问</option>
</select>
</Bar>
<Bar>
<select aria-label="子任务模型" value={childModel} onChange={(e) => setChildModel(e.target.value)}>
<option value="">子任务模型:跟随本任务模型</option>
{ctx.models.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<input type="text" placeholder="子任务模型手动输入(中转列表里没有时填,如 gpt-6-luna;留空=跟随本任务模型)" value={childModelCustom} onChange={(e) => setChildModelCustom(e.target.value)} />
<select aria-label="子任务强度" value={childEffort} onChange={(e) => setChildEffort(e.target.value)}>
<option value="">子任务强度:跟随本任务强度</option>
{EFFORTS.map((x) => <option key={x} value={x}>{x}</option>)}
</select>
</Bar>
<div className="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</div>
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div> : null}
<Bar>
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input ref={filesRef} type="file" multiple title="文件" />
<input ref={dirRef} type="file" multiple title="文件夹" {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" />
</Bar>
<Bar>
<button className="seal" onClick={submit}>提交任务</button>
<span className="muted">{msg || ctx.dispatchMsg}</span>
</Bar>
</Card>
);
}
@@ -0,0 +1,27 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
// Right-panel context for the Environments view: repo binding + counts.
// Previously embedded in the environment card; the centre now stays the
// environment list/create.
export default function EnvContextPanel() {
const ctx = useConsole();
const [repoSelect, setRepoSelect] = useState("");
return (
<Card title="仓库与环境上下文">
<div className="muted">{ctx.envRepoLine || "先选项目"}</div>
<div className="muted">{ctx.envCount}</div>
{ctx.envRepoBar ? (
<Bar>
<select aria-label="补仓库" value={repoSelect} onChange={(e) => setRepoSelect(e.target.value)}>
<option value="">(从 GitHub App 安装仓库里选)</option>
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
</select>
<button onClick={() => void ctx.saveEnvRepo(repoSelect)}>保存仓库</button>
</Bar>
) : null}
<span className="muted">{ctx.envRepoMsg}</span>
</Card>
);
}
@@ -0,0 +1,41 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui";
import { shortId } from "../api/client";
export default function EnvSection() {
const ctx = useConsole();
const [name, setName] = useState("");
const [branch, setBranch] = useState("");
const [executor, setExecutor] = useState("");
return (
<Card id="nav-env" title={<>环境 <span className="muted">(选、建、管:当前项目的基准检出)</span></>}>
<Bar>
<button onClick={ctx.refreshEnvs}>刷新</button>
<span className="muted">{ctx.envCount}</span>
</Bar>
{ctx.envs.length === 0 ? <div className="muted">先选项目</div> : (
<DataListRaw>
{ctx.envs.map((e) => (
<li key={e.id}>
<span className="row-main">
{e.name}({shortId(e.id)}) / 分支 {e.branch || "默认分支"} / 执行器 {e.executor || "不限"} / 状态 {e.status}
{e.status === "failed" && e.error_text ? " / 失败原因:" + e.error_text : ""}
</span>
{e.status === "archived" ? null : <button onClick={() => void ctx.archiveEnv(e.id)}>归档</button>}
</li>
))}
</DataListRaw>
)}
<Bar>
<input type="text" placeholder="新环境名" value={name} onChange={(e) => setName(e.target.value)} />
<input type="text" placeholder="分支(可选,空=默认分支)" value={branch} onChange={(e) => setBranch(e.target.value)} />
<select aria-label="新环境执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
<option value="">执行器:不限</option><option value="local">local</option><option value="docker">docker</option>
</select>
<button onClick={async () => { await ctx.createEnv(name, branch, executor); setName(""); setBranch(""); }}>新建环境</button>
</Bar>
<span className="muted">{ctx.envMsg}</span>
</Card>
);
}
@@ -0,0 +1,17 @@
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
// Right-panel context for Workers / Admin / API Key views.
export default function HealthPanel() {
const ctx = useConsole();
return (
<Card title="健康与配额">
<Bar>
<span className="muted">{ctx.healthText}</span>
<button onClick={ctx.refreshHealth}>刷新</button>
</Bar>
<div><span className="muted">{ctx.quotaText}</span></div>
<div className="muted">{ctx.workersSummary}</div>
</Card>
);
}
@@ -0,0 +1,48 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui";
import { ROLES } from "../api/types";
// Right-panel context for the Projects view. Previously embedded in the
// projects card; moved here so the centre stays the project list/create
// and the right stays the people for the selected project.
export default function MembersPanel() {
const ctx = useConsole();
const [inviteUser, setInviteUser] = useState("");
const [inviteRole, setInviteRole] = useState("contributor");
const proj = ctx.projects.find((p) => p.id === ctx.projectId);
return (
<Card title="项目成员">
{proj ? <div className="muted">{proj.name} | {proj.id} | 仓库 {proj.repo_url || "未绑定"} | 我的角色 {proj.my_role || "未知"}</div> : <div className="muted">先选项目</div>}
<Bar>
<input type="text" placeholder="成员 user_id 或用户名" value={inviteUser} onChange={(e) => setInviteUser(e.target.value)} />
<select aria-label="邀请角色" value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
<option value="viewer">viewer(只读)</option>
<option value="contributor">contributor(可建任务)</option>
<option value="maintainer">maintainer(可批合并)</option>
<option value="owner">owner(可管项目)</option>
</select>
<button onClick={async () => { await ctx.inviteMember(inviteUser, inviteRole); setInviteUser(""); }}>邀请成员</button>
<button onClick={ctx.refreshMembers}>刷新成员</button>
</Bar>
{ctx.memberError ? <div className="muted">{ctx.memberError}</div>
: ctx.members.length === 0 ? <div className="muted">先选项目</div>
: (
<DataListRaw>
{ctx.members.map((m) => {
const label = (m.username || "") + (m.username ? "(" + String(m.user_id).slice(0, 8) + ")" : String(m.user_id).slice(0, 8));
return (
<li key={m.user_id}>
<span className="row-main">{label}</span>
<select aria-label="成员角色" value={m.role || "viewer"} onChange={(e) => void ctx.changeMemberRole(m.user_id, e.target.value)}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</li>
);
})}
</DataListRaw>
)}
<span className="muted">{ctx.memberMsg}</span>
</Card>
);
}
@@ -0,0 +1,32 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { shortId } from "../api/client";
export default function ProjectsSection() {
const ctx = useConsole();
const [name, setName] = useState("");
const [repoSelect, setRepoSelect] = useState("");
const [repo, setRepo] = useState("");
return (
<Card id="nav-projects" title="项目">
<Bar>
<select aria-label="项目" value={ctx.projectId} onChange={(e) => ctx.selectProject(e.target.value)}>
{ctx.projects.length === 0 ? <option value="">(先加载项目)</option> : null}
{ctx.projects.map((p) => <option key={p.id} value={p.id}>{p.name} ({shortId(p.id)})</option>)}
</select>
<button onClick={ctx.refreshProjects}>刷新</button>
</Bar>
<Bar>
<input type="text" placeholder="新项目名" value={name} onChange={(e) => setName(e.target.value)} />
<select aria-label="从 GitHub 仓库选" value={repoSelect} onChange={(e) => { setRepoSelect(e.target.value); if (e.target.value) setRepo(e.target.value); }}>
<option value="">(从 GitHub App 安装仓库里选,或手动输入)</option>
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
</select>
<input type="text" placeholder="repo_url(可选)" value={repo} onChange={(e) => setRepo(e.target.value)} />
<button onClick={async () => { await ctx.createProject(name, repo); setName(""); setRepo(""); setRepoSelect(""); }}>新建项目</button>
</Bar>
<span className="muted">{ctx.projectMsg}</span>
</Card>
);
}
@@ -0,0 +1,25 @@
import { useConsole } from "../context";
import { Card, Bar, DataList } from "../components/ui";
export default function SkillsSection() {
const ctx = useConsole();
return (
<Card id="nav-skills" title="技能与 MCP">
<div className="muted">反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。</div>
<Bar>
<button onClick={ctx.refreshSkills}>刷新</button>
<span className="muted">{ctx.skillMsg}</span>
</Bar>
<h3>技能</h3>
{ctx.skillError ? <div className="muted">{ctx.skillError}</div>
: <DataList empty="暂无技能" items={ctx.skills.map((x, i) => <span key={i} className="row-main">{x.name || x.id || ""}{x.description ? " - " + x.description : ""}{x.detail ? " - " + x.detail : ""}</span>)} />}
<h3>应用</h3>
<DataList empty="暂无应用" items={ctx.apps.map((x, i) => <span key={i} className="row-main">{x.name || x.id || ""}{x.description ? " - " + x.description : ""}</span>)} />
<h3>MCP 服务</h3>
<DataList empty="暂无 MCP 服务" items={ctx.mcpServers.map((x, i) => {
const det = x.detail || x.details || x.state || "";
return <span key={i} className="row-main">{x.name || x.id || ""} | 状态 {x.status || ""}{det ? " | " + det : ""}</span>;
})} />
</Card>
);
}
@@ -0,0 +1,24 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
// Right-panel context for the Skills view: MCP reload + OAuth initiate.
// Previously embedded in the skills card; the centre now stays the lists.
export default function SkillsToolsPanel() {
const ctx = useConsole();
const [oauthName, setOauthName] = useState("");
return (
<Card title="MCP 操作">
<Bar>
<button onClick={ctx.reloadMcp}>重载配置</button>
<span className="muted">{ctx.mcpMsg}</span>
</Bar>
<Bar>
<input type="text" placeholder="MCP 服务名" value={oauthName} onChange={(e) => setOauthName(e.target.value)} />
<button onClick={() => void ctx.mcpOauth(oauthName)}>发起 OAuth 登录</button>
<span className="muted">{ctx.mcpOauthMsg}</span>
</Bar>
{ctx.mcpOauthBox ? <pre>{ctx.mcpOauthBox}</pre> : null}
</Card>
);
}
@@ -0,0 +1,52 @@
import { useConsole } from "../context";
import { Card } from "../components/ui";
import { shortId, fmt } from "../api/client";
import { STATUS_EXPLAIN } from "../api/types";
import DetailActions from "./detail/DetailActions";
import ReviewPanel from "./detail/ReviewPanel";
import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
// Right detail column for the task view. Everything structured lives
// here — never inside a chat bubble: metadata, actions, reviews,
// approvals, coordinator children, sub-agents, threads, cost.
export default function TaskDetailPanel() {
const ctx = useConsole();
const t = ctx.currentTask;
if (!t) return <Card id="nav-detail" title="任务详情"><div className="muted">点任务行查看详情</div></Card>;
return (
<Card id="nav-detail" title="任务详情">
<div>
状态 <b className={"st-" + t.status}>{t.status}</b>({STATUS_EXPLAIN[t.status] || "未知状态"})
<br />任务 {t.id} | turn {shortId(t.turn_id)} | root turn {shortId(t.root_turn_id)}
<br />executor {t.executor || ""} | 主机 {t.worktree_host || ""}
<br />模型 {t.model || "默认"} | 强度 {t.effort || "默认"}
{(t.child_model || t.child_effort) ? <><br />子任务默认 {(t.child_model || "跟随本任务") + " / " + (t.child_effort || "跟随本任务")}</> : null}
<br />权限 {t.approval || "默认"} | 项目 {shortId(t.project_id)}
<br />更新 {fmt(t.updated_at)}
{t.plan ? <><br />计划模式(只读)</> : null}
{t.forked_from ? <><br />分支自 {shortId(t.forked_from)}</> : null}
{t.goal_mode ? <><br />goal 模式(不限轮次,模型宣告完成)</> : t.verify_command ? <><br />goal 模式(旧语义,验收命令)</> : null}
{t.coordinator_parts ? <><br />协调员:{t.coordinator_parts.length} 个子任务({t.coordinator_parts.map((p) => p.name).join("、")})</> : null}
{t.parent_task_id ? <><br />协调子任务,父任务 {shortId(t.parent_task_id)}{t.coordinator_part ? "(" + t.coordinator_part + ")" : ""}</> : null}
{(t.attachments && t.attachments.length) ? <><br />附件:{t.attachments.join("、")}</> : null}
{t.pr_url ? <><br /><a href={t.pr_url} target="_blank" rel="noopener noreferrer">PR #{t.pr_number}</a></> : null}
<br />prompt:{t.prompt || ""}
</div>
<DetailActions />
<pre>{ctx.detailBody || "(暂无 result/error 输出)"}</pre>
{ctx.costBox ? <pre>{ctx.costBox}</pre> : null}
<h3>协调子任务</h3>
<CoordPanel />
<h3>子代理链路</h3>
<AgentsPanel />
<h3>评审队列</h3>
<ReviewPanel />
<h3>运行时审批队列</h3>
<ApprovalPanel />
<h3>给其他任务发消息</h3>
<PeerPanel />
<h3>线程</h3>
<ThreadPanel />
</Card>
);
}
@@ -0,0 +1,46 @@
import { useConsole } from "../context";
import { DataListRaw } from "../components/ui";
import { shortId } from "../api/client";
// Left-rail compact task list. The live 9-column table does not fit a
// 300px rail, so the rail shows id + status + prompt + executor; the
// full metadata (turn, root turn, host, PR, updated) lives in the right
// detail panel for the selected task. Filters/search are the live ones.
export default function TaskListSection() {
const ctx = useConsole();
return (
<div className="left-tasks">
<div className="bar">
<select aria-label="状态筛选" value={ctx.statusFilter} onChange={(e) => ctx.setStatusFilter(e.target.value)}>
<option value="">状态:全部</option>
{["queued", "running", "completed", "failed", "interrupted", "merged", "merge-failed"].map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select aria-label="执行器筛选" value={ctx.executorFilter} onChange={(e) => ctx.setExecutorFilter(e.target.value)}>
<option value="">执行器:全部</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option>
</select>
</div>
<input type="text" placeholder="搜索任务 id / prompt" value={ctx.searchText} onChange={(e) => ctx.setSearchText(e.target.value)} />
<div className="bar">
<button onClick={ctx.refreshTasks}>刷新</button>
<span className="muted">{ctx.taskCountText}</span>
</div>
{ctx.taskError ? <div className="err">{ctx.taskError}</div>
: !ctx.projectId ? <div className="muted">先选项目</div>
: ctx.filteredTasks.length === 0 ? <div className="muted">{ctx.tasks.length ? "没有符合筛选条件的任务" : "该项目暂无任务"}</div>
: (
<DataListRaw>
{ctx.filteredTasks.slice().reverse().map((t) => (
<li key={t.id} className={ctx.currentTask?.id === t.id ? "selected-task" : ""} onClick={() => ctx.showDetail(t.id)} style={{ cursor: "pointer" }}>
<span className="row-main">
<b>{shortId(t.id)}</b> <span className={"st-" + t.status}>{t.status}</span>
<br />
{t.plan ? "[计划] " : ""}{t.forked_from ? "[分支] " : ""}{t.coordinator_parts ? "[协调] " : ""}{t.parent_task_id ? "[协调子任务] " : ""}{String(t.prompt || "").slice(0, 60)}
<br /><span className="muted">{t.executor || ""}</span>
</span>
</li>
))}
</DataListRaw>
)}
</div>
);
}
@@ -0,0 +1,13 @@
import { TriagePanel } from "./detail/ProjectToolsPanels";
import { Card, Bar } from "../components/ui";
import { useConsole } from "../context";
export default function TriageSection() {
const ctx = useConsole();
return (
<Card id="nav-triage" title="分诊收件箱">
<Bar><button onClick={ctx.refreshTriage}>刷新分诊</button></Bar>
<TriagePanel />
</Card>
);
}
@@ -0,0 +1,33 @@
import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui";
import { workerAgeText, shortId } from "../api/client";
export default function WorkersSection() {
const ctx = useConsole();
return (
<Card id="nav-workers" title="Workers">
<Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar>
{ctx.workersError ? <div className="muted">{ctx.workersError}</div>
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}</div>
: ctx.workers.length === 0 ? <div className="muted">无 worker 注册</div>
: (
<DataListRaw>
{ctx.workers.map((w, i) => {
const run = w.running === undefined || w.running === null ? "?" : w.running;
const cap = w.capacity === undefined || w.capacity === null ? "?" : w.capacity;
const info = workerAgeText(w);
const alive = w.alive !== undefined && w.alive !== null ? !!w.alive : (info.age !== null && info.age <= 120);
const live = alive ? " / 在线" : " / 离线(占用为上次上报值)";
return (
<li key={i} className={alive ? "is-online" : "is-offline"}>
<span className="row-main">
{w.worker_id || w.id || "?"} / {w.executor || ""} / 占用 {run}/{cap}{live} / 最后心跳 {info.text}
</span>
</li>
);
})}
</DataListRaw>
)}
</Card>
);
}
@@ -0,0 +1,47 @@
import { useMemo } from "react";
import { useConsole } from "../../context";
import { ChatList, ChatInput } from "../../components/chat/Chat";
import { traceEntryToChatMessage, type ChatMessage } from "../../components/chat/mapTrace";
import { TRACE_TERMINAL } from "../../api/types";
// Continue chat: same ChatList/ChatBubble/ChatInput as the trace timeline.
// History is the task prompt (user) + message-kind trace entries
// (assistant) + followups already sent (user). Live followups and trace
// entries carry no timestamps, so they cannot be interleaved exactly:
// followups are appended after trace messages and this is stated here
// and in the final report, not hidden by a fabricated order.
// Sending uses only live APIs: terminal tasks -> POST /tasks/:id/continue,
// queued/running tasks -> POST /tasks/:id/followups (direct). No new API.
export default function ContinuePanel() {
const ctx = useConsole();
const t = ctx.currentTask;
const messages = useMemo<ChatMessage[]>(() => {
if (!t) return [];
const head: ChatMessage[] = t.prompt ? [{ id: "task-prompt", role: "user", text: t.prompt }] : [];
const assistant = ctx.traceEntries.filter((e) => e.kind === "message").map((e, i) => traceEntryToChatMessage(e, i));
const followups: ChatMessage[] = ctx.followups.map((f, i) => ({
id: "followup-" + i, role: "user" as const, text: String(f.body || ""), label: "后续消息",
}));
return [...head, ...assistant, ...followups];
}, [t, ctx.traceEntries, ctx.followups]);
if (!t) return <div className="muted">点任务行查看详情后,这里显示继续会话</div>;
const terminal = TRACE_TERMINAL.includes(t.status);
const running = ["queued", "running"].includes(t.status);
const canSend = terminal || running;
const hint = terminal
? "终态任务:发送走继续接口(同一会话再跑一轮)"
: running
? "运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)"
: "merged/merge-failed 任务不可继续,也不可发后续消息";
return (
<div className="chat-column">
<ChatList messages={messages} empty="(暂无会话消息)" />
<ChatInput
disabled={!canSend}
placeholder={hint + ",回车发送,Shift+回车换行"}
onSend={(text) => void ctx.sendChatMessage(text)}
/>
<div className="muted">{hint}。{ctx.chatMsg}</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { useState } from "react";
import { useConsole } from "../../context";
import { Bar } from "../../components/ui";
import { TRACE_TERMINAL } from "../../api/types";
// Detail actions + fork only. Continue/followup input lives in
// ContinuePanel (shared ChatInput), not in a second form here.
export default function DetailActions() {
const ctx = useConsole();
const t = ctx.currentTask;
const [forkMsg, setForkMsg] = useState("");
if (!t) return null;
const terminal = TRACE_TERMINAL.includes(t.status);
const retryOk = ["failed", "interrupted", "merge-failed"].includes(t.status);
return (
<>
<Bar>
<button disabled={!retryOk} title={retryOk ? "" : "仅 failed/interrupted/merge-failed 可重试"} onClick={() => ctx.doAction("retry")}>重试</button>
<button className="danger" disabled={terminal} title={terminal ? "终态任务不可取消" : ""} onClick={() => ctx.doAction("cancel")}>取消</button>
<button disabled={t.status !== "completed"} title={t.status !== "completed" ? "仅 completed 可合并" : ""} onClick={() => ctx.doAction("merge")}>合并(completed → merged)</button>
<button disabled={t.status !== "merged"} title={t.status !== "merged" ? "仅 merged 可提 PR" : ""} onClick={() => ctx.doAction("pr")}>提 PR(须 merged)</button>
{terminal ? <button onClick={() => ctx.setForkVisible(true)}>分支</button> : null}
<button onClick={ctx.taskCost}>查成本</button>
<span className="muted">{ctx.actionMsg} {ctx.actionReasons}</span>
</Bar>
{ctx.forkVisible ? (
<div>
<input type="text" placeholder="分支首轮消息(必填,带历史的新会话)" style={{ width: "100%", boxSizing: "border-box", margin: "6px 0" }} value={forkMsg} onChange={(e) => setForkMsg(e.target.value)} />
<Bar><button className="seal" onClick={async () => { await ctx.forkTask(forkMsg); setForkMsg(""); }}>创建分支任务</button><span className="muted">{ctx.forkMsg}</span></Bar>
</div>
) : null}
</>
);
}
@@ -0,0 +1,109 @@
import { useState } from "react";
import { useConsole } from "../../context";
import { Bar, DataList, DataListRaw } from "../../components/ui";
import { shortId, fmt, traceLineText } from "../../api/client";
export function CoordPanel() {
const ctx = useConsole();
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示协调员的子任务</div>;
if (!ctx.currentTask.coordinator_parts) return <div className="muted">(非协调员任务)</div>;
if (ctx.coordChildren.length === 0) return <div className="muted">(子任务尚未创建)</div>;
return (
<DataListRaw>
{ctx.coordChildren.map((k) => (
<li key={k.id}>
<span className="row-main">分工 <b>{k.coordinator_part || ""}</b> | 状态 <span className={"st-" + k.status}>{k.status}</span> | {shortId(k.id)} | {String(k.prompt || "").slice(0, 60)}</span>
</li>
))}
</DataListRaw>
);
}
export function AgentsPanel() {
const ctx = useConsole();
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示子代理链路</div>;
if (ctx.agents.length === 0) return <div className="muted">(暂无子代理){ctx.agentsNote ? <span className="err">{ctx.agentsNote}</span> : null}</div>;
const kindLabel: Record<string, string> = { main: "主任务", delegate: "委派子任务", model: "模型子代理" };
const statusLabel: Record<string, string> = { running: "进行中", completed: "已完成", failed: "失败", interrupted: "已中断", unknown: "未知" };
return (
<DataListRaw>
{ctx.agents.map((a, i) => (
<li key={i}>
<span className="row-main">
<b>{kindLabel[a.kind || ""] || a.kind || ""}:</b>{a.name || ""} <span className={"st-" + (a.status || "")}>{statusLabel[a.status || ""] || a.status || "?"}</span>
{" | 过程调用 " + (a.calls ? a.calls.length : 0) + " 条"}
{a.updated_ts ? " | 最近活动 " + fmt(a.updated_ts / 1000) : ""}
{a.prompt ? <><br /><span className="muted">任务:{a.prompt}</span></> : null}
{a.calls && a.calls.length ? <><br />{a.calls.slice(-50).map((c, j) => <div key={j}>{traceLineText(c)}</div>)}</> : <><br /><span className="muted">(暂无过程调用)</span></>}
</span>
</li>
))}
</DataListRaw>
);
}
export function ApprovalPanel() {
const ctx = useConsole();
return (
<>
<Bar><button onClick={ctx.refreshApprovals}>刷新审批队列</button><span className="muted">{ctx.approvalMsg}</span></Bar>
<div className="muted">待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。</div>
<DataList empty="(暂无审批项)" items={ctx.approvals.map((a) => (
<span key={a.id} className="row-main">
[{a.kind}|{a.status}] {a.summary}
{["approved", "denied", "expired"].includes(a.status || "") ? "|回执:" + (a.decision || a.status) + " by " + String(a.decided_by || "").slice(0, 8) + " " + fmt(a.decided_at) : ""}
{a.status === "pending" ? (
<>
{" "}<button onClick={() => ctx.decideApproval(a.id, "approved")}>记录允许</button>
{" "}<button onClick={() => ctx.decideApproval(a.id, "denied")}>记录拒绝</button>
</>
) : null}
</span>
))} />
</>
);
}
export function PeerPanel() {
const ctx = useConsole();
const [target, setTarget] = useState("");
const [body, setBody] = useState("");
return (
<>
<input placeholder="目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)" style={{ width: "100%" }} value={target} onChange={(e) => setTarget(e.target.value)} />
<textarea placeholder="消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)" value={body} onChange={(e) => setBody(e.target.value)} />
<Bar>
<button onClick={async () => { await ctx.postPeer("queued", target, body); setBody(""); }}>排队发给对方</button>
<button className="primary" onClick={async () => { await ctx.postPeer("direct", target, body); setBody(""); }}>直接发送</button>
<span className="muted">{ctx.peerMsg}</span>
</Bar>
<DataList empty="(本任务尚未发出任务间消息)" items={ctx.peerMessages.map((f, i) => (
<span key={i} className="row-main">[发给 {String(f.task_id).slice(0, 8)}|{f.mode}|{f.status}] {String(f.body || "").slice(0, 120)}</span>
))} />
</>
);
}
export function ThreadPanel() {
const ctx = useConsole();
const [name, setName] = useState("");
const gateOk = ctx.currentTask ? ["completed", "failed", "interrupted"].includes(ctx.currentTask.status) : false;
return (
<>
<div className="muted">{ctx.threadText || "点任务行查看详情后,这里显示线程元数据"}</div>
<Bar>
<input type="text" placeholder="新线程名" value={name} onChange={(e) => setName(e.target.value)} />
<button disabled={!gateOk} onClick={() => ctx.threadAction("rename", name)}>重命名</button>
<button disabled={!gateOk} onClick={() => ctx.threadAction("archive", "")}>归档</button>
<button disabled={!gateOk} onClick={() => ctx.threadAction("unarchive", "")}>取消归档</button>
<button disabled={!gateOk} onClick={() => ctx.threadAction("compact", "")}>压缩</button>
<span className="muted">{ctx.threadGateNote}</span>
<span className="muted">{ctx.threadMsg}</span>
</Bar>
<DataList empty="暂无其他线程" items={ctx.threadList.map((t, i) => (
<span key={i} className="row-main">{t.name || t.thread_name || t.id || ""} [{t.id || ""}]{t.archived ? " [已归档]" : ""}{t.cwd ? " " + t.cwd : ""}</span>
))} />
<div className="muted">限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。</div>
</>
);
}
@@ -0,0 +1,54 @@
import { useState } from "react";
import { useConsole } from "../../context";
import { Bar, DataList } from "../../components/ui";
import { fmt, shortId } from "../../api/client";
export function AutomationPanel() {
const ctx = useConsole();
const [name, setName] = useState("");
const [prompt, setPrompt] = useState("");
const [interval, setInterval] = useState("");
const [repo, setRepo] = useState("");
return (
<>
<input placeholder="自动化名称(同项目内启用中的名称唯一)" style={{ width: "100%" }} value={name} onChange={(e) => setName(e.target.value)} />
<textarea placeholder="定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<Bar>
<input placeholder="间隔秒数(默认 86400,最小 60)" style={{ width: "40%" }} value={interval} onChange={(e) => setInterval(e.target.value)} />
<input placeholder="仓库(可选)" style={{ width: "40%" }} value={repo} onChange={(e) => setRepo(e.target.value)} />
<button className="primary" onClick={async () => { await ctx.createAutomation(name, prompt, interval, repo); setName(""); setPrompt(""); setInterval(""); setRepo(""); }}>创建自动化</button>
<button onClick={ctx.refreshAutomations}>刷新自动化</button>
<span className="muted">{ctx.autoMsg}</span>
</Bar>
<DataList empty="(暂无自动化)" items={ctx.automations.map((a) => (
<span key={a.id} className="row-main">
[{a.status}{a.due ? "|到期" : ""}] {a.name}|每 {a.interval_seconds} 秒|下次 {fmt(a.next_run_at)}
{a.status === "active" ? <> <button onClick={() => ctx.cancelAutomation(a.id)}>取消</button></> : null}
</span>
))} />
</>
);
}
export function TriagePanel() {
const ctx = useConsole();
const t = ctx.triage;
if (ctx.triageError) return <div className="muted">{ctx.triageError}</div>;
if (!t) return <div className="muted">(先选项目)</div>;
return (
<>
<div className="data-group"><b>到期自动化({t.counts?.automations_due || 0})</b>
<DataList empty="(无)" items={(t.automations_due || []).map((a, i) => <span key={i} className="row-main">{a.name}|每 {a.interval_seconds} 秒</span>)} />
</div>
<div className="data-group"><b>失败/中断任务({t.counts?.failed_tasks || 0})</b>
<DataList empty="(无)" items={(t.failed_tasks || []).map((x, i) => <span key={i} className="row-main">{shortId(x.id)}|{x.status}|{String(x.prompt || "").slice(0, 80)}</span>)} />
</div>
<div className="data-group"><b>待处理后续消息({t.counts?.pending_followups || 0})</b>
<DataList empty="(无)" items={(t.pending_followups || []).map((f, i) => <span key={i} className="row-main">任务 {shortId(f.task_id)}|{String(f.body || "").slice(0, 80)}</span>)} />
</div>
<div className="data-group"><b>最近自动化产出({t.counts?.recent_runs || 0})</b>
<DataList empty="(无)" items={(t.recent_runs || []).map((r, i) => <span key={i} className="row-main">{shortId(r.automation_id)}|{r.status}{r.task_id ? "|任务 " + shortId(r.task_id) : ""}{r.task_status ? "|" + r.task_status : ""}</span>)} />
</div>
</>
);
}
@@ -0,0 +1,55 @@
import { useState } from "react";
import { useConsole } from "../../context";
import { Bar, DataListRaw } from "../../components/ui";
import { shortId, fmt } from "../../api/client";
export default function ReviewPanel() {
const ctx = useConsole();
const [targetType, setTargetType] = useState("uncommittedChanges");
const [branch, setBranch] = useState("");
const [sha, setSha] = useState("");
const [instructions, setInstructions] = useState("");
return (
<>
<h3>评审队列</h3>
<select aria-label="评审目标" value={targetType} onChange={(e) => setTargetType(e.target.value)}>
<option value="uncommittedChanges">未提交改动(工作区)</option>
<option value="baseBranch">与分支的差异</option>
<option value="commit">指定提交</option>
<option value="custom">自定义评审指令</option>
</select>
<input className="stacked" placeholder="与分支的差异用:分支名(如 main)" style={{ width: "100%" }} value={branch} onChange={(e) => setBranch(e.target.value)} />
<input className="stacked" placeholder="指定提交用:提交 sha" style={{ width: "100%" }} value={sha} onChange={(e) => setSha(e.target.value)} />
<textarea placeholder="自定义评审指令用:评审指令正文" value={instructions} onChange={(e) => setInstructions(e.target.value)} />
<Bar>
<button className="primary" onClick={() => void ctx.postReview({ target_type: targetType, branch: branch.trim() || undefined, sha: sha.trim() || undefined, instructions: instructions.trim() || undefined })}>登记评审需求</button>
<button onClick={ctx.refreshReviews}>刷新队列</button>
<span className="muted">{ctx.reviewMsg}</span>
</Bar>
<div className="muted">本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。</div>
{ctx.reviewError ? <div className="muted">{ctx.reviewError}</div>
: ctx.reviews.length === 0 ? <div className="muted">点任务行查看详情后,点“刷新队列”查看评审记录</div>
: (
<DataListRaw>
{ctx.reviews.map((r) => {
let target = r.target_type || "";
if (r.branch) target += ":" + r.branch;
if (r.sha) target += ":" + String(r.sha).slice(0, 12);
if (r.instructions) target += ":" + String(r.instructions).slice(0, 80);
return (
<li key={r.id}>
<span className="row-main">
<b>评审</b> {shortId(r.id)} | {target} | <span className={"st-" + (r.status || "")}>{r.status}</span>
{r.created_at ? " | " + fmt(r.created_at) : ""}
<br />
{r.findings ? String(r.findings).slice(0, 1000) : <span className="muted">(暂无 findings:评审尚未执行或尚未写回)</span>}
</span>
<button onClick={() => ctx.submitFindings(r.id)}>写 findings</button>
</li>
);
})}
</DataListRaw>
)}
</>
);
}
@@ -0,0 +1,37 @@
import { useMemo } from "react";
import { useConsole } from "../../context";
import { Bar } from "../../components/ui";
import { ChatList } from "../../components/chat/Chat";
import { traceEntryToChatMessage, type ChatMessage } from "../../components/chat/mapTrace";
export default function TracePanel() {
const ctx = useConsole();
const messages = useMemo<ChatMessage[]>(() => {
const t = ctx.currentTask;
const head: ChatMessage[] = t && t.prompt ? [{ id: "task-prompt", role: "user", text: t.prompt }] : [];
const pool = ctx.traceKind ? ctx.traceEntries.filter((e) => e.kind === ctx.traceKind) : ctx.traceEntries;
const body = pool.map((e, i) => traceEntryToChatMessage(e, i));
const tail: ChatMessage[] = ctx.traceDone ? [{ id: "trace-done", role: "status", text: "链路已结束(" + ctx.traceDone + ")" }] : [];
return [...head, ...body, ...tail];
}, [ctx.currentTask, ctx.traceEntries, ctx.traceKind, ctx.traceDone]);
return (
<>
<h3>实时链路</h3>
<Bar>
<select aria-label="链路类型" value={ctx.traceKind} onChange={(e) => ctx.setTraceKind(e.target.value)}>
<option value="">链路类型:全部</option>
{["exec", "file", "call", "tool", "message", "reasoning", "plan", "agent-start", "agent-done"].map((k) => (
<option key={k} value={k}>{k === "exec" ? "命令" : k === "file" ? "文件" : k === "call" ? "调用" : k === "tool" ? "工具" : k === "message" ? "消息" : k === "reasoning" ? "思考" : k === "plan" ? "计划" : "子代理"}</option>
))}
</select>
<button onClick={ctx.downloadTrace}>下载完整链路</button>
<span className="muted">{ctx.traceMsg}</span>
</Bar>
{ctx.traceNote ? <div><span className="err">{ctx.traceNote}</span></div> : null}
<div className="chat-column">
<ChatList messages={messages} empty="(暂无链路条目)" />
</div>
<div className="muted">对话时间线:首条是本任务 prompt(用户);助手回复来自 message 条目,命令/文件/工具/调用是工具气泡,计划/思考/子代理开始结束是居中状态提示。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。</div>
</>
);
}
+147
View File
@@ -0,0 +1,147 @@
/* Single token source. Colours are the v3 porcelain/ink/seal set only:
ink, paper, white cards, pine #14544a, seal #be2a1e as accent.
No other coloured backgrounds. Spacing is an 8px grid. */
:root{
--ink:#1c2220; --ink-soft:#4a554f; --paper:#f5f3ee; --card:#ffffff;
--line:#e6e1d6; --line-strong:#d3ccbc;
--seal:#be2a1e; --seal-soft:#fdeeeb; --seal-dark:#9c2118;
--pine:#14544a; --pine-soft:#e2efea;
--amber:#92400e; --amber-soft:#fef3c7;
--success:#15803d; --danger:#b42318; --danger-soft:#fef3f2; --purple:#6941c6;
--radius:16px; --radius-sm:10px;
--shadow:0 1px 2px rgba(28,34,32,.05), 0 12px 32px rgba(28,34,32,.06);
--serif:"Songti SC","Noto Serif SC","SimSun",serif;
--sans:-apple-system,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
--mono:"IBM Plex Mono",Menlo,Consolas,monospace;
--ctl:40px;
}
*{box-sizing:border-box}
body{font-family:var(--sans);margin:0;color:var(--ink);background:var(--paper);line-height:1.6;font-size:15px}
a{color:var(--pine);text-underline-offset:3px}
:focus-visible{outline:3px solid var(--seal);outline-offset:2px}
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
.muted{color:#7a857e;font-size:13px;line-height:1.6}
.err{color:var(--danger)} .ok{color:var(--success)}
/* Top bar + fixed nav */
.site-header{position:sticky;top:0;z-index:30;background:#fff}
.topbar{display:flex;align-items:center;gap:16px;padding:16px 28px;background:#fff;border-bottom:1px solid var(--line)}
.seal-mark{flex:none;width:38px;height:38px;background:var(--seal);color:#fff;display:grid;place-items:center;font-family:var(--serif);font-size:20px;font-weight:700;border-radius:8px}
.topbar h1{font-family:var(--serif);font-size:25px;line-height:1.15;margin:0;font-weight:700;letter-spacing:1px}
.topbar .subtitle{font-size:13px;color:#7a857e;margin:1px 0 0;letter-spacing:2px}
.version{font-size:12px;color:#7a857e;border:1px solid var(--line);background:#fff;border-radius:999px;padding:6px 11px;white-space:nowrap}
.topnav{display:flex;gap:4px;flex-wrap:wrap;align-items:center;padding:8px 28px;background:#fff;border-bottom:1px solid var(--line);overflow-x:auto;scroll-margin-top:0}
.topnav a{color:var(--ink-soft);text-decoration:none;font-size:13.5px;font-weight:600;padding:6px 10px;border-radius:999px;white-space:nowrap}
.topnav a:hover{color:var(--ink);background:var(--pine-soft)}
.topnav a.active{color:var(--ink);font-weight:700}
.topnav .auto-refresh{margin-left:auto;color:#7a857e;font-size:12px;white-space:nowrap}
section[id],div[id]{scroll-margin-top:130px}
/* Layout: three columns, ChatGPT/Claude style.
Left 300px (nav + task list), centre elastic (content centred to
~800px), right 340px (detail/context). */
.shell{display:grid;grid-template-columns:300px minmax(0,1fr) 340px;gap:20px;align-items:start;padding:24px 28px 48px}
.rail{display:flex;flex-direction:column;gap:20px;min-width:0}
.center-col{min-width:0}
.center-inner{max-width:800px;margin:0 auto;width:100%;display:flex;flex-direction:column;gap:20px}
.detail-col{display:flex;flex-direction:column;gap:20px;min-width:0}
.left-nav{display:flex;flex-direction:column;gap:8px}
.left-nav button{text-align:left;border-radius:12px;background:#fff}
.left-nav button.active{background:var(--ink);color:#fff;border-color:var(--ink)}
.left-tasks input[type="text"]{width:100%}
.selected-task{border-color:var(--seal)!important;background:var(--seal-soft)!important}
.conversation{display:flex;flex-direction:column;gap:20px}
/* Cards: every module is one rounded independent white card */
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px}
.card h2{font-family:var(--serif);font-size:18px;margin:0 0 12px;letter-spacing:.5px}
.card h3{font-family:var(--serif);font-size:15px;margin:24px 0 12px;padding-top:20px;border-top:1px solid var(--line)}
.card h3:first-of-type{border-top:0;padding-top:0}
.composer h2{font-size:22px}
.composer textarea#prompt,.composer textarea.prompt{ min-height:148px;font-size:16px;padding:16px 18px;border-radius:12px}
.composer textarea.coord{font-family:var(--mono);font-size:13px}
/* Unified controls: same-row controls are all 40px */
.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}
.bar > button,.bar > label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}
.bar > input,.bar > select{height:var(--ctl);min-height:var(--ctl)}
input,select,textarea,button{font:inherit;font-size:14px;padding:10px 13px;border-radius:var(--radius-sm);border:1px solid var(--line-strong);background:#fff;color:var(--ink)}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--ink);box-shadow:0 0 0 3px rgba(28,34,32,.08)}
button{cursor:pointer;font-weight:600;border-radius:999px;padding:9px 16px}
button:hover{box-shadow:0 4px 14px rgba(28,34,32,.10)}
button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}
button.primary{background:var(--ink);border-color:var(--ink);color:#fff}
button.primary:hover{background:#000}
button.seal{background:var(--seal);border-color:var(--seal);color:#fff}
button.seal:hover{background:var(--seal-dark)}
button.danger{color:var(--danger);border-color:#fecdca;background:var(--danger-soft)}
button.danger:hover{background:#fee4e2}
select{appearance:none;-webkit-appearance:none;cursor:pointer;padding-right:34px;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%23737373' stroke-width='1.5' stroke-linecap='round'/></svg>");background-repeat:no-repeat;background-position:right 12px center}
select::-ms-expand{display:none}
select:disabled,input:disabled{color:#7a857e;background:var(--paper);cursor:not-allowed}
input[type="checkbox"]{width:16px;height:16px;accent-color:var(--seal)}
input[type="file"]{height:var(--ctl);display:inline-flex;align-items:center;padding:0 12px;border:1px dashed var(--line-strong);border-radius:999px;background:#fff}
input[type="file"]::file-selector-button{height:28px;padding:0 12px;margin-right:10px;border:0;border-radius:999px;background:var(--paper);color:var(--ink);font:inherit;font-size:13px;cursor:pointer}
textarea{width:100%;min-height:84px;resize:vertical;line-height:1.65;padding:12px 14px;border-radius:12px}
.composer .bar label{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line-strong);border-radius:999px;padding:7px 13px;background:#fff;cursor:pointer;font-size:13px;font-weight:600}
.composer .bar label:has(input:checked){background:var(--ink);color:#fff;border-color:var(--ink)}
.rail .bar > input,.rail .bar > select{flex:1 1 120px;min-width:0}
.rail .bar > button,.rail .bar > label{flex:none}
.stacked{margin-bottom:12px}
.stacked + .bar{margin-top:0}
/* Data lists: one shared list style, no per-section inline styles */
.data-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
.data-list li{position:relative;margin:0;padding:10px 12px;background:var(--paper);border:1px solid var(--line);border-radius:12px;line-height:1.6;display:flex;flex-wrap:wrap;align-items:center;gap:8px;overflow-wrap:anywhere}
.data-list li .row-main{flex:1 1 200px;min-width:0}
.data-list li button{flex:none}
.data-list li.is-online,.data-list li.is-offline{padding-left:26px}
.data-list li.is-online::before,.data-list li.is-offline::before{content:"";position:absolute;left:12px;top:17px;width:8px;height:8px;border-radius:50%}
.data-list li.is-online::before{background:var(--pine)}
.data-list li.is-offline::before{background:var(--seal)}
.data-group{margin-bottom:12px}.data-group:last-child{margin-bottom:0}.data-group > b{display:block;margin-bottom:8px}
/* Status pills / dots */
.st-queued{color:#7a857e}.st-running{color:var(--amber);font-weight:700}.st-completed{color:var(--success)}.st-failed,.st-merge-failed{color:var(--danger)}.st-merged{color:var(--purple)}.st-interrupted{color:var(--amber)}
/* Task table */
.table-wrap{border:0;border-top:1px solid var(--line);overflow:auto;background:var(--card)}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{padding:12px 16px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line)}
th{background:#faf9f6;color:#7a857e;font-size:12px;font-weight:600;white-space:nowrap;letter-spacing:.3px}
tbody tr{cursor:pointer;border-left:3px solid transparent}
tbody tr:hover{background:#faf9f6}
tr.selected{background:var(--seal-soft);border-left-color:var(--seal)}
td.prompt-col{font-family:var(--serif);font-size:15px;line-height:1.55}
td.st-queued::before,td.st-running::before,td.st-completed::before,td.st-failed::before,td.st-merge-failed::before,td.st-merged::before,td.st-interrupted::before{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;background:currentColor;margin-right:7px;vertical-align:1px}
/* Dark result / trace block: the one dark element */
pre{background:var(--ink);color:#ece7db;border:1px solid var(--ink);border-radius:12px;padding:16px 18px;overflow:auto;max-height:420px;white-space:pre-wrap;word-break:break-word;font-size:13px;font-family:var(--mono);line-height:1.7}
.trace-box{background:var(--ink);color:#ece7db;border:1px solid var(--ink);border-left:4px solid var(--seal);border-radius:12px;padding:16px 18px;max-height:520px;overflow:auto;font-size:13px;line-height:1.9;font-family:var(--mono)}
.trace-box .muted{color:#a8b0aa}
/* Chat: one shared conversation style for trace timeline and continue.
Centred column, user right, assistant left, tool mono, status centred.
Colours are v3 tokens only; seal is only the follow-back dot. */
.chat-column{max-width:680px;margin:0 auto;width:100%}
.chat-list{position:relative;display:flex;flex-direction:column;gap:12px;max-height:520px;overflow:auto;padding:8px 4px}
.chat-row{display:flex}.chat-row.user{justify-content:flex-end}.chat-row.assistant,.chat-row.tool{justify-content:flex-start}
.chat-bubble{max-width:85%;padding:10px 14px;border-radius:16px;line-height:1.65;white-space:pre-wrap;word-break:break-word}
.chat-bubble.user{background:var(--pine);color:#fff;border-bottom-right-radius:4px}
.chat-bubble.assistant{background:#fff;border:1px solid var(--line);border-bottom-left-radius:4px}
.chat-bubble.tool{background:var(--paper);border:1px solid var(--line);font-family:var(--mono);font-size:13px;border-bottom-left-radius:4px}
.chat-label{font-size:12px;color:#7a857e;margin-bottom:4px}
.chat-bubble.user .chat-label{color:#cfe3dd}
.chat-status{text-align:center;font-size:12px;color:#7a857e}
.chat-follow{position:sticky;bottom:8px;align-self:center;background:var(--seal);border-color:var(--seal);color:#fff;z-index:2}
.chat-input-bar{position:sticky;bottom:0;display:flex;gap:8px;align-items:center;background:#fff;padding:12px 0;border-top:1px solid var(--line);z-index:2}
.chat-input{flex:1;height:var(--ctl);min-height:var(--ctl);resize:none}
.chat-send{height:var(--ctl);flex:none}
@media (max-width:1240px){
.shell{grid-template-columns:280px minmax(0,1fr)}
.detail-col{grid-column:1 / -1}
}
@media (max-width:900px){
.shell{grid-template-columns:1fr;padding:16px 14px 32px;gap:16px}
.topbar{padding:12px 16px}.topbar h1{font-size:19px}.topbar .subtitle{display:none}
.topnav{flex-wrap:nowrap;overflow-x:auto;padding:8px 16px}.topnav a{flex:none}.topnav .auto-refresh{margin-left:0;flex:none}
.card{padding:20px}
th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3),th:nth-child(7),td:nth-child(7){display:none}
}
@media (max-width:560px){
.version{display:none}.card{padding:16px}
th:nth-child(6),td:nth-child(6),th:nth-child(9),td:nth-child(9){display:none}
.bar > input[type="text"],.bar > input[type="password"],.bar > select{flex:1 1 100%}
th,td{padding:10px 12px}
}
+1
View File
@@ -0,0 +1 @@
{"compilerOptions":{"allowImportingTsExtensions":true,"forceConsistentCasingInFileNames":true,"isolatedModules":true,"jsx":"react-jsx","lib":["ES2020","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"bundler","noEmit":true,"noFallthroughCasesInSwitch":true,"noUnusedLocals":false,"noUnusedParameters":false,"resolveJsonModule":true,"skipLibCheck":true,"strict":true,"target":"ES2020","useDefineForClassFields":true},"include":["src","vite.config.ts"]}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: { assetsInlineLimit: 100000000, chunkSizeWarningLimit: 100000000 },
});
+67 -1721
View File
File diff suppressed because it is too large. Load diff
+17 -11
View File
@@ -63,15 +63,19 @@ class ConsoleTestCase(unittest.TestCase):
text = body.decode("utf-8")
self.assertIn("Codex 命令台", text)
# 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。
# React 版(2026-10-29 起):旧单页的 agentBox / threadBox /
# THREAD_MUTATION_STATUSES / skillList / appList / mcpList
# id 与 /thread/<动作> 字面路径已不存在,改为 React 的
# taskAgents / threadText / threadAction + 动作名 /
# nav-skills;断言意图不变(壳确实调了这些接口),
# 只换成构建产物里实际存在的标记。
for marker in ("/health", "/projects", "/tasks", "/workers",
"X-API-Key", "agentBox", "/agents",
"X-API-Key", "taskAgents", "/agents",
"peerMessage", "/messages",
"threadBox", "threadGateNote", "threadList",
"THREAD_MUTATION_STATUSES",
"/thread", "/threads",
"/thread/rename", "/thread/archive",
"/thread/unarchive", "/thread/compact",
"skillList", "appList", "mcpList", "mcpOauthBox",
"threadText", "threadGateNote", "threadList",
"threadAction", "/thread", "/threads",
"rename", "archive", "unarchive", "compact",
"nav-skills", "mcpOauthBox",
"/skills", "/apps", "/mcp-servers",
"/mcp-servers/reload", "/oauth-login"):
self.assertIn(marker, text)
@@ -99,12 +103,14 @@ class ConsoleTestCase(unittest.TestCase):
# 回访用户 key 已在 localStorage 里:初始化序列必须含
# loadRelayModels 调用,否则模型下拉永远只有默认项
# (2026-10-27 实测:此前只在点“保存”时调一次)。
# React 版(2026-10-29 起):旧单页的 setKeyState/loadHealth
# 序列已不存在,初始化在 useConsoleStore 的 useEffect 里,
# 有 key 时调 loadRelayModels;断言意图不变,只换成
# React 构建产物里实际存在的标记。
_status, _headers, body = self._get("/console")
text = body.decode("utf-8")
anchor = "setKeyState();\nloadHealth();\nloadProjects();\nloadWorkers();"
self.assertIn(anchor, text)
init = text[text.index(anchor):]
self.assertIn("if (getKey()) loadRelayModels();", init)
self.assertIn("loadRelayModels", text)
self.assertIn("/relay/models", text)
if __name__ == "__main__":
+6 -2
View File
@@ -115,9 +115,13 @@ class ServerGoalModeTestCase(unittest.TestCase):
def test_console_goal_hint_and_no_old_inputs(self):
# console 壳是静态页:无 key 也能取,_req 对非 JSON 直接回文本。
# React 版(2026-10-29 起):旧单页的 goalHint id 与
# body.goal_mode = 1 字面量已不存在,goal 提示改为
# DispatchSection 的条件渲染文案,提交时仍设 goal_mode=1;
# 断言意图不变,只换成构建产物里实际存在的标记。
_status, text = self._req("GET", "/console")
self.assertIn('id="goalHint"', text)
self.assertIn("body.goal_mode = 1", text)
self.assertIn("goal 模式:输入框内容即目标", text)
self.assertIn("goal_mode", text)
# 旧的专用 prompt / 验收命令 / 轮次输入已移除。
self.assertNotIn('id="verifyCommand"', text)
self.assertNotIn('id="goalRounds"', text)
+13 -5
View File
@@ -327,12 +327,20 @@ class P2ConsoleTestCase(unittest.TestCase):
text = resp.read().decode("utf-8")
conn.close()
self.assertEqual(resp.status, 200)
for marker in ("/members", "inviteMember", "memberList",
# React 版(2026-10-29 起):旧单页的 memberList /
# taskStatusFilter / taskExecutorFilter / taskSearch /
# btnDownloadTrace / traceKindFilter / workerAgeText
# id 与函数名已不存在(React 用 members / statusFilter /
# executorFilter / searchText / downloadTrace / traceKind,
# worker 年龄函数在构建产物里被压缩改名,但“最后心跳”
# 文案与 120 秒在线阈值逻辑仍在源码里);断言意图不变,
# 只换成构建产物里实际存在的标记。
for marker in ("/members", "inviteMember", "项目成员",
"adminBody", "/admin/sweep", "/admin/backup",
"/admin/quotas", "taskStatusFilter",
"taskExecutorFilter", "taskSearch",
"btnDownloadTrace", "traceKindFilter",
"workerAgeText", "last_seen"):
"/admin/quotas", "statusFilter",
"executorFilter", "searchText",
"downloadTrace", "traceKind",
"最后心跳", "last_seen"):
self.assertIn(marker, text, marker)
+7 -3
View File
@@ -295,9 +295,13 @@ class ServerEnvironmentsTestCase(unittest.TestCase):
def test_console_has_environment_ui(self):
path = Path(__file__).resolve().parents[1] / "console.html"
html = path.read_text(encoding="utf-8")
for marker in ("envSelect", "loadEnvironments",
"createEnvironment", "refreshEnvironments",
"newEnvName", "newEnvBranch", "newEnvExecutor",
# React 版(2026-10-29 起):旧单页的 loadEnvironments /
# createEnvironment / newEnv* id 已不存在,环境 UI 改为
# React 组件(EnvSection + useCatalog),断言意图不变,
# 只换成构建产物里实际存在的标记:环境栏、建环境表单、
# 刷新/新建/归档操作与环境列表接口。
for marker in ("nav-env", "新环境名", "新建环境", "归档",
"refreshEnvs", "createEnv", "archiveEnv",
"/environments?project_id="):
self.assertIn(marker, html)
+8 -4
View File
@@ -175,10 +175,14 @@ class ConsoleColumnsTestCase(unittest.TestCase):
def test_console_task_list_has_turn_columns(self):
html = (Path(__file__).resolve().parents[2]
/ "cloud" / "console.html").read_text()
self.assertIn("<th>turn</th>", html)
self.assertIn("<th>root turn</th>", html)
self.assertIn("shortId(t.turn_id)", html)
self.assertIn("shortId(t.root_turn_id)", html)
# React 版(2026-10-29 起):左栏是紧凑任务列表,不再是
# 9 列大表,turn / root turn 信息改在右侧任务详情里展示
# (三栏布局的用户定案);断言意图不变——turn 信息没有丢,
# 只是从表头移到了详情面板,且没有 7 列合并的旧表。
self.assertIn("root turn", html)
self.assertIn("turn", html)
self.assertIn("turn_id", html)
self.assertIn("root_turn_id", html)
self.assertNotIn("colspan='7'", html)
self.assertNotIn('colspan="7"', html)