Merge pull request #124 from openqj/feat-console-react
console: React 生产级重写(左列表 + 中对话 + 右详情)
This commit is contained in:
57 files changed
+4789
-1746
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -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 需
|
||||
独立用真实页面验一遍。
|
||||
@@ -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>
|
||||
Generated
+1864
File diff suppressed because it is too large.
Load diff
@@ -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}`);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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"]}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+17
-11
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user