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:
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user