@spzxg/cli-ui 0.6.5 → 0.6.7

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 (80) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +109 -30
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +11 -8
  11. package/src/board/BrowserPane.tsx +198 -53
  12. package/src/board/CliBoard.tsx +128 -100
  13. package/src/board/FileViewer.tsx +276 -136
  14. package/src/board/FilesPane.tsx +129 -40
  15. package/src/board/HtmlReadingPreview.tsx +52 -0
  16. package/src/board/MarkdownPreview.tsx +134 -0
  17. package/src/board/PanelControls.tsx +19 -7
  18. package/src/board/fileKind.ts +138 -0
  19. package/src/board/fileOps.ts +9 -2
  20. package/src/board/fileScope.ts +29 -0
  21. package/src/board/fileSync.ts +343 -0
  22. package/src/board/fileSyncCache.ts +250 -0
  23. package/src/board/fileSyncProcessor.ts +71 -0
  24. package/src/board/fileSyncQueue.ts +56 -0
  25. package/src/board/fileSyncTransport.ts +85 -0
  26. package/src/board/fileWatch.ts +177 -70
  27. package/src/board/folderDownload.ts +39 -5
  28. package/src/board/frameQuote.ts +12 -10
  29. package/src/board/htmlReading.ts +148 -0
  30. package/src/board/textLoad.ts +18 -41
  31. package/src/board/useBoardUpload.tsx +51 -0
  32. package/src/board/useBoardVisibility.ts +35 -0
  33. package/src/board/workspace.ts +53 -11
  34. package/src/chat/CliChat.tsx +105 -55
  35. package/src/chat/Composer.tsx +55 -21
  36. package/src/chat/GroupChat.tsx +113 -0
  37. package/src/chat/RunControls.tsx +132 -42
  38. package/src/chat/draft.ts +5 -2
  39. package/src/core/blocks.ts +17 -5
  40. package/src/core/client.ts +88 -19
  41. package/src/core/conversation.ts +1 -0
  42. package/src/core/documentUpload.ts +196 -0
  43. package/src/core/provider.tsx +7 -0
  44. package/src/core/store.ts +134 -36
  45. package/src/core/types.ts +31 -10
  46. package/src/index.ts +3 -0
  47. package/src/layout/split.ts +67 -30
  48. package/src/msglist/CliMsgList.tsx +18 -5
  49. package/src/operator/node/Chat.tsx +7 -8
  50. package/src/operator/node/Composer.tsx +99 -72
  51. package/src/operator/node/Orchestrator.tsx +1 -1
  52. package/src/operator/node/SessionRail.tsx +71 -32
  53. package/src/operator/node/SessionView.tsx +25 -25
  54. package/src/operator/node/Transcript.tsx +28 -11
  55. package/src/operator/node/boardBackend.ts +6 -23
  56. package/src/operator/node/boardEvents.ts +21 -3
  57. package/src/operator/node/boardState.ts +18 -1
  58. package/src/operator/node/boardWorkspace.ts +4 -1
  59. package/src/operator/node/composerDraft.ts +6 -2
  60. package/src/operator/node/format.ts +4 -2
  61. package/src/operator/node/liveStream.ts +14 -2
  62. package/src/operator/node/protocol/types.ts +2 -2
  63. package/src/operator/node/useTaskStream.ts +8 -5
  64. package/src/operator.base.css +148 -37
  65. package/src/operator.css +148 -37
  66. package/src/styles.css +274 -32
  67. package/src/ui/Markdown.tsx +2 -2
  68. package/src/ui/Popover.tsx +32 -14
  69. package/src/ui/QueuedMessages.tsx +69 -0
  70. package/src/ui/ReferenceCard.tsx +28 -0
  71. package/src/ui/TextBlock.tsx +6 -4
  72. package/src/ui/Transcript.tsx +27 -12
  73. package/src/ui/UploadProgress.tsx +46 -0
  74. package/src/ui/WorkspaceDrawer.tsx +64 -0
  75. package/src/ui/WorkspaceTabs.tsx +60 -0
  76. package/src/ui/format.ts +3 -1
  77. package/src/ui/reference.ts +40 -0
  78. package/src/ui/useChatScrollbar.ts +29 -0
  79. package/src/ui/useComposerAutosize.ts +54 -0
  80. package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
@@ -2,9 +2,12 @@ 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
+ import { createDocumentUploader, extractDocumentReferences, documentRequest, isTaskUploadPath, type UploadDocument, type DocumentUploadOptions, type DocumentReceipt } from "./documentUpload";
9
+ import { syncFile, type FileSyncTransport } from "../board/fileSync";
10
+ import { createFileSyncTransport } from "../board/fileSyncTransport";
8
11
 
9
12
  /**
10
13
  * center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
@@ -78,6 +81,11 @@ const ERROR_TEXT: Record<string, string> = {
78
81
  task_already_finished: "当前子任务已经结束,请刷新状态后继续",
79
82
  task_not_paused: "当前子任务没有暂停,请刷新状态后重试",
80
83
  job_already_paused: "整个任务已暂停,请先恢复整个任务",
84
+ file_watch_unsupported: "当前服务不支持文件事件",
85
+ browser_required: "传递浏览器 YAML 手册或操作间隔时,需要同时启用 browser: true",
86
+ invalid_browser_playbook: "浏览器 YAML 手册校验失败,请检查格式和步骤配置",
87
+ invalid_browser_pacing: "浏览器操作间隔无效,请检查最小值和最大值",
88
+ conversation_browser_fields_fixed: "浏览器手册和操作间隔在创建对话时确定,追加消息不能修改",
81
89
  };
82
90
 
83
91
  /**
@@ -87,6 +95,8 @@ const ERROR_TEXT: Record<string, string> = {
87
95
  * 带 ? 的是可选能力:后端不提供,界面就按 Operator 在「没有这个能力」时的样子显示(例如提问的回答改走下一条消息)。
88
96
  */
89
97
  export interface ConversationBackend {
98
+ /** Upload original HTML/Markdown without embedding document bytes in a message. */
99
+ uploadDocument?: UploadDocument;
90
100
  baseUrl: string;
91
101
  getJob(jobId: string): Promise<CenterJob>;
92
102
  /** taskId:只要对话任务里这一个对话的轮次。 */
@@ -138,6 +148,15 @@ export interface ConversationBackend {
138
148
  * 方法里的 jobId 也可以是对话键("job_x#task_y"):看对话任务里那个对话的文件、改动和浏览器。
139
149
  */
140
150
  export interface BoardBackend {
151
+ /** Save an original file to this conversation without sending a message or starting the agent. */
152
+ uploadFile?(jobId: string, file: File, options: DocumentUploadOptions): Promise<DocumentReceipt>;
153
+ /** Authorized input directory of this task; browsing it never starts an agent turn. */
154
+ uploadDirectory?(jobId: string, signal?: AbortSignal): Promise<string | null>;
155
+ /** Immutable revisions and content-addressed blocks; refreshes reuse validated local blocks. */
156
+ fileSync?: FileSyncTransport;
157
+ /** One directory page only. No recursive scan or file body prefetch. */
158
+ listFiles?(jobId: string, dir: string, options?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
159
+ pollFiles?(jobId: string, scope: { paths: string[]; dirs: string[]; cursor?: string }, signal?: AbortSignal): Promise<WorkspaceFileBatch & { cursor?: string; directories: Array<{ path: string; revision: string | null }> }>;
141
160
  /** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
142
161
  directLinks: boolean;
143
162
  info(jobId: string): Promise<BoardInfo>;
@@ -158,12 +177,12 @@ export interface BoardBackend {
158
177
  * tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
159
178
  * 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
160
179
  */
161
- readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
180
+ readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
162
181
  /**
163
182
  * 读一段原始字节:从 start 开始(缺省 0)最多 maxBytes;tail = 末尾 maxBytes。返回这段在文件里的起点和文件总大小。
164
183
  * 看板用它在文件变长时只取新增的部分(日志、还在写的报告),不整个重读。没有这个方法的后端每次整个重读。
165
184
  */
166
- readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
185
+ readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
167
186
  /**
168
187
  * 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
169
188
  * 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
@@ -281,6 +300,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
281
300
 
282
301
  return {
283
302
  baseUrl: base,
303
+ uploadDocument: createDocumentUploader(doFetch, `${base}/api/file-sync`, headers),
284
304
  getJob: (jobId: string) => request<CenterJob>(`/api/jobs/${id(jobId)}`),
285
305
  listTurns: (jobId: string, query: { afterSeq?: number; limit?: number; taskId?: string } = {}) => {
286
306
  const params = new URLSearchParams();
@@ -321,18 +341,21 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
321
341
  ...(opts.callback ? { callback: opts.callback } : {}),
322
342
  tasks: [{
323
343
  title,
324
- prompt: firstMessage,
344
+ prompt: extractDocumentReferences(firstMessage).text,
345
+ ...(extractDocumentReferences(firstMessage).attachments ? { attachments: extractDocumentReferences(firstMessage).attachments } : {}),
325
346
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
326
347
  ...(opts.agent ? { agent: opts.agent } : {}),
327
348
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
328
- ...(opts.browser ? { browser: true } : {}),
349
+ ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
350
+ ...(opts.browserPlaybook !== undefined ? { browser_playbook: opts.browserPlaybook } : {}),
351
+ ...(opts.browserPacing !== undefined ? { browser_pacing: opts.browserPacing } : {}),
329
352
  }],
330
353
  });
331
354
  },
332
355
  /** 带 business_id 时 center 按它去重,网络超时可以放心重发同一条。 */
333
356
  sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number; taskId?: string } = {}) =>
334
357
  post<SendResult>(`/api/jobs/${id(jobId)}/messages`, {
335
- text,
358
+ ...extractDocumentReferences(text),
336
359
  ...(opts.taskId ? { task_id: opts.taskId } : {}),
337
360
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
338
361
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
@@ -340,7 +363,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
340
363
  continueTask: async (jobId: string, taskId: string, text: string, opts: { title?: string; businessId?: string } = {}) => {
341
364
  const result = await post<{ task_ids: string[] }>(`/api/jobs`, {
342
365
  job_id: jobId, mode: "conversation",
343
- tasks: [{ title: opts.title || firstLine(text, 60), prompt: text, continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
366
+ tasks: [{ title: opts.title || firstLine(text, 60), prompt: extractDocumentReferences(text).text, ...(extractDocumentReferences(text).attachments ? { attachments: extractDocumentReferences(text).attachments } : {}), continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
344
367
  });
345
368
  return { task_id: result.task_ids[0] };
346
369
  },
@@ -349,7 +372,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
349
372
  /** 同一个 business_id 重发只开一个对话。 */
350
373
  openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
351
374
  post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
352
- text,
375
+ ...extractDocumentReferences(text),
353
376
  ...(opts.title ? { title: opts.title } : {}),
354
377
  ...(opts.agent ? { agent: opts.agent } : {}),
355
378
  ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
@@ -465,6 +488,13 @@ function openSse(
465
488
  try {
466
489
  arm();
467
490
  const response = await doFetch(url, { headers: { ...headers, Accept: "text/event-stream" }, signal: ctl.signal });
491
+ // A host-supplied fetch may resolve after cancellation despite signal.
492
+ // Retire its body before acquiring a reader or restarting the watchdog.
493
+ if (ctl.signal.aborted) {
494
+ void response.body?.cancel().catch(() => undefined);
495
+ if (silent) onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0));
496
+ return;
497
+ }
468
498
  if (!response.ok || !response.body) {
469
499
  const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
470
500
  throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? `连接失败(HTTP ${response.status})`, body?.error ?? `HTTP_${response.status}`, response.status);
@@ -548,7 +578,11 @@ function createBoardClient(
548
578
  if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
549
579
  ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
550
580
  });
551
- response = await Promise.race([doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" }), stopped]);
581
+ const fetching = doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" });
582
+ // Host fetch adapters can ignore abort. Dispose a response that arrives
583
+ // after the caller already cancelled or timed out.
584
+ void fetching.then(late => { if (ctl.signal.aborted) void late.body?.cancel().catch(() => undefined); }, () => undefined);
585
+ response = await Promise.race([fetching, stopped]);
552
586
  } catch (error) {
553
587
  release();
554
588
  if (signal?.aborted) throw aborted();
@@ -556,21 +590,19 @@ function createBoardClient(
556
590
  throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
557
591
  }
558
592
  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
593
  const stallError = () => new CenterError(`读取「${name}」时 ${FILE_STALL_MS / 1000} 秒没有收到数据,传输已中断,请重试`, "NETWORK", 0);
566
594
  /** 读响应体:limit 读够就停;onBytes 报进度;FILE_STALL_MS 没数据当断线。 */
567
595
  const body = async (opts: { limit?: number; onBytes?: (bytes: number) => void } = {}): Promise<Uint8Array<ArrayBuffer>> => {
596
+ if (signal?.aborted) { release(); void response.body?.cancel().catch(() => undefined); throw aborted(); }
568
597
  // 空文件要「前 N 字节」:节点回 416,就是空的。
569
598
  if (response.status === 416) { release(); return new Uint8Array(0); }
570
599
  const reader = response.body?.getReader();
571
600
  if (!reader) {
572
601
  try { return new Uint8Array(await response.arrayBuffer()); } finally { release(); }
573
602
  }
603
+ const cancelBody = () => { void reader.cancel().catch(() => undefined); };
604
+ ctl.signal.addEventListener("abort", cancelBody, { once: true });
605
+ if (ctl.signal.aborted) cancelBody();
574
606
  const chunks: Uint8Array[] = [];
575
607
  let got = 0;
576
608
  let stalled = false;
@@ -598,6 +630,11 @@ function createBoardClient(
598
630
  throw new CenterError(`读取「${name}」中断:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
599
631
  } finally {
600
632
  clearTimeout(stall);
633
+ ctl.signal.removeEventListener("abort", cancelBody);
634
+ // Progress callbacks may throw too; no abandoned response should keep
635
+ // consuming the mobile connection after the read has failed.
636
+ void reader.cancel().catch(() => undefined);
637
+ try { reader.releaseLock(); } catch { /* already released */ }
601
638
  release();
602
639
  }
603
640
  if (signal?.aborted) throw aborted();
@@ -612,16 +649,25 @@ function createBoardClient(
612
649
  }
613
650
  return all;
614
651
  };
652
+ if (!response.ok && response.status !== 416) {
653
+ // Error bodies need the same abort/stall bounds as file data. A proxy
654
+ // returning 401 headers and then hanging must not park a read forever.
655
+ const bytes = await body({ limit: 8192 });
656
+ let problem: { error?: string; detail?: string } | null = null;
657
+ try { problem = JSON.parse(new TextDecoder().decode(bytes)); } catch { /* use the HTTP fallback */ }
658
+ const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : problem?.error ?? `读取失败(HTTP ${response.status})`;
659
+ throw new CenterError(problem?.detail ?? ERROR_TEXT[problem?.error ?? ""] ?? fallback, problem?.error ?? `HTTP_${response.status}`, response.status);
660
+ }
615
661
  return { response, body };
616
662
  };
617
663
  /**
618
664
  * 读一段:开头 / 从 start 起 maxBytes,或者末尾 maxBytes(超大日志)。只向中枢要这一段(Range);
619
665
  * 不支持 Range 的节点回整个文件(200),这里边读边截,大文件不整个拉进浏览器。
620
666
  */
621
- const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }) => {
667
+ const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }) => {
622
668
  const from = Math.max(0, opts.start ?? 0);
623
669
  const { maxBytes } = opts;
624
- const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` });
670
+ const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` }, opts.signal);
625
671
  const contentType = response.headers.get("content-type") ?? "text/plain";
626
672
  const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
627
673
  // 416:要的起点不在文件里(空文件,或文件变短了)。
@@ -643,10 +689,28 @@ function createBoardClient(
643
689
  const minting = new Map<string, Promise<string | null>>();
644
690
  let linksUnsupported = false;
645
691
  const LINK_REFRESH_MS = 5 * 60_000;
692
+ const uploads = new Map<string, UploadDocument>();
693
+ const fileSync = createFileSyncTransport(doFetch, (jobId, operation) => `${base}${boardPath(jobId, `file-sync/${operation}`)}`, headers);
646
694
  return {
647
695
  directLinks,
696
+ uploadFile(jobId, file, options) {
697
+ let upload = uploads.get(jobId);
698
+ if (!upload) {
699
+ const { job, task } = boardTarget(jobId);
700
+ upload = createDocumentUploader(doFetch, `${base}/api/jobs/${job}${task ? `/tasks/${task}` : ""}/file-sync`, headers);
701
+ uploads.set(jobId, upload);
702
+ }
703
+ return upload(file, options);
704
+ },
705
+ async uploadDirectory(jobId, signal) {
706
+ const capabilities = await documentRequest<{ uploadsPath?: string }>(doFetch, `${base}${boardPath(jobId, "file-sync/capabilities")}`, { signal }, headers);
707
+ return typeof capabilities.uploadsPath === "string" ? capabilities.uploadsPath : null;
708
+ },
709
+ fileSync,
710
+ listFiles: (jobId, dir, options = {}) => documentRequest<DirListing>(doFetch, `${base}${boardPath(jobId, "file-sync/list", new URLSearchParams({ dir: dir || ".", limit: String(options.limit ?? 200), ...(options.cursor ? { cursor: options.cursor } : {}) }).toString())}`, { signal: options.signal }, headers),
648
711
  info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
649
- stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
712
+ pollFiles: (jobId, scope, signal) => documentRequest(doFetch, `${base}${boardPath(jobId, "file-sync/changes")}`, { method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify(scope) }, headers),
713
+ stat: (jobId, path = ".") => documentRequest<DirListing | FileInfo>(doFetch, `${base}${boardPath(jobId, "file-sync/stat", `path=${encodeURIComponent(path)}`)}`, {}, headers),
650
714
  fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
651
715
  async fileLinkBase(jobId) {
652
716
  const { job, task } = boardTarget(jobId);
@@ -669,7 +733,7 @@ function createBoardClient(
669
733
  return work;
670
734
  },
671
735
  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 });
736
+ const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, signal: opts.signal, onBytes: opts.onBytes });
673
737
  if (!size && !bytes.length) return { text: "", truncated: false, size: 0, contentType };
674
738
  const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
675
739
  return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
@@ -679,6 +743,11 @@ function createBoardClient(
679
743
  return { bytes, start, size };
680
744
  },
681
745
  async readBuffer(jobId, path, opts = {}) {
746
+ if (isTaskUploadPath(path)) {
747
+ const file = await syncFile(fileSync, jobId, path, { signal: opts.signal, decode: false,
748
+ onProgress: progress => opts.onBytes?.(progress.loadedBytes) });
749
+ return file.bytes.buffer as ArrayBuffer;
750
+ }
682
751
  const { body } = await fileFetch(jobId, path, {}, opts.signal);
683
752
  return (await body({ onBytes: opts.onBytes })).buffer;
684
753
  },
@@ -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
  /** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
@@ -0,0 +1,196 @@
1
+ import { createFileWorkQueue } from "../board/fileSyncQueue";
2
+ /** Original attachments. The message contains an authorized reference only. */
3
+ export const DOCUMENT_MAX_BYTES = 100 * 1024 * 1024;
4
+ export const UPLOAD_OTHER_MAX_BYTES = 10 * 1024 * 1024;
5
+ export const DOCUMENT_CHUNK_BYTES = 1024 * 1024;
6
+ export const isOriginalDocument = (name: string) => /\.(html?|md|markdown)$/i.test(name);
7
+ export const isTaskUploadPath = (path: string) => /\/(?:uploads|attachments)\/[^/]+\/[^/]+$/.test(path.replace(/\\/g, "/"));
8
+ const UPLOAD_TYPES: Record<string, string> = {
9
+ html: "text/html", htm: "text/html", md: "text/markdown", markdown: "text/markdown",
10
+ txt: "text/plain", log: "text/plain", yaml: "text/yaml", yml: "text/yaml", csv: "text/csv", tsv: "text/tab-separated-values", json: "application/json",
11
+ js: "text/plain", jsx: "text/plain", mjs: "text/plain", cjs: "text/plain", ts: "text/plain", tsx: "text/plain", mts: "text/plain", cts: "text/plain", py: "text/plain", sh: "text/plain", css: "text/plain", mjsx: "text/plain", cjsx: "text/plain", mtsx: "text/plain", ctsx: "text/plain",
12
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
13
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
14
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsm: "application/vnd.ms-excel.sheet.macroEnabled.12",
15
+ };
16
+ export const FILE_UPLOAD_ACCEPT = Object.keys(UPLOAD_TYPES).map(extension => `.${extension}`).join(",");
17
+ export const uploadFileType = (name: string) => {
18
+ const dot = name.lastIndexOf(".");
19
+ const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : undefined;
20
+ return extension && Object.hasOwn(UPLOAD_TYPES, extension) ? UPLOAD_TYPES[extension] : undefined;
21
+ };
22
+ export type DocumentReceipt = { uploadId: string; draftId: string; name: string; size: number; sha256: string; path?: string; url?: string };
23
+ export type UploadPhase = "queued" | "checking" | "uploading" | "verifying";
24
+ export type DocumentUploadOptions = { draftId: string; signal?: AbortSignal; onBytes?: (bytes: number) => void; onPhase?: (phase: UploadPhase) => void };
25
+ export type UploadDocument = (file: File, options: DocumentUploadOptions) => Promise<DocumentReceipt>;
26
+ const aborted = () => new DOMException("上传已取消", "AbortError");
27
+ function pause(ms: number, signal?: AbortSignal) {
28
+ return new Promise<void>((resolve, reject) => {
29
+ if (signal?.aborted) return reject(aborted());
30
+ const stop = () => { clearTimeout(timer); reject(aborted()); };
31
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms);
32
+ signal?.addEventListener("abort", stop, { once: true });
33
+ });
34
+ }
35
+
36
+ /** Blob cloning is cheap; hashing a 100 MiB file does not copy it through the UI thread. */
37
+ export async function hashDocument(file: Blob, signal?: AbortSignal): Promise<string> {
38
+ if (signal?.aborted) throw aborted();
39
+ if (typeof Worker !== "undefined" && typeof URL.createObjectURL === "function") {
40
+ const source = `onmessage=async e=>{try{const bytes=await e.data.arrayBuffer();const hash=await crypto.subtle.digest('SHA-256',bytes);postMessage({hash:Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join('')});}catch(e){postMessage({error:String(e)})}}`;
41
+ const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
42
+ let worker: Worker | undefined;
43
+ try {
44
+ worker = new Worker(url);
45
+ return await new Promise<string>((resolve, reject) => {
46
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", stop); worker!.onmessage = null; worker!.onerror = null; };
47
+ const stop = () => { cleanup(); reject(aborted()); };
48
+ const timer = setTimeout(() => { cleanup(); reject(Object.assign(new Error("文件校验超时,请重试"), { status: 408, code: "hash_timeout" })); }, 120_000);
49
+ signal?.addEventListener("abort", stop, { once: true });
50
+ worker!.onmessage = e => { cleanup(); e.data?.hash ? resolve(e.data.hash) : reject(new Error(e.data?.error || "文件校验失败,请重试")); };
51
+ worker!.onerror = () => { cleanup(); reject(new Error("文件校验失败,请重试")); };
52
+ if (signal?.aborted) { stop(); return; }
53
+ try { worker!.postMessage(file); } catch (error) { cleanup(); reject(error); }
54
+ });
55
+ } finally { worker?.terminate(); URL.revokeObjectURL(url); }
56
+ }
57
+ const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
58
+ if (signal?.aborted) throw aborted();
59
+ return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, "0")).join("");
60
+ }
61
+
62
+ /** All calls are bounded short requests, including body reads. */
63
+ export async function documentRequest<T>(fetcher: typeof fetch, url: string, init: RequestInit = {}, headers: () => Record<string, string> = () => ({})): Promise<T> {
64
+ const ctl = new AbortController();
65
+ const signal = init.signal;
66
+ const cancel = () => ctl.abort();
67
+ if (signal?.aborted) throw aborted();
68
+ signal?.addEventListener("abort", cancel, { once: true });
69
+ const timer = setTimeout(cancel, 30_000);
70
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
71
+ let rejectAbort: (() => void) | undefined;
72
+ try {
73
+ const cancelled = new Promise<never>((_resolve, reject) => { rejectAbort = () => reject(aborted()); ctl.signal.addEventListener("abort", rejectAbort, { once: true }); });
74
+ return await Promise.race([cancelled, (async () => {
75
+ const response = await fetcher(url, { ...init, cache: "no-store", signal: ctl.signal, headers: { ...headers(), ...init.headers } });
76
+ if (ctl.signal.aborted) { void response.body?.cancel().catch(() => undefined); throw aborted(); }
77
+ reader = response.body?.getReader();
78
+ let raw = "", bytes = 0;
79
+ if (reader) {
80
+ const decoder = new TextDecoder();
81
+ for (;;) {
82
+ const part = await reader.read(); if (part.done) break;
83
+ bytes += part.value.byteLength;
84
+ if (bytes > 2 * 1024 * 1024) throw Object.assign(new Error("文件响应超过协议上限"), { status: 502, code: "response_too_large" });
85
+ raw += decoder.decode(part.value, { stream: true });
86
+ }
87
+ raw += decoder.decode();
88
+ } else raw = await response.text();
89
+ let value: Record<string, unknown>;
90
+ try { value = raw ? JSON.parse(raw) : {}; } catch { throw Object.assign(new Error("文件接口返回的数据无效"), { status: 502, code: "invalid_response" }); }
91
+ const retryHeader = response.headers.get("retry-after");
92
+ const retryHint = retryHeader ? (/^\d+(\.\d+)?$/.test(retryHeader) ? Number(retryHeader) * 1000 : Date.parse(retryHeader) - Date.now()) : undefined;
93
+ const retryAfterMs = retryHint !== undefined && Number.isFinite(retryHint) ? Math.max(0, retryHint) : undefined;
94
+ if (response.status === 202) throw Object.assign(new Error("文件正在校验"), { status: 202, retryAfterMs: retryAfterMs ?? 1000 });
95
+ if (!response.ok) {
96
+ const missing = value.error === "file_not_found" ? "文件不存在(可能已被删除或改名)" : "服务端尚未支持原件上传或增量文件同步,请升级节点和中枢";
97
+ const common: Record<string, string> = { file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
98
+ const error = new Error(common[String(value.error)] ?? String(value.detail || (response.status === 404 ? missing : value.error) || `文件请求失败(HTTP ${response.status})`));
99
+ Object.assign(error, { status: response.status, code: value.error, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
100
+ throw error;
101
+ }
102
+ return value as T;
103
+ })()]);
104
+ } catch (error) {
105
+ if (signal?.aborted) throw aborted();
106
+ if (ctl.signal.aborted) throw Object.assign(new Error("文件请求超时,请重试"), { status: 0, code: "timeout" });
107
+ throw error;
108
+ } finally { clearTimeout(timer); signal?.removeEventListener("abort", cancel); if (rejectAbort) ctl.signal.removeEventListener("abort", rejectAbort); ctl.abort(); void reader?.cancel().catch(() => undefined); }
109
+ }
110
+
111
+ const uploadQueues = new WeakMap<typeof fetch, ReturnType<typeof createFileWorkQueue>>();
112
+ async function retryUploadRequest<T>(work: () => Promise<T>, signal: AbortSignal | undefined, busyOnly = false, deadline = Date.now() + 180_000): Promise<T> {
113
+ for (let attempt = 0; ; attempt++) {
114
+ if (signal?.aborted) throw aborted();
115
+ try { return await work(); } catch (error) {
116
+ const failure = error as { status?: number; code?: string; name?: string; retryAfterMs?: number };
117
+ const status = failure.status ?? 0;
118
+ const retryable = busyOnly ? status === 429 : !status || [408, 429, 500, 502, 503, 504].includes(status);
119
+ if (signal?.aborted || failure.name === "AbortError" || !retryable || attempt >= 3 || ["invalid_response", "response_too_large", "integrity", "client_queue_full"].includes(failure.code ?? "")) throw error;
120
+ const base = 500 * 2 ** attempt, hint = Number(failure.retryAfterMs ?? 0);
121
+ const delay = Math.max(base, Number.isFinite(hint) ? hint : 0) + Math.floor(Math.random() * base / 4);
122
+ if (delay > deadline - Date.now()) throw error;
123
+ await pause(delay, signal);
124
+ }
125
+ }
126
+ }
127
+ export function createDocumentUploader(fetcher: typeof fetch, base: string, headers: () => Record<string, string> = () => ({})): UploadDocument {
128
+ // Share hashing/upload slots across tasks using this client, with cancellable bounded waiters.
129
+ let queue = uploadQueues.get(fetcher);
130
+ if (!queue) { queue = createFileWorkQueue(1, 128); uploadQueues.set(fetcher, queue); }
131
+ return (file, options) => {
132
+ options.onPhase?.("queued");
133
+ const upload = async (): Promise<DocumentReceipt> => {
134
+ const { signal, onBytes, onPhase, draftId } = options;
135
+ if (signal?.aborted) throw aborted();
136
+ const type = uploadFileType(file.name);
137
+ if (!type) throw new Error("暂不支持此文件类型,请选择文档、图片或文本文件");
138
+ const maxBytes = isOriginalDocument(file.name) ? DOCUMENT_MAX_BYTES : UPLOAD_OTHER_MAX_BYTES;
139
+ if (file.size > maxBytes) throw new Error(`“${file.name}”超过 ${isOriginalDocument(file.name) ? "100" : "10"} MiB,请缩小后重试。`);
140
+ if (new TextEncoder().encode(file.name).length > 240) throw new Error("文件名超过 240 个 UTF-8 字节,请缩短文件名后重试。");
141
+ onPhase?.("checking");
142
+ const sha256 = await hashDocument(file, signal);
143
+ const request = <T>(path: string, init: RequestInit) => documentRequest<T>(fetcher, `${base}${path}`, { ...init, signal }, headers);
144
+ const session = await retryUploadRequest(() => request<{ uploadId: string; chunkBytes: number }>("/uploads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId, name: file.name, size: file.size, type, sha256 }) }), signal, true);
145
+ if (!session.uploadId || session.chunkBytes !== DOCUMENT_CHUNK_BYTES) throw new Error("上传协议不匹配,请升级服务端");
146
+ const root = `/uploads/${encodeURIComponent(session.uploadId)}`;
147
+ try {
148
+ onPhase?.("uploading");
149
+ onBytes?.(0);
150
+ for (let start = 0, index = 0; start < file.size; start += session.chunkBytes, index++) {
151
+ const body = file.slice(start, Math.min(file.size, start + session.chunkBytes));
152
+ // Only these numbered chunks are safely idempotent after ambiguous network failures.
153
+ await retryUploadRequest(() => request(`${root}/chunks/${index}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body }), signal);
154
+ onBytes?.(Math.min(file.size, start + session.chunkBytes));
155
+ }
156
+ onPhase?.("verifying");
157
+ const deadline = Date.now() + 180_000;
158
+ for (;;) {
159
+ try {
160
+ const result = await retryUploadRequest(() => request<DocumentReceipt>(`${root}/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId }) }), signal, false, deadline);
161
+ if (result.sha256 !== sha256) throw new Error("上传文件校验不一致,请重试");
162
+ return { ...result, draftId, name: file.name, size: file.size };
163
+ } catch (error) {
164
+ const pending = error as { status?: number; retryAfterMs?: number };
165
+ if (pending.status !== 202 || Date.now() >= deadline) throw error;
166
+ const delay = Math.max(100, pending.retryAfterMs ?? 1000);
167
+ if (delay > deadline - Date.now()) throw error;
168
+ await pause(delay, signal);
169
+ }
170
+ }
171
+ } catch (error) {
172
+ void documentRequest(fetcher, `${base}${root}`, { method: "DELETE" }, headers).catch(() => undefined);
173
+ throw error;
174
+ }
175
+ };
176
+ return queue.run(upload, options.signal);
177
+ };
178
+ }
179
+
180
+ export function documentMarker(receipt: DocumentReceipt) {
181
+ if (receipt.path) {
182
+ if (/[\r\n\]]/.test(receipt.path)) throw new Error("上传文件路径无效,请重试");
183
+ return `[Attached ${uploadFileType(receipt.name)?.startsWith("image/") ? "image" : "file"}: ${receipt.path}]`;
184
+ }
185
+ const name = receipt.name.replace(/[\r\n\[\]]/g, " ");
186
+ return `[文件原件:${name}](cli-upload://${encodeURIComponent(receipt.draftId)}/${encodeURIComponent(receipt.uploadId)})`;
187
+ }
188
+
189
+ export function extractDocumentReferences(text: string) {
190
+ const attachments: Array<{ uploadId: string; draftId: string }> = [];
191
+ const clean = text.replace(/\[文件原件:([^\]\r\n]*)\]\(cli-upload:\/\/([^/\s)]+)\/([^\s)]+)\)/g, (_match, name, draft, upload) => {
192
+ try { attachments.push({ draftId: decodeURIComponent(draft), uploadId: decodeURIComponent(upload) }); } catch { throw new Error("文件引用无效,请移除后重新上传"); }
193
+ return `[文件原件:${name}]`;
194
+ });
195
+ return { text: clean, ...(attachments.length ? { attachments } : {}) };
196
+ }
@@ -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;