Files
codex-app-server/cloud/console-react/src/hooks/useTaskDetail.ts
T
host f02c513729 chat: 持久化继续消息,chat-history 交替历史
基线 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,旧服务端自动回退
2026-10-29 07:01:57 +08:00

421 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
};
}