@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.
package/src/core/store.ts CHANGED
@@ -2,13 +2,16 @@
2
2
  * 组件共享的状态仓库(纯 TS,无 React)。积木之间靠它拼起来:
3
3
  *
4
4
  * · 选中哪个对话(cli-msglist 点一下,cli-chat 跟着切);
5
- * · 列表里有哪些对话(宿主给 jobIds,或本地记住 + 从 center 发现);
5
+ * · 列表里有哪些对话任务(宿主给 jobIds,或本地记住 + 从 center 发现);
6
6
  * · 每个对话的实时状态:一个对话只开一条实时流,几个组件同时看也只连一次(引用计数)。
7
7
  *
8
+ * 「一个对话」用对话键标识(见 conversation.ts convKey):一个对话任务里可以有几个对话,"job_x#task_y" 是其中一个,
9
+ * 只有 "job_x" 是它的第一个(和以前一样);"job_x#new" 是在这个对话任务里新开一个对话。jobs / jobIds 仍按对话任务。
10
+ *
8
11
  * 单独用某个组件时它会自己建一个仓库;放进同一个 <CliCenterProvider>(或同一个 group 的 Web Component)就共享一个。
9
12
  */
10
13
  import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client.js";
11
- import { applyItems, applySnapshot, closedCallback, emptyConv, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
14
+ import { addTurnsIfAbsent, applyItems, applySnapshot, closedCallback, convEnded, convKey, emptyConv, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
12
15
  import { formatAnswersText } from "../ui/format.js";
13
16
  import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types.js";
14
17
 
@@ -22,9 +25,13 @@ export interface JobEntry {
22
25
  }
23
26
 
24
27
  export interface CliState {
28
+ /** 选中的对话(对话键)。 */
25
29
  selected: string | null;
30
+ /** 列表里的对话任务(job_id)。 */
26
31
  jobIds: string[];
32
+ /** 按 job_id。 */
27
33
  jobs: Record<string, JobEntry>;
34
+ /** 按对话键。 */
28
35
  convs: Record<string, ConvState>;
29
36
  listLoading: boolean;
30
37
  listError: string | null;
@@ -32,14 +39,28 @@ export interface CliState {
32
39
 
33
40
  /** 与 center 回调同名同义的事件,方便宿主把前端事件和后端回调对上。 */
34
41
  export type CliEvent =
42
+ /** jobId 是选中的对话键(只有 job_id,或 "job_id#task_id")。 */
35
43
  | { type: "select"; jobId: string | null }
36
- /** 一轮结束。payload 与 center 回调 conversation_turn 的请求体同名同义。 */
37
- | { type: "conversation_turn"; jobId: string; turn: CenterTurn; payload: ConversationTurnCallback }
38
- /** 对话结束。payload 与 center 回调 conversation_closed 的请求体同名同义。 */
39
- | { type: "conversation_closed"; jobId: string; reason: string | null; continuedBy: string | null; payload: ConversationClosedCallback }
40
- /** 新建了对话,或旧对话结束后消息开了新对话(job_id 变了)。 */
41
- | { type: "job_change"; jobId: string; previousJobId: string | null }
42
- | { type: "error"; jobId: string | null; error: CenterError };
44
+ /** 一轮结束。payload 与 center 回调 conversation_turn 的请求体同名同义。key 是这个对话的对话键。 */
45
+ | { type: "conversation_turn"; jobId: string; key: string; turn: CenterTurn; payload: ConversationTurnCallback }
46
+ /** 对话结束(整个对话任务结束,或只关掉了这一个对话)。payload 与 center 回调 conversation_closed 的请求体同名同义。 */
47
+ | { type: "conversation_closed"; jobId: string; key: string; reason: string | null; continuedBy: string | null; payload: ConversationClosedCallback }
48
+ /**
49
+ * 新建了对话(新对话任务,或在对话任务里新开的对话),或旧对话结束后消息开了新对话(job_id 变了)。
50
+ * taskId / key:新的那个对话;previousKey:之前的对话键(新建时是 null 或 "job#new")。
51
+ */
52
+ | { type: "job_change"; jobId: string; previousJobId: string | null; taskId: string | null; key: string; previousKey: string | null }
53
+ | { type: "error"; jobId: string | null; error: CenterError }
54
+ /**
55
+ * 有人要在看板里打开东西(对话里点了本地文件链接、「查看浏览器」,或宿主调了 store.openInBoard)。
56
+ * handled = 有看板接住了;没有时对话组件自己弹窗打开。宿主可以据此展开右侧面板。
57
+ */
58
+ | { type: "open"; jobId: string; target: OpenTarget; handled: boolean };
59
+
60
+ /** 看板要打开的东西。 */
61
+ export type OpenTarget = { kind: "file"; path: string } | { kind: "browser" } | { kind: "changes" } | { kind: "files"; path?: string };
62
+ /** 看板登记的接收函数:接得住(显示的是这个对话)就返回 true。 */
63
+ export type BoardSurface = (jobId: string, target: OpenTarget) => boolean;
43
64
 
44
65
  export interface CliStoreOptions extends CenterClientOptions {
45
66
  client?: CenterClient;
@@ -80,6 +101,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
80
101
  const listeners = new Set<() => void>();
81
102
  const eventListeners = new Set<(event: CliEvent) => void>();
82
103
  const settledTurns = new Set<string>();
104
+ const watchedTurns = new Set<string>();
83
105
  const closedJobs = new Set<string>();
84
106
  const seenOpen = new Set<string>();
85
107
  let hostIds = false;
@@ -89,12 +111,12 @@ export function createCliStore(options: CliStoreOptions = {}) {
89
111
  for (const fn of listeners) fn();
90
112
  };
91
113
  const emit = (event: CliEvent) => { for (const fn of eventListeners) fn(event); };
92
- const patchConv = (jobId: string, fn: (conv: ConvState) => ConvState) => {
93
- const prev = state.convs[jobId] ?? emptyConv(jobId);
114
+ const patchConv = (key: string, fn: (conv: ConvState) => ConvState) => {
115
+ const prev = state.convs[key] ?? emptyConv(key);
94
116
  const next = fn(prev);
95
117
  if (next !== prev) {
96
- noticeTransitions(jobId, prev, next);
97
- set({ convs: { ...state.convs, [jobId]: next } });
118
+ noticeTransitions(key, prev, next);
119
+ set({ convs: { ...state.convs, [key]: next } });
98
120
  }
99
121
  };
100
122
  const patchJob = (jobId: string, patch: Partial<JobEntry>) => {
@@ -103,32 +125,37 @@ export function createCliStore(options: CliStoreOptions = {}) {
103
125
  };
104
126
 
105
127
  // 一轮从未结束变成结束 → conversation_turn;对话变成结束 → conversation_closed。
106
- function noticeTransitions(jobId: string, prev: ConvState, next: ConvState) {
128
+ function noticeTransitions(key: string, prev: ConvState, next: ConvState) {
129
+ const { jobId } = next;
107
130
  if (prev.turns !== next.turns) {
108
131
  const before = new Map(prev.turns.map((turn) => [turn.turn_id, turn.status]));
109
132
  // 有轮次开始执行 / 新排队:列表卡片的「运行中」「排队中」也要跟上,不等下一次轮询。
110
133
  if (next.turns.some((turn) => before.get(turn.turn_id) !== turn.status && !TERMINAL_TURN.has(turn.status))) void refreshJob(jobId);
134
+ // 本页看着还没结束过的轮次才发结果(打开页面时就已结束的历史轮次不发)。
135
+ for (const turn of next.turns) if (!TERMINAL_TURN.has(turn.status)) watchedTurns.add(turn.turn_id);
136
+ // 按 seq 顺序发,与 center 投递 callback 的顺序一致:前面还有没结束的轮次,后面结束了的先等着
137
+ // (例如第 2 条排队消息被撤回时第 1 轮还在跑,要等第 1 轮结束后先发 1、再发 2)。
111
138
  for (const turn of next.turns) {
112
- if (!TERMINAL_TURN.has(turn.status) || settledTurns.has(turn.turn_id)) continue;
139
+ if (!TERMINAL_TURN.has(turn.status)) break;
140
+ if (settledTurns.has(turn.turn_id)) continue;
113
141
  settledTurns.add(turn.turn_id);
114
- const was = before.get(turn.turn_id);
115
- // 打开页面时已经结束的历史轮次不算「刚结束」。
116
- // 被合并进别的轮次的消息不单独回调(center 也不单独投递),结果在那一轮的 messages 里。
117
- if (was !== undefined && !TERMINAL_TURN.has(was) && turn.status !== "merged") {
118
- const continuedFrom = state.jobs[jobId]?.job?.conversation?.continued_from_job_id ?? null;
119
- emit({ type: "conversation_turn", jobId, turn, payload: turnCallback(next, turn, continuedFrom) });
120
- void refreshJob(jobId, true);
121
- }
142
+ // 被合并进别的轮次的消息不单独发(center 也不单独投递),结果在那一轮的 messages 里。
143
+ if (!watchedTurns.has(turn.turn_id) || turn.status === "merged") continue;
144
+ const continuedFrom = state.jobs[jobId]?.job?.conversation?.continued_from_job_id ?? null;
145
+ emit({ type: "conversation_turn", jobId, key, turn, payload: turnCallback(next, turn, continuedFrom) });
146
+ void refreshJob(jobId, true);
122
147
  }
123
148
  }
124
- // 对话结束:只对本页看着还开着的对话发(打开时就已结束的不算);而且要等所有轮次都收尾之后再发,
125
- // 保证 conversation_closed 排在这个对话最后一个 conversation_turn 之后(与 center 的 callback_seq 一致)。
126
- if (next.job && !TERMINAL_JOB.has(next.job.status)) seenOpen.add(jobId);
127
- if (next.job && TERMINAL_JOB.has(next.job.status) && seenOpen.has(jobId) && !closedJobs.has(jobId)) {
149
+ // 对话结束(整个对话任务结束,或只关掉了这一个对话):只对本页看着还开着的对话发(打开时就已结束的不算);
150
+ // 而且要等所有轮次都收尾之后再发,保证 conversation_closed 排在这个对话最后一个 conversation_turn 之后(与 center 的 callback_seq 一致)。
151
+ if (next.job && !convEnded(next)) seenOpen.add(key);
152
+ // 一次性任务(只读查看)没有对话的结果通知。
153
+ if (next.job && convEnded(next) && seenOpen.has(key) && !closedJobs.has(key) && !next.batch) {
128
154
  const settled = next.turns.every((turn) => TERMINAL_TURN.has(turn.status));
129
155
  if (settled || next.stream === "ended") {
130
- closedJobs.add(jobId);
131
- emit({ type: "conversation_closed", jobId, reason: next.job.closed_reason, continuedBy: next.continuedBy, payload: closedCallback(next) });
156
+ closedJobs.add(key);
157
+ const payload = closedCallback(next);
158
+ emit({ type: "conversation_closed", jobId, key, reason: payload.closed_reason, continuedBy: next.continuedBy, payload });
132
159
  }
133
160
  }
134
161
  // 实时流里的对话状态同步给列表。
@@ -165,14 +192,17 @@ export function createCliStore(options: CliStoreOptions = {}) {
165
192
  persist(ids);
166
193
  }
167
194
 
168
- /** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。 */
195
+ /** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。oldId / newId 可以是对话键。 */
169
196
  function replaceJob(oldId: string, newId: string) {
170
197
  if (oldId === newId) return;
171
- const ids = state.jobIds.includes(oldId) ? state.jobIds.map((id) => (id === oldId ? newId : id)) : [newId, ...state.jobIds];
198
+ const from = parseConvKey(oldId);
199
+ const to = parseConvKey(newId);
200
+ const ids = from.jobId === to.jobId ? state.jobIds
201
+ : state.jobIds.includes(from.jobId) ? state.jobIds.map((id) => (id === from.jobId ? to.jobId : id)) : [to.jobId, ...state.jobIds];
172
202
  set({ jobIds: [...new Set(ids)], selected: state.selected === oldId ? newId : state.selected });
173
203
  persist(state.jobIds);
174
- emit({ type: "job_change", jobId: newId, previousJobId: oldId });
175
- void refreshJob(newId, true);
204
+ emit({ type: "job_change", jobId: to.jobId, previousJobId: from.jobId, taskId: to.taskId, key: newId, previousKey: oldId });
205
+ void refreshJob(to.jobId, true);
176
206
  }
177
207
 
178
208
  const refreshAgain = new Set<string>();
@@ -186,16 +216,17 @@ export function createCliStore(options: CliStoreOptions = {}) {
186
216
  const job = await client.getJob(jobId);
187
217
  let firstInput = state.jobs[jobId]?.firstInput ?? null;
188
218
  if (firstInput === null && job.mode === "conversation") {
219
+ // 卡片描述取第一个对话的第一条消息(按 seq 最早的那一轮)。
189
220
  const first = await client.listTurns(jobId, { limit: 1 }).catch(() => null);
190
221
  firstInput = first?.turns[0]?.input ?? "";
191
222
  }
192
223
  patchJob(jobId, { job, firstInput, loading: false, error: null, fetchedAt: Date.now() });
193
- // 新数据里已包含实时用量,清零累计。
194
- if (state.convs[jobId]) patchConv(jobId, (conv) => ({ ...conv, liveUsage: { fresh: 0, cacheRead: 0 } }));
224
+ // 新数据里已包含实时用量,清零累计(这个对话任务里的每个对话)。
225
+ for (const conv of Object.values(state.convs)) if (conv.jobId === jobId) patchConv(conv.key, (c) => ({ ...c, liveUsage: { fresh: 0, cacheRead: 0 } }));
195
226
  } catch (error) {
196
227
  patchJob(jobId, { loading: false, error: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() });
197
228
  // 本地记住的对话在 center 已经不存在(被删、换了环境):从列表里拿掉。宿主给的 jobIds 不动,由宿主决定。
198
- if (!hostIds && error instanceof CenterError && error.status === 404 && state.selected !== jobId) {
229
+ if (!hostIds && error instanceof CenterError && error.status === 404 && (!state.selected || parseConvKey(state.selected).jobId !== jobId)) {
199
230
  refreshAgain.delete(jobId);
200
231
  removeJob(jobId);
201
232
  return;
@@ -259,38 +290,41 @@ export function createCliStore(options: CliStoreOptions = {}) {
259
290
 
260
291
  const streams = new Map<string, { refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number }>();
261
292
 
262
- function connect(jobId: string) {
263
- const entry = streams.get(jobId);
293
+ function connect(key: string) {
294
+ const entry = streams.get(key);
264
295
  if (!entry || entry.refs <= 0) return;
265
- patchConv(jobId, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
296
+ const { jobId, taskId } = parseConvKey(key);
297
+ patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
266
298
  let ended = false;
267
299
  entry.stop = client.stream(jobId, {
268
- onOpen: () => { entry.attempt = 0; patchConv(jobId, (conv) => ({ ...conv, stream: "live", streamError: null })); },
269
- onSnapshot: (snapshot) => patchConv(jobId, (conv) => applySnapshot(conv, snapshot)),
270
- onChunk: (items) => patchConv(jobId, (conv) => applyItems(conv, items)),
300
+ onOpen: () => { entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
301
+ onSnapshot: (snapshot) => patchConv(key, (conv) => applySnapshot(conv, snapshot)),
302
+ onChunk: (items) => patchConv(key, (conv) => applyItems(conv, items)),
271
303
  onEnd: (end) => {
272
304
  ended = true;
273
- patchConv(jobId, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy: end.continued_by_job_id ?? null, nodeRunning: false }));
305
+ // 接续的新对话:指定了对话的跟到接续它的那个对话(还没有就不跟,再发消息时 center 会开出来);只有 job_id 的照旧跟新 job。
306
+ const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
307
+ patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
274
308
  void refreshJob(jobId, true);
275
309
  },
276
310
  onClose: (error) => {
277
311
  entry.stop = null;
278
- const current = streams.get(jobId);
312
+ const current = streams.get(key);
279
313
  if (!current || current.refs <= 0) return;
280
314
  // 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
281
- if (ended && state.convs[jobId]?.endReason !== "server_shutdown") return;
315
+ if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
282
316
  // 404 / 401:对话不存在或没权限,重连也没用。
283
317
  if (error && (error.status === 404 || error.status === 401 || error.status === 403)) {
284
- patchConv(jobId, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
285
- emit({ type: "error", jobId, error });
318
+ patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
319
+ emit({ type: "error", jobId: key, error });
286
320
  return;
287
321
  }
288
322
  const delay = RECONNECT_MS[Math.min(current.attempt, RECONNECT_MS.length - 1)];
289
323
  current.attempt += 1;
290
- patchConv(jobId, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
291
- current.timer = setTimeout(() => { current.timer = null; connect(jobId); }, delay);
324
+ patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
325
+ current.timer = setTimeout(() => { current.timer = null; connect(key); }, delay);
292
326
  },
293
- });
327
+ }, undefined, taskId);
294
328
  }
295
329
 
296
330
  // 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
@@ -308,35 +342,43 @@ export function createCliStore(options: CliStoreOptions = {}) {
308
342
  document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reconnectNow(); });
309
343
  }
310
344
 
311
- /** 开始看一个对话(返回停止函数)。同一对话多处同时看只连一次。 */
312
- function open(jobId: string): () => void {
313
- let entry = streams.get(jobId);
345
+ /**
346
+ * 开始看一个对话(对话键;返回停止函数)。同一对话多处同时看只连一次。
347
+ * "job#new"(还没开出来的对话)没有实时流,只拉对话任务的信息。
348
+ */
349
+ function open(key: string): () => void {
350
+ const { jobId, taskId } = parseConvKey(key);
351
+ if (taskId === NEW_TASK) {
352
+ void refreshJob(jobId);
353
+ return () => undefined;
354
+ }
355
+ let entry = streams.get(key);
314
356
  if (!entry) {
315
357
  entry = { refs: 0, stop: null, timer: null, attempt: 0 };
316
- streams.set(jobId, entry);
358
+ streams.set(key, entry);
317
359
  }
318
360
  entry.refs += 1;
319
361
  if (entry.refs === 1) {
320
362
  void refreshJob(jobId);
321
- connect(jobId);
363
+ connect(key);
322
364
  }
323
365
  return () => {
324
- const current = streams.get(jobId);
366
+ const current = streams.get(key);
325
367
  if (!current) return;
326
368
  current.refs -= 1;
327
369
  if (current.refs > 0) return;
328
370
  current.stop?.();
329
371
  if (current.timer) clearTimeout(current.timer);
330
- streams.delete(jobId);
331
- patchConv(jobId, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
372
+ streams.delete(key);
373
+ patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
332
374
  };
333
375
  }
334
376
 
335
377
  // ── 发消息 / 新建 / 撤回 / 结束 ───────────────────────
336
378
 
337
379
  /**
338
- * 发一条消息。jobId 为 null 时新建对话(这条就是第一轮)。返回实际接收消息的对话 id。
339
- * 带 business_id 发:网络抖动时重发一次也只会跑一轮。
380
+ * 发一条消息。key 是对话键:null 时新建对话任务(这条就是第一轮);"job#new" 时在这个对话任务里新开一个对话。
381
+ * 返回实际接收消息的对话键。带 business_id 发:网络抖动时重发一次也只会跑一轮。
340
382
  */
341
383
  // 同一个对话的消息严格按发出的顺序送到 center(输入框、提问卡片、重试可能同时发):
342
384
  // center 按到达顺序给轮次编号,并发发出去可能后发先到。每个对话一条队列,前一条失败不挡后面的。
@@ -351,14 +393,27 @@ export function createCliStore(options: CliStoreOptions = {}) {
351
393
  return run;
352
394
  }
353
395
 
354
- async function sendNow(jobId: string | null, text: string, overrides: SendOptions): Promise<string> {
396
+ async function sendNow(key: string | null, text: string, overrides: SendOptions): Promise<string> {
355
397
  // 发消息的组件自己的参数优先(同一个仓库里可以有几个对话组件,各带各的新建参数),没有再用仓库的。
356
398
  const newConversation = overrides.newConversation ?? options.newConversation;
357
399
  const createConversation = overrides.createConversation ?? options.createConversation;
358
400
  const businessIdFor = overrides.businessIdFor ?? options.businessIdFor;
359
- const businessId = businessIdFor?.(text, jobId ? "message" : "create") || `cli-${uid()}`;
401
+ const ref = key ? parseConvKey(key) : null;
402
+ const businessId = businessIdFor?.(text, ref && ref.taskId !== NEW_TASK ? "message" : "create") || `cli-${uid()}`;
360
403
  try {
361
- if (!jobId) {
404
+ if (ref?.taskId === NEW_TASK) {
405
+ // 在对话任务里新开一个对话:标题取第一行,智能体 / 浏览器模式沿用对话任务的第一个对话(center 决定)。
406
+ if (!client.openConversation) throw new CenterError("当前后端不支持在对话任务里新开对话", "UNSUPPORTED", 0);
407
+ const result = await retryOnce(() => client.openConversation!(ref.jobId, text, { title: firstLine(text, 60), businessId }));
408
+ const target = convKey(result.job_id, result.task_id ?? result.turn.task_id);
409
+ patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
410
+ addJobIfMissing(result.job_id);
411
+ if (overrides.select !== false && state.selected === key) set({ selected: target });
412
+ emit({ type: "job_change", jobId: result.job_id, previousJobId: result.job_id !== ref.jobId ? ref.jobId : null, taskId: parseConvKey(target).taskId, key: target, previousKey: key });
413
+ void refreshJob(result.job_id, true);
414
+ return target;
415
+ }
416
+ if (!key || !ref) {
362
417
  let newId: string;
363
418
  if (createConversation) {
364
419
  newId = await createConversation(text);
@@ -366,25 +421,27 @@ export function createCliStore(options: CliStoreOptions = {}) {
366
421
  // 新建只发一次:center 的 POST /api/jobs 没有幂等,建好了却没收到响应时重发会建出第二个对话。
367
422
  const created = await client.createConversation(text, { ...newConversation, title: newConversation?.title || firstLine(text), businessId: newConversation?.businessId || businessId });
368
423
  newId = created.id;
369
- patchConv(newId, (conv) => ({ ...conv, turns: upsertTurns(conv.turns, created.turn ? [created.turn] : []) }));
424
+ patchConv(newId, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, created.turn ? [created.turn] : []) }));
370
425
  }
371
426
  addJob(newId);
372
427
  // 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
373
428
  if (overrides.select !== false) set({ selected: newId });
374
- emit({ type: "job_change", jobId: newId, previousJobId: null });
429
+ emit({ type: "job_change", jobId: newId, previousJobId: null, taskId: null, key: newId, previousKey: null });
375
430
  void refreshJob(newId, true);
376
431
  return newId;
377
432
  }
378
- const result = await retryOnce(() => client.sendMessage(jobId, text, { businessId }));
379
- const target = result.job_id || jobId;
380
- if (result.new_conversation && target !== jobId) replaceJob(jobId, target);
433
+ const result = await retryOnce(() => client.sendMessage(ref.jobId, text, { businessId, ...(ref.taskId ? { taskId: ref.taskId } : {}) }));
434
+ const jobTarget = result.job_id || ref.jobId;
435
+ // 指定了对话的:接续后跟到新对话任务里对应的那个对话;只有 job_id 的照旧只换 job。
436
+ const target = ref.taskId ? convKey(jobTarget, result.task_id ?? result.turn.task_id ?? ref.taskId) : jobTarget;
437
+ if (target !== key) replaceJob(key, target);
381
438
  // 先把这一轮放进状态:排队气泡立刻出现,不用等实时流的下一个块。
382
- patchConv(target, (conv) => ({ ...conv, turns: upsertTurns(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
383
- if (target === jobId) addJobIfMissing(jobId);
439
+ patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
440
+ if (jobTarget === ref.jobId) addJobIfMissing(ref.jobId);
384
441
  return target;
385
442
  } catch (error) {
386
443
  const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
387
- emit({ type: "error", jobId, error: err });
444
+ emit({ type: "error", jobId: key, error: err });
388
445
  throw err;
389
446
  }
390
447
  }
@@ -398,18 +455,18 @@ export function createCliStore(options: CliStoreOptions = {}) {
398
455
  * 所以回答作为下一条消息发出——与 Operator 在没有等待中的回合时(/answer 返回 resolved=false)的做法一致;
399
456
  * 卡片先在本地标成已答(Operator 同样是先乐观更新)。
400
457
  */
401
- async function answerAsk(jobId: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions = {}): Promise<string> {
458
+ async function answerAsk(key: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions = {}): Promise<string> {
402
459
  // 与 Operator 的 answerQuestion 同一流程:先乐观标成已答 → 交给正在等的回合 → 没有在等的回合就当普通回复发出。
403
- patchConv(jobId, (conv) => ({ ...conv, localAnswers: { ...conv.localAnswers, [askId]: answers } }));
460
+ patchConv(key, (conv) => ({ ...conv, localAnswers: { ...conv.localAnswers, [askId]: answers } }));
404
461
  try {
405
462
  if (askId && client.answerAsk) {
406
- const { resolved } = await client.answerAsk(jobId, askId, answers);
407
- if (resolved) return jobId;
463
+ const { resolved } = await client.answerAsk(key, askId, answers);
464
+ if (resolved) return key;
408
465
  }
409
- return await send(jobId, formatAnswersText(questions, answers), overrides);
466
+ return await send(key, formatAnswersText(questions, answers), overrides);
410
467
  } catch (error) {
411
468
  // 没发出去:卡片恢复成可回答。
412
- patchConv(jobId, (conv) => {
469
+ patchConv(key, (conv) => {
413
470
  const { [askId]: _drop, ...rest } = conv.localAnswers;
414
471
  return { ...conv, localAnswers: rest };
415
472
  });
@@ -417,31 +474,59 @@ export function createCliStore(options: CliStoreOptions = {}) {
417
474
  }
418
475
  }
419
476
 
420
- async function cancelTurn(jobId: string, turnId: string) {
477
+ async function cancelTurn(key: string, turnId: string) {
421
478
  try {
422
- const turn = await client.cancelTurn(jobId, turnId);
423
- patchConv(jobId, (conv) => ({ ...conv, turns: upsertTurns(conv.turns, [turn]) }));
479
+ const turn = await client.cancelTurn(parseConvKey(key).jobId, turnId);
480
+ // 撤回排队的消息会立刻收尾(cancelled):响应里已经结束、而我们还没收到的,先用上;其余以实时流为准。
481
+ patchConv(key, (conv) => {
482
+ const known = conv.turns.find((t) => t.turn_id === turn.turn_id);
483
+ if (known && (TERMINAL_TURN.has(known.status) || !TERMINAL_TURN.has(turn.status))) return conv;
484
+ return { ...conv, turns: upsertTurns(conv.turns, [turn]) };
485
+ });
424
486
  } catch (error) {
425
487
  const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
426
- emit({ type: "error", jobId, error: err });
488
+ emit({ type: "error", jobId: key, error: err });
427
489
  throw err;
428
490
  }
429
491
  }
430
492
 
431
- async function close(jobId: string) {
493
+ /** 结束对话:指定了对话("job#task")的只关掉这一个;只有 job_id 的结束整个对话任务(和以前一样)。 */
494
+ async function close(key: string) {
495
+ const { jobId, taskId } = parseConvKey(key);
432
496
  try {
433
- await client.closeConversation(jobId);
497
+ if (taskId && taskId !== NEW_TASK) {
498
+ if (!client.closeConversationTask) throw new CenterError("当前后端不支持单独关闭对话", "UNSUPPORTED", 0);
499
+ await client.closeConversationTask(jobId, taskId);
500
+ } else {
501
+ await client.closeConversation(jobId);
502
+ }
434
503
  await refreshJob(jobId, true);
435
504
  } catch (error) {
436
505
  const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
437
- emit({ type: "error", jobId, error: err });
506
+ emit({ type: "error", jobId: key, error: err });
438
507
  throw err;
439
508
  }
440
509
  }
441
510
 
511
+ // 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
512
+ const surfaces: BoardSurface[] = [];
513
+ function openInBoard(jobId: string, target: OpenTarget): boolean {
514
+ let handled = false;
515
+ for (let i = surfaces.length - 1; i >= 0 && !handled; i--) handled = surfaces[i](jobId, target);
516
+ emit({ type: "open", jobId, target, handled });
517
+ return handled;
518
+ }
519
+
442
520
  return {
443
521
  client,
444
522
  getState: () => state,
523
+ /** 在看板里打开文件 / 浏览器 / 改动。返回有没有看板接住。 */
524
+ openInBoard,
525
+ /** 看板登记自己;返回注销函数。 */
526
+ registerBoard(surface: BoardSurface) {
527
+ surfaces.push(surface);
528
+ return () => { const i = surfaces.indexOf(surface); if (i >= 0) surfaces.splice(i, 1); };
529
+ },
445
530
  subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
446
531
  on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
447
532
  select(jobId: string | null) {