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

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

No files matched your search

@@ -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>
</>
);
}