@spzxg/cli-ui 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +33 -13
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +9 -8
  11. package/src/board/BrowserPane.tsx +198 -53
  12. package/src/board/FileViewer.tsx +22 -9
  13. package/src/board/fileWatch.ts +9 -3
  14. package/src/board/frameQuote.ts +2 -2
  15. package/src/board/textLoad.ts +7 -5
  16. package/src/board/workspace.ts +15 -5
  17. package/src/chat/CliChat.tsx +21 -11
  18. package/src/chat/Composer.tsx +4 -2
  19. package/src/core/blocks.ts +15 -4
  20. package/src/core/client.ts +96 -14
  21. package/src/core/conversation.ts +1 -0
  22. package/src/core/provider.tsx +7 -0
  23. package/src/core/store.ts +68 -27
  24. package/src/core/types.ts +30 -9
  25. package/src/layout/split.ts +67 -30
  26. package/src/msglist/CliMsgList.tsx +9 -3
  27. package/src/operator/node/Transcript.tsx +7 -2
  28. package/src/operator/node/boardBackend.ts +6 -4
  29. package/src/operator/node/protocol/types.ts +2 -2
  30. package/src/operator/node/useTaskStream.ts +5 -4
  31. package/src/operator.base.css +8 -0
  32. package/src/operator.css +8 -0
  33. package/src/styles.css +29 -1
  34. package/src/ui/Popover.tsx +10 -5
  35. package/src/ui/Transcript.tsx +7 -2
  36. package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
@@ -62,7 +62,7 @@ export interface CliWorkspace {
62
62
  /** 列 dir 下所有文件(递归,只含文件)。 */
63
63
  listAll(dir?: string, opts?: { signal?: AbortSignal }): Promise<FileEntry[]>;
64
64
  /** 读成文字(GBK 导出的文件会自动转码)。 */
65
- readText(path: string): Promise<string>;
65
+ readText(path: string, opts?: { signal?: AbortSignal }): Promise<string>;
66
66
  /** 读成 Blob。onProgress 报已收到的字节数;超过 maxBytes(缺省 500 MB)报错,改用 fileUrl / download。 */
67
67
  readFile(path: string, opts?: { signal?: AbortSignal; onProgress?: (bytes: number) => void; maxBytes?: number }): Promise<Blob>;
68
68
  /** 浏览器能直接打开 / 下载的地址(没有同源代理时是 30 分钟有效的短期链接);接入方式不支持时为 null,改用 readFile。 */
@@ -110,10 +110,20 @@ export function createWorkspace(deps: {
110
110
  const files = await listFolder(backend(), key, clean(dir), { signal: opts.signal, maxFiles: WORKSPACE_LIST_MAX, maxBytes: Infinity });
111
111
  return files.map((f) => ({ name: f.path.split("/").pop() || f.path, path: f.path, type: "file" as const, size: f.size, mtime: f.mtime }));
112
112
  },
113
- async readText(path) {
114
- const r = await backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
115
- if (r.truncated) throw new Error(`文件较大(超过 ${WORKSPACE_TEXT_MAX / 1024 / 1024} MB),请用 readFile 读成 Blob`);
116
- return r.text;
113
+ async readText(path, opts = {}) {
114
+ const signal = opts.signal;
115
+ if (signal?.aborted) throw new DOMException("已取消", "AbortError");
116
+ let rejectAbort: (() => void) | undefined;
117
+ try {
118
+ const reading = signal ? backend().readText(key, clean(path), WORKSPACE_TEXT_MAX, { signal }) : backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
119
+ const r = signal ? await Promise.race([reading, new Promise<never>((_resolve, reject) => {
120
+ rejectAbort = () => reject(new DOMException("已取消", "AbortError"));
121
+ signal.addEventListener("abort", rejectAbort, { once: true });
122
+ if (signal.aborted) rejectAbort();
123
+ })]) : await reading;
124
+ if (r.truncated) throw new Error(`文件较大(超过 ${WORKSPACE_TEXT_MAX / 1024 / 1024} MB),请用 readFile 读成 Blob`);
125
+ return r.text;
126
+ } finally { if (rejectAbort) signal?.removeEventListener("abort", rejectAbort); }
117
127
  },
118
128
  async readFile(path, opts = {}) {
119
129
  const p = clean(path);
@@ -169,7 +169,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
169
169
  useEffect(() => (key ? store.open(key) : undefined), [store, key]);
170
170
  // 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
171
171
  const keyRef = useRef(key);
172
- keyRef.current = key;
172
+ useLayoutEffect(() => { keyRef.current = key; }, [key]);
173
173
  useEffect(() => store.on((event) => {
174
174
  if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
175
175
  }), [store, go]);
@@ -235,7 +235,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
235
235
  : jobTitle ?? (key ? "对话" : "新对话");
236
236
  const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
237
237
  const lastTurn = conv?.turns[conv.turns.length - 1];
238
- const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
238
+ const listedAwaiting = (info ?? job?.conversation?.conversations?.find(item => item.task_id === task?.id))?.awaiting_input;
239
+ // An explicit null retires an old wait flag when its node observation expires.
240
+ const remoteAwaiting = conv?.task?.awaiting_input !== undefined ? conv.task.awaiting_input
241
+ : listedAwaiting !== undefined ? listedAwaiting : task?.awaiting_input !== undefined ? task.awaiting_input
242
+ : !manyConvs ? job?.conversation?.awaiting_input : undefined;
243
+ const awaiting = !ended && !TERMINAL_JOB.has(jobStatus ?? "") && !(batch && batchTaskStatus && SETTLED_TASK.has(batchTaskStatus))
244
+ && (remoteAwaiting === true || (!running && !!lastTurn?.asked && lastTurn.status === "succeeded"));
239
245
  // 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
240
246
  const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
241
247
  const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
@@ -248,9 +254,9 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
248
254
 
249
255
  const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
250
256
  if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
257
+ if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
251
258
  if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
252
259
  if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
253
- if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
254
260
  if (running) return { dotCls: "a", statusText: "运行中" };
255
261
  if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
256
262
  return { dotCls: "a", statusText: "进行中" };
@@ -261,12 +267,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
261
267
  const [actionError, setActionError] = useState<string | null>(null);
262
268
  useEffect(() => setActionError(null), [key]);
263
269
  const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
270
+ const sourceKey = keyRef.current;
264
271
  try {
265
272
  const result = await work;
266
- setActionError(null);
273
+ if (keyRef.current === sourceKey) setActionError(null);
267
274
  return result;
268
275
  } catch (error) {
269
- setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
276
+ if (keyRef.current === sourceKey) setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
270
277
  throw error;
271
278
  }
272
279
  }, []);
@@ -274,12 +281,15 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
274
281
  // (几个子任务同时跑时,卡片属于它,不是中枢缺省的那个)。和能不能发消息无关。
275
282
  const runKey = batch && !taskId && conv?.task?.id && jobId ? convKey(jobId, conv.task.id) : key;
276
283
  const latest = useRef({ key, messages, conv, runKey });
277
- latest.current = { key, messages, conv, runKey };
284
+ // A concurrent render may suspend while the old chat remains interactive.
285
+ // Event handlers must keep the target and options of the committed view.
286
+ useLayoutEffect(() => { latest.current = { key, messages, conv, runKey }; });
278
287
  // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
279
288
  const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
280
- sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
289
+ useLayoutEffect(() => { sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled }; });
281
290
  const [skipPassive,setSkipPassive]=useState(false);
282
- const skipPassiveRef=useRef(false);skipPassiveRef.current=skipPassive;
291
+ const skipPassiveRef=useRef(false);
292
+ useLayoutEffect(()=>{skipPassiveRef.current=skipPassive;},[skipPassive]);
283
293
  useEffect(()=>setSkipPassive(false),[key]);
284
294
  const send = useCallback(async (text: string) => {
285
295
  // 一次性任务:发给正在看的那个子任务(接着它聊)。
@@ -306,7 +316,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
306
316
  throw error;
307
317
  }
308
318
  if(latest.current.key===refKey)setSkipPassive(false);
309
- if (target !== id) go(target);
319
+ if (target !== id && latest.current.key === refKey) go(target);
310
320
  }, [store, go, passiveContext]);
311
321
  const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
312
322
  const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
@@ -351,7 +361,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
351
361
  // 一次性任务:提问时回合开着等,答案直接交给节点;不能改发消息。
352
362
  if (batch && !chat) { await report(store.answerAsk(latest.current.runKey ?? id, askId, questions, answers, { fallback: false }).then(() => undefined), "回答没有送到"); return; }
353
363
  const sent = askId
354
- ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
364
+ ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id && latest.current.key === id) go(target); })
355
365
  : send(formatAnswersText(questions, answers));
356
366
  await report(sent, "回答没有发出去");
357
367
  },
@@ -726,7 +736,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
726
736
  <button className="btn btn-ghost btn-sm" onClick={() => scrollToBottom("auto")}>跳到最新</button>
727
737
  </div>
728
738
  )}
729
- {cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
739
+ {cfg.transcript.runningIndicator && running && !awaiting && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
730
740
  {!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
731
741
  {ended && cfg.transcript.status && (
732
742
  <div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
@@ -67,7 +67,9 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
67
67
  readFileText(file)
68
68
  .then((text) => {
69
69
  if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
70
- updateComposerDraft(draft, s => ({ ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }));
70
+ updateComposerDraft(draft, s => s.pending.some(p => p.key === key && p.status === "reading")
71
+ ? { ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }
72
+ : s);
71
73
  })
72
74
  .catch((err: unknown) => {
73
75
  setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
@@ -142,7 +144,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
142
144
  <div key={p.key} className={`attach-chip file ${p.status}`} title={p.error || p.name}>
143
145
  <span className="attach-file">{Icon.doc()} {p.name}</span>
144
146
  <span className={`attach-badge ${p.status === "error" ? "err" : ""}`}>{p.status === "reading" ? "解析中…" : p.error}</span>
145
- {p.status === "error" && <button className="attach-x" title="移除" aria-label={`移除 ${p.name}`} onClick={() => setPending((prev) => prev.filter((x) => x.key !== p.key))}>×</button>}
147
+ <button type="button" className="attach-x" title={p.status === "reading" ? "取消添加,忽略尚未完成的解析结果" : "移除"} aria-label={`移除 ${p.name}`} onClick={() => setPending((prev) => prev.filter((x) => x.key !== p.key))}>×</button>
146
148
  </div>
147
149
  ))}
148
150
  </div>
@@ -48,16 +48,16 @@ export function backgroundBlock(items: Array<{ title?: string; text: string }>):
48
48
  return { name: BACKGROUND_NAME, kind: "background", text: `以下是补充的项目背景信息,请在这次及之后的工作中一并参考:\n\n${body}` };
49
49
  }
50
50
 
51
- /** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、HTML、CSV / TSV。都在浏览器里转成文字。 */
52
- export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".html", ".htm", ".csv", ".tsv"] as const;
51
+ /** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、YAML、HTML、CSV / TSV。都在浏览器里转成文字。 */
52
+ export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".yaml", ".yml", ".html", ".htm", ".csv", ".tsv"] as const;
53
53
  export const ACCEPT_ATTR = [
54
54
  ...ACCEPTED_EXTENSIONS,
55
- "text/plain", "text/markdown", "text/html", "text/csv", "text/tab-separated-values",
55
+ "text/plain", "text/markdown", "text/yaml", "application/yaml", "text/html", "text/csv", "text/tab-separated-values",
56
56
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
57
57
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel.sheet.macroEnabled.12",
58
58
  ].join(",");
59
59
  /** 不支持时提示里列的格式。 */
60
- export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、HTML、CSV";
60
+ export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、YAML(.yaml / .yml)、HTML、CSV";
61
61
  /** 一些常见但读不了的文档:给出另存为什么。 */
62
62
  const SAVE_AS: Record<string, string> = {
63
63
  ".doc": "老版 Word(.doc)请另存为 .docx 后再上传",
@@ -72,6 +72,8 @@ export const PASTE_BLOCK_CHARS = 2_000;
72
72
  export const PASTE_BLOCK_LINES = 40;
73
73
  /** center 单条消息上限(POST /api/jobs/:id/messages 的 text 最长 200,000 字符)。 */
74
74
  export const MAX_MESSAGE_CHARS = 200_000;
75
+ /** Same source-file budget as the full node composer; reject before reading/parsing. */
76
+ export const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
75
77
  export const PASTE_NAME = "粘贴的文本";
76
78
 
77
79
  const OPEN = /^----- 附件:(.+?)([\d,]+ 字)-----$/;
@@ -138,6 +140,13 @@ export class FileReadError extends Error {}
138
140
  * 其他文件(图片、PDF、压缩包……)不收;老版 .doc / .xls 浏览器里读不了,提示另存为。
139
141
  */
140
142
  export async function readFileText(file: File): Promise<string> {
143
+ if (file.size > MAX_DOCUMENT_BYTES) throw new FileReadError(`“${file.name}”超过 10 MB,请缩小后重试。`);
144
+ const text = await extractFileText(file);
145
+ if (text.length > MAX_MESSAGE_CHARS) throw new FileReadError(`“${file.name}”解析后的正文超过 ${MAX_MESSAGE_CHARS.toLocaleString("en-US")} 字符,请拆分后重试。`);
146
+ return text;
147
+ }
148
+
149
+ async function extractFileText(file: File): Promise<string> {
141
150
  const ext = extensionOf(file.name);
142
151
  if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
143
152
  if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
@@ -165,6 +174,8 @@ export async function readFileText(file: File): Promise<string> {
165
174
  }
166
175
  }
167
176
  const { text } = decodeText(new Uint8Array(await readAs(file, "buffer")));
177
+ // YAML literal scalars depend on blank lines, indentation and the final newline.
178
+ if (ext === ".yaml" || ext === ".yml") return text;
168
179
  if (ext === ".html" || ext === ".htm") return normalizeText(text.replace(INLINE_DATA, (m) => `${m.slice(0, m.indexOf(",") + 1)}…(内嵌数据已省略)`));
169
180
  return normalizeText(text);
170
181
  }
@@ -2,7 +2,7 @@ import { createSseParser } from "./sse";
2
2
  import { NEW_TASK, parseConvKey } from "./conversation";
3
3
  import type {
4
4
  BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo, Message,
5
- BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
5
+ BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot, WorkspaceFileBatch,
6
6
  } from "./types";
7
7
  import { decodeText } from "./textDecode";
8
8
 
@@ -78,6 +78,11 @@ const ERROR_TEXT: Record<string, string> = {
78
78
  task_already_finished: "当前子任务已经结束,请刷新状态后继续",
79
79
  task_not_paused: "当前子任务没有暂停,请刷新状态后重试",
80
80
  job_already_paused: "整个任务已暂停,请先恢复整个任务",
81
+ file_watch_unsupported: "当前服务不支持文件事件",
82
+ browser_required: "传递浏览器 YAML 手册或操作间隔时,需要同时启用 browser: true",
83
+ invalid_browser_playbook: "浏览器 YAML 手册校验失败,请检查格式和步骤配置",
84
+ invalid_browser_pacing: "浏览器操作间隔无效,请检查最小值和最大值",
85
+ conversation_browser_fields_fixed: "浏览器手册和操作间隔在创建对话时确定,追加消息不能修改",
81
86
  };
82
87
 
83
88
  /**
@@ -158,12 +163,12 @@ export interface BoardBackend {
158
163
  * tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
159
164
  * 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
160
165
  */
161
- readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
166
+ readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
162
167
  /**
163
168
  * 读一段原始字节:从 start 开始(缺省 0)最多 maxBytes;tail = 末尾 maxBytes。返回这段在文件里的起点和文件总大小。
164
169
  * 看板用它在文件变长时只取新增的部分(日志、还在写的报告),不整个重读。没有这个方法的后端每次整个重读。
165
170
  */
166
- readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
171
+ readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
167
172
  /**
168
173
  * 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
169
174
  * 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
@@ -325,7 +330,9 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
325
330
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
326
331
  ...(opts.agent ? { agent: opts.agent } : {}),
327
332
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
328
- ...(opts.browser ? { browser: true } : {}),
333
+ ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
334
+ ...(opts.browserPlaybook !== undefined ? { browser_playbook: opts.browserPlaybook } : {}),
335
+ ...(opts.browserPacing !== undefined ? { browser_pacing: opts.browserPacing } : {}),
329
336
  }],
330
337
  });
331
338
  },
@@ -465,6 +472,13 @@ function openSse(
465
472
  try {
466
473
  arm();
467
474
  const response = await doFetch(url, { headers: { ...headers, Accept: "text/event-stream" }, signal: ctl.signal });
475
+ // A host-supplied fetch may resolve after cancellation despite signal.
476
+ // Retire its body before acquiring a reader or restarting the watchdog.
477
+ if (ctl.signal.aborted) {
478
+ void response.body?.cancel().catch(() => undefined);
479
+ if (silent) onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0));
480
+ return;
481
+ }
468
482
  if (!response.ok || !response.body) {
469
483
  const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
470
484
  throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? `连接失败(HTTP ${response.status})`, body?.error ?? `HTTP_${response.status}`, response.status);
@@ -520,6 +534,31 @@ function boardPath(key: string, rest: string, query = "") {
520
534
  return `/api/jobs/${job}/${rest}${params ? `?${params}` : ""}`;
521
535
  }
522
536
 
537
+ /** Reject a malformed manifest as a whole: silently dropping entries could invent deletions. */
538
+ function isWorkspaceFileBatch(value: unknown): value is WorkspaceFileBatch {
539
+ if (!value || typeof value !== "object") return false;
540
+ const batch = value as Record<string, unknown>;
541
+ if (typeof batch.complete !== "boolean" || (batch.revision !== undefined && typeof batch.revision !== "string")) return false;
542
+ const file = (value: unknown) => {
543
+ if (!value || typeof value !== "object") return false;
544
+ const entry = value as Record<string, unknown>;
545
+ return typeof entry.name === "string" && typeof entry.path === "string" && !!entry.path
546
+ && (entry.type === "file" || entry.type === "dir")
547
+ && typeof entry.size === "number" && Number.isFinite(entry.size) && entry.size >= 0
548
+ && typeof entry.mtime === "number" && Number.isFinite(entry.mtime)
549
+ && (entry.changeToken === undefined || typeof entry.changeToken === "string");
550
+ };
551
+ if (batch.type === "snapshot") return Array.isArray(batch.files) && batch.files.every(file);
552
+ if (batch.type === "resync") return batch.complete === false && typeof batch.reason === "string";
553
+ return batch.type === "changes" && Array.isArray(batch.changes) && batch.changes.every(value => {
554
+ if (!value || typeof value !== "object") return false;
555
+ const change = value as Record<string, unknown>;
556
+ return ["created", "modified", "deleted"].includes(String(change.kind)) && typeof change.path === "string" && !!change.path
557
+ && (change.file === undefined || (file(change.file) && (change.file as { path: string }).path === change.path))
558
+ && (change.previous === undefined || (file(change.previous) && (change.previous as { path: string }).path === change.path));
559
+ });
560
+ }
561
+
523
562
  function createBoardClient(
524
563
  base: string,
525
564
  headers: (extra?: Record<string, string>) => Record<string, string>,
@@ -548,7 +587,11 @@ function createBoardClient(
548
587
  if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
549
588
  ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
550
589
  });
551
- response = await Promise.race([doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" }), stopped]);
590
+ const fetching = doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" });
591
+ // Host fetch adapters can ignore abort. Dispose a response that arrives
592
+ // after the caller already cancelled or timed out.
593
+ void fetching.then(late => { if (ctl.signal.aborted) void late.body?.cancel().catch(() => undefined); }, () => undefined);
594
+ response = await Promise.race([fetching, stopped]);
552
595
  } catch (error) {
553
596
  release();
554
597
  if (signal?.aborted) throw aborted();
@@ -556,21 +599,19 @@ function createBoardClient(
556
599
  throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
557
600
  }
558
601
  clearTimeout(timer);
559
- if (!response.ok && response.status !== 416) {
560
- release();
561
- const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
562
- const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : body?.error ?? `读取失败(HTTP ${response.status})`;
563
- throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? fallback, body?.error ?? `HTTP_${response.status}`, response.status);
564
- }
565
602
  const stallError = () => new CenterError(`读取「${name}」时 ${FILE_STALL_MS / 1000} 秒没有收到数据,传输已中断,请重试`, "NETWORK", 0);
566
603
  /** 读响应体:limit 读够就停;onBytes 报进度;FILE_STALL_MS 没数据当断线。 */
567
604
  const body = async (opts: { limit?: number; onBytes?: (bytes: number) => void } = {}): Promise<Uint8Array<ArrayBuffer>> => {
605
+ if (signal?.aborted) { release(); void response.body?.cancel().catch(() => undefined); throw aborted(); }
568
606
  // 空文件要「前 N 字节」:节点回 416,就是空的。
569
607
  if (response.status === 416) { release(); return new Uint8Array(0); }
570
608
  const reader = response.body?.getReader();
571
609
  if (!reader) {
572
610
  try { return new Uint8Array(await response.arrayBuffer()); } finally { release(); }
573
611
  }
612
+ const cancelBody = () => { void reader.cancel().catch(() => undefined); };
613
+ ctl.signal.addEventListener("abort", cancelBody, { once: true });
614
+ if (ctl.signal.aborted) cancelBody();
574
615
  const chunks: Uint8Array[] = [];
575
616
  let got = 0;
576
617
  let stalled = false;
@@ -598,6 +639,11 @@ function createBoardClient(
598
639
  throw new CenterError(`读取「${name}」中断:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
599
640
  } finally {
600
641
  clearTimeout(stall);
642
+ ctl.signal.removeEventListener("abort", cancelBody);
643
+ // Progress callbacks may throw too; no abandoned response should keep
644
+ // consuming the mobile connection after the read has failed.
645
+ void reader.cancel().catch(() => undefined);
646
+ try { reader.releaseLock(); } catch { /* already released */ }
601
647
  release();
602
648
  }
603
649
  if (signal?.aborted) throw aborted();
@@ -612,16 +658,25 @@ function createBoardClient(
612
658
  }
613
659
  return all;
614
660
  };
661
+ if (!response.ok && response.status !== 416) {
662
+ // Error bodies need the same abort/stall bounds as file data. A proxy
663
+ // returning 401 headers and then hanging must not park a read forever.
664
+ const bytes = await body({ limit: 8192 });
665
+ let problem: { error?: string; detail?: string } | null = null;
666
+ try { problem = JSON.parse(new TextDecoder().decode(bytes)); } catch { /* use the HTTP fallback */ }
667
+ const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : problem?.error ?? `读取失败(HTTP ${response.status})`;
668
+ throw new CenterError(problem?.detail ?? ERROR_TEXT[problem?.error ?? ""] ?? fallback, problem?.error ?? `HTTP_${response.status}`, response.status);
669
+ }
615
670
  return { response, body };
616
671
  };
617
672
  /**
618
673
  * 读一段:开头 / 从 start 起 maxBytes,或者末尾 maxBytes(超大日志)。只向中枢要这一段(Range);
619
674
  * 不支持 Range 的节点回整个文件(200),这里边读边截,大文件不整个拉进浏览器。
620
675
  */
621
- const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }) => {
676
+ const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }) => {
622
677
  const from = Math.max(0, opts.start ?? 0);
623
678
  const { maxBytes } = opts;
624
- const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` });
679
+ const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` }, opts.signal);
625
680
  const contentType = response.headers.get("content-type") ?? "text/plain";
626
681
  const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
627
682
  // 416:要的起点不在文件里(空文件,或文件变短了)。
@@ -646,6 +701,33 @@ function createBoardClient(
646
701
  return {
647
702
  directLinks,
648
703
  info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
704
+ watchFiles(jobId, handlers) {
705
+ let active = true;
706
+ let stop: () => void = () => {};
707
+ const close = (error: CenterError | null) => {
708
+ if (!active) return;
709
+ active = false; stop(); handlers.onClose(error);
710
+ };
711
+ const fetchStream: typeof fetch = async (input, init) => {
712
+ const response = await doFetch(input, init);
713
+ if (response.ok && !/^text\/event-stream(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) {
714
+ await response.body?.cancel().catch(() => undefined);
715
+ throw new CenterError("文件事件接口未返回事件流,请检查服务代理配置", "INVALID_RESPONSE", 502);
716
+ }
717
+ return response;
718
+ };
719
+ stop = openSse(fetchStream, `${base}${boardPath(jobId, "files/events")}`, headers(), (event, data) => {
720
+ if (!active) return;
721
+ if (event === "files" || event === "message") {
722
+ if (!isWorkspaceFileBatch(data)) { close(new CenterError("文件事件数据不完整,正在重新同步", "INVALID_RESPONSE", 502)); return; }
723
+ handlers.onEvent(data);
724
+ } else if (event === "end") {
725
+ const end = data && typeof data === "object" ? data as Record<string, unknown> : {};
726
+ close(new CenterError(typeof end.detail === "string" ? end.detail : "文件监听连接中断,正在重连", typeof end.reason === "string" ? end.reason : "NETWORK", typeof end.status === "number" ? end.status : 0));
727
+ }
728
+ }, close);
729
+ return () => { active = false; stop(); };
730
+ },
649
731
  stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
650
732
  fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
651
733
  async fileLinkBase(jobId) {
@@ -669,7 +751,7 @@ function createBoardClient(
669
751
  return work;
670
752
  },
671
753
  async readText(jobId, path, maxBytes = 512 * 1024, opts = {}) {
672
- const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, onBytes: opts.onBytes });
754
+ const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, signal: opts.signal, onBytes: opts.onBytes });
673
755
  if (!size && !bytes.length) return { text: "", truncated: false, size: 0, contentType };
674
756
  const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
675
757
  return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
@@ -135,6 +135,7 @@ const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
135
135
  // 对话式子任务跑完一条不算结束:还能接着聊。
136
136
  id: task.id, seq: task.seq, title: task.title, status: task.status, closed: !task.conversation && TERMINAL_TASK.has(task.status),
137
137
  remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
138
+ awaiting_input: task.awaiting_input, awaiting_input_observed_at: task.awaiting_input_observed_at,
138
139
  });
139
140
 
140
141
  /** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
@@ -70,6 +70,13 @@ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurn
70
70
  if (selectedId !== undefined) { created.select(selectedId); applied.current = selectedId; }
71
71
  return created;
72
72
  }, [external]); // eslint-disable-line react-hooks/exhaustive-deps
73
+ // Creation defaults can arrive after a file/configuration load. Update only
74
+ // these defaults on commit; the connection and workspace state stay stable.
75
+ useLayoutEffect(() => {
76
+ optionsRef.current.newConversation = options.newConversation;
77
+ optionsRef.current.createConversation = options.createConversation;
78
+ optionsRef.current.businessIdFor = options.businessIdFor;
79
+ }, [options.newConversation, options.createConversation, options.businessIdFor]);
73
80
  // 之后宿主改了 selectedId:一起切过去(在绘制前,免得闪一下旧的)。
74
81
  useLayoutEffect(() => {
75
82
  if (selectedId === undefined || selectedId === applied.current) return;
package/src/core/store.ts CHANGED
@@ -121,6 +121,9 @@ export type CliEvent =
121
121
  export function cloneWorkspaceEvent(event:CliEvent):CliEvent {
122
122
  if(event.type==='files_changed')return cloneWorkspaceChange(event);
123
123
  if(event.type==='files_watch')return {...event};
124
+ if(event.type==='board_ref')return {...event,refs:event.refs.map(ref=>({...ref}))};
125
+ if(event.type==='context')return {...event,pending:event.pending.map(item=>({...item})),...(event.sent?{sent:event.sent.map(item=>({...item}))}:{})};
126
+ if(event.type==='open')return {...event,target:{...event.target}};
124
127
  if(event.type==='board_context')return {...event,view:cloneBoardView(event.view)};
125
128
  if(event.type==='board_change')return {...event,view:event.view?cloneBoardView(event.view):null};
126
129
  return event;
@@ -384,24 +387,27 @@ export function createCliStore(options: CliStoreOptions = {}) {
384
387
  function connect(key: string) {
385
388
  const entry = streams.get(key);
386
389
  if (!entry || entry.refs <= 0) return;
390
+ const previous = entry.stop;
391
+ entry.stop = null;
392
+ previous?.();
393
+ if (streams.get(key) !== entry || entry.refs <= 0) return;
387
394
  const { jobId, taskId } = parseConvKey(key);
388
- patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
389
- let ended = false;
390
- entry.stop = client.stream(jobId, {
391
- onOpen: () => { entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
392
- onSnapshot: (snapshot) => patchConv(key, (conv) => applySnapshot(conv, snapshot)),
393
- onChunk: (items) => patchConv(key, (conv) => applyItems(conv, items)),
394
- onEnd: (end) => {
395
- ended = true;
396
- // 接续的新对话:指定了对话的跟到接续它的那个对话(还没有就不跟,再发消息时 center 会开出来);只有 job_id 的照旧跟新 job。
397
- 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;
398
- patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
399
- void refreshJob(jobId, true);
400
- },
401
- onClose: (error) => {
402
- entry.stop = null;
403
- const current = streams.get(key);
404
- if (!current || current.refs <= 0) return;
395
+ let ended = false, retired = false;
396
+ let cleanup: (() => void) | null = null;
397
+ const owns = () => streams.get(key) === entry && entry.refs > 0 && entry.stop === retire;
398
+ const active = () => !retired && owns();
399
+ const retire = () => {
400
+ if (retired) return;
401
+ retired = true;
402
+ if (entry.stop === retire) entry.stop = null;
403
+ const dispose = cleanup; cleanup = null;
404
+ notifyCallback(dispose ?? undefined, undefined);
405
+ };
406
+ const close = (error: CenterError | null) => {
407
+ if (!active()) return;
408
+ retire();
409
+ const current = streams.get(key);
410
+ if (current !== entry || current.refs <= 0 || current.stop) return;
405
411
  // 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
406
412
  if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
407
413
  // 404:对话不存在,重连也没用。
@@ -422,8 +428,32 @@ export function createCliStore(options: CliStoreOptions = {}) {
422
428
  current.attempt += 1;
423
429
  patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
424
430
  current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(delay));
425
- },
426
- }, undefined, taskId);
431
+ };
432
+ entry.stop = retire;
433
+ patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
434
+ if (!active()) return;
435
+ try {
436
+ cleanup = client.stream(jobId, {
437
+ onOpen: () => { if (!active()) return; entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
438
+ onSnapshot: (snapshot) => { if (active()) patchConv(key, (conv) => applySnapshot(conv, snapshot)); },
439
+ onChunk: (items) => { if (active()) patchConv(key, (conv) => applyItems(conv, items)); },
440
+ onEnd: (end) => {
441
+ if (!active()) return;
442
+ ended = true;
443
+ 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;
444
+ patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
445
+ void refreshJob(jobId, true);
446
+ },
447
+ onClose: close,
448
+ }, undefined, taskId);
449
+ // Some custom transports close synchronously before returning cleanup.
450
+ if (retired || !owns()) {
451
+ const dispose = cleanup; cleanup = null;
452
+ notifyCallback(dispose ?? undefined, undefined);
453
+ }
454
+ } catch (error) {
455
+ close(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
456
+ }
427
457
  }
428
458
 
429
459
  // 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
@@ -472,16 +502,22 @@ export function createCliStore(options: CliStoreOptions = {}) {
472
502
  void refreshJob(jobId);
473
503
  connect(key);
474
504
  }
505
+ const owned = entry;
506
+ let active = true;
475
507
  return () => {
508
+ if (!active) return;
509
+ active = false;
476
510
  const current = streams.get(key);
477
- if (!current) return;
511
+ if (current !== owned) return;
478
512
  current.refs -= 1;
479
513
  if (current.refs > 0) return;
480
- current.stop?.();
481
- if (current.timer) clearTimeout(current.timer);
514
+ // Retire the registry before calling host cleanup: it may synchronously
515
+ // acquire a new lease for the same task.
482
516
  streams.delete(key);
517
+ if (current.timer) { clearTimeout(current.timer); current.timer = null; }
483
518
  syncLive();
484
- patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
519
+ current.stop?.();
520
+ if (!streams.has(key)) patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
485
521
  };
486
522
  }
487
523
 
@@ -552,6 +588,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
552
588
  return target;
553
589
  }
554
590
  if (!key || !ref) {
591
+ const selectedBeforeCreate = state.selected;
555
592
  let newId: string;
556
593
  if (createConversation) {
557
594
  newId = await createConversation(text);
@@ -563,7 +600,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
563
600
  }
564
601
  addJob(newId);
565
602
  // 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
566
- if (overrides.select !== false) set({ selected: newId });
603
+ if (overrides.select !== false && state.selected === selectedBeforeCreate) set({ selected: newId });
567
604
  emit({ type: "job_change", jobId: newId, previousJobId: null, taskId: null, key: newId, previousKey: null });
568
605
  void refreshJob(newId, true);
569
606
  return newId;
@@ -695,7 +732,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
695
732
 
696
733
  function setRefs(key: string, refs: BoardRef[]) {
697
734
  const next = { ...state.refs };
698
- if (refs.length) next[key] = refs; else delete next[key];
735
+ if (refs.length) next[key] = refs.map(ref => ({ ...ref })); else delete next[key];
699
736
  set({ refs: next });
700
737
  emit({ type: "board_ref", key, refs });
701
738
  }
@@ -822,7 +859,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
822
859
  if (next.length !== list.length) setContext(k, next);
823
860
  }
824
861
  /** 还没发出的补充背景。 */
825
- const pendingContext = (key: string | null): ContextItem[] => [...(state.context[ctxKey(key)] ?? [])];
862
+ const pendingContext = (key: string | null): ContextItem[] => (state.context[ctxKey(key)] ?? []).map(item => ({ ...item }));
826
863
 
827
864
  function workspace(key?: string | null): CliWorkspace {
828
865
  const k = key ?? state.selected;
@@ -855,7 +892,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
855
892
 
856
893
  function openInBoard(jobId: string, target: OpenTarget): boolean {
857
894
  let handled = false;
858
- for (let i = surfaces.length - 1; i >= 0 && !handled; i--) handled = surfaces[i](jobId, target);
895
+ for (const surface of [...surfaces].reverse()) {
896
+ try { handled = surface(jobId, { ...target }); }
897
+ catch (error) { console.error("[@spzxg/cli-ui] board surface callback failed", error); }
898
+ if (handled) break;
899
+ }
859
900
  emit({ type: "open", jobId, target, handled });
860
901
  return handled;
861
902
  }