diff --git a/cloud/console-react/src/api/paths.ts b/cloud/console-react/src/api/paths.ts index 8afcc82..c8d044d 100644 --- a/cloud/console-react/src/api/paths.ts +++ b/cloud/console-react/src/api/paths.ts @@ -38,6 +38,7 @@ export const paths = { taskApprovals: (id: string) => "/tasks/" + enc(id) + "/approvals", taskApprovalDecision: (id: string, aid: string, verb: string) => "/tasks/" + enc(id) + "/approvals/" + enc(aid) + "/" + verb, taskFollowups: (id: string) => "/tasks/" + enc(id) + "/followups", + taskChatHistory: (id: string) => "/tasks/" + enc(id) + "/chat-history", taskMessages: (id: string) => "/tasks/" + enc(id) + "/messages", taskMessagesTo: (target: string) => "/tasks/" + enc(target) + "/messages", taskThread: (id: string) => "/tasks/" + enc(id) + "/thread", diff --git a/cloud/console-react/src/api/types.ts b/cloud/console-react/src/api/types.ts index 55751ab..dff04bb 100644 --- a/cloud/console-react/src/api/types.ts +++ b/cloud/console-react/src/api/types.ts @@ -17,6 +17,13 @@ export interface Task { export interface Review { id: string; target_type?: string; branch?: string; sha?: string; instructions?: string; status?: string; created_at?: number; findings?: string } export interface Approval { id: string; kind?: string; status?: string; summary?: string; decision?: string; decided_by?: string; decided_at?: number } export interface Followup { mode?: string; status?: string; sender_task_id?: string; body?: string } +// Chat history from GET /tasks/:id/chat-history: server-merges the task +// prompt, persisted continue messages, followups and trace entries in +// timestamp order (see server _task_chat_history). ts_ms is null for +// trace entries that carried no ts; the server order is authoritative, +// the client must not re-sort. +export interface ChatHistoryMessage { id: string; role: "user" | "assistant" | "tool" | "status"; source?: string; label?: string; text: string; ts_ms?: number | null } +export interface ChatHistory { task_id?: string; status?: string; messages: ChatHistoryMessage[]; continue_messages_stored?: number; legacy_continue_count?: number | null; trace_note?: string } export interface PeerMessage { task_id?: string; mode?: string; status?: string; body?: string } export interface Automation { id: string; name?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number } export interface Member { user_id: string; username?: string; role?: string } diff --git a/cloud/console-react/src/context.ts b/cloud/console-react/src/context.ts index ed97cbc..6ab0d69 100644 --- a/cloud/console-react/src/context.ts +++ b/cloud/console-react/src/context.ts @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry } from "./api/types"; +import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types"; export interface ConsoleCtx { keySaved: boolean; keyState: string; @@ -30,6 +30,7 @@ export interface ConsoleCtx { reviews: Review[]; reviewMsg: string; reviewError: string; refreshReviews: () => void; postReview: (body: ReviewBody) => Promise; submitFindings: (id: string) => void; traceEntries: TraceEntry[]; traceNote: string; traceDone: string; traceKind: string; setTraceKind: (v: string) => void; downloadTrace: () => void; traceMsg: string; sendChatMessage: (text: string) => Promise; chatMsg: string; + chatHistory: ChatHistory | null; followups: Followup[]; approvals: Approval[]; approvalMsg: string; refreshApprovals: () => void; decideApproval: (id: string, decision: string) => void; peerMessages: PeerMessage[]; peerMsg: string; postPeer: (mode: string, target: string, text: string) => Promise; diff --git a/cloud/console-react/src/hooks/useConsoleStore.ts b/cloud/console-react/src/hooks/useConsoleStore.ts index f64bed9..a7ce497 100644 --- a/cloud/console-react/src/hooks/useConsoleStore.ts +++ b/cloud/console-react/src/hooks/useConsoleStore.ts @@ -126,6 +126,7 @@ export function useConsoleStore(): ConsoleCtx { traceEntries: detail.traceEntries, traceNote: detail.traceNote, traceDone: detail.traceDone, traceKind: detail.traceKind, setTraceKind: detail.setTraceKind, downloadTrace: detail.downloadTrace, traceMsg: detail.traceMsg, sendChatMessage: detail.sendChatMessage, chatMsg: detail.chatMsg, + chatHistory: detail.chatHistory, followups: detail.followups, approvals: detail.approvals, approvalMsg: detail.approvalMsg, refreshApprovals: detail.refreshApprovals, decideApproval: detail.decideApproval, peerMessages: detail.peerMessages, peerMsg: detail.peerMsg, postPeer: detail.postPeer, diff --git a/cloud/console-react/src/hooks/useTaskDetail.ts b/cloud/console-react/src/hooks/useTaskDetail.ts index 18e8710..b6a0894 100644 --- a/cloud/console-react/src/hooks/useTaskDetail.ts +++ b/cloud/console-react/src/hooks/useTaskDetail.ts @@ -2,7 +2,7 @@ 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"; +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: @@ -36,6 +36,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR const [threadMsg, setThreadMsg] = useState(""); const [threadList, setThreadList] = useState([]); const [chatMsg, setChatMsg] = useState(""); + const [chatHistory, setChatHistory] = useState(null); const traceAbortRef = useRef(null); const agentTimerRef = useRef | null>(null); const currentTaskRef = useRef(null); @@ -72,6 +73,15 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR 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(paths.taskChatHistory(taskId)); setChatHistory(d); } + catch { setChatHistory(null); } + }, []); + const loadReviews = useCallback(async (taskId?: string) => { const id = taskId || currentTaskRef.current?.id; if (!id) return; @@ -226,6 +236,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR void startTrace(taskId, t.status); void loadAgents(taskId); void loadCoordChildren(t); + void loadChatHistory(taskId); void loadFollowups(taskId); void loadPeerMessages(taskId); void loadThread(taskId); @@ -236,7 +247,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR setCurrentTask(null); setDetailBody("加载失败:" + e.message); } - }, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, getRole]); + }, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, getRole]); const refreshDetailLists = useCallback(async () => { const t = currentTaskRef.current; @@ -250,6 +261,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR 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); @@ -257,7 +269,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR void loadPeerMessages(t.id); void loadThread(t.id); void loadThreads(t.id); - }, [loadFollowups, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads]); + }, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads]); const doAction = useCallback(async (kind: string) => { const t = currentTaskRef.current; @@ -370,11 +382,12 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR 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]); + }, [showDetail, onTasksReload, loadFollowups, loadChatHistory]); const downloadTrace = useCallback(async () => { const t = currentTaskRef.current; @@ -399,7 +412,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR 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, + threadText, threadGateNote, threadMsg, threadList, chatMsg, chatHistory, showDetail, refreshDetailLists, doAction, taskCost, forkTask, postReview, submitFindings, decideApproval, postPeer, threadAction, sendChatMessage, downloadTrace, refreshReviews: () => void loadReviews(), refreshApprovals: () => void loadApprovals(), diff --git a/cloud/console-react/src/sections/detail/ContinuePanel.tsx b/cloud/console-react/src/sections/detail/ContinuePanel.tsx index 7377e5b..8849516 100644 --- a/cloud/console-react/src/sections/detail/ContinuePanel.tsx +++ b/cloud/console-react/src/sections/detail/ContinuePanel.tsx @@ -5,25 +5,33 @@ import { traceEntryToChatMessage, type ChatMessage } from "../../components/chat 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. +// Preferred source is GET /chat-history (server-merged true alternating +// history: prompt + persisted continues + followups + trace, in +// timestamp order). Only when that endpoint is unavailable (old server) +// fall back to the previous view: prompt + message-kind trace entries +// + followups appended after — that fallback cannot interleave exactly +// and cannot show pre-persistence continues, and it says so nowhere +// else, so the fallback path is kept only as a compatibility shim. +// Sending uses only live APIs: terminal tasks -> POST /tasks/:id/continue +// (the server now persists that message in the same transaction), +// queued/running tasks -> POST /tasks/:id/followups (direct). export default function ContinuePanel() { const ctx = useConsole(); const t = ctx.currentTask; const messages = useMemo(() => { if (!t) return []; + if (ctx.chatHistory) { + return ctx.chatHistory.messages.map((m) => ({ + id: m.id, role: m.role, text: m.text, label: m.label, + })); + } 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]); + }, [t, ctx.chatHistory, ctx.traceEntries, ctx.followups]); if (!t) return
点任务行查看详情后,这里显示继续会话
; const terminal = TRACE_TERMINAL.includes(t.status); const running = ["queued", "running"].includes(t.status); @@ -33,8 +41,15 @@ export default function ContinuePanel() { : running ? "运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)" : "merged/merge-failed 任务不可继续,也不可发后续消息"; + const legacy = ctx.chatHistory ? (ctx.chatHistory.legacy_continue_count ?? null) : null; return (
+ {legacy !== null && legacy > 0 ? ( +
此前有 {legacy} 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。
+ ) : null} + {ctx.chatHistory && ctx.chatHistory.trace_note ? ( +
{ctx.chatHistory.trace_note}
+ ) : null} Codex 命令台 - +`);Ue=Pl.pop()||"";for(const ui of Pl){const ou=ui.split(` +`).filter(Ye=>Ye.indexOf("data: ")===0).map(Ye=>Ye.slice(6)).join(` +`);if(ou)try{const Ye=JSON.parse(ou);if(Ye.type==="snapshot")F=Ye.entries||[],Object.keys(Nt).forEach(He=>delete Nt[He]),F.forEach((He,ii)=>{He.id&&(Nt[He.id]=ii)}),_l=Ye.note||"",Dt([...F]),U(_l),pe(H);else if(Ye.type==="entry"){const He=Ye.entry;He.id&&He.id in Nt?F[Nt[He.id]]=He:(He.id&&(Nt[He.id]=F.length),F.push(He)),Dt([...F]),pe(H)}else Ye.type==="done"&&(De=!0,O=Ye.status,k(O))}catch{}}if(De)break}}catch(Y){if(Y&&Y.name==="AbortError")return;U("链路流中断:"+Y.message);return}De&&r()},[qe,pe,r]),$l=y.useCallback(async H=>{qe();try{const D=await lt(P.task(H));o(D);let Z="";D.result_text&&(Z+=`=== result === +`+D.result_text.slice(0,8e3)+` +`),D.error_text&&(Z+=`=== error === +`+D.error_text.slice(0,8e3)+` +`),Z||(Z="(暂无 result/error 输出)"),M(Z),j(""),ut(!1),tt(""),Q(""),Yt(""),La(D.status);const F=i(D.project_id),Nt=F==="maintainer"||F==="owner",_l=F==="contributor"||Nt,De=cu.includes(D.status),O=[];["failed","interrupted","merge-failed"].includes(D.status)&&Nt||O.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(Nt?"":",且仅 maintainer 可重试")),!De&&_l||O.push("取消:不可用——"+(De?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),D.status==="completed"&&Nt||O.push("合并:不可用——仅 completed 可合并"+(Nt?"":",且仅 maintainer 可合并")),D.status==="merged"&&Nt||O.push("提 PR:不可用——仅 merged 可提 PR"+(Nt?"":",且仅 maintainer 可提 PR")),z(O.join(";")),E(""),Wt(H,D.status),ol(H),Hl(D),Bl(H),dl(H),ll(H),ml(H),ql(H),we(H),Vt(H)}catch(D){o(null),M("加载失败:"+D.message)}},[qe,Wt,ol,Hl,Bl,dl,ll,ml,ql,we,Vt,i]),yn=y.useCallback(async()=>{const H=le.current;if(H){try{const D=await lt(P.task(H.id));o(D);let Z="";D.result_text&&(Z+=`=== result === +`+D.result_text.slice(0,8e3)+` +`),D.error_text&&(Z+=`=== error === +`+D.error_text.slice(0,8e3)+` +`),M(Z||"(暂无 result/error 输出)")}catch{}dl(H.id),Bl(H.id),we(H.id),Vt(H.id),Hl(H),ol(H.id),ll(H.id),ml(H.id),ql(H.id)}},[dl,Bl,we,Vt,Hl,ol,ll,ml,ql]),gn=y.useCallback(async H=>{const D=le.current;if(!D)return;const Z={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(Z[H]&&!window.confirm(Z[H])))try{const F=await lt(P.taskAction(D.id,H),"POST",{});E(H+" 已提交:"+JSON.stringify(F).slice(0,300)),r(),await $l(D.id)}catch(F){E(H+" 失败:"+F.message)}},[$l,r]),Ke=y.useCallback(async()=>{const H=le.current;if(H)try{const D=await lt(P.taskCost(H.id));j(JSON.stringify(D,null,2).slice(0,4e3))}catch(D){j("查成本失败:"+D.message)}},[]),ka=y.useCallback(async H=>{const D=le.current;if(D){if(!H.trim()){tt("分支消息必填");return}if(!cu.includes(D.status)){tt("仅终态任务可分支");return}try{const Z=await lt(P.taskFork(D.id),"POST",{message:H.trim()});tt("分支任务已建:"+Z.id.slice(0,8)),r()}catch(Z){tt("分支失败:"+Z.message)}}},[r]),ya=y.useCallback(async H=>{const D=le.current;if(D)try{await lt(P.taskReviews(D.id),"POST",H),Ft("评审已登记(requested)"),await we(D.id)}catch(Z){Ft("登记评审需求失败:"+Z.message)}},[we]),fu=y.useCallback(H=>{const D=le.current;if(!D)return;const Z=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");Z!==null&<(P.taskReviewFindings(D.id,H),"POST",{findings:Z}).then(()=>void we(D.id)).catch(F=>Ft("写 findings 失败:"+F.message))},[we]),ru=y.useCallback((H,D)=>{const Z=le.current;if(!Z)return;const F=D==="approved"?"approve":"deny";lt(P.taskApprovalDecision(Z.id,H,F),"POST",{}).then(()=>{J("已记录"+(D==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),Vt(Z.id)}).catch(Nt=>J("决定失败:"+Nt.message))},[Vt]),Il=y.useCallback(async(H,D,Z)=>{const F=le.current;if(F){if(!D.trim()){Q("目标任务 id 不能为空");return}if(!Z.trim()){Q("消息正文不能为空");return}try{const Nt=await lt(P.taskMessagesTo(D.trim()),"POST",{from_task_id:F.id,body:Z.trim(),mode:H});Q("已发送("+Nt.delivery+")"),await ll(F.id)}catch(Nt){Q("发送失败:"+Nt.message)}}},[ll]),be=y.useCallback((H,D)=>{const Z=le.current;if(!Z)return;if(!["completed","failed","interrupted"].includes(Z.status)){Yt("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const F={};if(H==="rename"){if(!D.trim()){Yt("新线程名不能为空");return}F.name=D.trim()}lt(P.taskThreadAction(Z.id,H),"POST",F).then(Nt=>{Yt(H+" 已提交:"+JSON.stringify(Nt).slice(0,300)),ml(Z.id),ql(Z.id)}).catch(Nt=>Yt(H+" 失败:"+Nt.message))},[ml,ql]),hl=y.useCallback(async H=>{const D=le.current;if(!D)return;const Z=H.trim();if(!Z){ee("消息不能为空");return}try{cu.includes(D.status)&&D.status!=="merged"?(await lt(P.taskContinue(D.id),"POST",{message:Z}),ee("已重新排队,同一会话继续"),r(),await $l(D.id)):["queued","running"].includes(D.status)?(await lt(P.taskFollowups(D.id),"POST",{body:Z,mode:"direct"}),ee("已直接发送"),await dl(D.id),await Bl(D.id)):ee("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(F){ee("发送失败:"+F.message)}},[$l,r,dl,Bl]),xe=y.useCallback(async()=>{const H=le.current;if(!H){te("先选一个任务");return}try{const D=await lt(P.taskTrace(H.id)),Z=new Blob([JSON.stringify({task_id:H.id,status:D.status,note:D.note||"",count:(D.entries||[]).length,entries:D.entries||[]},null,2)],{type:"application/json"}),F=document.createElement("a");F.href=URL.createObjectURL(Z),F.download="trace-"+H.id.slice(0,8)+".json",document.body.appendChild(F),F.click(),F.remove(),setTimeout(()=>URL.revokeObjectURL(F.href),1e3),te("已下载完整链路 "+(D.entries||[]).length+" 条")}catch(D){te("下载失败:"+D.message)}},[]);return y.useEffect(()=>()=>qe(),[qe]),{currentTask:m,detailBody:_,actionMsg:N,actionReasons:B,costBox:G,forkVisible:V,setForkVisible:ut,forkMsg:K,coordChildren:it,agents:Xt,agentsNote:Oe,reviews:fe,reviewMsg:ht,reviewError:Pt,traceEntries:re,traceNote:tl,traceDone:w,traceKind:et,setTraceKind:Tt,traceMsg:vt,followups:ze,approvals:v,approvalMsg:W,peerMessages:yt,peerMsg:xt,threadText:at,threadGateNote:Za,threadMsg:Le,threadList:At,chatMsg:Wl,chatHistory:va,showDetail:$l,refreshDetailLists:yn,doAction:gn,taskCost:Ke,forkTask:ka,postReview:ya,submitFindings:fu,decideApproval:ru,postPeer:Il,threadAction:be,sendChatMessage:hl,downloadTrace:xe,refreshReviews:()=>void we(),refreshApprovals:()=>void Vt()}}function $y(i){const[r,m]=y.useState([]),[o,_]=y.useState(""),[M,N]=y.useState(null),[E,B]=y.useState(""),z=y.useCallback(async ut=>{const K=ut!==void 0?ut:i();if(!K){m([]);return}try{const tt=await lt(P.projectAutomations(K));m(tt.automations||[])}catch(tt){_("自动化加载失败:"+tt.message)}},[i]),G=y.useCallback(async(ut,K,tt,it)=>{const jt=i();if(!jt){_("先选项目");return}if(!ut.trim()){_("自动化名称必填");return}if(!K.trim()){_("定时任务内容必填");return}const Xt={name:ut.trim(),prompt:K.trim()};tt.trim()&&(Xt.interval_seconds=parseInt(tt.trim(),10)),it.trim()&&(Xt.repo_url=it.trim());try{await lt(P.projectAutomations(jt),"POST",Xt),_("自动化已创建(登记定义,到期后由执行器认领)"),await z(jt)}catch(ge){_("创建失败:"+ge.message)}},[i,z]),j=y.useCallback(ut=>{const K=i();K&<(P.projectAutomationCancel(K,ut),"POST",{}).then(()=>{_("自动化已取消"),z(K)}).catch(tt=>_("取消失败:"+tt.message))},[i,z]),V=y.useCallback(async ut=>{const K=ut!==void 0?ut:i();if(!K){N(null);return}try{const tt=await lt(P.projectTriage(K));N(tt),B("")}catch(tt){B("分诊加载失败:"+tt.message)}},[i]);return{automations:r,autoMsg:o,triage:M,triageError:E,refreshAutomations:z,createAutomation:G,cancelAutomation:j,refreshTriage:V}}function Iy(i,r){const m=y.useRef(i);m.current=i,y.useEffect(()=>{const o=setInterval(()=>{const _=document.activeElement;m.current(_?_.tagName==="SELECT":!1)},r);return()=>clearInterval(o)},[r])}function Py(i,r){const m=y.useRef(i);m.current=i,y.useEffect(()=>{const o=setInterval(()=>m.current(),r);return()=>clearInterval(o)},[r])}function tg(i){const r=m=>String(m).padStart(2,"0");return r(i.getHours())+":"+r(i.getMinutes())+":"+r(i.getSeconds())}function eg(){const i=Jy(),r=Fy(),m=y.useRef("");m.current=i.projectId;const o=y.useCallback(()=>m.current,[]),_=$y(o),M=y.useCallback(K=>{const tt=i.projects.find(it=>it.id===K);return tt&&tt.my_role||null},[i.projects]),N=y.useCallback(()=>{r.refreshTasks(void 0,m.current)},[r]),E=Wy(M,N),[B,z]=y.useState("--:--:--"),G=y.useCallback(K=>{i.setProjectId(K),m.current=K,r.refreshTasks(K),i.refreshEnvs(K),i.refreshMembers(K),i.refreshSkills(K),_.refreshAutomations(K),_.refreshTriage(K)},[i,r,_]),j=y.useCallback(K=>{i.saveKeyInput(K),K.trim()&&(i.refreshHealth(),i.refreshProjects(),i.refreshWorkers(),m.current&&(r.refreshTasks(m.current),i.refreshEnvs(m.current),i.refreshMembers(m.current)),i.loadRelayModels(),i.loadQuota(),i.loadGithubRepos(),i.probeAdmin())},[i,r]),V=y.useCallback(K=>{if(z(tg(new Date)),!Rl()){i.refreshHealth();return}r.refreshTasks(void 0,m.current),i.refreshHealth(),i.loadQuota(),i.refreshWorkers(),i.refreshMembers(),i.refreshSkills(),_.refreshAutomations(),_.refreshTriage(),E.refreshDetailLists(),K||(i.refreshProjects(),i.refreshEnvs())},[i,r,_,E]),ut=y.useCallback(()=>{Rl()&&(i.loadRelayModels(),i.loadGithubRepos())},[i]);return y.useEffect(()=>{i.refreshHealth(),i.refreshProjects(),i.refreshWorkers(),i.loadQuota(),i.loadGithubRepos(),Rl()&&i.loadRelayModels(),i.probeAdmin()},[]),{keySaved:i.keySaved,keyState:i.keyState,saveKeyInput:j,clearKeyInput:i.clearKeyInput,quotaText:i.quotaText,healthText:i.healthText,refreshHealth:i.refreshHealth,adminGate:i.adminGate,adminBody:i.adminBody,adminResult:i.adminResult,adminUserId:i.adminUserId,setAdminUserId:i.setAdminUserId,adminQuota:i.adminQuota,adminSweep:i.adminSweep,adminBackup:i.adminBackup,projects:i.projects,projectId:i.projectId,projectMsg:i.projectMsg,selectProject:G,refreshProjects:i.refreshProjects,createProject:i.createProject,githubRepos:i.githubRepos,workers:i.workers,workersSummary:i.workersSummary,workersError:i.workersError,refreshWorkers:i.refreshWorkers,members:i.members,memberMsg:i.memberMsg,memberError:i.memberError,refreshMembers:()=>void i.refreshMembers(),inviteMember:i.inviteMember,changeMemberRole:i.changeMemberRole,skills:i.skills,apps:i.apps,mcpServers:i.mcpServers,skillMsg:i.skillMsg,skillError:i.skillError,mcpMsg:i.mcpMsg,mcpOauthMsg:i.mcpOauthMsg,mcpOauthBox:i.mcpOauthBox,refreshSkills:()=>void i.refreshSkills(),reloadMcp:i.reloadMcp,mcpOauth:i.mcpOauth,envs:i.envs,envCount:i.envCount,envMsg:i.envMsg,envRepoLine:i.envRepoLine,envRepoBar:i.envRepoBar,envRepoMsg:i.envRepoMsg,envSelect:i.envSelect,setEnvSelect:i.setEnvSelect,refreshEnvs:()=>void i.refreshEnvs(),createEnv:i.createEnv,archiveEnv:i.archiveEnv,saveEnvRepo:i.saveEnvRepo,models:i.models,modelCustomVisible:i.modelCustomVisible,tasks:r.tasks,taskCountText:r.taskCountText,taskError:r.taskError,refreshTasks:()=>void r.refreshTasks(void 0,m.current),statusFilter:r.statusFilter,setStatusFilter:r.setStatusFilter,executorFilter:r.executorFilter,setExecutorFilter:r.setExecutorFilter,searchText:r.searchText,setSearchText:r.setSearchText,filteredTasks:r.filteredTasks,currentTask:E.currentTask,detailBody:E.detailBody,actionMsg:E.actionMsg,actionReasons:E.actionReasons,showDetail:K=>void E.showDetail(K),doAction:E.doAction,taskCost:E.taskCost,costBox:E.costBox,forkVisible:E.forkVisible,setForkVisible:E.setForkVisible,forkTask:E.forkTask,forkMsg:E.forkMsg,coordChildren:E.coordChildren,coordText:"",agents:E.agents,agentsNote:E.agentsNote,reviews:E.reviews,reviewMsg:E.reviewMsg,reviewError:E.reviewError,refreshReviews:E.refreshReviews,postReview:E.postReview,submitFindings:E.submitFindings,traceEntries:E.traceEntries,traceNote:E.traceNote,traceDone:E.traceDone,traceKind:E.traceKind,setTraceKind:E.setTraceKind,downloadTrace:E.downloadTrace,traceMsg:E.traceMsg,sendChatMessage:E.sendChatMessage,chatMsg:E.chatMsg,chatHistory:E.chatHistory,followups:E.followups,approvals:E.approvals,approvalMsg:E.approvalMsg,refreshApprovals:E.refreshApprovals,decideApproval:E.decideApproval,peerMessages:E.peerMessages,peerMsg:E.peerMsg,postPeer:E.postPeer,automations:_.automations,autoMsg:_.autoMsg,refreshAutomations:()=>void _.refreshAutomations(),createAutomation:_.createAutomation,cancelAutomation:_.cancelAutomation,triage:_.triage,triageError:_.triageError,refreshTriage:()=>void _.refreshTriage(),threadText:E.threadText,threadGateNote:E.threadGateNote,threadMsg:E.threadMsg,threadList:E.threadList,threadAction:E.threadAction,dispatch:async(K,tt)=>{const it=await r.dispatch(K,tt);return r.refreshTasks(m.current),it},dispatchMsg:"",refreshAll:V,refreshSlow:ut,refreshStamp:B}}function Fl({children:i}){return s.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",children:i})}function Yh({view:i}){switch(i){case"task":return s.jsx(Fl,{children:s.jsx("path",{d:"M21 11.5a8.5 8.5 0 0 1-12.4 7.5L3 21l2-5.6A8.5 8.5 0 1 1 21 11.5Z"})});case"projects":return s.jsxs(Fl,{children:[s.jsx("path",{d:"M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"}),s.jsx("path",{d:"M14 3v5h5"}),s.jsx("path",{d:"M9 13h6M9 17h6"})]});case"env":return s.jsx(Fl,{children:s.jsx("path",{d:"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"})});case"workers":return s.jsxs(Fl,{children:[s.jsx("rect",{x:"3",y:"3",width:"7",height:"7",rx:"1.5"}),s.jsx("rect",{x:"14",y:"3",width:"7",height:"7",rx:"1.5"}),s.jsx("rect",{x:"3",y:"14",width:"7",height:"7",rx:"1.5"}),s.jsx("rect",{x:"14",y:"14",width:"7",height:"7",rx:"1.5"})]});case"skills":return s.jsxs(Fl,{children:[s.jsx("path",{d:"M9 18h6M10 21h4"}),s.jsx("path",{d:"M12 3a6 6 0 0 0-3.5 10.8c.8.6 1.5 1.4 1.5 2.2h4c0-.8.7-1.6 1.5-2.2A6 6 0 0 0 12 3Z"})]});case"automations":return s.jsxs(Fl,{children:[s.jsx("circle",{cx:"12",cy:"12",r:"9"}),s.jsx("path",{d:"M12 7v5l3 2"})]});case"triage":return s.jsxs(Fl,{children:[s.jsx("circle",{cx:"12",cy:"12",r:"9"}),s.jsx("path",{d:"M8 12.5l2.5 2.5L16 9.5"})]});case"admin":return s.jsxs(Fl,{children:[s.jsx("path",{d:"M12 3l7 3v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6Z"}),s.jsx("path",{d:"M9 12l2 2 4-4"})]});case"key":return s.jsxs(Fl,{children:[s.jsx("circle",{cx:"8",cy:"15",r:"4"}),s.jsx("path",{d:"M11.5 11.5L20 3"}),s.jsx("path",{d:"M17 6l2 2M20 3l-1 3"})]});default:return s.jsx(Fl,{children:s.jsx("circle",{cx:"12",cy:"12",r:"9"})})}}const lg=[["task","任务"],["projects","项目"],["env","环境"],["workers","Workers"],["skills","技能与 MCP"],["automations","自动化"],["triage","分诊"],["admin","管理区"],["key","API Key"]];function ag({view:i,onSelect:r}){return s.jsx("nav",{className:"icon-rail","aria-label":"模块快捷方式",children:lg.map(([m,o])=>s.jsx("button",{type:"button",title:o,"aria-label":o,className:i===m?"active":"",onClick:()=>r(m),children:s.jsx(Yh,{view:m})},m))})}function Ze({id:i,className:r,title:m,children:o}){return s.jsxs("section",{id:i,className:"card"+(r?" "+r:""),children:[m?s.jsx("h2",{children:m}):null,o]})}function Bt({children:i}){return s.jsx("div",{className:"bar",children:i})}function Dl({items:i,empty:r}){return i.length?s.jsx("ul",{className:"data-list",children:i.map((m,o)=>s.jsx("li",{children:m},o))}):s.jsx("div",{className:"muted",children:r||"(暂无)"})}function su({children:i}){return s.jsx("ul",{className:"data-list",children:i})}function ng(){const i=wt(),[r,m]=y.useState("");return s.jsxs(Ze,{id:"nav-key",title:"API Key",children:[s.jsxs(Bt,{children:[s.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:o=>m(o.target.value)}),s.jsx("button",{className:"primary",onClick:()=>{i.saveKeyInput(r),m("")},children:"保存到本浏览器"}),s.jsx("button",{onClick:i.clearKeyInput,children:"清除"}),s.jsx("span",{className:"muted",children:i.keyState})]}),s.jsx("div",{children:s.jsx("span",{className:"muted",children:i.quotaText})})]})}function ug(){const i=wt();return s.jsxs(Ze,{id:"adminCard",title:s.jsxs(s.Fragment,{children:["管理区 ",s.jsx("span",{className:"muted",children:"(仅全局管理员)"})]}),children:[s.jsx("div",{className:"muted",children:i.adminGate}),i.adminBody?s.jsxs(s.Fragment,{children:[s.jsxs(Bt,{children:[s.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:r=>i.setAdminUserId(r.target.value)}),s.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),s.jsxs(Bt,{children:[s.jsx("button",{onClick:i.adminSweep,children:"手动触发 sweep(清理陈旧 running 任务)"}),s.jsx("button",{onClick:i.adminBackup,children:"手动触发备份"})]}),s.jsx("div",{className:"muted",children:i.adminResult})]}):null]})}function ig(){const i=wt(),[r,m]=y.useState(""),[o,_]=y.useState(""),[M,N]=y.useState("");return s.jsxs(Ze,{id:"nav-projects",title:"项目",children:[s.jsxs(Bt,{children:[s.jsxs("select",{"aria-label":"项目",value:i.projectId,onChange:E=>i.selectProject(E.target.value),children:[i.projects.length===0?s.jsx("option",{value:"",children:"(先加载项目)"}):null,i.projects.map(E=>s.jsxs("option",{value:E.id,children:[E.name," (",de(E.id),")"]},E.id))]}),s.jsx("button",{onClick:i.refreshProjects,children:"刷新"})]}),s.jsxs(Bt,{children:[s.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:E=>m(E.target.value)}),s.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:o,onChange:E=>{_(E.target.value),E.target.value&&N(E.target.value)},children:[s.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(E=>s.jsx("option",{value:E.clone_url,children:E.repo_full_name},E.clone_url))]}),s.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:M,onChange:E=>N(E.target.value)}),s.jsx("button",{onClick:async()=>{await i.createProject(r,M),m(""),N(""),_("")},children:"新建项目"})]}),s.jsx("span",{className:"muted",children:i.projectMsg})]})}function cg(){const i=wt(),[r,m]=y.useState(""),[o,_]=y.useState(""),[M,N]=y.useState("");return s.jsxs(Ze,{id:"nav-env",title:s.jsxs(s.Fragment,{children:["环境 ",s.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[s.jsxs(Bt,{children:[s.jsx("button",{onClick:i.refreshEnvs,children:"刷新"}),s.jsx("span",{className:"muted",children:i.envCount})]}),i.envs.length===0?s.jsx("div",{className:"muted",children:"先选项目"}):s.jsx(su,{children:i.envs.map(E=>s.jsxs("li",{children:[s.jsxs("span",{className:"row-main",children:[E.name,"(",de(E.id),") / 分支 ",E.branch||"默认分支"," / 执行器 ",E.executor||"不限"," / 状态 ",E.status,E.status==="failed"&&E.error_text?" / 失败原因:"+E.error_text:""]}),E.status==="archived"?null:s.jsx("button",{onClick:()=>void i.archiveEnv(E.id),children:"归档"})]},E.id))}),s.jsxs(Bt,{children:[s.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:E=>m(E.target.value)}),s.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:o,onChange:E=>_(E.target.value)}),s.jsxs("select",{"aria-label":"新环境执行器",value:M,onChange:E=>N(E.target.value),children:[s.jsx("option",{value:"",children:"执行器:不限"}),s.jsx("option",{value:"local",children:"local"}),s.jsx("option",{value:"docker",children:"docker"})]}),s.jsx("button",{onClick:async()=>{await i.createEnv(r,o,M),m(""),_("")},children:"新建环境"})]}),s.jsx("span",{className:"muted",children:i.envMsg})]})}function sg(){const i=wt();return s.jsxs(Ze,{id:"nav-workers",title:"Workers",children:[s.jsx(Bt,{children:s.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),i.workersError?s.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?s.jsx("div",{className:"muted",children:i.workersSummary||"未加载"}):i.workers.length===0?s.jsx("div",{className:"muted",children:"无 worker 注册"}):s.jsx(su,{children:i.workers.map((r,m)=>{const o=r.running===void 0||r.running===null?"?":r.running,_=r.capacity===void 0||r.capacity===null?"?":r.capacity,M=Ky(r),N=r.alive!==void 0&&r.alive!==null?!!r.alive:M.age!==null&&M.age<=120,E=N?" / 在线":" / 离线(占用为上次上报值)";return s.jsx("li",{className:N?"is-online":"is-offline",children:s.jsxs("span",{className:"row-main",children:[r.worker_id||r.id||"?"," / ",r.executor||""," / 占用 ",o,"/",_,E," / 最后心跳 ",M.text]})},m)})})]})}function fg(){const i=wt();return s.jsxs(Ze,{id:"nav-skills",title:"技能与 MCP",children:[s.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),s.jsxs(Bt,{children:[s.jsx("button",{onClick:i.refreshSkills,children:"刷新"}),s.jsx("span",{className:"muted",children:i.skillMsg})]}),s.jsx("h3",{children:"技能"}),i.skillError?s.jsx("div",{className:"muted",children:i.skillError}):s.jsx(Dl,{empty:"暂无技能",items:i.skills.map((r,m)=>s.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:"",r.detail?" - "+r.detail:""]},m))}),s.jsx("h3",{children:"应用"}),s.jsx(Dl,{empty:"暂无应用",items:i.apps.map((r,m)=>s.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:""]},m))}),s.jsx("h3",{children:"MCP 服务"}),s.jsx(Dl,{empty:"暂无 MCP 服务",items:i.mcpServers.map((r,m)=>{const o=r.detail||r.details||r.state||"";return s.jsxs("span",{className:"row-main",children:[r.name||r.id||""," | 状态 ",r.status||"",o?" | "+o:""]},m)})})]})}function rg(){const i=wt(),[r,m]=y.useState(""),[o,_]=y.useState(""),[M,N]=y.useState(""),[E,B]=y.useState("");return s.jsxs(s.Fragment,{children:[s.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:z=>m(z.target.value)}),s.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:o,onChange:z=>_(z.target.value)}),s.jsxs(Bt,{children:[s.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:M,onChange:z=>N(z.target.value)}),s.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:E,onChange:z=>B(z.target.value)}),s.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,o,M,E),m(""),_(""),N(""),B("")},children:"创建自动化"}),s.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),s.jsx("span",{className:"muted",children:i.autoMsg})]}),s.jsx(Dl,{empty:"(暂无自动化)",items:i.automations.map(z=>s.jsxs("span",{className:"row-main",children:["[",z.status,z.due?"|到期":"","] ",z.name,"|每 ",z.interval_seconds," 秒|下次 ",ni(z.next_run_at),z.status==="active"?s.jsxs(s.Fragment,{children:[" ",s.jsx("button",{onClick:()=>i.cancelAutomation(z.id),children:"取消"})]}):null]},z.id))})]})}function og(){var m,o,_,M;const i=wt(),r=i.triage;return i.triageError?s.jsx("div",{className:"muted",children:i.triageError}):r?s.jsxs(s.Fragment,{children:[s.jsxs("div",{className:"data-group",children:[s.jsxs("b",{children:["到期自动化(",((m=r.counts)==null?void 0:m.automations_due)||0,")"]}),s.jsx(Dl,{empty:"(无)",items:(r.automations_due||[]).map((N,E)=>s.jsxs("span",{className:"row-main",children:[N.name,"|每 ",N.interval_seconds," 秒"]},E))})]}),s.jsxs("div",{className:"data-group",children:[s.jsxs("b",{children:["失败/中断任务(",((o=r.counts)==null?void 0:o.failed_tasks)||0,")"]}),s.jsx(Dl,{empty:"(无)",items:(r.failed_tasks||[]).map((N,E)=>s.jsxs("span",{className:"row-main",children:[de(N.id),"|",N.status,"|",String(N.prompt||"").slice(0,80)]},E))})]}),s.jsxs("div",{className:"data-group",children:[s.jsxs("b",{children:["待处理后续消息(",((_=r.counts)==null?void 0:_.pending_followups)||0,")"]}),s.jsx(Dl,{empty:"(无)",items:(r.pending_followups||[]).map((N,E)=>s.jsxs("span",{className:"row-main",children:["任务 ",de(N.task_id),"|",String(N.body||"").slice(0,80)]},E))})]}),s.jsxs("div",{className:"data-group",children:[s.jsxs("b",{children:["最近自动化产出(",((M=r.counts)==null?void 0:M.recent_runs)||0,")"]}),s.jsx(Dl,{empty:"(无)",items:(r.recent_runs||[]).map((N,E)=>s.jsxs("span",{className:"row-main",children:[de(N.automation_id),"|",N.status,N.task_id?"|任务 "+de(N.task_id):"",N.task_status?"|"+N.task_status:""]},E))})]})]}):s.jsx("div",{className:"muted",children:"(先选项目)"})}function Gh(){return s.jsx(Ze,{id:"nav-automations",title:"自动化",children:s.jsx(rg,{})})}function Qh(){const i=wt();return s.jsxs(Ze,{id:"nav-triage",title:"分诊收件箱",children:[s.jsx(Bt,{children:s.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),s.jsx(og,{})]})}function dg({onNew:i}){const r=wt();return s.jsxs("div",{children:[s.jsxs("div",{className:"search-box",children:[s.jsxs("svg",{viewBox:"0 0 24 24",width:"18",height:"18",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round","aria-hidden":"true",children:[s.jsx("circle",{cx:"11",cy:"11",r:"7"}),s.jsx("path",{d:"M21 21l-4.3-4.3"})]}),s.jsx("input",{type:"text",placeholder:"搜索","aria-label":"搜索任务",value:r.searchText,onChange:m=>r.setSearchText(m.target.value)})]}),s.jsxs("div",{className:"list-section-head",children:[s.jsx("span",{children:"主要聊天"}),s.jsx("button",{type:"button","aria-label":"派发新任务",title:"派发新任务",onClick:i,children:"+"})]}),s.jsxs("div",{className:"bar",children:[s.jsxs("select",{"aria-label":"状态筛选",value:r.statusFilter,onChange:m=>r.setStatusFilter(m.target.value),children:[s.jsx("option",{value:"",children:"状态:全部"}),["queued","running","completed","failed","interrupted","merged","merge-failed"].map(m=>s.jsx("option",{value:m,children:m},m))]}),s.jsxs("select",{"aria-label":"执行器筛选",value:r.executorFilter,onChange:m=>r.setExecutorFilter(m.target.value),children:[s.jsx("option",{value:"",children:"执行器:全部"}),s.jsx("option",{value:"local",children:"local"}),s.jsx("option",{value:"docker",children:"docker"}),s.jsx("option",{value:"cloud",children:"cloud"})]})]}),s.jsxs("div",{className:"bar",children:[s.jsx("button",{onClick:r.refreshTasks,children:"刷新"}),s.jsx("span",{className:"muted",children:r.taskCountText})]}),r.taskError?s.jsx("div",{className:"err",children:r.taskError}):r.projectId?r.filteredTasks.length===0?s.jsx("div",{className:"muted",children:r.tasks.length?"没有符合筛选条件的任务":"该项目暂无任务"}):s.jsx("ul",{className:"muse-list",children:r.filteredTasks.slice().reverse().map(m=>{var o;return s.jsxs("li",{className:"muse-item"+(((o=r.currentTask)==null?void 0:o.id)===m.id?" selected":""),onClick:()=>r.showDetail(m.id),children:[s.jsxs("div",{className:"muse-item-title",children:[m.plan?"[计划] ":"",m.forked_from?"[分支] ":"",m.coordinator_parts?"[协调] ":"",m.parent_task_id?"[协调子任务] ":"",String(m.prompt||de(m.id)).slice(0,60)]}),s.jsxs("div",{className:"muse-item-sub",children:[s.jsx("span",{className:"task-dot st-"+m.status}),s.jsx("span",{children:de(m.id)}),s.jsx("span",{className:"st-"+m.status,children:m.status}),s.jsx("span",{children:m.executor||""})]})]},m.id)})}):s.jsx("div",{className:"muted",children:"先选项目"})]})}function mg(i){const r=y.useRef(null),[m,o]=y.useState(!0),_=y.useRef(!0),M=()=>{const N=r.current;if(!N)return;const E=N.scrollHeight-N.scrollTop-N.clientHeight<80;_.current=E,o(E)};return y.useEffect(()=>{_.current&&r.current&&(r.current.scrollTop=r.current.scrollHeight)},[i]),{ref:r,following:m,onScroll:M}}function hg({msg:i}){return i.role==="status"?s.jsx("div",{className:"chat-status",children:i.text}):s.jsx("div",{className:"chat-row "+i.role,children:s.jsxs("div",{className:"chat-bubble "+i.role,children:[i.label?s.jsx("div",{className:"chat-label",children:i.label}):null,s.jsx("div",{className:"chat-text",children:i.text})]})})}function Xh({messages:i,empty:r}){const{ref:m,following:o,onScroll:_}=mg(i.length);return s.jsxs("div",{className:"chat-list",ref:m,onScroll:_,children:[i.length===0?s.jsx("div",{className:"muted",children:r||"(暂无消息)"}):i.map(M=>s.jsx(hg,{msg:M},M.id)),!o&&i.length>0?s.jsx("button",{className:"chat-follow",onClick:()=>{m.current&&(m.current.scrollTop=m.current.scrollHeight)},children:"回到最新 ↓"}):null]})}function vg({onSend:i,disabled:r,placeholder:m}){const[o,_]=y.useState(""),M=()=>{const N=o.trim();!N||r||(i(N),_(""))};return s.jsxs("div",{className:"chat-input-bar",children:[s.jsx("textarea",{"aria-label":"会话输入",className:"chat-input",rows:1,placeholder:m||"消息",value:o,disabled:r,onChange:N=>_(N.target.value),onKeyDown:N=>{N.key==="Enter"&&!N.shiftKey&&(N.preventDefault(),M())}}),s.jsx("button",{type:"button",className:"chat-send","aria-label":"发送",disabled:r||!o.trim(),onClick:M,children:"↑"})]})}function Vh(i,r){const m=i.id||"trace-"+r;if(i.kind==="message")return{id:m,role:"assistant",text:(i.phase?"["+i.phase+"] ":"")+(i.text||""),label:"助手"};if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const o=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:m,role:"tool",text:fr(i),label:o}}return i.kind==="plan"?{id:m,role:"status",text:i.text||""}:i.kind==="reasoning"?{id:m,role:"status",text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:m,role:"status",text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:m,role:"status",text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:m,role:"status",text:fr(i)}}function yg(){const i=wt(),r=y.useMemo(()=>{const m=i.currentTask,o=m&&m.prompt?[{id:"task-prompt",role:"user",text:m.prompt}]:[],M=(i.traceKind?i.traceEntries.filter(E=>E.kind===i.traceKind):i.traceEntries).map((E,B)=>Vh(E,B)),N=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...o,...M,...N]},[i.currentTask,i.traceEntries,i.traceKind,i.traceDone]);return s.jsxs(s.Fragment,{children:[s.jsx("div",{className:"muted",children:"实时链路"}),s.jsxs(Bt,{children:[s.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:m=>i.setTraceKind(m.target.value),children:[s.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(m=>s.jsx("option",{value:m,children:m==="exec"?"命令":m==="file"?"文件":m==="call"?"调用":m==="tool"?"工具":m==="message"?"消息":m==="reasoning"?"思考":m==="plan"?"计划":"子代理"},m))]}),s.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),s.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?s.jsx("div",{children:s.jsx("span",{className:"err",children:i.traceNote})}):null,s.jsx("div",{className:"chat-column",children:s.jsx(Xh,{messages:r,empty:"(暂无链路条目)"})}),s.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复来自 message 条目,命令/文件/工具/调用是工具气泡,计划/思考/子代理开始结束是居中状态提示。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function gg(){const i=wt(),r=i.currentTask,m=y.useMemo(()=>{if(!r)return[];if(i.chatHistory)return i.chatHistory.messages.map(j=>({id:j.id,role:j.role,text:j.text,label:j.label}));const B=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],z=i.traceEntries.filter(j=>j.kind==="message").map((j,V)=>Vh(j,V)),G=i.followups.map((j,V)=>({id:"followup-"+V,role:"user",text:String(j.body||""),label:"后续消息"}));return[...B,...z,...G]},[r,i.chatHistory,i.traceEntries,i.followups]);if(!r)return s.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const o=cu.includes(r.status),_=["queued","running"].includes(r.status),M=o||_,N=o?"终态任务:发送走继续接口(同一会话再跑一轮)":_?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",E=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return s.jsxs("div",{className:"chat-column",children:[E!==null&&E>0?s.jsxs("div",{className:"muted",children:["此前有 ",E," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?s.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,s.jsx(Xh,{messages:m,empty:"(暂无会话消息)"}),s.jsx(vg,{disabled:!M,placeholder:"消息",onSend:B=>void i.sendChatMessage(B)}),s.jsxs("div",{className:"muted",children:[N,"。",i.chatMsg]})]})}function Sg(){const i=wt(),[r,m]=y.useState(""),[o,_]=y.useState(""),[M,N]=y.useState(!1),[E,B]=y.useState(!1),[z,G]=y.useState("docker"),[j,V]=y.useState(""),[ut,K]=y.useState(""),[tt,it]=y.useState(""),[jt,Xt]=y.useState(""),[ge,Oe]=y.useState(""),[Jt,fe]=y.useState(""),[st,ht]=y.useState(""),Ft=y.useRef(null),Pt=y.useRef(null),kt=y.useRef(null),[re,Dt]=y.useState("");async function tl(){var vt,te,ze;if(Dt(""),!i.projectId){Dt("先选项目");return}if(!r.trim()){Dt("prompt 必填");return}const U={project_id:i.projectId,prompt:r.trim()};i.envSelect&&(U.environment_id=i.envSelect),z&&(U.executor=z);const w=ut.trim()||j;w&&(U.model=w),tt&&(U.effort=tt);const k=Jt.trim()||ge;if(k&&(U.child_model=k),st&&(U.child_effort=st),jt){if(jt==="full"&&!window.confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认提交吗?"))return;U.approval=jt}if(E){if(jt==="full"){Dt("计划模式不能与完全访问同用");return}if(M){Dt("计划模式不能与 goal 模式同用");return}U.plan=1}M&&(U.goal_mode=1);const et=o.trim();if(et){try{U.coordinator_parts=JSON.parse(et)}catch{Dt("协调员子任务 JSON 解析失败");return}if(!Array.isArray(U.coordinator_parts)){Dt("协调员子任务必须是数组");return}if(M||E){Dt("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON");return}}const Tt=ky(((vt=Ft.current)==null?void 0:vt.files)||null,((te=Pt.current)==null?void 0:te.files)||null,((ze=kt.current)==null?void 0:ze.files)||null);try{const ne=await i.dispatch(U,Tt);Dt(ne),m(""),_(""),Ft.current&&(Ft.current.value=""),Pt.current&&(Pt.current.value=""),kt.current&&(kt.current.value="")}catch(ne){Dt("提交失败:"+((ne==null?void 0:ne.message)||String(ne)))}}return s.jsxs(Ze,{id:"nav-newtask",className:"composer",title:"新建任务",children:[s.jsxs(Bt,{children:[s.jsxs("select",{"aria-label":"环境",value:i.envSelect,onChange:U=>i.setEnvSelect(U.target.value),children:[s.jsx("option",{value:"",children:"环境:不指定(走项目仓库)"}),i.envs.map(U=>s.jsxs("option",{value:U.id,disabled:U.status!=="ready",children:[U.name,"(",U.status,")"]},U.id))]}),s.jsxs("select",{"aria-label":"执行器",value:z,onChange:U=>G(U.target.value),children:[s.jsx("option",{value:"docker",children:"docker(默认,多用户下隔离最强)"}),s.jsx("option",{value:"local",children:"local"}),s.jsx("option",{value:"cloud",children:"cloud"})]}),s.jsxs("label",{children:[s.jsx("input",{type:"checkbox",checked:M,onChange:U=>N(U.target.checked)})," goal 模式"]}),s.jsxs("label",{children:[s.jsx("input",{type:"checkbox",checked:E,onChange:U=>B(U.target.checked)})," 计划模式(只读)"]})]}),s.jsx("textarea",{className:"prompt stacked",placeholder:"任务 prompt(必填)",value:r,onChange:U=>m(U.target.value)}),s.jsx("textarea",{className:"coord stacked",placeholder:"协调员子任务 JSON(可选),留空为普通任务",value:o,onChange:U=>_(U.target.value)}),s.jsxs("div",{className:"muted",children:["协调员格式:[","{",'"name":"后端","prompt":"做后端"',"}",",","{",'"name":"前端","prompt":"做前端"',"}","];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。"]}),s.jsxs(Bt,{children:[s.jsxs("select",{"aria-label":"模型",value:j,onChange:U=>V(U.target.value),children:[s.jsx("option",{value:"",children:"模型:默认(跟随配置)"}),i.models.map(U=>s.jsx("option",{value:U,children:U},U))]}),i.modelCustomVisible?s.jsx("input",{type:"text",placeholder:"手动输入模型(中转列表取不到时用)",value:ut,onChange:U=>K(U.target.value)}):null,s.jsxs("select",{"aria-label":"推理强度",value:tt,onChange:U=>it(U.target.value),children:[s.jsx("option",{value:"",children:"推理强度:默认(跟随配置)"}),Bh.map(U=>s.jsx("option",{value:U,children:U},U))]}),s.jsxs("select",{"aria-label":"权限",value:jt,onChange:U=>Xt(U.target.value),children:[s.jsx("option",{value:"",children:"权限:默认(跟随配置)"}),s.jsx("option",{value:"ask",children:"请求批准"}),s.jsx("option",{value:"auto",children:"帮我批准"}),s.jsx("option",{value:"full",children:"完全访问"})]})]}),s.jsxs(Bt,{children:[s.jsxs("select",{"aria-label":"子任务模型",value:ge,onChange:U=>Oe(U.target.value),children:[s.jsx("option",{value:"",children:"子任务模型:跟随本任务模型"}),i.models.map(U=>s.jsx("option",{value:U,children:U},U))]}),s.jsx("input",{type:"text",placeholder:"子任务模型手动输入(中转列表里没有时填,如 gpt-6-luna;留空=跟随本任务模型)",value:Jt,onChange:U=>fe(U.target.value)}),s.jsxs("select",{"aria-label":"子任务强度",value:st,onChange:U=>ht(U.target.value),children:[s.jsx("option",{value:"",children:"子任务强度:跟随本任务强度"}),Bh.map(U=>s.jsx("option",{value:U,children:U},U))]})]}),s.jsx("div",{className:"muted",children:"子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。"}),M?s.jsx("div",{className:"muted",children:"goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。"}):null,s.jsxs(Bt,{children:[s.jsx("span",{className:"muted",children:"附件(放进任务工作区,图片与普通文件不区分):"}),s.jsx("input",{ref:Ft,type:"file",multiple:!0,title:"文件"}),s.jsx("input",{ref:Pt,type:"file",multiple:!0,title:"文件夹",webkitdirectory:""}),s.jsx("input",{ref:kt,type:"file",accept:"image/*",multiple:!0,title:"图片"})]}),s.jsxs(Bt,{children:[s.jsx("button",{className:"seal",onClick:tl,children:"提交任务"}),s.jsx("span",{className:"muted",children:re||i.dispatchMsg})]})]})}function pg(){return wt().currentTask?s.jsxs("div",{className:"conversation",children:[s.jsx(yg,{}),s.jsx("div",{className:"conv-divider"}),s.jsx(gg,{})]}):s.jsxs("div",{className:"conversation",children:[s.jsx(Sg,{}),s.jsx("div",{className:"muted",children:"左侧点一条任务,对话就切到该任务;也可以先在上面派发一个新任务。"})]})}function bg(){const i=wt(),r=i.currentTask,[m,o]=y.useState("");if(!r)return null;const _=cu.includes(r.status),M=["failed","interrupted","merge-failed"].includes(r.status);return s.jsxs(s.Fragment,{children:[s.jsxs(Bt,{children:[s.jsx("button",{disabled:!M,title:M?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),s.jsx("button",{className:"danger",disabled:_,title:_?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),s.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),s.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 merged)"}),_?s.jsx("button",{onClick:()=>i.setForkVisible(!0),children:"分支"}):null,s.jsx("button",{onClick:i.taskCost,children:"查成本"}),s.jsxs("span",{className:"muted",children:[i.actionMsg," ",i.actionReasons]})]}),i.forkVisible?s.jsxs("div",{children:[s.jsx("input",{type:"text",placeholder:"分支首轮消息(必填,带历史的新会话)",style:{width:"100%",boxSizing:"border-box",margin:"6px 0"},value:m,onChange:N=>o(N.target.value)}),s.jsxs(Bt,{children:[s.jsx("button",{className:"seal",onClick:async()=>{await i.forkTask(m),o("")},children:"创建分支任务"}),s.jsx("span",{className:"muted",children:i.forkMsg})]})]}):null]})}function xg(){const i=wt(),[r,m]=y.useState("uncommittedChanges"),[o,_]=y.useState(""),[M,N]=y.useState(""),[E,B]=y.useState("");return s.jsxs(s.Fragment,{children:[s.jsx("h3",{children:"评审队列"}),s.jsxs("select",{"aria-label":"评审目标",value:r,onChange:z=>m(z.target.value),children:[s.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),s.jsx("option",{value:"baseBranch",children:"与分支的差异"}),s.jsx("option",{value:"commit",children:"指定提交"}),s.jsx("option",{value:"custom",children:"自定义评审指令"})]}),s.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:o,onChange:z=>_(z.target.value)}),s.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:M,onChange:z=>N(z.target.value)}),s.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:E,onChange:z=>B(z.target.value)}),s.jsxs(Bt,{children:[s.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:o.trim()||void 0,sha:M.trim()||void 0,instructions:E.trim()||void 0}),children:"登记评审需求"}),s.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),s.jsx("span",{className:"muted",children:i.reviewMsg})]}),s.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?s.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?s.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):s.jsx(su,{children:i.reviews.map(z=>{let G=z.target_type||"";return z.branch&&(G+=":"+z.branch),z.sha&&(G+=":"+String(z.sha).slice(0,12)),z.instructions&&(G+=":"+String(z.instructions).slice(0,80)),s.jsxs("li",{children:[s.jsxs("span",{className:"row-main",children:[s.jsx("b",{children:"评审"})," ",de(z.id)," | ",G," | ",s.jsx("span",{className:"st-"+(z.status||""),children:z.status}),z.created_at?" | "+ni(z.created_at):"",s.jsx("br",{}),z.findings?String(z.findings).slice(0,1e3):s.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),s.jsx("button",{onClick:()=>i.submitFindings(z.id),children:"写 findings"})]},z.id)})})]})}function jg(){const i=wt();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?s.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):s.jsx(su,{children:i.coordChildren.map(r=>s.jsx("li",{children:s.jsxs("span",{className:"row-main",children:["分工 ",s.jsx("b",{children:r.coordinator_part||""})," | 状态 ",s.jsx("span",{className:"st-"+r.status,children:r.status})," | ",de(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):s.jsx("div",{className:"muted",children:"(非协调员任务)"}):s.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function Tg(){const i=wt();if(!i.currentTask)return s.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示子代理链路"});if(i.agents.length===0)return s.jsxs("div",{className:"muted",children:["(暂无子代理)",i.agentsNote?s.jsx("span",{className:"err",children:i.agentsNote}):null]});const r={main:"主任务",delegate:"委派子任务",model:"模型子代理"},m={running:"进行中",completed:"已完成",failed:"失败",interrupted:"已中断",unknown:"未知"};return s.jsx(su,{children:i.agents.map((o,_)=>s.jsx("li",{children:s.jsxs("span",{className:"row-main",children:[s.jsxs("b",{children:[r[o.kind||""]||o.kind||"",":"]}),o.name||""," ",s.jsx("span",{className:"st-"+(o.status||""),children:m[o.status||""]||o.status||"?"})," | 过程调用 "+(o.calls?o.calls.length:0)+" 条",o.updated_ts?" | 最近活动 "+ni(o.updated_ts/1e3):"",o.prompt?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),s.jsxs("span",{className:"muted",children:["任务:",o.prompt]})]}):null,o.calls&&o.calls.length?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),o.calls.slice(-50).map((M,N)=>s.jsx("div",{children:fr(M)},N))]}):s.jsxs(s.Fragment,{children:[s.jsx("br",{}),s.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},_))})}function Eg(){const i=wt();return s.jsxs(s.Fragment,{children:[s.jsxs(Bt,{children:[s.jsx("button",{onClick:i.refreshApprovals,children:"刷新审批队列"}),s.jsx("span",{className:"muted",children:i.approvalMsg})]}),s.jsx("div",{className:"muted",children:"待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。"}),s.jsx(Dl,{empty:"(暂无审批项)",items:i.approvals.map(r=>s.jsxs("span",{className:"row-main",children:["[",r.kind,"|",r.status,"] ",r.summary,["approved","denied","expired"].includes(r.status||"")?"|回执:"+(r.decision||r.status)+" by "+String(r.decided_by||"").slice(0,8)+" "+ni(r.decided_at):"",r.status==="pending"?s.jsxs(s.Fragment,{children:[" ",s.jsx("button",{onClick:()=>i.decideApproval(r.id,"approved"),children:"记录允许"})," ",s.jsx("button",{onClick:()=>i.decideApproval(r.id,"denied"),children:"记录拒绝"})]}):null]},r.id))})]})}function _g(){const i=wt(),[r,m]=y.useState(""),[o,_]=y.useState("");return s.jsxs(s.Fragment,{children:[s.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:M=>m(M.target.value)}),s.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:o,onChange:M=>_(M.target.value)}),s.jsxs(Bt,{children:[s.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,o),_("")},children:"排队发给对方"}),s.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,o),_("")},children:"直接发送"}),s.jsx("span",{className:"muted",children:i.peerMsg})]}),s.jsx(Dl,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((M,N)=>s.jsxs("span",{className:"row-main",children:["[发给 ",String(M.task_id).slice(0,8),"|",M.mode,"|",M.status,"] ",String(M.body||"").slice(0,120)]},N))})]})}function Ng(){const i=wt(),[r,m]=y.useState(""),o=i.currentTask?["completed","failed","interrupted"].includes(i.currentTask.status):!1;return s.jsxs(s.Fragment,{children:[s.jsx("div",{className:"muted",children:i.threadText||"点任务行查看详情后,这里显示线程元数据"}),s.jsxs(Bt,{children:[s.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:_=>m(_.target.value)}),s.jsx("button",{disabled:!o,onClick:()=>i.threadAction("rename",r),children:"重命名"}),s.jsx("button",{disabled:!o,onClick:()=>i.threadAction("archive",""),children:"归档"}),s.jsx("button",{disabled:!o,onClick:()=>i.threadAction("unarchive",""),children:"取消归档"}),s.jsx("button",{disabled:!o,onClick:()=>i.threadAction("compact",""),children:"压缩"}),s.jsx("span",{className:"muted",children:i.threadGateNote}),s.jsx("span",{className:"muted",children:i.threadMsg})]}),s.jsx(Dl,{empty:"暂无其他线程",items:i.threadList.map((_,M)=>s.jsxs("span",{className:"row-main",children:[_.name||_.thread_name||_.id||""," [",_.id||"","]",_.archived?" [已归档]":"",_.cwd?" "+_.cwd:""]},M))}),s.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}function Cg(){const i=wt(),r=i.currentTask;return r?s.jsxs(Ze,{id:"nav-detail",title:"任务详情",children:[s.jsxs("div",{children:["状态 ",s.jsx("b",{className:"st-"+r.status,children:r.status}),"(",Qy[r.status]||"未知状态",")",s.jsx("br",{}),"任务 ",r.id," | turn ",de(r.turn_id)," | root turn ",de(r.root_turn_id),s.jsx("br",{}),"executor ",r.executor||""," | 主机 ",r.worktree_host||"",s.jsx("br",{}),"模型 ",r.model||"默认"," | 强度 ",r.effort||"默认",r.child_model||r.child_effort?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),"子任务默认 ",(r.child_model||"跟随本任务")+" / "+(r.child_effort||"跟随本任务")]}):null,s.jsx("br",{}),"权限 ",r.approval||"默认"," | 项目 ",de(r.project_id),s.jsx("br",{}),"更新 ",ni(r.updated_at),r.plan?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),"计划模式(只读)"]}):null,r.forked_from?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),"分支自 ",de(r.forked_from)]}):null,r.goal_mode?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),"goal 模式(不限轮次,模型宣告完成)"]}):r.verify_command?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),"goal 模式(旧语义,验收命令)"]}):null,r.coordinator_parts?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),"协调员:",r.coordinator_parts.length," 个子任务(",r.coordinator_parts.map(m=>m.name).join("、"),")"]}):null,r.parent_task_id?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),"协调子任务,父任务 ",de(r.parent_task_id),r.coordinator_part?"("+r.coordinator_part+")":""]}):null,r.attachments&&r.attachments.length?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),"附件:",r.attachments.join("、")]}):null,r.pr_url?s.jsxs(s.Fragment,{children:[s.jsx("br",{}),s.jsxs("a",{href:r.pr_url,target:"_blank",rel:"noopener noreferrer",children:["PR #",r.pr_number]})]}):null,s.jsx("br",{}),"prompt:",r.prompt||""]}),s.jsx(bg,{}),s.jsx("pre",{children:i.detailBody||"(暂无 result/error 输出)"}),i.costBox?s.jsx("pre",{children:i.costBox}):null,s.jsx("h3",{children:"协调子任务"}),s.jsx(jg,{}),s.jsx("h3",{children:"子代理链路"}),s.jsx(Tg,{}),s.jsx("h3",{children:"评审队列"}),s.jsx(xg,{}),s.jsx("h3",{children:"运行时审批队列"}),s.jsx(Eg,{}),s.jsx("h3",{children:"给其他任务发消息"}),s.jsx(_g,{}),s.jsx("h3",{children:"线程"}),s.jsx(Ng,{})]}):s.jsx(Ze,{id:"nav-detail",title:"任务详情",children:s.jsx("div",{className:"muted",children:"点任务行查看详情"})})}function Ag(){const i=wt(),[r,m]=y.useState(""),[o,_]=y.useState("contributor"),M=i.projects.find(N=>N.id===i.projectId);return s.jsxs(Ze,{title:"项目成员",children:[M?s.jsxs("div",{className:"muted",children:[M.name," | ",M.id," | 仓库 ",M.repo_url||"未绑定"," | 我的角色 ",M.my_role||"未知"]}):s.jsx("div",{className:"muted",children:"先选项目"}),s.jsxs(Bt,{children:[s.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:N=>m(N.target.value)}),s.jsxs("select",{"aria-label":"邀请角色",value:o,onChange:N=>_(N.target.value),children:[s.jsx("option",{value:"viewer",children:"viewer(只读)"}),s.jsx("option",{value:"contributor",children:"contributor(可建任务)"}),s.jsx("option",{value:"maintainer",children:"maintainer(可批合并)"}),s.jsx("option",{value:"owner",children:"owner(可管项目)"})]}),s.jsx("button",{onClick:async()=>{await i.inviteMember(r,o),m("")},children:"邀请成员"}),s.jsx("button",{onClick:i.refreshMembers,children:"刷新成员"})]}),i.memberError?s.jsx("div",{className:"muted",children:i.memberError}):i.members.length===0?s.jsx("div",{className:"muted",children:"先选项目"}):s.jsx(su,{children:i.members.map(N=>{const E=(N.username||"")+(N.username?"("+String(N.user_id).slice(0,8)+")":String(N.user_id).slice(0,8));return s.jsxs("li",{children:[s.jsx("span",{className:"row-main",children:E}),s.jsx("select",{"aria-label":"成员角色",value:N.role||"viewer",onChange:B=>void i.changeMemberRole(N.user_id,B.target.value),children:Xy.map(B=>s.jsx("option",{value:B,children:B},B))})]},N.user_id)})}),s.jsx("span",{className:"muted",children:i.memberMsg})]})}function Mg(){const i=wt(),[r,m]=y.useState("");return s.jsxs(Ze,{title:"仓库与环境上下文",children:[s.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),s.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?s.jsxs(Bt,{children:[s.jsxs("select",{"aria-label":"补仓库",value:r,onChange:o=>m(o.target.value),children:[s.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选)"}),i.githubRepos.map(o=>s.jsx("option",{value:o.clone_url,children:o.repo_full_name},o.clone_url))]}),s.jsx("button",{onClick:()=>void i.saveEnvRepo(r),children:"保存仓库"})]}):null,s.jsx("span",{className:"muted",children:i.envRepoMsg})]})}function Og(){const i=wt(),[r,m]=y.useState("");return s.jsxs(Ze,{title:"MCP 操作",children:[s.jsxs(Bt,{children:[s.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),s.jsx("span",{className:"muted",children:i.mcpMsg})]}),s.jsxs(Bt,{children:[s.jsx("input",{type:"text",placeholder:"MCP 服务名",value:r,onChange:o=>m(o.target.value)}),s.jsx("button",{onClick:()=>void i.mcpOauth(r),children:"发起 OAuth 登录"}),s.jsx("span",{className:"muted",children:i.mcpOauthMsg})]}),i.mcpOauthBox?s.jsx("pre",{children:i.mcpOauthBox}):null]})}function zg(){const i=wt();return s.jsxs(Ze,{title:"健康与配额",children:[s.jsxs(Bt,{children:[s.jsx("span",{className:"muted",children:i.healthText}),s.jsx("button",{onClick:i.refreshHealth,children:"刷新"})]}),s.jsx("div",{children:s.jsx("span",{className:"muted",children:i.quotaText})}),s.jsx("div",{className:"muted",children:i.workersSummary})]})}function Rg({view:i,onSelect:r,onClose:m}){const o=wt(),_=o.keySaved,M=o.tasks.slice().reverse().slice(0,10),N=(()=>{switch(i){case"projects":return s.jsx(Ag,{});case"env":return s.jsx(Mg,{});case"skills":return s.jsx(Og,{});case"workers":case"admin":case"key":return s.jsx(zg,{});case"automations":return s.jsx(Qh,{});case"triage":return s.jsx(Gh,{});default:return s.jsx(Cg,{})}})(),E=[["task","任务"],["admin","管理区"],["automations","自动化"],["key","API Key"]];return s.jsxs("div",{children:[s.jsxs("div",{className:"right-profile",children:[s.jsx("button",{type:"button",className:"icon-btn","aria-label":"关闭右侧栏",onClick:m,children:"×"}),s.jsx("div",{className:"right-avatar","aria-hidden":"true",children:"C"}),s.jsx("div",{className:"right-name",children:"Codex 命令台"}),s.jsx("div",{className:_?"right-status":"right-status off",children:_?"● 已连接":"○ 未连接(先在 API Key 里保存 key)"}),s.jsx("div",{className:"muted",children:o.healthText}),s.jsx("div",{className:"muted",children:o.quotaText}),s.jsxs("div",{className:"muted",children:["自动刷新 · 每 10 秒 · 已刷新 ",o.refreshStamp]})]}),s.jsx("div",{className:"right-icon-row","aria-label":"快捷方式",children:E.map(([B,z])=>s.jsx("button",{type:"button",title:z,"aria-label":z,className:i===B?"active":"",onClick:()=>r(B),children:s.jsx(Yh,{view:B})},B))}),s.jsxs("div",{children:[s.jsx("div",{className:"list-section-head",children:s.jsx("span",{children:"今天"})}),M.length===0?s.jsx("div",{className:"muted",children:"今天暂无任务"}):s.jsx("ul",{className:"today-list",children:M.map(B=>{const z=B.status==="completed"||B.status==="merged",G=B.updated_at?new Date(B.updated_at*1e3).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}):"";return s.jsxs("li",{className:"today-item",onClick:()=>{o.showDetail(B.id),r("task")},children:[s.jsx("span",{className:"today-check"+(z?" done":""),children:z?"✓":""}),s.jsxs("span",{className:"today-item-main",children:[s.jsx("span",{className:"today-item-title",children:String(B.prompt||de(B.id)).slice(0,60)||de(B.id)}),s.jsxs("span",{className:"today-item-sub",children:[de(B.id)," · ",s.jsx("span",{className:"st-"+B.status,children:B.status})," · ",B.executor||""]})]}),s.jsx("span",{className:"today-item-time",children:G})]},B.id)})})]}),s.jsxs("details",{className:"right-detail",open:!0,children:[s.jsx("summary",{children:i==="task"?"任务详情":"模块详情"}),N]})]})}function Dg(){const i=eg(),[r,m]=y.useState("task"),[o,_]=y.useState(!0);Iy(N=>i.refreshAll(N),1e4),Py(()=>i.refreshSlow(),6e4);const M=(()=>{switch(r){case"projects":return s.jsx(ig,{});case"env":return s.jsx(cg,{});case"workers":return s.jsx(sg,{});case"skills":return s.jsx(fg,{});case"automations":return s.jsx(Gh,{});case"triage":return s.jsx(Qh,{});case"admin":return s.jsx(ug,{});case"key":return s.jsx(ng,{});default:return s.jsx(pg,{})}})();return s.jsx(wh.Provider,{value:i,children:s.jsxs("div",{className:"muse-app"+(o?"":" no-right"),children:[s.jsx(ag,{view:r,onSelect:m}),s.jsx("aside",{className:"list-col",children:s.jsx(dg,{onNew:()=>m("task")})}),s.jsxs("main",{className:"center-col",children:[s.jsxs("div",{className:"center-topbar",children:[s.jsx("button",{type:"button",className:"icon-btn","aria-label":"返回任务",title:"返回任务",onClick:()=>m("task"),children:"←"}),o?s.jsx("span",{className:"muted",children:"Codex 命令台"}):s.jsx("button",{type:"button",onClick:()=>_(!0),children:"显示右侧栏"})]}),s.jsx("div",{className:"center-inner",children:M})]}),o?s.jsx("aside",{className:"right-col",children:s.jsx(Rg,{view:r,onSelect:m,onClose:()=>_(!1)})}):null]})})}Yy.createRoot(document.getElementById("root")).render(s.jsx(zy.StrictMode,{children:s.jsx(Dg,{})})); diff --git a/cloud/server.py b/cloud/server.py index 1a9ba23..ff69ba4 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -1215,6 +1215,97 @@ def _followup_to_dict(row): } +def _ensure_continue_messages_table(conn): + """幂等建继续消息表:POST /continue 接受的每条用户消息都在 + 与状态改回 queued 的同一事务里落盘(见 _continue_task), + 否则继续消息只活在 resume_message 列里、worker 跑完即清, + 聊天历史永远拼不出用户那一半。老库第一次跑到这里时自动 + 补表,不丢数据;此前已发生的继续(audit 里 task_continued + 有、表里没有)无法追回,chat-history 会如实标注缺口数, + 不编造。列写法与 followups 表一致(DOUBLE PRECISION 在 + SQLite 与 Postgres 下都可用)。""" + conn.execute( + "CREATE TABLE IF NOT EXISTS task_continue_messages(" + "id TEXT PRIMARY KEY, " + "task_id TEXT, " + "project_id TEXT, " + "body TEXT, " + "created_by TEXT, " + "created_at DOUBLE PRECISION)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_continue_messages_task" + " ON task_continue_messages(task_id, created_at, id)") + + +def _continue_message_to_dict(row): + return { + "id": row["id"], + "task_id": row["task_id"], + "project_id": row["project_id"], + "body": row["body"], + "created_by": row["created_by"], + "created_at": row["created_at"], + } + + +def _chat_message_from_trace(entry): + """链路条目 -> 聊天消息(与 React 的 mapTrace.ts 同款映射, + 两边改动要同步):message 是助手回复,exec/file/tool/call + 是工具气泡,plan/reasoning/agent-start/agent-done 是居中 + 状态提示。链路条目本身没有发送方字段,这里不编造角色, + 只按 kind 分流。""" + kind = entry.get("kind") or "" + eid = entry.get("id") or "" + if kind == "message": + text = ((("[" + entry["phase"] + "] ") if entry.get("phase") + else "") + (entry.get("text") or "")) + return {"id": eid, "role": "assistant", "source": "trace", + "kind": kind, "label": "助手", "text": text} + if kind in ("exec", "file", "tool", "call"): + label = {"exec": "命令", "file": "文件", + "tool": "工具"}.get(kind, "调用") + if kind == "exec": + # 与 React traceLineText 同款:缺失字段为空,不渲染 None。 + def _s(v): + return "" if v is None else str(v) + text = "%s(exit=%s,%sms,cwd=%s)" % ( + entry.get("command") or "", _s(entry.get("exitCode")), + _s(entry.get("durationMs")), entry.get("cwd") or "") + elif kind == "file": + text = "%s %s %s" % (entry.get("path") or "", + entry.get("change") or "", + entry.get("status") or "") + elif kind == "tool": + text = "%s/%s %s" % (entry.get("server") or "", + entry.get("tool") or "", + entry.get("status") or "") + else: + text = entry.get("name") or "" + return {"id": eid, "role": "tool", "source": "trace", + "kind": kind, "label": label, "text": text} + if kind == "plan": + return {"id": eid, "role": "status", "source": "trace", + "kind": kind, "text": entry.get("text") or ""} + if kind == "reasoning": + return {"id": eid, "role": "status", "source": "trace", + "kind": kind, + "text": "思考:" + (entry.get("summary") or "")} + if kind == "agent-start": + return {"id": eid, "role": "status", "source": "trace", + "kind": kind, + "text": "子代理开始:" + (entry.get("name") or "") + + (("(" + entry["prompt"] + ")") + if entry.get("prompt") else "")} + if kind == "agent-done": + return {"id": eid, "role": "status", "source": "trace", + "kind": kind, + "text": "子代理结束:" + (entry.get("name") or "") + + " " + (entry.get("status") or "")} + return {"id": eid, "role": "status", "source": "trace", + "kind": kind, "text": json.dumps(entry)[:300]} + + def _ensure_environments_table(conn): """幂等建环境表:server init_db 与各环境接口都可调它,老库 第一次跑到这里时自动补表,不丢数据。类型写法与 followups @@ -1420,6 +1511,7 @@ def init_db(db_path): migrate_tasks_table(conn) migrate_projects_table(conn) _ensure_followups_table(conn) + _ensure_continue_messages_table(conn) _ensure_environments_table(conn) _ensure_quotas_table(conn) _backfill_default_quotas(conn) @@ -1454,6 +1546,7 @@ def init_db(db_path): migrate_tasks_table(conn) migrate_projects_table(conn) _ensure_followups_table(conn) + _ensure_continue_messages_table(conn) _ensure_environments_table(conn) _ensure_quotas_table(conn) _backfill_default_quotas(conn) @@ -1983,6 +2076,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self._create_followup(user_id, rest[: -len("/followups")]) elif rest.endswith("/followups") and method == "GET": self._list_followups(user_id, rest[: -len("/followups")]) + elif rest.endswith("/chat-history") and method == "GET": + self._task_chat_history( + user_id, rest[: -len("/chat-history")]) elif rest.endswith("/messages") and method == "POST": self._create_peer_message(user_id, rest[: -len("/messages")]) elif rest.endswith("/messages") and method == "GET": @@ -4476,6 +4572,124 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self._send_json(200, { "followups": [_followup_to_dict(r) for r in rows]}) + def _task_chat_history(self, user_id, task_id): + """GET /tasks/{id}/chat-history:真正的交替历史,一次性 + 按时间交错拼好返回,前端不再猜顺序。 + + 来源(全部是后端已有记录,不编造): + - 首条 prompt:任务行 prompt + created_at(用户) + - 继续消息:task_continue_messages(用户,部署后才入库) + - 后续消息:task_followups(用户,含任务间互发) + - 助手/工具/状态:链路 message/exec/file/tool/call/ + plan/reasoning/agent-* 条目(trace 的 ts 是毫秒时间戳, + 无 ts 的条目按链路顺序贴在上一条有 ts 的条目之后, + 不给它伪造时间戳——输出里 ts_ms 为 null) + + 排序:按 ts_ms 升序,同一毫秒用户消息在链路条目之前 + (用户消息是触发该轮的原因),其余按原顺序稳定排序。 + 旧任务缺口:部署前发生的继续只在 audit 里留了 + task_continued(只有任务 id,没有正文),无法追回; + legacy_continue_count = audit 次数 - 已入库条数,>0 时 + 前端必须在聊天窗上标“此前继续消息未入库,历史不完整”, + 不能假装这就是全部对话。异机 worktree 时链路读不到, + messages 里只有用户侧 + note,不谎报无过程。""" + row = self._fetch_task(task_id) + if row is None: + self._send_json(404, {"error": "task not found"}) + return + task = _task_to_dict(row) + with _auth_store(self.server.db_path) as store: + if not store.check_permission( + user_id, task["project_id"], "read"): + self._send_json(403, {"error": "forbidden"}) + return + entries, trace_note = self._trace_snapshot(task) + conn = self._db() + try: + _ensure_continue_messages_table(conn) + _ensure_followups_table(conn) + cont_rows = conn.execute( + "SELECT * FROM task_continue_messages WHERE task_id = ?" + " ORDER BY created_at, id", + (task_id,), + ).fetchall() + fu_rows = conn.execute( + "SELECT * FROM task_followups WHERE task_id = ?" + " ORDER BY created_at, id", + (task_id,), + ).fetchall() + legacy = None + try: + aud = conn.execute( + "SELECT COUNT(*) FROM audit_log" + " WHERE action = 'task_continued' AND detail = ?", + (task_id,), + ).fetchone() + legacy = max(0, int(aud[0]) - len(cont_rows)) + except Exception: + legacy = None + finally: + conn.close() + + merged = [] + + def _ms(seconds): + try: + return int(float(seconds) * 1000) + except (TypeError, ValueError): + return None + + if task.get("prompt"): + merged.append((0, _ms(task.get("created_at")), { + "id": "task-prompt", "role": "user", "source": "prompt", + "label": "用户", "text": task["prompt"], + "ts_ms": _ms(task.get("created_at"))})) + for r in cont_rows: + d = _continue_message_to_dict(r) + merged.append((0, _ms(d["created_at"]), { + "id": "continue-" + d["id"], "role": "user", + "source": "continue", "label": "用户", + "text": d["body"], "ts_ms": _ms(d["created_at"])})) + for r in fu_rows: + d = _followup_to_dict(r) + merged.append((0, _ms(d["created_at"]), { + "id": "followup-" + d["id"], "role": "user", + "source": "followup", "label": "后续消息", + "text": d["body"], "ts_ms": _ms(d["created_at"])})) + last_trace_ms = _ms(task.get("created_at")) + for e in entries: + if not isinstance(e, dict): + continue + if str(e.get("id") or "").startswith("fu-"): + # 直接发送的后续消息已在链路里留了一条回声 + # (_append_direct_trace 写 kind=message), + # followups 表里同一条已作用户消息返回;这里 + # 跳过回声,否则同一条消息会先以用户、再以 + # 助手各出现一次。 + continue + msg = _chat_message_from_trace(e) + ts = e.get("ts") + if isinstance(ts, (int, float)): + last_trace_ms = int(ts) + sort_ms = int(ts) + else: + sort_ms = last_trace_ms + msg["ts_ms"] = int(ts) if isinstance(ts, (int, float)) else None + merged.append((1, sort_ms, msg)) + # 排序键:(时间, 用户优先, 原顺序)。merged 里用户项的 + # 第一元组是 0、链路项是 1;Python 排序稳定,同一 + # (时间, 优先级) 内保持插入顺序。 + merged.sort(key=lambda item: ( + item[1] if item[1] is not None else 0, item[0])) + self._send_json(200, { + "task_id": task_id, + "status": task["status"], + "messages": [m for _p, _t, m in merged], + "continue_messages_stored": len(cont_rows), + "legacy_continue_count": legacy, + "trace_note": trace_note, + }) + def _create_peer_message(self, user_id, task_id): """任务间互发消息(API 发送方):以 from_task_id 任务的名义 给目标任务发消息。投递语义与 worker 路径共用 deliver 层, @@ -6623,13 +6837,30 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): % ", ".join(sets), tuple(vals), ) - conn.commit() if cur.rowcount != 1: + # 状态已变化:更新 0 行,同一事务里什么都不留—— + # 尤其不能先把继续消息落盘再报 409,那会凭空多 + # 一条从未被执行的“用户消息”。 + conn.rollback() self._send_json(409, { "error": "任务状态已变化,请刷新后重试", "status": task["status"], }) return + # 继续消息与状态改回 queued 同一事务:要么一起生效, + # 要么一起不生效,绝不出现“任务已重排队、消息却丢了” + # 的半截状态。created_at 用本请求的 updated_at 时间戳 + # (vals[1]),与任务行的时间源一致,便于按时间交错 + # 拼聊天历史。 + _ensure_continue_messages_table(conn) + conn.execute( + "INSERT INTO task_continue_messages(id, task_id," + " project_id, body, created_by, created_at)" + " VALUES(?, ?, ?, ?, ?, ?)", + (uuid.uuid4().hex, task_id, task["project_id"], + message, user_id, vals[1]), + ) + conn.commit() new_row = conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() diff --git a/cloud/tests/test_chat_history.py b/cloud/tests/test_chat_history.py new file mode 100644 index 0000000..e8347c1 --- /dev/null +++ b/cloud/tests/test_chat_history.py @@ -0,0 +1,215 @@ +"""继续消息持久化 + 交替历史单测:POST /continue 接受的用户 +消息必须落盘(与状态改回 queued 同一事务),GET +/tasks/{id}/chat-history 按时间交错拼出 prompt / 继续 / +后续消息 / 链路助手消息;409 的继续绝不能留 phantom 消息; +部署前发生的继续在 audit 里只有任务 id、正文无法追回, +接口要如实报 legacy_continue_count,不编造。 + +不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时 +SQLite,链路通过 server.trace_dir 注入临时 jsonl。 +""" + +import http.client +import json +import os +import sqlite3 +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +try: + from cloud.auth import AuthStore + from cloud.server import create_server +except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.auth import AuthStore + from cloud.server import create_server + + +class ChatHistoryTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.trace_dir = os.path.join(self.tmp.name, "traces") + os.makedirs(self.trace_dir) + self.auth = AuthStore(self.db_path) + self.owner_id, self.owner_key = self.auth.create_user("owner") + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.server.trace_dir = self.trace_dir + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + status, self.project = self._req( + "POST", "/projects", {"name": "demo"}, self.owner_key) + self.assertEqual(status, 201) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + try: + self.auth.conn.close() + except Exception: + pass + self.tmp.cleanup() + + def _req(self, method, path, body=None, key=None): + conn = http.client.HTTPConnection( + "127.0.0.1", self.port, timeout=10) + hdrs = {} + payload = None + if key: + hdrs["X-API-Key"] = key + if body is not None: + payload = json.dumps(body) + hdrs["Content-Type"] = "application/json" + conn.request(method, path, body=payload, headers=hdrs) + resp = conn.getresponse() + raw = resp.read() + conn.close() + try: + return resp.status, json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return resp.status, {} + + def _task(self, status="completed"): + _s, task = self._req( + "POST", "/tasks", + {"project_id": self.project["id"], "prompt": "original"}, + self.owner_key) + self.assertEqual(_s, 201, task) + if status != "queued": + conn = sqlite3.connect(self.db_path) + conn.execute("UPDATE tasks SET status = ? WHERE id = ?", + (status, task["id"])) + conn.commit() + conn.close() + return task["id"] + + def _set_status(self, task_id, status): + conn = sqlite3.connect(self.db_path) + conn.execute("UPDATE tasks SET status = ? WHERE id = ?", + (status, task_id)) + conn.commit() + conn.close() + + def _chat(self, task_id, key=None): + return self._req( + "GET", "/tasks/%s/chat-history" % task_id, + key=key or self.owner_key) + + def test_continue_persisted_and_interleaved(self): + tid = self._task("completed") + s, _t = self._req( + "POST", "/tasks/%s/continue" % tid, + {"message": "first follow-up"}, self.owner_key) + self.assertEqual(s, 200) + self._set_status(tid, "completed") + s, _t = self._req( + "POST", "/tasks/%s/continue" % tid, + {"message": "second follow-up"}, self.owner_key) + self.assertEqual(s, 200) + s, chat = self._chat(tid) + self.assertEqual(s, 200, chat) + self.assertEqual(chat["continue_messages_stored"], 2) + # 两次继续都有 audit,全部已入库,缺口为 0。 + self.assertEqual(chat["legacy_continue_count"], 0) + texts = [(m["role"], m["source"], m["text"]) + for m in chat["messages"]] + self.assertEqual(texts, [ + ("user", "prompt", "original"), + ("user", "continue", "first follow-up"), + ("user", "continue", "second follow-up"), + ]) + # 注入链路:把两次继续与任务创建时间钉到确定时间戳, + # 助手回复落在两次继续之间与之后,按时间交错(不靠 + # 墙钟间隔赌顺序——两次继续在测试里只隔几十毫秒, + # 助手 +500ms 本就该排在第二次继续之后)。 + conn = sqlite3.connect(self.db_path) + conn.execute("UPDATE tasks SET created_at = ? WHERE id = ?", + (1699999900.0, tid)) + conn.execute( + "UPDATE task_continue_messages SET created_at = ?" + " WHERE task_id = ? AND body = ?", + (1700000000.0, tid, "first follow-up")) + conn.execute( + "UPDATE task_continue_messages SET created_at = ?" + " WHERE task_id = ? AND body = ?", + (1700000100.0, tid, "second follow-up")) + conn.commit() + conn.close() + entries = [ + {"kind": "message", "id": "m1", "ts": 1700000050000, + "phase": "final_answer", "text": "done first"}, + {"kind": "message", "id": "m2", "ts": 1700000150000, + "phase": "final_answer", "text": "done second"}, + {"kind": "exec", "id": "e1", "ts": 1700000150600, + "command": "ls", "exitCode": 0, "durationMs": 1, + "cwd": "/tmp"}, + ] + with open(os.path.join(self.trace_dir, tid + ".jsonl"), + "w", encoding="utf-8") as f: + for e in entries: + f.write(json.dumps(e) + "\n") + s, chat = self._chat(tid) + self.assertEqual(s, 200, chat) + texts = [(m["role"], m["source"], m["text"]) + for m in chat["messages"]] + self.assertEqual(texts, [ + ("user", "prompt", "original"), + ("user", "continue", "first follow-up"), + ("assistant", "trace", "[final_answer] done first"), + ("user", "continue", "second follow-up"), + ("assistant", "trace", "[final_answer] done second"), + ("tool", "trace", "ls(exit=0,1ms,cwd=/tmp)"), + ]) + + def test_followup_and_legacy_gap_reported(self): + # 后续消息仅支持进行中任务,completed 会 409。 + tid = self._task("running") + s, _f = self._req( + "POST", "/tasks/%s/followups" % tid, + {"body": "ping while running", "mode": "direct"}, + self.owner_key) + self.assertEqual(s, 201, _f) + # 模拟部署前的一次继续:audit 里有记录、表里没有正文。 + conn = sqlite3.connect(self.db_path) + conn.execute( + "INSERT INTO audit_log(user_id, project_id, action," + " detail, created_at) VALUES(?, ?, 'task_continued', ?, ?)", + (self.owner_id, self.project["id"], tid, time.time())) + conn.commit() + conn.close() + s, chat = self._chat(tid) + self.assertEqual(s, 200, chat) + self.assertEqual(chat["continue_messages_stored"], 0) + self.assertEqual(chat["legacy_continue_count"], 1) + sources = [(m["role"], m["source"]) for m in chat["messages"]] + self.assertEqual(sources, [ + ("user", "prompt"), ("user", "followup")]) + + def test_rejected_continue_stores_nothing(self): + tid = self._task("queued") + code, _b = self._req( + "POST", "/tasks/%s/continue" % tid, + {"message": "should not persist"}, self.owner_key) + self.assertEqual(code, 409) + s, chat = self._chat(tid) + self.assertEqual(s, 200, chat) + self.assertEqual(chat["continue_messages_stored"], 0) + self.assertEqual(len(chat["messages"]), 1) # 只有 prompt + + def test_chat_history_auth(self): + tid = self._task("completed") + code, _b = self._chat(tid, key="bad-key") + self.assertEqual(code, 401) + code, _b = self._chat("no-such-task") + self.assertEqual(code, 404) + + +if __name__ == "__main__": + unittest.main()