@spzxg/cli-ui 0.1.0 → 0.3.1

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.
@@ -6,13 +6,44 @@
6
6
  * · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
7
7
  * 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
8
8
  */
9
- import type { AskAnswers, AskQuestion, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, JobStatus, Message, Msg, NodeEvent, StreamItem, StreamSnapshot, ToolData, TurnUsage } from "./types.js";
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.js";
10
10
  import { indexFromEnd, mergeSnapshot, toolDataOf } from "../ui/transcriptModel.js";
11
11
  import { formatAnswersText } from "../ui/format.js";
12
12
 
13
13
  export const TERMINAL_TURN = new Set(["succeeded", "failed", "cancelled", "merged"]);
14
14
  export const ACTIVE_TURN = new Set(["queued", "dispatching", "running"]);
15
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;
16
47
  /** Operator 首轮固定的启动语句:正文其实在任务描述里(= 中枢第 1 轮的 input)。 */
17
48
  export const OPERATOR_INITIAL_PROMPT = "Start working on the task described in the task context.";
18
49
 
@@ -24,8 +55,14 @@ export interface ConvJob {
24
55
  }
25
56
 
26
57
  export interface ConvState {
58
+ /** 对话键(见 convKey)。 */
59
+ key: string;
27
60
  jobId: string;
61
+ /** 对话键里指定的子任务;只有 job_id 时为 null(看第一个对话)。 */
62
+ taskId: string | null;
28
63
  job: ConvJob | null;
64
+ /** 实时流看的这个对话(子任务)的状态;关闭了 closed=true。旧版 center 没有。 */
65
+ task: StreamTask | null;
29
66
  /** 中枢轮次,按 seq 升序。 */
30
67
  turns: CenterTurn[];
31
68
  /** 节点转录(Operator 消息)。 */
@@ -38,14 +75,23 @@ export interface ConvState {
38
75
  stream: "idle" | "connecting" | "live" | "reconnecting" | "ended" | "error";
39
76
  streamError: string | null;
40
77
  endReason: string | null;
78
+ /** 接续这个对话的新对话的键(对话任务结束后再发消息开出来的)。 */
41
79
  continuedBy: string | null;
42
80
  /** 本页提交过的提问回答(askId → 答案)。节点上那一轮已被中枢结束,不会回 ask_answered,只能本地记。 */
43
81
  localAnswers: Record<string, AskAnswers>;
82
+ /**
83
+ * 一次性任务(含续派,不是对话任务):只读查看其中一个子任务。没有轮次,task 是那个子任务,
84
+ * batchTask 是它的原样(状态、错误、原文),remoteTaskId 是它在节点上的对话(流里只认这个对话的转录)。
85
+ */
86
+ batch: boolean;
87
+ batchTask: BatchStreamTask | null;
88
+ remoteTaskId: string | null;
44
89
  }
45
90
 
46
- export const emptyConv = (jobId: string): ConvState => ({
47
- jobId, job: null, turns: [], msgs: [], hasTranscript: false, nodeRunning: false, resolvedModel: null,
91
+ export const emptyConv = (key: string): ConvState => ({
92
+ key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, nodeRunning: false, resolvedModel: null,
48
93
  liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
94
+ batch: false, batchTask: null, remoteTaskId: null,
49
95
  });
50
96
 
51
97
  export function upsertTurns(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
@@ -55,21 +101,81 @@ export function upsertTurns(turns: CenterTurn[], incoming: CenterTurn[]): Center
55
101
  return [...byId.values()].sort((a, b) => a.seq - b.seq);
56
102
  }
57
103
 
58
- export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot): ConvState {
104
+ /**
105
+ * 只补上还不认识的轮次(发消息 / 撤回的接口响应用)。接口响应是请求那一刻的快照,可能比实时流晚到:
106
+ * 实时流已经报了「执行中 / 已结束」,再拿响应里的「排队中」去盖,这一轮就会一直显示排队(之后也不会再有更新)。
107
+ * 已有的轮次只认实时流——状态可能合法地往回走(节点忙时中枢会把派发中的轮次放回队列),不能按状态大小判断新旧。
108
+ */
109
+ export function addTurnsIfAbsent(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
110
+ const known = new Set(turns.map((turn) => turn.turn_id));
111
+ const fresh = incoming.filter((turn) => !known.has(turn.turn_id));
112
+ return fresh.length ? upsertTurns(turns, fresh) : turns;
113
+ }
114
+
115
+ export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot | BatchStreamSnapshot): ConvState {
116
+ if (isBatchSnapshot(snapshot)) return applyBatchSnapshot(conv, snapshot);
59
117
  const { job } = snapshot;
60
118
  return {
61
119
  ...conv,
62
120
  job: { status: job.status, closed_reason: job.closed_reason, node_id: job.node_id, title: job.title },
63
- turns: upsertTurns([], snapshot.turns),
121
+ task: snapshot.task ?? conv.task,
122
+ turns: upsertTurns([], Array.isArray(snapshot.turns) ? snapshot.turns : []),
64
123
  resolvedModel: conv.resolvedModel ?? job.conversation?.last_turn?.resolved_model ?? null,
65
124
  };
66
125
  }
67
126
 
68
- const toMsg = (m: Message): Msg => ({ id: m.id, role: m.role, content: m.content, generation: m.generation, ts: m.created_at });
127
+ // ── 一次性任务(含续派):只读查看其中一个子任务 ─────────
128
+ //
129
+ // 指定了子任务就看它;没指定看最近一个派发过的(续派开出新的子任务时跟过去)。
130
+ // 流里可能同时有几个节点对话的事件(带 remote_task_id),只认看的这个子任务所在的那个。
131
+
132
+ const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
133
+ id: task.id, seq: task.seq, title: task.title, status: task.status, closed: TERMINAL_TASK.has(task.status),
134
+ remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
135
+ });
136
+
137
+ /** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
138
+ export const latestBatchTask = (tasks: BatchStreamTask[]) =>
139
+ [...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);
140
+
141
+ /** 换成看另一个子任务(或同一个子任务换了节点对话,例如重试):节点转录清掉,等新对话的转录。 */
142
+ function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvState {
143
+ const remote = task?.remote_task_id ?? null;
144
+ const sameRemote = remote === conv.remoteTaskId;
145
+ // 状态变化事件不一定带原文(没指定子任务时):沿用已有的。
146
+ const kept = conv.batchTask && task && conv.batchTask.id === task.id ? { prompt: conv.batchTask.prompt, opener_prompt: conv.batchTask.opener_prompt } : {};
147
+ const batchTask = task ? { ...kept, ...task } : null;
148
+ return {
149
+ ...conv, batch: true, batchTask, task: batchTask ? batchStreamTask(batchTask) : null, remoteTaskId: remote,
150
+ ...(sameRemote ? {} : { msgs: [], hasTranscript: false, nodeRunning: false }),
151
+ };
152
+ }
153
+
154
+ function applyBatchSnapshot(conv: ConvState, snapshot: BatchStreamSnapshot): ConvState {
155
+ const { job } = snapshot;
156
+ const tasks = Array.isArray(snapshot.tasks) ? snapshot.tasks : [];
157
+ // 指定了子任务就看它;没指定看流给的那个(最近一个派发过的),旧版中枢不给时自己挑。
158
+ const wanted = conv.taskId ? tasks.find((task) => task.id === conv.taskId) ?? null : snapshot.task ?? latestBatchTask(tasks);
159
+ const shown = wanted && snapshot.task?.id === wanted.id ? { ...wanted, ...snapshot.task } : wanted;
160
+ return focusBatchTask({ ...conv, job: { status: job.status, closed_reason: null, node_id: job.node_id, title: job.title }, turns: [] }, shown);
161
+ }
162
+
163
+ /** 一次性任务里的子任务状态变化。 */
164
+ function applyBatchTask(conv: ConvState, task: BatchStreamTask): ConvState {
165
+ if (conv.batchTask?.id === task.id) return focusBatchTask(conv, task);
166
+ // 没指定子任务:续派开出了更新的子任务(已经派发到节点),跟过去。
167
+ if (!conv.taskId && task.remote_task_id && (!conv.batchTask || task.seq > conv.batchTask.seq)) return focusBatchTask(conv, task);
168
+ return conv;
169
+ }
170
+
171
+ // 缺字段时兜底:generation 缺省 1(按它分会话,缺了会把顺序排乱);时间缺就不显示。
172
+ 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 } : {}) });
69
173
 
70
174
  export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
71
175
  let next = conv;
72
176
  for (const item of items) {
177
+ // 一次性任务:只认看的这个子任务所在的节点对话。
178
+ if (next.batch && (item.type === "transcript" || item.type === "node") && item.remote_task_id && item.remote_task_id !== next.remoteTaskId) continue;
73
179
  if (item.type === "transcript") {
74
180
  // 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
75
181
  const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
@@ -77,9 +183,15 @@ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
77
183
  } else if (item.type === "node") {
78
184
  next = applyNodeEvent(next, item.event);
79
185
  } else if (item.type === "turn") {
80
- next = { ...next, turns: upsertTurns(next.turns, [item.turn]), resolvedModel: item.turn.resolved_model ?? next.resolvedModel };
186
+ const turns = upsertTurns(next.turns, [item.turn]);
187
+ // 中枢只在节点上的回合真的停下后才把执行过的一轮收尾,所以一轮结束、又没有别的轮次在排队或执行时,
188
+ // 节点一定没在跑了——不只靠节点的 turn_end 事件(重连的空档里可能错过,界面就会一直显示「运行中」)。
189
+ const settled = TERMINAL_TURN.has(item.turn.status) && !turns.some((turn) => ACTIVE_TURN.has(turn.status));
190
+ next = { ...next, turns, resolvedModel: item.turn.resolved_model ?? next.resolvedModel, ...(settled ? { nodeRunning: false } : {}) };
81
191
  } else if (item.type === "job") {
82
- next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason, node_id: item.job.node_id } };
192
+ next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason ?? null, node_id: item.job.node_id } };
193
+ } else if (item.type === "task") {
194
+ next = next.batch ? applyBatchTask(next, item.task as BatchStreamTask) : { ...next, task: item.task as StreamTask };
83
195
  }
84
196
  }
85
197
  return next;
@@ -167,10 +279,13 @@ export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
167
279
  }
168
280
  }
169
281
 
282
+ /** 这个对话结束了:整个对话任务结束,或者只是这一个对话被关闭。 */
283
+ export const convEnded = (conv: Pick<ConvState, "job" | "task">) => !!conv.task?.closed || (!!conv.job && TERMINAL_JOB.has(conv.job.status));
284
+
170
285
  /** 正在执行的那一轮(派发中或执行中)。 */
171
286
  export const runningTurn = (conv: ConvState) => conv.turns.find((turn) => turn.status === "running" || turn.status === "dispatching") ?? null;
172
287
 
173
- export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv);
288
+ export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv) || (conv.batch && ACTIVE_TASK.has(conv.batchTask?.status ?? ""));
174
289
 
175
290
  /**
176
291
  * 界面上的转录:
@@ -179,17 +294,25 @@ export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(
179
294
  * 3. 还在中枢排队的消息,作为「排队中」气泡(可撤回),由界面钉在最下面。
180
295
  */
181
296
  export function viewMessages(conv: ConvState): Msg[] {
297
+ if (conv.batch) return batchViewMessages(conv);
182
298
  let out: Msg[] = [];
183
299
  // 有节点消息就以节点为准:通常先收到全量转录,但新对话可能先来增量事件(user / assistant / tool),也算。
184
300
  if (conv.msgs.length) {
185
301
  // 节点转录只有当前这个 Operator 会话:它的开场是固定的启动语句,正文其实是开出这个会话的那一轮——
186
- // 第 1 轮,或者节点会话重建过(context_reset)时最近重建的那一轮。
187
- const opener = [...conv.turns].reverse().find((turn) => turn.context_reset && turn.started_at) ?? conv.turns.find((turn) => turn.seq === 1);
302
+ // 这个对话的第一轮,或者节点会话重建过(context_reset)时最近重建的那一轮。
303
+ // 轮次号在整个对话任务里统一编号:对话任务里后开的对话,第一轮不是 1,取这个对话最早的那一轮。
304
+ const opener = [...conv.turns].reverse().find((turn) => turn.context_reset && turn.started_at) ?? conv.turns[0];
305
+ // 认开场语句:Operator 一个会话的第一条用户消息总是固定的启动语句(忽略请求正文),所以按位置认——
306
+ // 转录里的第一条用户消息、内容又不是开出会话那一轮的原文,就是它;措辞以后变了也认得出。
307
+ // 之后的消息里再出现这句固定语句(例如会话被重启),按原文匹配。
308
+ const openerText = opener ? opener.sent_input ?? opener.input : null;
188
309
  let replaced = false;
189
310
  for (const m of conv.msgs) {
190
- if (!replaced && m.role === "user" && m.content.trim() === OPERATOR_INITIAL_PROMPT && opener) {
191
- replaced = true;
192
- out.push({ ...m, content: opener.sent_input ?? opener.input });
311
+ const firstUser = !replaced && m.role === "user";
312
+ if (firstUser) replaced = true;
313
+ if (openerText && m.role === "user" && m.content.trim() !== openerText.trim()
314
+ && ((firstUser && m.id === conv.msgs.find((x) => x.role === "user")?.id) || m.content.trim() === OPERATOR_INITIAL_PROMPT)) {
315
+ out.push({ ...m, content: openerText });
193
316
  } else out.push(m);
194
317
  }
195
318
  } else {
@@ -212,7 +335,9 @@ export function viewMessages(conv: ConvState): Msg[] {
212
335
  const ts = new Date(turn.ended_at ?? turn.created_at).getTime();
213
336
  const reason = turn.last_error ? `:${describeTurnError(turn.last_error)}` : "";
214
337
  if ((turn.status === "failed" || turn.status === "cancelled") && !turn.started_at) {
215
- notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 第 ${turn.seq} 条消息${turn.status === "cancelled" ? "已撤回" : "没有执行"}${reason}`, generation, ts });
338
+ // 用户自己撤回的不用再写原因(「已撤回:已取消」是同一件事);中枢取消的(例如对话结束时还在排队)写明原因。
339
+ const byCaller = turn.status === "cancelled" && /cancelled by caller/i.test(turn.last_error ?? "");
340
+ notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 第 ${turn.seq} 条消息${turn.status === "cancelled" ? "已撤回" : "没有执行"}${byCaller ? "" : reason}`, generation, ts });
216
341
  } else if (turn.status === "failed") {
217
342
  notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 这一轮没有完成${reason}`, generation, ts });
218
343
  }
@@ -230,6 +355,48 @@ export function viewMessages(conv: ConvState): Msg[] {
230
355
  return out;
231
356
  }
232
357
 
358
+ // 中枢派发一次性任务时加在正文前的标记行,和完成后的自动核对(见 center scheduler.ts)。
359
+ const CENTER_MARKER_LINES = /^(?:\[CENTER-(?:ATTEMPT|TERMINAL-REVISION):[^\]\n]*\][ \t]*(?:\r?\n|$))+/;
360
+ const CONFIRM_MARK = "[CENTER-CONFIRM]";
361
+ const BATCH_TASK_ERROR: Record<string, string> = { failed: "子任务失败", cancelled: "子任务已取消" };
362
+
363
+ /**
364
+ * 一次性任务(含续派)的转录,只读:
365
+ * 1. 第一条用户消息是 Operator 固定的启动语句,换成开出这个节点对话的子任务原文(流里给了才换);
366
+ * 2. 去掉中枢加在正文前的标记行([CENTER-ATTEMPT:…]、[CENTER-TERMINAL-REVISION:…]);
367
+ * 3. 中枢的完成核对([CENTER-CONFIRM] 追问 + Agent 回的 TASK_COMPLETE / TASK_INCOMPLETE)合成一行说明;
368
+ * 4. 还没派发、拿不到执行记录、失败 / 取消时补一行说明。
369
+ */
370
+ function batchViewMessages(conv: ConvState): Msg[] {
371
+ const out: Msg[] = [];
372
+ const task = conv.batchTask;
373
+ const opener = task?.opener_prompt ?? null;
374
+ const firstUserId = conv.msgs.find((m) => m.role === "user")?.id;
375
+ for (let i = 0; i < conv.msgs.length; i++) {
376
+ const m = conv.msgs[i];
377
+ if (m.role === "user" && m.content.includes(CONFIRM_MARK)) {
378
+ const reply = conv.msgs[i + 1]?.role === "assistant" ? conv.msgs[++i] : null;
379
+ const content = !reply ? "中枢正在核对任务是否完成…" : /^\s*TASK_COMPLETE\b/.test(reply.content) ? "✓ 中枢核对:任务已完成" : "⚠ 中枢核对:任务没有完成";
380
+ out.push({ id: `confirm-${m.id}`, role: "system", content, generation: m.generation, ...(m.ts !== undefined ? { ts: m.ts } : {}) });
381
+ continue;
382
+ }
383
+ if (m.role !== "user") { out.push(m); continue; }
384
+ const content = m.id === firstUserId && opener ? opener : m.content.replace(CENTER_MARKER_LINES, "");
385
+ out.push(content === m.content ? m : { ...m, content });
386
+ }
387
+ const generation = out.length ? out[out.length - 1].generation : 1;
388
+ const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
389
+ if (!task) {
390
+ if (conv.job) note("none", "这个任务里还没有子任务");
391
+ } else if (!conv.msgs.length && !task.remote_task_id && !TERMINAL_TASK.has(task.status)) {
392
+ note("waiting", "子任务还没派发到执行节点,派发后这里会实时显示执行过程");
393
+ } else if (!conv.msgs.length && conv.stream === "ended") {
394
+ note("empty", "没有拿到这个子任务的执行记录(执行节点上的会话可能已经清理)");
395
+ }
396
+ 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)}` : ""}`);
397
+ return out;
398
+ }
399
+
233
400
  /** 把说明行按时间插进转录:放在时间不晚于它的最后一条消息之后(没有时间的消息沿用前一条的时间)。 */
234
401
  function insertByTime(msgs: Msg[], notices: Msg[]): Msg[] {
235
402
  if (!notices.length) return msgs;
@@ -362,8 +529,10 @@ export function closedCallback(conv: ConvState): ConversationClosedCallback {
362
529
  mode: "conversation",
363
530
  callback_seq: turns + 1,
364
531
  job_id: conv.jobId,
365
- status: conv.job?.status ?? "succeeded",
366
- closed_reason: conv.job?.closed_reason ?? null,
532
+ // 只关掉了这一个对话(对话任务还开着):带上是哪个,原因写 conversation_closed。
533
+ ...(conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? { task_id: conv.task.id } : {}),
534
+ status: conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? "succeeded" : conv.job?.status ?? "succeeded",
535
+ closed_reason: conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? "conversation_closed" : conv.job?.closed_reason ?? null,
367
536
  turns,
368
537
  continued_by_job_id: conv.continuedBy,
369
538
  occurred_at: new Date().toISOString(),
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
2
2
  import { createCliStore, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store.js";
3
- import type { ConversationCallback } from "./types.js";
3
+ import type { ConversationCallback, ConversationEndPayload, TurnEndPayload } from "./types.js";
4
4
 
5
5
  /**
6
6
  * 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库:
@@ -15,30 +15,43 @@ import type { ConversationCallback } from "./types.js";
15
15
  */
16
16
  const StoreContext = createContext<CliStore | null>(null);
17
17
 
18
- export interface CliCenterProviderProps extends CliStoreOptions {
18
+ export interface CliCenterProviderProps extends CliStoreOptions, ResultHandlers {
19
19
  /** 外部建好的仓库(例如 Web Component 按 group 共享的);给了就不再自己建。 */
20
20
  store?: CliStore;
21
21
  /** 监听仓库事件:选中、一轮结束、对话结束、job 变化、错误。 */
22
22
  onEvent?: (event: CliEvent) => void;
23
- /**
24
- * 前端回调:每轮结束 / 对话结束时调用,载荷与 center 投递给 callback.url 的请求体同名同义
25
- * (conversation_turn / conversation_closed)。用来刷新业务界面;落库以后端回调为准。
26
- */
27
- onCallback?: (payload: ConversationCallback) => void;
28
23
  children: ReactNode;
29
24
  }
30
25
 
31
- export function CliCenterProvider({ store: external, onEvent, onCallback, children, ...options }: CliCenterProviderProps) {
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) {
32
45
  // 连接参数只在建仓库时用一次:中途换 baseUrl / token 请换 key 重建。
33
46
  const optionsRef = useRef(options);
34
47
  const store = useMemo(() => external ?? createCliStore(optionsRef.current), [external]);
35
48
  const onEventRef = useRef(onEvent);
36
49
  onEventRef.current = onEvent;
37
- const onCallbackRef = useRef(onCallback);
38
- onCallbackRef.current = onCallback;
50
+ const resultRef = useRef<ResultHandlers>({});
51
+ resultRef.current = { onTurnEnd, onConversationEnd, onCallback };
39
52
  useEffect(() => store.on((event) => {
40
53
  onEventRef.current?.(event);
41
- if (event.type === "conversation_turn" || event.type === "conversation_closed") onCallbackRef.current?.(event.payload);
54
+ dispatchResult(event, resultRef.current);
42
55
  }), [store]);
43
56
  return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
44
57
  }
@@ -60,30 +73,31 @@ export function useCliState<T>(selector: (state: CliState) => T): T {
60
73
  }
61
74
 
62
75
  /** 组件外面没有 Provider 时,用组件自己的 props 包一层。 */
63
- export function withOwnProvider<P extends object>(props: P & CliStoreOptions & { onEvent?: (event: CliEvent) => void; onCallback?: (payload: ConversationCallback) => void; store?: CliStore }, inner: ReactNode, hasContext: boolean) {
76
+ export function withOwnProvider<P extends object>(props: P & CliStoreOptions & ResultHandlers & { onEvent?: (event: CliEvent) => void; store?: CliStore }, inner: ReactNode, hasContext: boolean) {
64
77
  if (hasContext) return inner;
65
- const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onCallback, store } = props;
78
+ const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store } = props;
66
79
  // 连接参数变了(换了中枢地址、令牌、产品线、命名空间)就换一个新仓库重连,不沿用旧连接。
67
80
  const connection = [baseUrl ?? "", token ?? "", line ?? "", namespace ?? ""].join("\u0000");
68
81
  return (
69
- <CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onCallback, store }}>
82
+ <CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store }}>
70
83
  {inner}
71
84
  </CliCenterProvider>
72
85
  );
73
86
  }
74
87
 
75
88
  /**
76
- * 组件自己的 onEvent / onCallback:组件放在外层 Provider 里时由这里订阅(自己建 Provider 时交给那个 Provider,免得调两次)。
89
+ * 组件自己的 onEvent / onTurnEnd / onConversationEnd:组件放在外层 Provider 里时由这里订阅
90
+ * (自己建 Provider 时交给那个 Provider,免得调两次)。
77
91
  */
78
- export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefined, onCallback: ((payload: ConversationCallback) => void) | undefined, enabled: boolean) {
92
+ export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefined, results: ResultHandlers, enabled: boolean) {
79
93
  const store = useContext(StoreContext);
80
- const ref = useRef({ onEvent, onCallback });
81
- ref.current = { onEvent, onCallback };
94
+ const ref = useRef({ onEvent, results });
95
+ ref.current = { onEvent, results };
82
96
  useEffect(() => {
83
97
  if (!enabled || !store) return;
84
98
  return store.on((event) => {
85
99
  ref.current.onEvent?.(event);
86
- if (event.type === "conversation_turn" || event.type === "conversation_closed") ref.current.onCallback?.(event.payload);
100
+ dispatchResult(event, ref.current.results);
87
101
  });
88
102
  }, [store, enabled]);
89
103
  }