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