@spzxg/cli-ui 0.3.1 → 0.6.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.
package/src/core/store.ts CHANGED
@@ -11,8 +11,10 @@
11
11
  * 单独用某个组件时它会自己建一个仓库;放进同一个 <CliCenterProvider>(或同一个 group 的 Web Component)就共享一个。
12
12
  */
13
13
  import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client.js";
14
- import { addTurnsIfAbsent, applyItems, applySnapshot, closedCallback, convEnded, convKey, emptyConv, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
14
+ import { addTurnsIfAbsent, applyItems, applyOlderPage, applySnapshot, closedCallback, convEnded, convKey, emptyConv, isChatTask, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
15
15
  import { formatAnswersText } from "../ui/format.js";
16
+ import { backgroundBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "./blocks.js";
17
+ import { createWorkspace, type BoardView, type CliWorkspace } from "../board/workspace.js";
16
18
  import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types.js";
17
19
 
18
20
  export interface JobEntry {
@@ -35,6 +37,44 @@ export interface CliState {
35
37
  convs: Record<string, ConvState>;
36
38
  listLoading: boolean;
37
39
  listError: string | null;
40
+ /** 看板里引用到对话的东西(按对话键),下一条消息发出时一起带上(见 BoardRef)。 */
41
+ refs: Record<string, BoardRef[]>;
42
+ /**
43
+ * 看板上正在看的东西(按对话键,现在是打开着的文件):缺省跟着下一条消息带上一次,对话输入框上方显示成虚线卡片;
44
+ * off = 这一个已经带过 / 用户点了不带。换了看的东西重新算。
45
+ */
46
+ viewing: Record<string, { ref: BoardRef; off: boolean }>;
47
+ /** 看板现在的样子(按对话键):正在看的文件、开着的文件页签、当前页签。没有看板时没有。 */
48
+ boards: Record<string, BoardView>;
49
+ /** 业务方补充的项目背景(按对话键;"" = 还没建的新对话),随下一条消息发出(见 appendContext)。 */
50
+ context: Record<string, ContextItem[]>;
51
+ }
52
+
53
+ /** 一条补充背景。 */
54
+ export interface ContextItem {
55
+ /** 同一个 id 再追加会替换。 */
56
+ id: string;
57
+ /** 标题(输入框上方的小卡片、发给智能体的块里都显示)。 */
58
+ title?: string;
59
+ text: string;
60
+ }
61
+ /** 一条补充背景最长多少字(和创建任务时 workspace.context 的上限一致)。 */
62
+ export const CONTEXT_ITEM_MAX = 20_000;
63
+
64
+ /**
65
+ * 从看板引用到对话的一样东西:一个文件、浏览器当前页面、一处改动,或宿主自己加的页签里的内容(终端输出……)。
66
+ * 对话组件把它们显示在输入框上方;下一条消息发出时合成一个「看板上下文」块带给智能体
67
+ * (转录里显示成一张可展开的卡片,不占对话篇幅),发完清空。
68
+ */
69
+ export interface BoardRef {
70
+ /** 同一个 id 再引用一次会替换(例如浏览器换了页面)。 */
71
+ id: string;
72
+ /** file / browser / changes,或宿主自定义的种类(terminal …)。 */
73
+ kind: string;
74
+ /** 输入框上方小卡片里显示的名字。 */
75
+ label: string;
76
+ /** 带给智能体的那一行(说清是什么、在哪)。 */
77
+ text: string;
38
78
  }
39
79
 
40
80
  /** 与 center 回调同名同义的事件,方便宿主把前端事件和后端回调对上。 */
@@ -55,16 +95,27 @@ export type CliEvent =
55
95
  * 有人要在看板里打开东西(对话里点了本地文件链接、「查看浏览器」,或宿主调了 store.openInBoard)。
56
96
  * handled = 有看板接住了;没有时对话组件自己弹窗打开。宿主可以据此展开右侧面板。
57
97
  */
58
- | { type: "open"; jobId: string; target: OpenTarget; handled: boolean };
98
+ | { type: "open"; jobId: string; target: OpenTarget; handled: boolean }
99
+ /** 看板里引用 / 取消引用了东西(refs 是这个对话现在的全部引用),宿主可以据此做自己的提示。 */
100
+ | { type: "board_ref"; key: string; refs: BoardRef[] }
101
+ /** 看板上切了页签、打开 / 关掉了文件(view 为 null = 看板关了)。 */
102
+ | { type: "board_change"; key: string; view: BoardView | null }
103
+ /**
104
+ * 补充背景变了:追加 / 去掉(pending 是还没发出的),或随消息发出了(sent 是这次发出的)。
105
+ * key 是对话键;新对话还没建时是 ""。
106
+ */
107
+ | { type: "context"; key: string; pending: ContextItem[]; sent?: ContextItem[] };
59
108
 
60
109
  /** 看板要打开的东西。 */
61
- export type OpenTarget = { kind: "file"; path: string } | { kind: "browser" } | { kind: "changes" } | { kind: "files"; path?: string };
110
+ export type OpenTarget = { kind: "file"; path: string } | { kind: "browser" } | { kind: "changes" } | { kind: "files"; path?: string }
111
+ /** 宿主用 extraTabs 加的页签。 */
112
+ | { kind: "tab"; id: string };
62
113
  /** 看板登记的接收函数:接得住(显示的是这个对话)就返回 true。 */
63
114
  export type BoardSurface = (jobId: string, target: OpenTarget) => boolean;
64
115
 
65
116
  export interface CliStoreOptions extends CenterClientOptions {
66
117
  client?: CenterClient;
67
- /** 在本机记住对话列表用的命名空间(不同业务 / 产品线请区分)。不给 = 不记,组件之间没有任何隐式共享。 */
118
+ /** 命名空间(不同业务 / 产品线请区分;Web Component 没写 group 时用它区分仓库)。列表不会按它在本机记住任何对话。 */
68
119
  namespace?: string | null;
69
120
  /**
70
121
  * 新建对话时的参数(与 POST /api/jobs 对应):标题、业务 ID、工作区、回调、智能体、超时、浏览器模式。
@@ -90,14 +141,65 @@ export interface SendOptions {
90
141
  }
91
142
 
92
143
  const RECONNECT_MS = [1_000, 2_000, 5_000, 10_000, 20_000];
144
+ /**
145
+ * 退避时间上下浮动 ±30%:中枢重启时所有页面同时断开,不错开的话会在同一秒一起重连、一起拉快照,
146
+ * 人多时把刚起来的中枢再压垮一次。
147
+ */
148
+ export const jitter = (ms: number) => Math.round(ms * (0.7 + Math.random() * 0.6));
149
+ /**
150
+ * 标签页在后台超过这么久:先断开实时流(中枢那头每条流都在轮询数据库、连着执行节点),切回来时重连并拉一次快照补齐。
151
+ * 很多人开着页面不看时,中枢和节点不用一直为他们推数据。
152
+ */
153
+ export const PARK_HIDDEN_MS = 5 * 60_000;
154
+ /** 401 / 403:多半是宿主的登录会话过期了。宿主续上会话后要能自己恢复,所以慢慢重试,不永久放弃。 */
155
+ const AUTH_RETRY_MS = 30_000;
156
+
157
+ // 全局只挂一份「网络恢复 / 切回页面」监听,转给当前有实时流的仓库。
158
+ const reconnectors = new Set<() => void>();
159
+ const parkers = new Set<() => void>();
160
+ let listening = false;
161
+ let parkTimer: ReturnType<typeof setTimeout> | null = null;
162
+ const fireReconnect = () => { for (const fn of [...reconnectors]) fn(); };
163
+ const onVisible = () => {
164
+ if (document.visibilityState === "visible") {
165
+ if (parkTimer) { clearTimeout(parkTimer); parkTimer = null; }
166
+ fireReconnect();
167
+ return;
168
+ }
169
+ parkTimer ??= setTimeout(() => { parkTimer = null; if (document.visibilityState === "hidden") for (const fn of [...parkers]) fn(); }, PARK_HIDDEN_MS);
170
+ };
171
+ function watchReconnect(fn: () => void, park: () => void) {
172
+ reconnectors.add(fn);
173
+ parkers.add(park);
174
+ if (listening || typeof window === "undefined") return;
175
+ listening = true;
176
+ window.addEventListener("online", fireReconnect);
177
+ document.addEventListener("visibilitychange", onVisible);
178
+ }
179
+ function unwatchReconnect(fn: () => void, park: () => void) {
180
+ reconnectors.delete(fn);
181
+ parkers.delete(park);
182
+ if (!listening || reconnectors.size) return;
183
+ listening = false;
184
+ window.removeEventListener("online", fireReconnect);
185
+ document.removeEventListener("visibilitychange", onVisible);
186
+ }
187
+ /** 测试用:当前登记着的仓库数。 */
188
+ export const liveReconnectors = () => reconnectors.size;
93
189
  const JOB_REFRESH_GAP_MS = 1_500;
190
+ /**
191
+ * 列表在后台刷新 / 发现对话时同时最多发几个请求。HTTP/1.1 下浏览器对一个域名只有 6 个连接,
192
+ * 对话实时流、看板的浏览器画面又各占着长连接;列表少占一些,点开文件、发消息这类用户在等的请求才不用排队。
193
+ */
194
+ const LIST_CONCURRENCY = 2;
94
195
 
95
196
  export function createCliStore(options: CliStoreOptions = {}) {
96
197
  const client = options.client ?? createCenterClient(options);
97
198
  // 只有明确给了 namespace 才在本机记住对话列表:不给就不落任何共享状态,几个独立的组件互不影响。
98
- const storageKey = options.namespace ? `cli-ui:${options.namespace}:jobs` : null;
199
+ // 列表只放宿主给的 jobIds 和本页里新建 / 接续出来的对话:不从 center 发现,也不从本地存储恢复——
200
+ // 不传就不加载,免得把这个产品线令牌下别的业务、别的用户的对话列出来。
99
201
 
100
- let state: CliState = { selected: null, jobIds: loadIds(storageKey), jobs: {}, convs: {}, listLoading: false, listError: null };
202
+ let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, context: {} };
101
203
  const listeners = new Set<() => void>();
102
204
  const eventListeners = new Set<(event: CliEvent) => void>();
103
205
  const settledTurns = new Set<string>();
@@ -158,6 +260,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
158
260
  emit({ type: "conversation_closed", jobId, key, reason: payload.closed_reason, continuedBy: next.continuedBy, payload });
159
261
  }
160
262
  }
263
+ // 一次性任务里看着的子任务换了状态(开跑、结束):列表里那一行马上跟上,不等列表的定时刷新。
264
+ if (next.batchTask && (prev.batchTask?.id !== next.batchTask.id || prev.batchTask?.status !== next.batchTask.status)) void refreshJob(jobId);
161
265
  // 实时流里的对话状态同步给列表。
162
266
  if (next.job && prev.job?.status !== next.job.status) {
163
267
  const entry = state.jobs[jobId];
@@ -165,11 +269,6 @@ export function createCliStore(options: CliStoreOptions = {}) {
165
269
  }
166
270
  }
167
271
 
168
- function persist(ids: string[]) {
169
- if (!storageKey || hostIds) return;
170
- try { localStorage.setItem(storageKey, JSON.stringify(ids.slice(0, 200))); } catch { /* 存不下就不记 */ }
171
- }
172
-
173
272
  // ── 列表 ─────────────────────────────────────────────
174
273
 
175
274
  function setJobIds(ids: string[]) {
@@ -183,13 +282,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
183
282
  if (state.jobIds[0] === jobId) return;
184
283
  const ids = [jobId, ...state.jobIds.filter((id) => id !== jobId)];
185
284
  set({ jobIds: ids });
186
- persist(ids);
187
285
  }
188
286
 
189
287
  function removeJob(jobId: string) {
190
288
  const ids = state.jobIds.filter((id) => id !== jobId);
191
289
  set({ jobIds: ids, selected: state.selected === jobId ? null : state.selected });
192
- persist(ids);
193
290
  }
194
291
 
195
292
  /** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。oldId / newId 可以是对话键。 */
@@ -200,7 +297,6 @@ export function createCliStore(options: CliStoreOptions = {}) {
200
297
  const ids = from.jobId === to.jobId ? state.jobIds
201
298
  : state.jobIds.includes(from.jobId) ? state.jobIds.map((id) => (id === from.jobId ? to.jobId : id)) : [to.jobId, ...state.jobIds];
202
299
  set({ jobIds: [...new Set(ids)], selected: state.selected === oldId ? newId : state.selected });
203
- persist(state.jobIds);
204
300
  emit({ type: "job_change", jobId: to.jobId, previousJobId: from.jobId, taskId: to.taskId, key: newId, previousKey: oldId });
205
301
  void refreshJob(to.jobId, true);
206
302
  }
@@ -245,50 +341,16 @@ export function createCliStore(options: CliStoreOptions = {}) {
245
341
  const status = state.jobs[id]?.job?.status;
246
342
  return !status || !TERMINAL_JOB.has(status);
247
343
  });
248
- await runLimited(ids, 4, (id) => refreshJob(id, true));
344
+ await runLimited(ids, LIST_CONCURRENCY, (id) => refreshJob(id, true));
249
345
  set({ listLoading: false });
250
346
  } catch (error) {
251
347
  set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
252
348
  }
253
349
  }
254
350
 
255
- /**
256
- * 从 center 发现本产品线的对话:center 没有「列出对话」接口,只能从全部子任务里按 job 去重,
257
- * 再逐个看是不是对话任务。center 的 ID 是随机的,返回顺序不代表新旧,所以最多看 limit 个 job(缺省 200);
258
- * 对话多的产品线请由宿主传 jobIds。只在宿主没给 jobIds 时调用。
259
- */
260
- let discovering: Promise<void> | null = null;
261
- function discover(limit = 200) {
262
- // 同时多次调用(React StrictMode 下 effect 会跑两遍)只发现一次。
263
- discovering ??= discoverOnce(limit).finally(() => { discovering = null; });
264
- return discovering;
265
- }
266
-
267
- async function discoverOnce(limit: number) {
268
- set({ listLoading: true, listError: null });
269
- try {
270
- const { tasks } = await client.listTasks();
271
- const candidates = [...new Set(tasks.map((task) => task.job_id))].filter((id) => !state.jobIds.includes(id)).slice(0, limit);
272
- const found: string[] = [];
273
- await runLimited(candidates, 4, async (id) => {
274
- const job = await client.getJob(id).catch(() => null);
275
- if (job?.mode === "conversation") {
276
- found.push(id);
277
- patchJob(id, { job, firstInput: null, loading: false, error: null, fetchedAt: 0 });
278
- }
279
- });
280
- const ids = [...new Set([...state.jobIds, ...found])];
281
- set({ jobIds: ids, listLoading: false });
282
- persist(ids);
283
- await runLimited(found, 4, (id) => refreshJob(id, true));
284
- } catch (error) {
285
- set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
286
- }
287
- }
288
-
289
351
  // ── 实时流(引用计数 + 断线重连) ─────────────────────
290
352
 
291
- const streams = new Map<string, { refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number }>();
353
+ const streams = new Map<string, { refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number; parked?: boolean }>();
292
354
 
293
355
  function connect(key: string) {
294
356
  const entry = streams.get(key);
@@ -313,23 +375,33 @@ export function createCliStore(options: CliStoreOptions = {}) {
313
375
  if (!current || current.refs <= 0) return;
314
376
  // 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
315
377
  if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
316
- // 404 / 401:对话不存在或没权限,重连也没用。
317
- if (error && (error.status === 404 || error.status === 401 || error.status === 403)) {
378
+ // 404:对话不存在,重连也没用。
379
+ if (error && error.status === 404) {
318
380
  patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
319
381
  emit({ type: "error", jobId: key, error });
320
382
  return;
321
383
  }
384
+ // 401 / 403:多半是宿主的登录会话过期。报出来(宿主可以据此续会话),之后每 30 秒再试,会话续上了自己恢复。
385
+ if (error && (error.status === 401 || error.status === 403)) {
386
+ patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
387
+ if (current.attempt === 0) emit({ type: "error", jobId: key, error });
388
+ current.attempt += 1;
389
+ current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(AUTH_RETRY_MS));
390
+ return;
391
+ }
322
392
  const delay = RECONNECT_MS[Math.min(current.attempt, RECONNECT_MS.length - 1)];
323
393
  current.attempt += 1;
324
394
  patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
325
- current.timer = setTimeout(() => { current.timer = null; connect(key); }, delay);
395
+ current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(delay));
326
396
  },
327
397
  }, undefined, taskId);
328
398
  }
329
399
 
330
400
  // 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
401
+ // 只在有实时流时登记到全局(见 watchReconnect):组件卸载、流都关了就撤掉,宿主单页应用里反复挂载组件不会越积越多。
331
402
  function reconnectNow() {
332
403
  for (const [jobId, entry] of streams) {
404
+ if (entry.parked && entry.refs > 0) { entry.parked = false; entry.attempt = 0; connect(jobId); continue; }
333
405
  if (!entry.timer) continue;
334
406
  clearTimeout(entry.timer);
335
407
  entry.timer = null;
@@ -337,10 +409,18 @@ export function createCliStore(options: CliStoreOptions = {}) {
337
409
  connect(jobId);
338
410
  }
339
411
  }
340
- if (typeof window !== "undefined") {
341
- window.addEventListener("online", reconnectNow);
342
- document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reconnectNow(); });
412
+ // 页面在后台太久:停掉实时流(主动停止不算断线,不会触发重连),切回来由 reconnectNow 接上。
413
+ function parkAll() {
414
+ for (const entry of streams.values()) {
415
+ // 只停连着的和正在等重连的;对话已结束、不再连的不动。
416
+ if (entry.refs <= 0 || (!entry.stop && !entry.timer)) continue;
417
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
418
+ entry.stop?.();
419
+ entry.stop = null;
420
+ entry.parked = true;
421
+ }
343
422
  }
423
+ const syncLive = () => { if (streams.size) watchReconnect(reconnectNow, parkAll); else unwatchReconnect(reconnectNow, parkAll); };
344
424
 
345
425
  /**
346
426
  * 开始看一个对话(对话键;返回停止函数)。同一对话多处同时看只连一次。
@@ -356,6 +436,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
356
436
  if (!entry) {
357
437
  entry = { refs: 0, stop: null, timer: null, attempt: 0 };
358
438
  streams.set(key, entry);
439
+ syncLive();
359
440
  }
360
441
  entry.refs += 1;
361
442
  if (entry.refs === 1) {
@@ -370,6 +451,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
370
451
  current.stop?.();
371
452
  if (current.timer) clearTimeout(current.timer);
372
453
  streams.delete(key);
454
+ syncLive();
373
455
  patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
374
456
  };
375
457
  }
@@ -393,7 +475,34 @@ export function createCliStore(options: CliStoreOptions = {}) {
393
475
  return run;
394
476
  }
395
477
 
396
- async function sendNow(key: string | null, text: string, overrides: SendOptions): Promise<string> {
478
+ async function sendNow(key: string | null, typed: string, overrides: SendOptions): Promise<string> {
479
+ // 业务方补充的背景:随这条消息带上(发出前取走,失败放回去)。超过单条上限就不发,背景留着。
480
+ const ck = ctxKey(key);
481
+ const ctx = state.context[ck] ?? [];
482
+ let text = typed;
483
+ if (ctx.length) {
484
+ text = `${typed}\n\n${serializeBlock(backgroundBlock(ctx))}`;
485
+ if (charCount(text) > MAX_MESSAGE_CHARS) {
486
+ const err = new CenterError(`补充背景加上这条消息超过单条上限(${MAX_MESSAGE_CHARS.toLocaleString()} 字),请精简补充背景`, "too_long", 0);
487
+ emit({ type: "error", jobId: key, error: err });
488
+ throw err;
489
+ }
490
+ setContext(ck, []);
491
+ }
492
+ try {
493
+ const target = await sendWithText(key, text, overrides);
494
+ if (ctx.length) emit({ type: "context", key: target, pending: state.context[ck] ?? [], sent: ctx });
495
+ return target;
496
+ } catch (error) {
497
+ if (ctx.length) {
498
+ const have = new Set((state.context[ck] ?? []).map((c) => c.id));
499
+ setContext(ck, [...ctx.filter((c) => !have.has(c.id)), ...(state.context[ck] ?? [])]);
500
+ }
501
+ throw error;
502
+ }
503
+ }
504
+
505
+ async function sendWithText(key: string | null, text: string, overrides: SendOptions): Promise<string> {
397
506
  // 发消息的组件自己的参数优先(同一个仓库里可以有几个对话组件,各带各的新建参数),没有再用仓库的。
398
507
  const newConversation = overrides.newConversation ?? options.newConversation;
399
508
  const createConversation = overrides.createConversation ?? options.createConversation;
@@ -430,6 +539,22 @@ export function createCliStore(options: CliStoreOptions = {}) {
430
539
  void refreshJob(newId, true);
431
540
  return newId;
432
541
  }
542
+ const shown = state.convs[key];
543
+ if (ref.taskId && (isChatTask(shown) || shown?.batch || state.convs[ref.jobId]?.batch)) {
544
+ // 一次性任务里的子任务(对话式的,或已经结束的普通子任务):续派一条接着它的对话(中枢接到这个对话最新的那条后面),对话键不变。
545
+ // 续派没有按 business_id 去重,不重发。
546
+ if (!client.continueTask) throw new CenterError("当前后端不支持接着对话式子任务聊", "UNSUPPORTED", 0);
547
+ const { task_id } = await client.continueTask(ref.jobId, ref.taskId, text, { businessId });
548
+ // 先显示成排队中,不用等实时流的下一次轮询。
549
+ patchConv(key, (conv) => conv.batchTask && !conv.batchTask.queued?.some((q) => q.id === task_id)
550
+ ? { ...conv, batchTask: { ...conv.batchTask, queued: [...(conv.batchTask.queued ?? []), { id: task_id, prompt: text, created_at: new Date().toISOString() }] } }
551
+ : conv);
552
+ // 大任务结束时旧版中枢会关流:重新连上,接着看这一条。
553
+ const entry = streams.get(key);
554
+ if (entry && entry.refs > 0 && !entry.stop && !entry.timer) { entry.attempt = 0; connect(key); }
555
+ void refreshJob(ref.jobId, true);
556
+ return key;
557
+ }
433
558
  const result = await retryOnce(() => client.sendMessage(ref.jobId, text, { businessId, ...(ref.taskId ? { taskId: ref.taskId } : {}) }));
434
559
  const jobTarget = result.job_id || ref.jobId;
435
560
  // 指定了对话的:接续后跟到新对话任务里对应的那个对话;只有 job_id 的照旧只换 job。
@@ -455,7 +580,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
455
580
  * 所以回答作为下一条消息发出——与 Operator 在没有等待中的回合时(/answer 返回 resolved=false)的做法一致;
456
581
  * 卡片先在本地标成已答(Operator 同样是先乐观更新)。
457
582
  */
458
- async function answerAsk(key: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions = {}): Promise<string> {
583
+ /** fallback = false:只交给正在等的回合,不改发消息(一次性任务不能发消息);没有在等的回合时报错。 */
584
+ async function answerAsk(key: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions & { fallback?: boolean } = {}): Promise<string> {
459
585
  // 与 Operator 的 answerQuestion 同一流程:先乐观标成已答 → 交给正在等的回合 → 没有在等的回合就当普通回复发出。
460
586
  patchConv(key, (conv) => ({ ...conv, localAnswers: { ...conv.localAnswers, [askId]: answers } }));
461
587
  try {
@@ -463,6 +589,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
463
589
  const { resolved } = await client.answerAsk(key, askId, answers);
464
590
  if (resolved) return key;
465
591
  }
592
+ if (overrides.fallback === false) throw new CenterError("这个提问已经没有在等回答了(回合已经结束)", "ASK_EXPIRED", 0);
466
593
  return await send(key, formatAnswersText(questions, answers), overrides);
467
594
  } catch (error) {
468
595
  // 没发出去:卡片恢复成可回答。
@@ -508,8 +635,164 @@ export function createCliStore(options: CliStoreOptions = {}) {
508
635
  }
509
636
  }
510
637
 
638
+ /**
639
+ * 往上翻:取这个对话更早的一页节点消息,接在最前面。实时流只给最新一段(older 是更早还有几条)。
640
+ * 同一个对话同一时刻只取一页。返回取到没有。
641
+ */
642
+ const loadingOlder = new Set<string>();
643
+ async function loadOlder(key: string): Promise<boolean> {
644
+ const conv = state.convs[key];
645
+ // 节点会话:一次性任务是正在看的那个对话,对话任务是流里这个对话的远端任务。
646
+ const remote = conv ? conv.remoteTaskId ?? conv.task?.remote_task_id ?? null : null;
647
+ const first = conv?.msgs[0];
648
+ if (!conv || conv.older <= 0 || !remote || !first || !client.history || loadingOlder.has(key)) return false;
649
+ loadingOlder.add(key);
650
+ try {
651
+ const page = await client.history(key, remote, first.id, 200);
652
+ // 取的过程中换了节点会话(重建、续派切过去):这一页作废。
653
+ patchConv(key, (c) => ((c.remoteTaskId ?? c.task?.remote_task_id ?? null) === remote && c.msgs[0]?.id === first.id ? applyOlderPage(c, page.messages ?? [], page.older) : c));
654
+ return true;
655
+ } catch (error) {
656
+ emit({ type: "error", jobId: key, error: error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0) });
657
+ return false;
658
+ } finally {
659
+ loadingOlder.delete(key);
660
+ }
661
+ }
662
+
511
663
  // 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
512
664
  const surfaces: BoardSurface[] = [];
665
+ // ── 看板 → 对话的引用 ─────────────────────────────
666
+
667
+ function setRefs(key: string, refs: BoardRef[]) {
668
+ const next = { ...state.refs };
669
+ if (refs.length) next[key] = refs; else delete next[key];
670
+ set({ refs: next });
671
+ emit({ type: "board_ref", key, refs });
672
+ }
673
+ /** 把看板里的东西引用到这个对话的下一条消息(同 id 替换)。 */
674
+ function addRef(key: string, ref: BoardRef) {
675
+ const list = (state.refs[key] ?? []).filter((r) => r.id !== ref.id);
676
+ // 引用是「指向看板里的什么」,不是把内容整段塞进消息:每条截到 REF_TEXT_MAX,最多留 REF_MAX 条,消息不会因此超长。
677
+ const text = ref.text.length > REF_TEXT_MAX ? `${ref.text.slice(0, REF_TEXT_MAX)}…(已截断,共 ${ref.text.length} 字)` : ref.text;
678
+ setRefs(key, [...list, { ...ref, text }].slice(-REF_MAX));
679
+ }
680
+ function removeRef(key: string, id: string) {
681
+ const list = state.refs[key] ?? [];
682
+ if (list.some((r) => r.id === id)) setRefs(key, list.filter((r) => r.id !== id));
683
+ }
684
+ /** 取走这个对话的引用(发消息时用;发失败时用 restoreRefs 放回去)。 */
685
+ function takeRefs(key: string): BoardRef[] {
686
+ const list = state.refs[key] ?? [];
687
+ if (list.length) setRefs(key, []);
688
+ return list;
689
+ }
690
+ function restoreRefs(key: string, refs: BoardRef[]) {
691
+ if (!refs.length) return;
692
+ const have = new Set((state.refs[key] ?? []).map((r) => r.id));
693
+ setRefs(key, [...refs.filter((r) => !have.has(r.id)), ...(state.refs[key] ?? [])]);
694
+ }
695
+
696
+ // ── 看板上正在看的东西(缺省带进下一条消息一次)────────
697
+ function setViewingState(key: string, value: { ref: BoardRef; off: boolean } | null) {
698
+ const next = { ...state.viewing };
699
+ if (value) next[key] = value; else delete next[key];
700
+ set({ viewing: next });
701
+ }
702
+ /**
703
+ * 看板报告这个对话正在看什么(null = 没在看)。同一个东西重复报告不变。
704
+ * owner:哪个看板报的。同一个对话开着两个看板(常驻的 + 弹出的)时,一个撤掉只撤自己报的,不清掉另一个的。
705
+ */
706
+ // 每个看板各自报告(按 owner 记),生效的是最近报告、还开着的那个;一个看板撤掉只撤它自己的。
707
+ const viewSources = new Map<string, Map<string, BoardRef>>();
708
+ const boardSources = new Map<string, Map<string, BoardView>>();
709
+ /** 记下 owner 的报告,返回现在生效的那个(最近报告的)。 */
710
+ function latestReport<T>(sources: Map<string, Map<string, T>>, key: string, owner: string, value: T | null): T | null {
711
+ const m = sources.get(key) ?? new Map<string, T>();
712
+ m.delete(owner);
713
+ if (value) m.set(owner, value);
714
+ if (m.size) sources.set(key, m); else sources.delete(key);
715
+ return m.size ? [...m.values()][m.size - 1] : null;
716
+ }
717
+ function setViewing(key: string, ref: BoardRef | null, owner = "default") {
718
+ const top = latestReport(viewSources, key, owner, ref);
719
+ const cur = state.viewing[key];
720
+ if (!top) { if (cur) setViewingState(key, null); return; }
721
+ if (cur && cur.ref.id === top.id && cur.ref.text === top.text) return;
722
+ setViewingState(key, { ref: top, off: cur?.ref.id === top.id ? cur.off : false });
723
+ }
724
+
725
+ // ── 看板现在的样子(给业务方:当前打开的文件等)────────
726
+ function reportBoard(key: string, view: BoardView | null, owner = "default") {
727
+ const top = latestReport(boardSources, key, owner, view ? { ...view, files: [...view.files] } : null);
728
+ const cur = state.boards[key];
729
+ if (!top && !cur) return;
730
+ if (top && cur && cur.tab === top.tab && cur.dir === top.dir && cur.file === top.file && cur.files.join("\n") === top.files.join("\n")) return;
731
+ const next = { ...state.boards };
732
+ if (top) next[key] = top; else delete next[key];
733
+ set({ boards: next });
734
+ emit({ type: "board_change", key, view: top ? { ...top, files: [...top.files] } : null });
735
+ }
736
+
737
+ // ── 补充背景:业务方随时追加,随下一条消息发给智能体 ────────
738
+ const ctxKey = (key: string | null) => key ?? "";
739
+ function setContext(key: string, items: ContextItem[], sent?: ContextItem[]) {
740
+ const next = { ...state.context };
741
+ if (items.length) next[key] = items; else delete next[key];
742
+ set({ context: next });
743
+ emit({ type: "context", key, pending: items, ...(sent ? { sent } : {}) });
744
+ }
745
+ /**
746
+ * 给这个对话追加一段项目背景(null = 还没建的新对话,随第一条消息带上)。
747
+ * 下一条消息发出时一起带给智能体(对话里显示成一张「补充背景」卡片),发出后智能体在这个对话里一直记得。
748
+ * 同一个 id 再追加会替换。返回这条的 id。
749
+ */
750
+ function appendContext(key: string | null, text: string, opts: { title?: string; id?: string } = {}): string {
751
+ const body = String(text ?? "").trim();
752
+ if (!body) throw new Error("补充背景不能为空");
753
+ if (charCount(body) > CONTEXT_ITEM_MAX) throw new Error(`一条补充背景最长 ${CONTEXT_ITEM_MAX.toLocaleString()} 字,请拆成几条或精简`);
754
+ const k = ctxKey(key);
755
+ const id = opts.id ?? `ctx-${uid()}`;
756
+ const list = (state.context[k] ?? []).filter((c) => c.id !== id);
757
+ setContext(k, [...list, { id, text: body, ...(opts.title ? { title: opts.title.trim().slice(0, 60) } : {}) }]);
758
+ return id;
759
+ }
760
+ function removeContext(key: string | null, id?: string) {
761
+ const k = ctxKey(key);
762
+ const list = state.context[k] ?? [];
763
+ const next = id ? list.filter((c) => c.id !== id) : [];
764
+ if (next.length !== list.length) setContext(k, next);
765
+ }
766
+ /** 还没发出的补充背景。 */
767
+ const pendingContext = (key: string | null): ContextItem[] => [...(state.context[ctxKey(key)] ?? [])];
768
+
769
+ function workspace(key?: string | null): CliWorkspace {
770
+ const k = key ?? state.selected;
771
+ if (!k) throw new Error("没有指定对话:请传 jobId,或先选中一个对话");
772
+ return createWorkspace({
773
+ backend: client.board,
774
+ key: k,
775
+ view: () => state.boards[k],
776
+ open: (path) => openInBoard(k, { kind: "file", path }),
777
+ });
778
+ }
779
+ /** 这次不带上正在看的东西。 */
780
+ function dismissViewing(key: string) {
781
+ const cur = state.viewing[key];
782
+ if (cur && !cur.off) setViewingState(key, { ...cur, off: true });
783
+ }
784
+ /** 发消息时取走(取走后同一个东西不再重复带;看了别的才再带)。 */
785
+ function takeViewing(key: string): BoardRef | null {
786
+ const cur = state.viewing[key];
787
+ if (!cur || cur.off) return null;
788
+ setViewingState(key, { ...cur, off: true });
789
+ return cur.ref;
790
+ }
791
+ function restoreViewing(key: string, ref: BoardRef | null) {
792
+ const cur = state.viewing[key];
793
+ if (ref && cur && cur.ref.id === ref.id && cur.off) setViewingState(key, { ...cur, off: false });
794
+ }
795
+
513
796
  function openInBoard(jobId: string, target: OpenTarget): boolean {
514
797
  let handled = false;
515
798
  for (let i = surfaces.length - 1; i >= 0 && !handled; i--) handled = surfaces[i](jobId, target);
@@ -534,22 +817,21 @@ export function createCliStore(options: CliStoreOptions = {}) {
534
817
  set({ selected: jobId });
535
818
  emit({ type: "select", jobId });
536
819
  },
537
- setJobIds, addJob, removeJob, replaceJob, refreshJob, refreshAll, discover,
538
- open, send, answerAsk, cancelTurn, close,
820
+ setJobIds, addJob, removeJob, replaceJob, refreshJob, refreshAll,
821
+ addRef, removeRef, takeRefs, restoreRefs,
822
+ setViewing, dismissViewing, takeViewing, restoreViewing,
823
+ /** 看板报告自己的样子(看板组件用)。 */
824
+ reportBoard,
825
+ /** 业务方追加项目背景:随下一条消息发给智能体。 */
826
+ appendContext, removeContext, pendingContext,
827
+ /** 工作区文件接口(不显示看板也能用):列文件、读文件、下载、打包。不传 = 当前选中的对话。 */
828
+ workspace,
829
+ open, send, answerAsk, cancelTurn, close, loadOlder,
539
830
  };
540
831
  }
541
832
 
542
833
  export type CliStore = ReturnType<typeof createCliStore>;
543
834
 
544
- function loadIds(key: string | null): string[] {
545
- if (!key) return [];
546
- try {
547
- const value = JSON.parse(localStorage.getItem(key) || "[]");
548
- return Array.isArray(value) ? [...new Set(value.filter((id): id is string => typeof id === "string"))] : [];
549
- } catch {
550
- return [];
551
- }
552
- }
553
835
 
554
836
  async function runLimited<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>) {
555
837
  let index = 0;
@@ -559,6 +841,9 @@ async function runLimited<T>(items: T[], limit: number, fn: (item: T) => Promise
559
841
  await Promise.all(workers);
560
842
  }
561
843
 
844
+ const REF_MAX = 20;
845
+ const REF_TEXT_MAX = 4_000;
846
+
562
847
  /** 只有网络层失败(没收到响应)才重发:带 business_id,center 会去重。 */
563
848
  async function retryOnce<T>(fn: () => Promise<T>): Promise<T> {
564
849
  try {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 工作区文本文件的解码:智能体写的多是 UTF-8,但业务导出的 CSV / TXT 常常是 GBK(GB18030),
3
+ * 按 UTF-8 硬解会满屏乱码。顺序:BOM(UTF-8 / UTF-16)→ 严格 UTF-8 → GB18030。
4
+ *
5
+ * 只读了文件的一段(开头 / 末尾)时:开头那段末尾可能切在一个字中间——按流式解码,把不完整的尾巴留下不报错;
6
+ * 末尾那段开头可能切在一个 UTF-8 字中间——跳过开头最多 3 个续字节。
7
+ */
8
+ export function decodeText(bytes: Uint8Array, opts: { fromMiddle?: boolean } = {}): { text: string; encoding: string } {
9
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return { text: new TextDecoder("utf-8").decode(bytes.subarray(3), { stream: true }), encoding: "utf-8" };
10
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return { text: new TextDecoder("utf-16le").decode(bytes.subarray(2), { stream: true }), encoding: "utf-16le" };
11
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return { text: new TextDecoder("utf-16be").decode(bytes.subarray(2), { stream: true }), encoding: "utf-16be" };
12
+ let start = 0;
13
+ if (opts.fromMiddle) while (start < 3 && start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
14
+ const body = bytes.subarray(start);
15
+ try {
16
+ return { text: new TextDecoder("utf-8", { fatal: true }).decode(body, { stream: true }), encoding: "utf-8" };
17
+ } catch {
18
+ try {
19
+ return { text: new TextDecoder("gb18030").decode(bytes, { stream: true }), encoding: "gb18030" };
20
+ } catch {
21
+ return { text: new TextDecoder("utf-8").decode(body, { stream: true }), encoding: "utf-8" };
22
+ }
23
+ }
24
+ }