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