@xgspz/cli-ui 0.0.0-stage → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +517 -2
  3. package/dist/cli-ui-embed-vIXVNs6k.js +50295 -0
  4. package/dist/cli-ui-index-R24x28OX.js +11209 -0
  5. package/dist/cli-ui-jszip.min-DUghzByz.js +2324 -0
  6. package/dist/cli-ui-jszip.min-DfH6rdQD.js +26 -0
  7. package/dist/cli-ui-xlsx-CP0lJhff.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +960 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +581 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1109 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +507 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1952 -0
  149. package/src/operator.css +1952 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -0,0 +1,581 @@
1
+ /**
2
+ * 一个对话的前端状态,以及把 center 实时流的事件应用上去的纯函数(无 React,可单测)。
3
+ *
4
+ * 数据来自两层:
5
+ * · 中枢轮次(turn):每条消息一轮,排队 → 执行中 → 成功 / 失败 / 取消(或被合并进别的轮次)。
6
+ * · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
7
+ * 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
8
+ */
9
+ import { isBatchSnapshot, type AskAnswers, type AskQuestion, type BatchStreamSnapshot, type BatchStreamTask, type CenterTurn, type ConversationClosedCallback, type ConversationTurnCallback, type JobStatus, type Message, type Msg, type NodeEvent, type StreamItem, type StreamSnapshot, type StreamTask, type ToolData, type TurnUsage } from "./types";
10
+ import { indexFromEnd, mergeSnapshot, prependOlder, toolDataOf } from "../ui/transcriptModel";
11
+ import { formatAnswersText } from "../ui/format";
12
+
13
+ export const TERMINAL_TURN = new Set(["succeeded", "failed", "cancelled", "merged"]);
14
+ export const ACTIVE_TURN = new Set(["queued", "dispatching", "running"]);
15
+ export const TERMINAL_JOB = new Set(["succeeded", "failed", "cancelled"]);
16
+ /** 一次性任务的子任务:结束了的、正在节点上跑的。 */
17
+ export const TERMINAL_TASK = TERMINAL_JOB;
18
+ const ACTIVE_TASK = new Set(["dispatching", "running", "verifying"]);
19
+
20
+ // ── 对话键 ─────────────────────────────────────────────
21
+ //
22
+ // 一个对话任务(job)里可以有几个对话(几个子任务,各自一个 Agent 会话,共用节点和工作区)。
23
+ // 组件里「一个对话」用对话键标识:
24
+ // · "job_x" 对话任务的第一个对话(只有一个对话时就是它,和以前一样);
25
+ // · "job_x#task_y" 对话任务里的某个对话;
26
+ // · "job_x#new" 在这个对话任务里新开一个对话(还没发第一条消息)。
27
+
28
+ /** 「在这个对话任务里新开一个对话」的子任务占位。 */
29
+ export const NEW_TASK = "new";
30
+
31
+ export function convKey(jobId: string, taskId?: string | null): string {
32
+ return taskId ? `${jobId}#${taskId}` : jobId;
33
+ }
34
+
35
+ export function parseConvKey(key: string): { jobId: string; taskId: string | null } {
36
+ const at = key.indexOf("#");
37
+ return at < 0 ? { jobId: key, taskId: null } : { jobId: key.slice(0, at), taskId: key.slice(at + 1) || null };
38
+ }
39
+
40
+ /** 对话键里的真实子任务(「新开」的占位不算)。 */
41
+ export function taskOfKey(key: string): string | null {
42
+ const { taskId } = parseConvKey(key);
43
+ return taskId && taskId !== NEW_TASK ? taskId : null;
44
+ }
45
+
46
+ export const isNewInJob = (key: string | null | undefined) => !!key && parseConvKey(key).taskId === NEW_TASK;
47
+ /** Operator 首轮固定的启动语句:正文其实在任务描述里(= 中枢第 1 轮的 input)。 */
48
+ export const OPERATOR_INITIAL_PROMPT = "Start working on the task described in the task context.";
49
+
50
+ export interface ConvJob {
51
+ status: JobStatus;
52
+ closed_reason: string | null;
53
+ node_id: string | null;
54
+ title?: string;
55
+ }
56
+
57
+ export interface ConvState {
58
+ /** 对话键(见 convKey)。 */
59
+ key: string;
60
+ jobId: string;
61
+ /** 对话键里指定的子任务;只有 job_id 时为 null(看第一个对话)。 */
62
+ taskId: string | null;
63
+ job: ConvJob | null;
64
+ /** 实时流看的这个对话(子任务)的状态;关闭了 closed=true。旧版 center 没有。 */
65
+ task: StreamTask | null;
66
+ /** 中枢轮次,按 seq 升序。 */
67
+ turns: CenterTurn[];
68
+ /** 节点转录(Operator 消息)。 */
69
+ msgs: Msg[];
70
+ hasTranscript: boolean;
71
+ /** 中枢说这段对话记录暂时读不到(transcript_unavailable);读到转录后清掉。 */
72
+ transcriptError: string | null;
73
+ /** 节点转录里比 msgs 更早、还没取的条数(实时流只给最新一段;往上翻时 store.loadOlder 分页取)。 */
74
+ older: number;
75
+ nodeRunning: boolean;
76
+ /**
77
+ * 收尾计数:一轮在节点上结束(中枢把这一轮标成结束;没有轮次的一次性任务看节点 turn_end)、对话结束、
78
+ * 断线重连后补齐时 +1。首次连上不算。看板的「改动」据此重读 diff。
79
+ */
80
+ settled: number;
81
+ resolvedModel: string | null;
82
+ /** 上次刷新任务详情之后,实时流里累计的用量(usage 事件)。 */
83
+ liveUsage: { fresh: number; cacheRead: number };
84
+ /** offline:自动重连用完、已停下,等网络恢复 / 页面回到前台 / 获得焦点 / 发消息 / store.reconnect() 时再连。 */
85
+ stream: "idle" | "connecting" | "live" | "reconnecting" | "offline" | "ended" | "error";
86
+ streamError: string | null;
87
+ endReason: string | null;
88
+ /** 接续这个对话的新对话的键(对话任务结束后再发消息开出来的)。 */
89
+ continuedBy: string | null;
90
+ /** 本页提交过的提问回答(askId → 答案)。节点上那一轮已被中枢结束,不会回 ask_answered,只能本地记。 */
91
+ localAnswers: Record<string, AskAnswers>;
92
+ /**
93
+ * 一次性任务(含续派,不是对话任务):只读查看其中一个子任务。没有轮次,task 是那个子任务,
94
+ * batchTask 是它的原样(状态、错误、原文),remoteTaskId 是它在节点上的对话(流里只认这个对话的转录)。
95
+ */
96
+ batch: boolean;
97
+ batchTask: BatchStreamTask | null;
98
+ remoteTaskId: string | null;
99
+ }
100
+
101
+ export const emptyConv = (key: string): ConvState => ({
102
+ key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, transcriptError: null, older: 0, nodeRunning: false, settled: 0, resolvedModel: null,
103
+ liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
104
+ batch: false, batchTask: null, remoteTaskId: null,
105
+ });
106
+
107
+ export function upsertTurns(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
108
+ if (!incoming.length) return turns;
109
+ const byId = new Map(turns.map((turn) => [turn.turn_id, turn]));
110
+ for (const turn of incoming) byId.set(turn.turn_id, { ...byId.get(turn.turn_id), ...turn });
111
+ return [...byId.values()].sort((a, b) => a.seq - b.seq);
112
+ }
113
+
114
+ /**
115
+ * 只补上还不认识的轮次(发消息 / 撤回的接口响应用)。接口响应是请求那一刻的快照,可能比实时流晚到:
116
+ * 实时流已经报了「执行中 / 已结束」,再拿响应里的「排队中」去盖,这一轮就会一直显示排队(之后也不会再有更新)。
117
+ * 已有的轮次只认实时流——状态可能合法地往回走(节点忙时中枢会把派发中的轮次放回队列),不能按状态大小判断新旧。
118
+ */
119
+ export function addTurnsIfAbsent(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
120
+ const known = new Set(turns.map((turn) => turn.turn_id));
121
+ const fresh = incoming.filter((turn) => !known.has(turn.turn_id));
122
+ return fresh.length ? upsertTurns(turns, fresh) : turns;
123
+ }
124
+
125
+ export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot | BatchStreamSnapshot): ConvState {
126
+ if (isBatchSnapshot(snapshot)) return applyBatchSnapshot(conv, snapshot);
127
+ const { job } = snapshot;
128
+ return {
129
+ ...conv,
130
+ job: { status: job.status, closed_reason: job.closed_reason, node_id: job.node_id, title: job.title },
131
+ task: snapshot.task ?? conv.task,
132
+ turns: upsertTurns([], Array.isArray(snapshot.turns) ? snapshot.turns : []),
133
+ resolvedModel: conv.resolvedModel ?? job.conversation?.last_turn?.resolved_model ?? null,
134
+ };
135
+ }
136
+
137
+ // ── 一次性任务(含续派):只读查看其中一个子任务 ─────────
138
+ //
139
+ // 指定了子任务就看它;没指定看最近一个派发过的(续派开出新的子任务时跟过去)。
140
+ // 流里可能同时有几个节点对话的事件(带 remote_task_id),只认看的这个子任务所在的那个。
141
+
142
+ const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
143
+ // 对话式子任务跑完一条不算结束:还能接着聊。
144
+ id: task.id, seq: task.seq, title: task.title, status: task.status, closed: !task.conversation && TERMINAL_TASK.has(task.status),
145
+ remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
146
+ awaiting_input: task.awaiting_input, awaiting_input_observed_at: task.awaiting_input_observed_at,
147
+ });
148
+
149
+ /** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
150
+ export const latestBatchTask = (tasks: BatchStreamTask[]) =>
151
+ [...tasks].sort((a, b) => a.seq - b.seq).reverse().find((task) => task.remote_task_id) ?? tasks.reduce<BatchStreamTask | null>((last, task) => (!last || task.seq > last.seq ? task : last), null);
152
+
153
+ /** 对话式子任务(续派时 mode=conversation):可以接着聊,发消息 = 续派一条接着它的对话。 */
154
+ export const isChatTask = (conv: Pick<ConvState, "batch" | "batchTask"> | null | undefined) => !!conv?.batch && !!conv.batchTask?.conversation;
155
+
156
+ /** 换成看另一个子任务(或同一个子任务换了节点对话,例如重试):节点转录清掉,等新对话的转录。 */
157
+ function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvState {
158
+ // 对话式的对话里最新那条可能还没派发:节点对话用它们共用的那个。
159
+ const remote = task?.conversation_remote_task_id ?? task?.remote_task_id ?? null;
160
+ const sameRemote = remote === conv.remoteTaskId;
161
+ // 状态变化事件不一定带原文(没指定子任务时):沿用已有的。
162
+ const kept = conv.batchTask && task && conv.batchTask.id === task.id ? { prompt: conv.batchTask.prompt, opener_prompt: conv.batchTask.opener_prompt } : {};
163
+ const batchTask = task ? { ...kept, ...task } : null;
164
+ return {
165
+ ...conv, batch: true, batchTask, task: batchTask ? batchStreamTask(batchTask) : null, remoteTaskId: remote,
166
+ ...(sameRemote ? {} : { msgs: [], hasTranscript: false, transcriptError: null, older: 0, nodeRunning: false }),
167
+ };
168
+ }
169
+
170
+ function applyBatchSnapshot(conv: ConvState, snapshot: BatchStreamSnapshot): ConvState {
171
+ const { job } = snapshot;
172
+ const tasks = Array.isArray(snapshot.tasks) ? snapshot.tasks : [];
173
+ // 流给的那个优先:指定了子任务就是它(对话式的是它所在对话里最新的那条);没指定是最近一个派发过的。旧版中枢不给时自己挑。
174
+ const shown = snapshot.task ?? (conv.taskId ? tasks.find((task) => task.id === conv.taskId) ?? null : latestBatchTask(tasks));
175
+ return focusBatchTask({ ...conv, job: { status: job.status, closed_reason: null, node_id: job.node_id, title: job.title }, turns: [] }, shown);
176
+ }
177
+
178
+ /** 一次性任务里的子任务状态变化。 */
179
+ function applyBatchTask(conv: ConvState, task: BatchStreamTask): ConvState {
180
+ if (conv.batchTask?.id === task.id) return focusBatchTask(conv, task);
181
+ // 对话式的对话:接着聊的新消息成了最新那条(流只发这个对话的)。
182
+ if (conv.taskId && task.conversation) return focusBatchTask(conv, task);
183
+ // 没指定子任务:续派开出了更新的子任务(已经派发到节点),跟过去。
184
+ if (!conv.taskId && task.remote_task_id && (!conv.batchTask || task.seq > conv.batchTask.seq)) return focusBatchTask(conv, task);
185
+ return conv;
186
+ }
187
+
188
+ // 缺字段时兜底:generation 缺省 1(按它分会话,缺了会把顺序排乱);时间缺就不显示。
189
+ export const toMsg = (m: Message): Msg => ({ id: m.id, role: m.role, content: m.content, generation: typeof m.generation === "number" ? m.generation : 1, ...(typeof m.created_at === "number" ? { ts: m.created_at } : {}) });
190
+
191
+ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
192
+ let next = conv;
193
+ for (const item of items) {
194
+ // 一次性任务:只认看的这个子任务所在的节点对话。
195
+ if (next.batch && (item.type === "transcript" || item.type === "node" || item.type === "transcript_unavailable") && item.remote_task_id && item.remote_task_id !== next.remoteTaskId) continue;
196
+ if (item.type === "transcript") {
197
+ // 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
198
+ const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
199
+ const merged = mergeSnapshot(next.msgs, messages, typeof item.older === "number" ? item.older : 0);
200
+ next = { ...next, msgs: merged.msgs, older: merged.older, hasTranscript: true, transcriptError: null, nodeRunning: item.running === true };
201
+ } else if (item.type === "transcript_unavailable") {
202
+ if (!next.hasTranscript) next = { ...next, transcriptError: typeof item.error === "string" && item.error ? item.error : "暂时读不到这段对话记录" };
203
+ } else if (item.type === "node") {
204
+ next = applyNodeEvent(next, item.event);
205
+ } else if (item.type === "turn") {
206
+ const turns = upsertTurns(next.turns, [item.turn]);
207
+ // 中枢只在节点上的回合真的停下后才把执行过的一轮收尾,所以一轮结束、又没有别的轮次在排队或执行时,
208
+ // 节点一定没在跑了——不只靠节点的 turn_end 事件(重连的空档里可能错过,界面就会一直显示「运行中」)。
209
+ const settled = TERMINAL_TURN.has(item.turn.status) && !turns.some((turn) => ACTIVE_TURN.has(turn.status));
210
+ next = { ...next, turns, resolvedModel: item.turn.resolved_model ?? next.resolvedModel, ...(settled ? { nodeRunning: false } : {}) };
211
+ } else if (item.type === "job") {
212
+ next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason ?? null, node_id: item.job.node_id } };
213
+ } else if (item.type === "task") {
214
+ next = next.batch ? applyBatchTask(next, item.task as BatchStreamTask) : { ...next, task: item.task as StreamTask };
215
+ }
216
+ }
217
+ return next;
218
+ }
219
+
220
+ function upsertMsg(msgs: Msg[], m: Msg): Msg[] {
221
+ const i = indexFromEnd(msgs, m.id);
222
+ if (i === -1) return [...msgs, m];
223
+ const next = msgs.slice();
224
+ next[i] = { ...msgs[i], ...m };
225
+ return next;
226
+ }
227
+
228
+ let synthetic = 0;
229
+ const localId = (prefix: string) => `${prefix}-${Date.now()}-${++synthetic}`;
230
+
231
+ /** 节点的一个回合事件。与 Operator 的 handleStreamEvent 同一套规则。 */
232
+ export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
233
+ const e = ev as Record<string, any>;
234
+ const gen = typeof e.generation === "number" ? e.generation : 1;
235
+ const set = (msgs: Msg[]) => ({ ...conv, msgs });
236
+ switch (ev.type) {
237
+ case "user":
238
+ return { ...set(upsertMsg(conv.msgs, { id: e.msgId, role: "user", content: e.content, generation: gen, ts: e.ts })), nodeRunning: true };
239
+ case "assistant":
240
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("a"), role: "assistant", content: e.content, generation: gen, ts: e.ts }));
241
+ case "tool": {
242
+ const data: ToolData = { title: e.title, detail: e.detail, peek: e.peek, diff: e.diff, subagent: e.subagent, subagentMessage: e.subagentMessage };
243
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("t"), role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id, ts: e.ts }));
244
+ }
245
+ case "tool_result": {
246
+ // 先按消息 id 找(快照加载的消息也能对上),找不到再按 tool_use id。结果多在末尾,从后往前找。
247
+ const arr = conv.msgs;
248
+ let i = arr.length - 1;
249
+ while (i >= 0 && (arr[i].role !== "tool" || (arr[i].id !== e.msgId && arr[i].toolId !== e.id))) i--;
250
+ if (i < 0) {
251
+ if (!e.msgId || (!e.subagent && !e.subagentMessage)) return conv;
252
+ const data: ToolData = { title: e.subagent?.title ?? e.subagentMessage?.tool?.title ?? "子代理消息", subagent: e.subagent, subagentMessage: e.subagentMessage };
253
+ return set(upsertMsg(arr, { id: e.msgId, role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id, ts: e.ts }));
254
+ }
255
+ try {
256
+ const d = JSON.parse(arr[i].content) as ToolData;
257
+ d.result = e.content;
258
+ d.isError = e.isError;
259
+ if (e.subagent) d.subagent = e.subagent;
260
+ if (e.subagentMessage) d.subagentMessage = e.subagentMessage;
261
+ if (e.peek) d.peek = e.peek;
262
+ const next = arr.slice();
263
+ next[i] = { ...arr[i], content: JSON.stringify(d) };
264
+ return set(next);
265
+ } catch {
266
+ return conv;
267
+ }
268
+ }
269
+ case "ask": {
270
+ const data: ToolData = { title: "有问题需要你回答", ask: { id: e.id, questions: e.questions } };
271
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? `ask-${e.id}`, role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id }));
272
+ }
273
+ case "ask_answered":
274
+ return set(conv.msgs.map((m) => {
275
+ if (m.role !== "tool") return m;
276
+ try {
277
+ const d = JSON.parse(m.content) as ToolData;
278
+ if (m.toolId !== e.id && d.ask?.id !== e.id) return m;
279
+ d.ask = { id: d.ask?.id ?? e.id, questions: d.ask?.questions ?? [], answers: e.answers };
280
+ return { ...m, content: JSON.stringify(d) };
281
+ } catch {
282
+ return m;
283
+ }
284
+ }));
285
+ case "input": {
286
+ const data: ToolData = { title: "需要你提供信息", input: e.input };
287
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? `in-${e.input?.id}`, role: "tool", content: JSON.stringify(data), generation: gen }));
288
+ }
289
+ case "notice":
290
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("n"), role: "system", content: e.content, generation: gen }));
291
+ case "error":
292
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("e"), role: "system", content: e.content, generation: gen }));
293
+ case "model":
294
+ return { ...conv, resolvedModel: e.model };
295
+ case "usage": {
296
+ const u = e.usage as TurnUsage;
297
+ const fresh = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_tokens ?? 0);
298
+ return { ...conv, liveUsage: { fresh: conv.liveUsage.fresh + fresh, cacheRead: conv.liveUsage.cacheRead + (u.cache_read_tokens ?? 0) } };
299
+ }
300
+ case "turn_end":
301
+ return { ...conv, nodeRunning: false };
302
+ default:
303
+ // queued / dequeued 是 Operator 自己的排队;对话任务的排队在中枢(轮次),这里不用。
304
+ return conv;
305
+ }
306
+ }
307
+
308
+ /** 往上翻取到的一页更早的节点消息(旧的在前)。older:再往前还剩几条。 */
309
+ export function applyOlderPage(conv: ConvState, page: Message[], older: number): ConvState {
310
+ return { ...conv, msgs: prependOlder(conv.msgs, page.map(toMsg)), older: Math.max(0, older) };
311
+ }
312
+
313
+ /** 这个对话结束了:整个对话任务结束,或者只是这一个对话被关闭。 */
314
+ export const convEnded = (conv: Pick<ConvState, "job" | "task">) => !!conv.task?.closed || (!!conv.job && TERMINAL_JOB.has(conv.job.status));
315
+
316
+ /** 正在执行的那一轮(派发中或执行中)。 */
317
+ export const runningTurn = (conv: ConvState) => conv.turns.find((turn) => turn.status === "running" || turn.status === "dispatching") ?? null;
318
+
319
+ export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv) || (conv.batch && ACTIVE_TASK.has(conv.batchTask?.status ?? ""));
320
+
321
+ /**
322
+ * 界面上的转录:
323
+ * 1. 节点转录(首轮的固定启动语句换成真正的第一条消息);没有转录时用轮次兜底;
324
+ * 2. 没送到节点就失败 / 取消的轮次,补一行说明(否则用户只看到消息石沉大海);
325
+ * 3. 还在中枢排队的消息,作为「排队中」气泡(可撤回),由界面钉在最下面。
326
+ */
327
+ export function viewMessages(conv: ConvState): Msg[] {
328
+ if (conv.batch) return batchViewMessages(conv);
329
+ let out: Msg[] = [];
330
+ // 有节点消息就以节点为准:通常先收到全量转录,但新对话可能先来增量事件(user / assistant / tool),也算。
331
+ if (conv.msgs.length) {
332
+ // 节点转录只有当前这个 Operator 会话:它的开场是固定的启动语句,正文其实是开出这个会话的那一轮——
333
+ // 这个对话的第一轮,或者节点会话重建过(context_reset)时最近重建的那一轮。
334
+ // 轮次号在整个对话任务里统一编号:对话任务里后开的对话,第一轮不是 1,取这个对话最早的那一轮。
335
+ const opener = [...conv.turns].reverse().find((turn) => turn.context_reset && turn.started_at) ?? conv.turns[0];
336
+ // 认开场语句:Operator 一个会话的第一条用户消息总是固定的启动语句(忽略请求正文),所以按位置认——
337
+ // 转录里的第一条用户消息、内容又不是开出会话那一轮的原文,就是它;措辞以后变了也认得出。
338
+ // 之后的消息里再出现这句固定语句(例如会话被重启),按原文匹配。
339
+ const openerText = opener ? opener.sent_input ?? opener.input : null;
340
+ // 更早的消息还没取(older > 0)时,手里的第一条用户消息不是会话的开场:不按位置认。
341
+ let replaced = conv.older > 0;
342
+ for (const m of conv.msgs) {
343
+ const firstUser = !replaced && m.role === "user";
344
+ if (firstUser) replaced = true;
345
+ if (openerText && m.role === "user" && m.content.trim() !== openerText.trim()
346
+ && ((firstUser && m.id === conv.msgs.find((x) => x.role === "user")?.id) || m.content.trim() === OPERATOR_INITIAL_PROMPT)) {
347
+ out.push({ ...m, content: openerText });
348
+ } else out.push(m);
349
+ }
350
+ } else {
351
+ for (const turn of conv.turns) {
352
+ if (turn.status === "queued" || turn.status === "merged") continue;
353
+ if (turn.status !== "running" && !turn.started_at) continue;
354
+ const ts = new Date(turn.started_at ?? turn.created_at).getTime();
355
+ out.push({ id: `turn-in-${turn.turn_id}`, role: "user", content: turn.sent_input ?? turn.input, generation: 1, ts });
356
+ if (turn.reply) out.push({ id: `turn-out-${turn.turn_id}`, role: "assistant", content: turn.reply, generation: 1, ts: new Date(turn.ended_at ?? turn.created_at).getTime() });
357
+ }
358
+ }
359
+ out = settleAsks(out, conv.localAnswers);
360
+ const generation = out.length ? out[out.length - 1].generation : 1;
361
+ // 中枢判定的结果,节点转录里看不到,补一行说明,按时间插到对应位置:
362
+ // · 没送到节点就失败 / 被撤回的消息(否则用户只看到消息石沉大海);
363
+ // · 执行了但被中枢判为失败的(超时中止、读不到执行记录、节点会话丢失、没有输出)。
364
+ // 用户自己按停止取消的不补:Operator 里停止也不写提示。
365
+ const notices: Msg[] = [];
366
+ for (const turn of conv.turns) {
367
+ const ts = new Date(turn.ended_at ?? turn.created_at).getTime();
368
+ const reason = turn.last_error ? `:${describeTurnError(turn.last_error)}` : "";
369
+ if ((turn.status === "failed" || turn.status === "cancelled") && !turn.started_at) {
370
+ // 用户自己撤回的不用再写原因(「已撤回:已取消」是同一件事);中枢取消的(例如对话结束时还在排队)写明原因。
371
+ const byCaller = turn.status === "cancelled" && /cancelled by caller/i.test(turn.last_error ?? "");
372
+ notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 第 ${turn.seq} 条消息${turn.status === "cancelled" ? "已撤回" : "没有执行"}${byCaller ? "" : reason}`, generation, ts });
373
+ } else if (turn.status === "failed") {
374
+ notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 这一轮没有完成${reason}`, generation, ts });
375
+ }
376
+ }
377
+ out = insertByTime(out, notices);
378
+ const queuedHeads = new Set(conv.turns.filter((turn) => turn.status === "queued").map((turn) => turn.turn_id));
379
+ for (const turn of conv.turns) {
380
+ const waiting = turn.status === "queued" || (turn.status === "merged" && turn.merged_into_turn_id && queuedHeads.has(turn.merged_into_turn_id));
381
+ if (waiting) {
382
+ // 排在最前面、而且中枢记了等待原因的(节点连不上、没有可用节点、节点上还有回合没结束),把原因显示出来。
383
+ const note = turn.status === "queued" && turn.last_error ? describeTurnError(turn.last_error) : undefined;
384
+ out.push({ id: `turn-q-${turn.turn_id}`, role: "queued", content: turn.input, generation, ts: new Date(turn.created_at).getTime(), turnId: turn.turn_id, ...(note ? { note } : {}) });
385
+ }
386
+ }
387
+ return out;
388
+ }
389
+
390
+ // 中枢派发一次性任务时加在正文前的标记行,和完成后的自动核对(见 center scheduler.ts)。
391
+ const CENTER_MARKER_LINES = /^(?:\[CENTER-(?:ATTEMPT|TERMINAL-REVISION):[^\]\n]*\][ \t]*(?:\r?\n|$))+/;
392
+ const CONFIRM_MARK = "[CENTER-CONFIRM]";
393
+ const BATCH_TASK_ERROR: Record<string, string> = { failed: "子任务失败", cancelled: "子任务已取消" };
394
+ const CHAT_TASK_ERROR: Record<string, string> = { failed: "这一轮没有完成", cancelled: "这条消息已取消" };
395
+
396
+ /**
397
+ * 一次性任务(含续派)的转录,只读:
398
+ * 1. 第一条用户消息是 Operator 固定的启动语句,换成开出这个节点对话的子任务原文(流里给了才换);
399
+ * 2. 去掉中枢加在正文前的标记行([CENTER-ATTEMPT:…]、[CENTER-TERMINAL-REVISION:…]);
400
+ * 3. 中枢的完成核对([CENTER-CONFIRM] 追问 + Agent 回的 TASK_COMPLETE / TASK_INCOMPLETE)合成一行说明;
401
+ * 4. 还没派发、拿不到执行记录、失败 / 取消时补一行说明。
402
+ */
403
+ function batchViewMessages(conv: ConvState): Msg[] {
404
+ const out: Msg[] = [];
405
+ const task = conv.batchTask;
406
+ const opener = task?.opener_prompt ?? null;
407
+ // 更早的消息还没取时,手里的第一条用户消息不是开场语句。
408
+ const firstUserId = conv.older > 0 ? undefined : conv.msgs.find((m) => m.role === "user")?.id;
409
+ for (let i = 0; i < conv.msgs.length; i++) {
410
+ const m = conv.msgs[i];
411
+ if (m.role === "user" && m.content.includes(CONFIRM_MARK)) {
412
+ const reply = conv.msgs[i + 1]?.role === "assistant" ? conv.msgs[++i] : null;
413
+ const content = !reply ? "中枢正在核对任务是否完成…" : /^\s*TASK_COMPLETE\b/.test(reply.content) ? "✓ 中枢核对:任务已完成" : "⚠ 中枢核对:任务没有完成";
414
+ out.push({ id: `confirm-${m.id}`, role: "system", content, generation: m.generation, ...(m.ts !== undefined ? { ts: m.ts } : {}) });
415
+ continue;
416
+ }
417
+ if (m.role !== "user") { out.push(m); continue; }
418
+ const content = m.id === firstUserId && opener ? opener : m.content.replace(CENTER_MARKER_LINES, "");
419
+ out.push(content === m.content ? m : { ...m, content });
420
+ }
421
+ const generation = out.length ? out[out.length - 1].generation : 1;
422
+ const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
423
+ for (const q of task?.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
424
+ if (task?.conversation) {
425
+ // 对话式:像对话一样显示——还没派发的消息是「排队中」气泡,没有「子任务」字样。
426
+ if (CHAT_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${CHAT_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
427
+ return out;
428
+ }
429
+ if (!task) {
430
+ if (conv.job) note("none", "这个任务里还没有子任务");
431
+ } else if (!conv.msgs.length && !task.remote_task_id && !TERMINAL_TASK.has(task.status)) {
432
+ note("waiting", "子任务还没派发到执行节点,派发后这里会实时显示执行过程");
433
+ } else if (!conv.msgs.length && conv.stream === "ended") {
434
+ note("empty", "没有拿到这个子任务的执行记录(执行节点上的会话可能已经清理)");
435
+ }
436
+ if (task && BATCH_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${BATCH_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
437
+ return out;
438
+ }
439
+
440
+ /** 把说明行按时间插进转录:放在时间不晚于它的最后一条消息之后(没有时间的消息沿用前一条的时间)。 */
441
+ function insertByTime(msgs: Msg[], notices: Msg[]): Msg[] {
442
+ if (!notices.length) return msgs;
443
+ const out = msgs.slice();
444
+ for (const notice of [...notices].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0))) {
445
+ let at = out.length;
446
+ let lastTs = -Infinity;
447
+ for (let i = 0; i < out.length; i++) {
448
+ lastTs = out[i].ts ?? lastTs;
449
+ if (lastTs > (notice.ts ?? Infinity)) { at = i; break; }
450
+ }
451
+ out.splice(at, 0, notice);
452
+ }
453
+ return out;
454
+ }
455
+
456
+ /** 中枢的等待 / 失败原因(英文内部描述)翻成中文。 */
457
+ export function describeTurnError(raw: string): string {
458
+ const text = raw.trim();
459
+ const rules: Array<[RegExp, string]> = [
460
+ [/no healthy node available/i, "当前产品线没有可用的执行节点,等待超时"],
461
+ [/produced no output/i, "智能体没有产生任何输出,会话可能没能启动"],
462
+ [/transcript could not be read/i, "这一轮结束了,但一直读不到它的执行记录"],
463
+ [/remote task confirmed missing|remote task.*missing/i, "执行节点上的会话已丢失,下一条消息会开一个新会话"],
464
+ [/still running .* after abort/i, "已请求中止,但节点上的回合长时间没有停下"],
465
+ [/node unreachable/i, "执行节点暂时连不上,恢复后会继续"],
466
+ [/turn cancelled|cancel requested|cancelled by caller/i, "已取消"],
467
+ // 超时的原文也带 "aborted",要先于下面那条匹配。
468
+ [/timed? ?out|timeout/i, "执行超时,已中止"],
469
+ [/aborted|abort requested/i, "已中止"],
470
+ [/already running on the node/i, "节点上还有一轮没结束,等它结束后发送"],
471
+ [/center restarted/i, "中枢重启过,这条消息会重新发送"],
472
+ [/conversation (closed|expired)/i, "对话已结束"],
473
+ ];
474
+ for (const [re, label] of rules) if (re.test(text)) return label;
475
+ return text;
476
+ }
477
+
478
+ // ── 提问卡片(AskUserQuestion) ────────────────────────
479
+ //
480
+ // Operator 里 Agent 提问时回合停在提问上,界面把回答 POST 给节点的 /answer。对话任务里中枢一看到
481
+ // 提问(awaiting_input)就结束这一轮(asked=true),节点上的提问随之作废,回答走「下一条消息」——
482
+ // 这正是 Operator 自己在「没有在等的回合」(resolved=false)时的做法:formatAnswersText 组成回复发出,
483
+ // 卡片立刻显示「你的回答」。节点不会再回 ask_answered,所以卡片的已答状态由这里推出来:
484
+ // · 本页刚提交的:localAnswers;
485
+ // · 刷新 / 别处打开的:提问之后的第一条用户消息如果是回答格式,就按它解析回答案。
486
+
487
+ /** formatAnswersText 的反解:不是这几个问题的回答返回 null。 */
488
+ export function parseAnswersText(text: string, questions: AskQuestion[]): AskAnswers | null {
489
+ const body = text.trim();
490
+ if (!body.startsWith("回答你的问题:")) return null;
491
+ const lines = body.split("\n").slice(1).map((line) => line.trim()).filter(Boolean);
492
+ if (lines.length !== questions.length) return null;
493
+ const answers: AskAnswers = [];
494
+ for (let i = 0; i < questions.length; i++) {
495
+ const prefix = `- ${questions[i].header || questions[i].question}:`;
496
+ if (!lines[i].startsWith(prefix)) return null;
497
+ const picked = lines[i].slice(prefix.length);
498
+ answers.push(picked === "(未选择)" ? [] : picked.split("、"));
499
+ }
500
+ // 再拼一遍要完全一致,免得把普通消息误认成回答。
501
+ return formatAnswersText(questions, answers).trim() === body ? answers : null;
502
+ }
503
+
504
+ const settledCache = new WeakMap<Msg, { key: string; msg: Msg }>();
505
+
506
+ function settleAsks(msgs: Msg[], local: Record<string, AskAnswers>): Msg[] {
507
+ let changed = false;
508
+ const out = msgs.map((m, i) => {
509
+ if (m.role !== "tool") return m;
510
+ const data = toolDataOf(m);
511
+ if (!data.ask || data.ask.answers) return m;
512
+ let answers: AskAnswers | undefined = local[data.ask.id] ?? local[m.toolId ?? ""];
513
+ if (!answers) {
514
+ const next = msgs.slice(i + 1).find((x) => x.role === "user");
515
+ answers = (next && parseAnswersText(next.content, data.ask.questions)) || undefined;
516
+ }
517
+ if (!answers) return m;
518
+ // 同一条消息、同一份答案复用同一个对象:消息行是 memo 的,不要每次都重渲染。
519
+ const key = JSON.stringify(answers);
520
+ const hit = settledCache.get(m);
521
+ if (hit?.key === key) { changed = true; return hit.msg; }
522
+ const msg = { ...m, content: JSON.stringify({ ...data, ask: { ...data.ask, answers } }) };
523
+ settledCache.set(m, { key, msg });
524
+ changed = true;
525
+ return msg;
526
+ });
527
+ return changed ? out : msgs;
528
+ }
529
+
530
+ // ── 回调体 ───────────────────────────────────────────
531
+
532
+ /** 一轮结束 → 与中枢 conversation_turn 回调同样字段的载荷。 */
533
+ export function turnCallback(conv: ConvState, turn: CenterTurn, continuedFrom: string | null): ConversationTurnCallback {
534
+ const group = [turn, ...conv.turns.filter((t) => t.status === "merged" && t.merged_into_turn_id === turn.turn_id)].sort((a, b) => a.seq - b.seq);
535
+ return {
536
+ type: "conversation_turn",
537
+ callback_seq: turn.seq,
538
+ job_id: turn.job_id,
539
+ task_id: turn.task_id,
540
+ turn_id: turn.turn_id,
541
+ seq: turn.seq,
542
+ business_id: turn.business_id,
543
+ status: turn.status,
544
+ occurred_at: turn.ended_at ?? new Date().toISOString(),
545
+ input: turn.sent_input ?? turn.input,
546
+ messages: group.map((t) => ({ turn_id: t.turn_id, seq: t.seq, business_id: t.business_id, input: t.input })),
547
+ reply: turn.reply,
548
+ asked: turn.asked,
549
+ message: turn.last_error,
550
+ continued_from_job_id: continuedFrom,
551
+ context_reset: turn.context_reset,
552
+ merged: !!turn.merged,
553
+ merge_error: turn.merge_error ?? null,
554
+ ...(turn.saved !== undefined ? { saved: turn.saved, saved_at: turn.saved_at ?? null, save_error: turn.save_error ?? null } : {}),
555
+ agent: turn.agent,
556
+ resolved_model: turn.resolved_model,
557
+ total_tokens: turn.total_tokens, input_tokens: turn.input_tokens, output_tokens: turn.output_tokens,
558
+ cache_read_tokens: turn.cache_read_tokens, cache_creation_tokens: turn.cache_creation_tokens,
559
+ started_at: turn.started_at,
560
+ ended_at: turn.ended_at,
561
+ };
562
+ }
563
+
564
+ /** 对话结束 → 与中枢 conversation_closed 回调同样字段的载荷。 */
565
+ export function closedCallback(conv: ConvState): ConversationClosedCallback {
566
+ // 与 center 一致:turns = 最大的 seq(被合并的消息也算),callback_seq = turns + 1。
567
+ const turns = conv.turns.reduce((max, t) => Math.max(max, t.seq), 0);
568
+ return {
569
+ type: "conversation_closed",
570
+ mode: "conversation",
571
+ callback_seq: turns + 1,
572
+ job_id: conv.jobId,
573
+ // 只关掉了这一个对话(对话任务还开着):带上是哪个,原因写 conversation_closed。
574
+ ...(conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? { task_id: conv.task.id } : {}),
575
+ status: conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? "succeeded" : conv.job?.status ?? "succeeded",
576
+ closed_reason: conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? "conversation_closed" : conv.job?.closed_reason ?? null,
577
+ turns,
578
+ continued_by_job_id: conv.continuedBy,
579
+ occurred_at: new Date().toISOString(),
580
+ };
581
+ }