@spzxg/cli-ui 0.0.0-stage → 0.3.0

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.
@@ -0,0 +1,439 @@
1
+ /**
2
+ * 一个对话的前端状态,以及把 center 实时流的事件应用上去的纯函数(无 React,可单测)。
3
+ *
4
+ * 数据来自两层:
5
+ * · 中枢轮次(turn):每条消息一轮,排队 → 执行中 → 成功 / 失败 / 取消(或被合并进别的轮次)。
6
+ * · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
7
+ * 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
8
+ */
9
+ import type { AskAnswers, AskQuestion, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, JobStatus, Message, Msg, NodeEvent, StreamItem, StreamSnapshot, StreamTask, ToolData, TurnUsage } from "./types.js";
10
+ import { indexFromEnd, mergeSnapshot, toolDataOf } from "../ui/transcriptModel.js";
11
+ import { formatAnswersText } from "../ui/format.js";
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
+ // ── 对话键 ─────────────────────────────────────────────
18
+ //
19
+ // 一个对话任务(job)里可以有几个对话(几个子任务,各自一个 Agent 会话,共用节点和工作区)。
20
+ // 组件里「一个对话」用对话键标识:
21
+ // · "job_x" 对话任务的第一个对话(只有一个对话时就是它,和以前一样);
22
+ // · "job_x#task_y" 对话任务里的某个对话;
23
+ // · "job_x#new" 在这个对话任务里新开一个对话(还没发第一条消息)。
24
+
25
+ /** 「在这个对话任务里新开一个对话」的子任务占位。 */
26
+ export const NEW_TASK = "new";
27
+
28
+ export function convKey(jobId: string, taskId?: string | null): string {
29
+ return taskId ? `${jobId}#${taskId}` : jobId;
30
+ }
31
+
32
+ export function parseConvKey(key: string): { jobId: string; taskId: string | null } {
33
+ const at = key.indexOf("#");
34
+ return at < 0 ? { jobId: key, taskId: null } : { jobId: key.slice(0, at), taskId: key.slice(at + 1) || null };
35
+ }
36
+
37
+ /** 对话键里的真实子任务(「新开」的占位不算)。 */
38
+ export function taskOfKey(key: string): string | null {
39
+ const { taskId } = parseConvKey(key);
40
+ return taskId && taskId !== NEW_TASK ? taskId : null;
41
+ }
42
+
43
+ export const isNewInJob = (key: string | null | undefined) => !!key && parseConvKey(key).taskId === NEW_TASK;
44
+ /** Operator 首轮固定的启动语句:正文其实在任务描述里(= 中枢第 1 轮的 input)。 */
45
+ export const OPERATOR_INITIAL_PROMPT = "Start working on the task described in the task context.";
46
+
47
+ export interface ConvJob {
48
+ status: JobStatus;
49
+ closed_reason: string | null;
50
+ node_id: string | null;
51
+ title?: string;
52
+ }
53
+
54
+ export interface ConvState {
55
+ /** 对话键(见 convKey)。 */
56
+ key: string;
57
+ jobId: string;
58
+ /** 对话键里指定的子任务;只有 job_id 时为 null(看第一个对话)。 */
59
+ taskId: string | null;
60
+ job: ConvJob | null;
61
+ /** 实时流看的这个对话(子任务)的状态;关闭了 closed=true。旧版 center 没有。 */
62
+ task: StreamTask | null;
63
+ /** 中枢轮次,按 seq 升序。 */
64
+ turns: CenterTurn[];
65
+ /** 节点转录(Operator 消息)。 */
66
+ msgs: Msg[];
67
+ hasTranscript: boolean;
68
+ nodeRunning: boolean;
69
+ resolvedModel: string | null;
70
+ /** 上次刷新任务详情之后,实时流里累计的用量(usage 事件)。 */
71
+ liveUsage: { fresh: number; cacheRead: number };
72
+ stream: "idle" | "connecting" | "live" | "reconnecting" | "ended" | "error";
73
+ streamError: string | null;
74
+ endReason: string | null;
75
+ /** 接续这个对话的新对话的键(对话任务结束后再发消息开出来的)。 */
76
+ continuedBy: string | null;
77
+ /** 本页提交过的提问回答(askId → 答案)。节点上那一轮已被中枢结束,不会回 ask_answered,只能本地记。 */
78
+ localAnswers: Record<string, AskAnswers>;
79
+ }
80
+
81
+ export const emptyConv = (key: string): ConvState => ({
82
+ key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, nodeRunning: false, resolvedModel: null,
83
+ liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
84
+ });
85
+
86
+ export function upsertTurns(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
87
+ if (!incoming.length) return turns;
88
+ const byId = new Map(turns.map((turn) => [turn.turn_id, turn]));
89
+ for (const turn of incoming) byId.set(turn.turn_id, { ...byId.get(turn.turn_id), ...turn });
90
+ return [...byId.values()].sort((a, b) => a.seq - b.seq);
91
+ }
92
+
93
+ /**
94
+ * 只补上还不认识的轮次(发消息 / 撤回的接口响应用)。接口响应是请求那一刻的快照,可能比实时流晚到:
95
+ * 实时流已经报了「执行中 / 已结束」,再拿响应里的「排队中」去盖,这一轮就会一直显示排队(之后也不会再有更新)。
96
+ * 已有的轮次只认实时流——状态可能合法地往回走(节点忙时中枢会把派发中的轮次放回队列),不能按状态大小判断新旧。
97
+ */
98
+ export function addTurnsIfAbsent(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
99
+ const known = new Set(turns.map((turn) => turn.turn_id));
100
+ const fresh = incoming.filter((turn) => !known.has(turn.turn_id));
101
+ return fresh.length ? upsertTurns(turns, fresh) : turns;
102
+ }
103
+
104
+ export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot): ConvState {
105
+ const { job } = snapshot;
106
+ return {
107
+ ...conv,
108
+ job: { status: job.status, closed_reason: job.closed_reason, node_id: job.node_id, title: job.title },
109
+ task: snapshot.task ?? conv.task,
110
+ turns: upsertTurns([], snapshot.turns),
111
+ resolvedModel: conv.resolvedModel ?? job.conversation?.last_turn?.resolved_model ?? null,
112
+ };
113
+ }
114
+
115
+ // 缺字段时兜底:generation 缺省 1(按它分会话,缺了会把顺序排乱);时间缺就不显示。
116
+ 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 } : {}) });
117
+
118
+ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
119
+ let next = conv;
120
+ for (const item of items) {
121
+ if (item.type === "transcript") {
122
+ // 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
123
+ const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
124
+ next = { ...next, msgs: mergeSnapshot(next.msgs, messages), hasTranscript: true, nodeRunning: item.running === true };
125
+ } else if (item.type === "node") {
126
+ next = applyNodeEvent(next, item.event);
127
+ } else if (item.type === "turn") {
128
+ const turns = upsertTurns(next.turns, [item.turn]);
129
+ // 中枢只在节点上的回合真的停下后才把执行过的一轮收尾,所以一轮结束、又没有别的轮次在排队或执行时,
130
+ // 节点一定没在跑了——不只靠节点的 turn_end 事件(重连的空档里可能错过,界面就会一直显示「运行中」)。
131
+ const settled = TERMINAL_TURN.has(item.turn.status) && !turns.some((turn) => ACTIVE_TURN.has(turn.status));
132
+ next = { ...next, turns, resolvedModel: item.turn.resolved_model ?? next.resolvedModel, ...(settled ? { nodeRunning: false } : {}) };
133
+ } else if (item.type === "job") {
134
+ next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason, node_id: item.job.node_id } };
135
+ } else if (item.type === "task") {
136
+ next = { ...next, task: item.task };
137
+ }
138
+ }
139
+ return next;
140
+ }
141
+
142
+ function upsertMsg(msgs: Msg[], m: Msg): Msg[] {
143
+ const i = indexFromEnd(msgs, m.id);
144
+ if (i === -1) return [...msgs, m];
145
+ const next = msgs.slice();
146
+ next[i] = { ...msgs[i], ...m };
147
+ return next;
148
+ }
149
+
150
+ let synthetic = 0;
151
+ const localId = (prefix: string) => `${prefix}-${Date.now()}-${++synthetic}`;
152
+
153
+ /** 节点的一个回合事件。与 Operator 的 handleStreamEvent 同一套规则。 */
154
+ export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
155
+ const e = ev as Record<string, any>;
156
+ const gen = typeof e.generation === "number" ? e.generation : 1;
157
+ const set = (msgs: Msg[]) => ({ ...conv, msgs });
158
+ switch (ev.type) {
159
+ case "user":
160
+ return { ...set(upsertMsg(conv.msgs, { id: e.msgId, role: "user", content: e.content, generation: gen, ts: e.ts })), nodeRunning: true };
161
+ case "assistant":
162
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("a"), role: "assistant", content: e.content, generation: gen, ts: e.ts }));
163
+ case "tool": {
164
+ const data: ToolData = { title: e.title, detail: e.detail, peek: e.peek, diff: e.diff };
165
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("t"), role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id }));
166
+ }
167
+ case "tool_result": {
168
+ // 先按消息 id 找(快照加载的消息也能对上),找不到再按 tool_use id。结果多在末尾,从后往前找。
169
+ const arr = conv.msgs;
170
+ let i = arr.length - 1;
171
+ while (i >= 0 && (arr[i].role !== "tool" || (arr[i].id !== e.msgId && arr[i].toolId !== e.id))) i--;
172
+ if (i < 0) return conv;
173
+ try {
174
+ const d = JSON.parse(arr[i].content) as ToolData;
175
+ d.result = e.content;
176
+ d.isError = e.isError;
177
+ if (e.peek) d.peek = e.peek;
178
+ const next = arr.slice();
179
+ next[i] = { ...arr[i], content: JSON.stringify(d) };
180
+ return set(next);
181
+ } catch {
182
+ return conv;
183
+ }
184
+ }
185
+ case "ask": {
186
+ const data: ToolData = { title: "有问题需要你回答", ask: { id: e.id, questions: e.questions } };
187
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? `ask-${e.id}`, role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id }));
188
+ }
189
+ case "ask_answered":
190
+ return set(conv.msgs.map((m) => {
191
+ if (m.role !== "tool") return m;
192
+ try {
193
+ const d = JSON.parse(m.content) as ToolData;
194
+ if (m.toolId !== e.id && d.ask?.id !== e.id) return m;
195
+ d.ask = { id: d.ask?.id ?? e.id, questions: d.ask?.questions ?? [], answers: e.answers };
196
+ return { ...m, content: JSON.stringify(d) };
197
+ } catch {
198
+ return m;
199
+ }
200
+ }));
201
+ case "input": {
202
+ const data: ToolData = { title: "需要你提供信息", input: e.input };
203
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? `in-${e.input?.id}`, role: "tool", content: JSON.stringify(data), generation: gen }));
204
+ }
205
+ case "notice":
206
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("n"), role: "system", content: e.content, generation: gen }));
207
+ case "error":
208
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("e"), role: "system", content: e.content, generation: gen }));
209
+ case "model":
210
+ return { ...conv, resolvedModel: e.model };
211
+ case "usage": {
212
+ const u = e.usage as TurnUsage;
213
+ const fresh = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_tokens ?? 0);
214
+ return { ...conv, liveUsage: { fresh: conv.liveUsage.fresh + fresh, cacheRead: conv.liveUsage.cacheRead + (u.cache_read_tokens ?? 0) } };
215
+ }
216
+ case "turn_end":
217
+ return { ...conv, nodeRunning: false };
218
+ default:
219
+ // queued / dequeued 是 Operator 自己的排队;对话任务的排队在中枢(轮次),这里不用。
220
+ return conv;
221
+ }
222
+ }
223
+
224
+ /** 这个对话结束了:整个对话任务结束,或者只是这一个对话被关闭。 */
225
+ export const convEnded = (conv: Pick<ConvState, "job" | "task">) => !!conv.task?.closed || (!!conv.job && TERMINAL_JOB.has(conv.job.status));
226
+
227
+ /** 正在执行的那一轮(派发中或执行中)。 */
228
+ export const runningTurn = (conv: ConvState) => conv.turns.find((turn) => turn.status === "running" || turn.status === "dispatching") ?? null;
229
+
230
+ export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv);
231
+
232
+ /**
233
+ * 界面上的转录:
234
+ * 1. 节点转录(首轮的固定启动语句换成真正的第一条消息);没有转录时用轮次兜底;
235
+ * 2. 没送到节点就失败 / 取消的轮次,补一行说明(否则用户只看到消息石沉大海);
236
+ * 3. 还在中枢排队的消息,作为「排队中」气泡(可撤回),由界面钉在最下面。
237
+ */
238
+ export function viewMessages(conv: ConvState): Msg[] {
239
+ let out: Msg[] = [];
240
+ // 有节点消息就以节点为准:通常先收到全量转录,但新对话可能先来增量事件(user / assistant / tool),也算。
241
+ if (conv.msgs.length) {
242
+ // 节点转录只有当前这个 Operator 会话:它的开场是固定的启动语句,正文其实是开出这个会话的那一轮——
243
+ // 这个对话的第一轮,或者节点会话重建过(context_reset)时最近重建的那一轮。
244
+ // 轮次号在整个对话任务里统一编号:对话任务里后开的对话,第一轮不是 1,取这个对话最早的那一轮。
245
+ const opener = [...conv.turns].reverse().find((turn) => turn.context_reset && turn.started_at) ?? conv.turns[0];
246
+ // 认开场语句:Operator 一个会话的第一条用户消息总是固定的启动语句(忽略请求正文),所以按位置认——
247
+ // 转录里的第一条用户消息、内容又不是开出会话那一轮的原文,就是它;措辞以后变了也认得出。
248
+ // 之后的消息里再出现这句固定语句(例如会话被重启),按原文匹配。
249
+ const openerText = opener ? opener.sent_input ?? opener.input : null;
250
+ let replaced = false;
251
+ for (const m of conv.msgs) {
252
+ const firstUser = !replaced && m.role === "user";
253
+ if (firstUser) replaced = true;
254
+ if (openerText && m.role === "user" && m.content.trim() !== openerText.trim()
255
+ && ((firstUser && m.id === conv.msgs.find((x) => x.role === "user")?.id) || m.content.trim() === OPERATOR_INITIAL_PROMPT)) {
256
+ out.push({ ...m, content: openerText });
257
+ } else out.push(m);
258
+ }
259
+ } else {
260
+ for (const turn of conv.turns) {
261
+ if (turn.status === "queued" || turn.status === "merged") continue;
262
+ if (turn.status !== "running" && !turn.started_at) continue;
263
+ const ts = new Date(turn.started_at ?? turn.created_at).getTime();
264
+ out.push({ id: `turn-in-${turn.turn_id}`, role: "user", content: turn.sent_input ?? turn.input, generation: 1, ts });
265
+ 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() });
266
+ }
267
+ }
268
+ out = settleAsks(out, conv.localAnswers);
269
+ const generation = out.length ? out[out.length - 1].generation : 1;
270
+ // 中枢判定的结果,节点转录里看不到,补一行说明,按时间插到对应位置:
271
+ // · 没送到节点就失败 / 被撤回的消息(否则用户只看到消息石沉大海);
272
+ // · 执行了但被中枢判为失败的(超时中止、读不到执行记录、节点会话丢失、没有输出)。
273
+ // 用户自己按停止取消的不补:Operator 里停止也不写提示。
274
+ const notices: Msg[] = [];
275
+ for (const turn of conv.turns) {
276
+ const ts = new Date(turn.ended_at ?? turn.created_at).getTime();
277
+ const reason = turn.last_error ? `:${describeTurnError(turn.last_error)}` : "";
278
+ if ((turn.status === "failed" || turn.status === "cancelled") && !turn.started_at) {
279
+ // 用户自己撤回的不用再写原因(「已撤回:已取消」是同一件事);中枢取消的(例如对话结束时还在排队)写明原因。
280
+ const byCaller = turn.status === "cancelled" && /cancelled by caller/i.test(turn.last_error ?? "");
281
+ notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 第 ${turn.seq} 条消息${turn.status === "cancelled" ? "已撤回" : "没有执行"}${byCaller ? "" : reason}`, generation, ts });
282
+ } else if (turn.status === "failed") {
283
+ notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 这一轮没有完成${reason}`, generation, ts });
284
+ }
285
+ }
286
+ out = insertByTime(out, notices);
287
+ const queuedHeads = new Set(conv.turns.filter((turn) => turn.status === "queued").map((turn) => turn.turn_id));
288
+ for (const turn of conv.turns) {
289
+ const waiting = turn.status === "queued" || (turn.status === "merged" && turn.merged_into_turn_id && queuedHeads.has(turn.merged_into_turn_id));
290
+ if (waiting) {
291
+ // 排在最前面、而且中枢记了等待原因的(节点连不上、没有可用节点、节点上还有回合没结束),把原因显示出来。
292
+ const note = turn.status === "queued" && turn.last_error ? describeTurnError(turn.last_error) : undefined;
293
+ 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 } : {}) });
294
+ }
295
+ }
296
+ return out;
297
+ }
298
+
299
+ /** 把说明行按时间插进转录:放在时间不晚于它的最后一条消息之后(没有时间的消息沿用前一条的时间)。 */
300
+ function insertByTime(msgs: Msg[], notices: Msg[]): Msg[] {
301
+ if (!notices.length) return msgs;
302
+ const out = msgs.slice();
303
+ for (const notice of [...notices].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0))) {
304
+ let at = out.length;
305
+ let lastTs = -Infinity;
306
+ for (let i = 0; i < out.length; i++) {
307
+ lastTs = out[i].ts ?? lastTs;
308
+ if (lastTs > (notice.ts ?? Infinity)) { at = i; break; }
309
+ }
310
+ out.splice(at, 0, notice);
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /** 中枢的等待 / 失败原因(英文内部描述)翻成中文。 */
316
+ export function describeTurnError(raw: string): string {
317
+ const text = raw.trim();
318
+ const rules: Array<[RegExp, string]> = [
319
+ [/no healthy node available/i, "当前产品线没有可用的执行节点,等待超时"],
320
+ [/produced no output/i, "智能体没有产生任何输出,会话可能没能启动"],
321
+ [/transcript could not be read/i, "这一轮结束了,但一直读不到它的执行记录"],
322
+ [/remote task confirmed missing|remote task.*missing/i, "执行节点上的会话已丢失,下一条消息会开一个新会话"],
323
+ [/still running .* after abort/i, "已请求中止,但节点上的回合长时间没有停下"],
324
+ [/node unreachable/i, "执行节点暂时连不上,恢复后会继续"],
325
+ [/turn cancelled|cancel requested|cancelled by caller/i, "已取消"],
326
+ // 超时的原文也带 "aborted",要先于下面那条匹配。
327
+ [/timed? ?out|timeout/i, "执行超时,已中止"],
328
+ [/aborted|abort requested/i, "已中止"],
329
+ [/already running on the node/i, "节点上还有一轮没结束,等它结束后发送"],
330
+ [/center restarted/i, "中枢重启过,这条消息会重新发送"],
331
+ [/conversation (closed|expired)/i, "对话已结束"],
332
+ ];
333
+ for (const [re, label] of rules) if (re.test(text)) return label;
334
+ return text;
335
+ }
336
+
337
+ // ── 提问卡片(AskUserQuestion) ────────────────────────
338
+ //
339
+ // Operator 里 Agent 提问时回合停在提问上,界面把回答 POST 给节点的 /answer。对话任务里中枢一看到
340
+ // 提问(awaiting_input)就结束这一轮(asked=true),节点上的提问随之作废,回答走「下一条消息」——
341
+ // 这正是 Operator 自己在「没有在等的回合」(resolved=false)时的做法:formatAnswersText 组成回复发出,
342
+ // 卡片立刻显示「你的回答」。节点不会再回 ask_answered,所以卡片的已答状态由这里推出来:
343
+ // · 本页刚提交的:localAnswers;
344
+ // · 刷新 / 别处打开的:提问之后的第一条用户消息如果是回答格式,就按它解析回答案。
345
+
346
+ /** formatAnswersText 的反解:不是这几个问题的回答返回 null。 */
347
+ export function parseAnswersText(text: string, questions: AskQuestion[]): AskAnswers | null {
348
+ const body = text.trim();
349
+ if (!body.startsWith("回答你的问题:")) return null;
350
+ const lines = body.split("\n").slice(1).map((line) => line.trim()).filter(Boolean);
351
+ if (lines.length !== questions.length) return null;
352
+ const answers: AskAnswers = [];
353
+ for (let i = 0; i < questions.length; i++) {
354
+ const prefix = `- ${questions[i].header || questions[i].question}:`;
355
+ if (!lines[i].startsWith(prefix)) return null;
356
+ const picked = lines[i].slice(prefix.length);
357
+ answers.push(picked === "(未选择)" ? [] : picked.split("、"));
358
+ }
359
+ // 再拼一遍要完全一致,免得把普通消息误认成回答。
360
+ return formatAnswersText(questions, answers).trim() === body ? answers : null;
361
+ }
362
+
363
+ const settledCache = new WeakMap<Msg, { key: string; msg: Msg }>();
364
+
365
+ function settleAsks(msgs: Msg[], local: Record<string, AskAnswers>): Msg[] {
366
+ let changed = false;
367
+ const out = msgs.map((m, i) => {
368
+ if (m.role !== "tool") return m;
369
+ const data = toolDataOf(m);
370
+ if (!data.ask || data.ask.answers) return m;
371
+ let answers: AskAnswers | undefined = local[data.ask.id] ?? local[m.toolId ?? ""];
372
+ if (!answers) {
373
+ const next = msgs.slice(i + 1).find((x) => x.role === "user");
374
+ answers = (next && parseAnswersText(next.content, data.ask.questions)) || undefined;
375
+ }
376
+ if (!answers) return m;
377
+ // 同一条消息、同一份答案复用同一个对象:消息行是 memo 的,不要每次都重渲染。
378
+ const key = JSON.stringify(answers);
379
+ const hit = settledCache.get(m);
380
+ if (hit?.key === key) { changed = true; return hit.msg; }
381
+ const msg = { ...m, content: JSON.stringify({ ...data, ask: { ...data.ask, answers } }) };
382
+ settledCache.set(m, { key, msg });
383
+ changed = true;
384
+ return msg;
385
+ });
386
+ return changed ? out : msgs;
387
+ }
388
+
389
+ // ── 回调体 ───────────────────────────────────────────
390
+
391
+ /** 一轮结束 → 与中枢 conversation_turn 回调同样字段的载荷。 */
392
+ export function turnCallback(conv: ConvState, turn: CenterTurn, continuedFrom: string | null): ConversationTurnCallback {
393
+ 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);
394
+ return {
395
+ type: "conversation_turn",
396
+ callback_seq: turn.seq,
397
+ job_id: turn.job_id,
398
+ task_id: turn.task_id,
399
+ turn_id: turn.turn_id,
400
+ seq: turn.seq,
401
+ business_id: turn.business_id,
402
+ status: turn.status,
403
+ occurred_at: turn.ended_at ?? new Date().toISOString(),
404
+ input: turn.sent_input ?? turn.input,
405
+ messages: group.map((t) => ({ turn_id: t.turn_id, seq: t.seq, business_id: t.business_id, input: t.input })),
406
+ reply: turn.reply,
407
+ asked: turn.asked,
408
+ message: turn.last_error,
409
+ continued_from_job_id: continuedFrom,
410
+ context_reset: turn.context_reset,
411
+ merged: !!turn.merged,
412
+ merge_error: turn.merge_error ?? null,
413
+ agent: turn.agent,
414
+ resolved_model: turn.resolved_model,
415
+ total_tokens: turn.total_tokens, input_tokens: turn.input_tokens, output_tokens: turn.output_tokens,
416
+ cache_read_tokens: turn.cache_read_tokens, cache_creation_tokens: turn.cache_creation_tokens,
417
+ started_at: turn.started_at,
418
+ ended_at: turn.ended_at,
419
+ };
420
+ }
421
+
422
+ /** 对话结束 → 与中枢 conversation_closed 回调同样字段的载荷。 */
423
+ export function closedCallback(conv: ConvState): ConversationClosedCallback {
424
+ // 与 center 一致:turns = 最大的 seq(被合并的消息也算),callback_seq = turns + 1。
425
+ const turns = conv.turns.reduce((max, t) => Math.max(max, t.seq), 0);
426
+ return {
427
+ type: "conversation_closed",
428
+ mode: "conversation",
429
+ callback_seq: turns + 1,
430
+ job_id: conv.jobId,
431
+ // 只关掉了这一个对话(对话任务还开着):带上是哪个,原因写 conversation_closed。
432
+ ...(conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? { task_id: conv.task.id } : {}),
433
+ status: conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? "succeeded" : conv.job?.status ?? "succeeded",
434
+ closed_reason: conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? "conversation_closed" : conv.job?.closed_reason ?? null,
435
+ turns,
436
+ continued_by_job_id: conv.continuedBy,
437
+ occurred_at: new Date().toISOString(),
438
+ };
439
+ }
@@ -0,0 +1,103 @@
1
+ import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
2
+ import { createCliStore, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store.js";
3
+ import type { ConversationCallback, ConversationEndPayload, TurnEndPayload } from "./types.js";
4
+
5
+ /**
6
+ * 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库:
7
+ * 列表点哪个对话,对话组件就显示哪个;同一个对话只连一条实时流。
8
+ *
9
+ * <CliCenterProvider baseUrl="/api/center">
10
+ * <CliMsgList />
11
+ * <CliChat />
12
+ * </CliCenterProvider>
13
+ *
14
+ * 组件单独使用时不需要它:组件发现外面没有 Provider,会用自己的 props 建一个。
15
+ */
16
+ const StoreContext = createContext<CliStore | null>(null);
17
+
18
+ export interface CliCenterProviderProps extends CliStoreOptions, ResultHandlers {
19
+ /** 外部建好的仓库(例如 Web Component 按 group 共享的);给了就不再自己建。 */
20
+ store?: CliStore;
21
+ /** 监听仓库事件:选中、一轮结束、对话结束、job 变化、错误。 */
22
+ onEvent?: (event: CliEvent) => void;
23
+ children: ReactNode;
24
+ }
25
+
26
+ /**
27
+ * 结果通知:组件从实时流里看到一轮结束 / 对话结束时调用,全在浏览器里发生,**和 center 的 callback.url 无关**——
28
+ * 不配 callback 也照样会调。载荷字段与 center 回调体一致,方便业务后端和前端用同一套代码解析。
29
+ */
30
+ export interface ResultHandlers {
31
+ /** 一轮结束(成功 / 失败 / 取消)。 */
32
+ onTurnEnd?: (payload: TurnEndPayload) => void;
33
+ /** 对话结束(关闭 / 取消 / 过期……)。 */
34
+ onConversationEnd?: (payload: ConversationEndPayload) => void;
35
+ /** @deprecated 旧名字,等同于同时监听 onTurnEnd 和 onConversationEnd(payload.type 区分)。 */
36
+ onCallback?: (payload: ConversationCallback) => void;
37
+ }
38
+
39
+ export function dispatchResult(event: CliEvent, handlers: ResultHandlers) {
40
+ if (event.type === "conversation_turn") { handlers.onTurnEnd?.(event.payload); handlers.onCallback?.(event.payload); }
41
+ else if (event.type === "conversation_closed") { handlers.onConversationEnd?.(event.payload); handlers.onCallback?.(event.payload); }
42
+ }
43
+
44
+ export function CliCenterProvider({ store: external, onEvent, onTurnEnd, onConversationEnd, onCallback, children, ...options }: CliCenterProviderProps) {
45
+ // 连接参数只在建仓库时用一次:中途换 baseUrl / token 请换 key 重建。
46
+ const optionsRef = useRef(options);
47
+ const store = useMemo(() => external ?? createCliStore(optionsRef.current), [external]);
48
+ const onEventRef = useRef(onEvent);
49
+ onEventRef.current = onEvent;
50
+ const resultRef = useRef<ResultHandlers>({});
51
+ resultRef.current = { onTurnEnd, onConversationEnd, onCallback };
52
+ useEffect(() => store.on((event) => {
53
+ onEventRef.current?.(event);
54
+ dispatchResult(event, resultRef.current);
55
+ }), [store]);
56
+ return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
57
+ }
58
+
59
+ export function useCliStoreContext(): CliStore | null {
60
+ return useContext(StoreContext);
61
+ }
62
+
63
+ export function useCliStore(): CliStore {
64
+ const store = useContext(StoreContext);
65
+ if (!store) throw new Error("useCliStore 必须在 <CliCenterProvider> 内使用");
66
+ return store;
67
+ }
68
+
69
+ /** 订阅仓库状态的一部分。选择器返回的值按引用比较,状态是不可变的,所以返回子对象是安全的。 */
70
+ export function useCliState<T>(selector: (state: CliState) => T): T {
71
+ const store = useCliStore();
72
+ return useSyncExternalStore(store.subscribe, () => selector(store.getState()), () => selector(store.getState()));
73
+ }
74
+
75
+ /** 组件外面没有 Provider 时,用组件自己的 props 包一层。 */
76
+ export function withOwnProvider<P extends object>(props: P & CliStoreOptions & ResultHandlers & { onEvent?: (event: CliEvent) => void; store?: CliStore }, inner: ReactNode, hasContext: boolean) {
77
+ if (hasContext) return inner;
78
+ const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store } = props;
79
+ // 连接参数变了(换了中枢地址、令牌、产品线、命名空间)就换一个新仓库重连,不沿用旧连接。
80
+ const connection = [baseUrl ?? "", token ?? "", line ?? "", namespace ?? ""].join("\u0000");
81
+ return (
82
+ <CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store }}>
83
+ {inner}
84
+ </CliCenterProvider>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * 组件自己的 onEvent / onTurnEnd / onConversationEnd:组件放在外层 Provider 里时由这里订阅
90
+ * (自己建 Provider 时交给那个 Provider,免得调两次)。
91
+ */
92
+ export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefined, results: ResultHandlers, enabled: boolean) {
93
+ const store = useContext(StoreContext);
94
+ const ref = useRef({ onEvent, results });
95
+ ref.current = { onEvent, results };
96
+ useEffect(() => {
97
+ if (!enabled || !store) return;
98
+ return store.on((event) => {
99
+ ref.current.onEvent?.(event);
100
+ dispatchResult(event, ref.current.results);
101
+ });
102
+ }, [store, enabled]);
103
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 极简 SSE 解析器。用 fetch 读流而不是 EventSource:EventSource 不能带 Authorization 头,
3
+ * 而 center 的接口按产品线令牌鉴权(直连或经代理都可能需要带头)。
4
+ *
5
+ * 按 https://html.spec.whatwg.org/multipage/server-sent-events.html 的行规则:
6
+ * `event:` 设事件名,`data:` 累加数据,空行派发;以 `:` 开头的是注释(心跳 ping)。
7
+ */
8
+ export interface SseMessage { event: string; data: string }
9
+
10
+ export function createSseParser(onMessage: (message: SseMessage) => void) {
11
+ let buffer = "";
12
+ let event = "";
13
+ let data: string[] = [];
14
+
15
+ const dispatch = () => {
16
+ if (data.length) onMessage({ event: event || "message", data: data.join("\n") });
17
+ event = "";
18
+ data = [];
19
+ };
20
+
21
+ const line = (raw: string) => {
22
+ if (raw === "") return dispatch();
23
+ if (raw.startsWith(":")) return;
24
+ const colon = raw.indexOf(":");
25
+ const field = colon === -1 ? raw : raw.slice(0, colon);
26
+ let value = colon === -1 ? "" : raw.slice(colon + 1);
27
+ if (value.startsWith(" ")) value = value.slice(1);
28
+ if (field === "event") event = value;
29
+ else if (field === "data") data.push(value);
30
+ };
31
+
32
+ return {
33
+ push(chunk: string) {
34
+ buffer += chunk;
35
+ let index: number;
36
+ // 行尾可能是 \n、\r\n 或 \r。
37
+ while ((index = buffer.search(/\r\n|\r|\n/)) !== -1) {
38
+ const sep = buffer.startsWith("\r\n", index) ? 2 : 1;
39
+ // 只剩一个 \r 时等下一块:它可能是 \r\n 的前半。
40
+ if (buffer[index] === "\r" && sep === 1 && index === buffer.length - 1) break;
41
+ line(buffer.slice(0, index));
42
+ buffer = buffer.slice(index + sep);
43
+ }
44
+ },
45
+ end() {
46
+ if (buffer) line(buffer);
47
+ buffer = "";
48
+ dispatch();
49
+ },
50
+ };
51
+ }