基线 main 811912c(PR #125 Muse 风格之后):服务端与单测与 9538825 相同;React 侧在 Muse 风格 ContinuePanel 上重放 chat-history 集成(新版输入框占位文案保持 Muse 风格的“消息”), console.html 由新版 src 重新构建。 - 新表 task_continue_messages:POST /continue 的用户消息与状态改回 queued 同一事务落盘,409 被拒不留 phantom 消息 - GET /tasks/{id}/chat-history:prompt + 继续 + 后续消息 + 链路按 时间交错拼好返回;部署前继续以 legacy_continue_count 如实标注 - React ContinuePanel 优先用 chat-history,旧服务端自动回退
421 lines
20 KiB
TypeScript
421 lines
20 KiB
TypeScript
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, ChatHistory, 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 [chatHistory, setChatHistory] = useState<ChatHistory | null>(null);
|
||
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([]); }
|
||
}, []);
|
||
|
||
// True alternating history: server-merged prompt + persisted
|
||
// continues + followups + trace, in timestamp order. On failure
|
||
// (old server without /chat-history) keep null and ContinuePanel
|
||
// falls back to its previous prompt + trace + followups view.
|
||
const loadChatHistory = useCallback(async (taskId: string) => {
|
||
try { const d = await api<ChatHistory>(paths.taskChatHistory(taskId)); setChatHistory(d); }
|
||
catch { setChatHistory(null); }
|
||
}, []);
|
||
|
||
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 loadChatHistory(taskId);
|
||
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, loadChatHistory, 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 loadChatHistory(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, loadChatHistory, 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);
|
||
await loadChatHistory(t.id);
|
||
} else {
|
||
setChatMsg("merged/merge-failed 任务不可继续,也不可发后续消息");
|
||
}
|
||
} catch (e: any) { setChatMsg("发送失败:" + e.message); }
|
||
}, [showDetail, onTasksReload, loadFollowups, loadChatHistory]);
|
||
|
||
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, chatHistory,
|
||
showDetail, refreshDetailLists, doAction, taskCost, forkTask, postReview, submitFindings,
|
||
decideApproval, postPeer, threadAction, sendChatMessage, downloadTrace,
|
||
refreshReviews: () => void loadReviews(), refreshApprovals: () => void loadApprovals(),
|
||
};
|
||
}
|