@xgspz/cli-ui 0.0.0-stage → 0.6.15

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 (169) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +517 -2
  3. package/dist/cli-ui-embed-vIXVNs6k.js +50295 -0
  4. package/dist/cli-ui-index-R24x28OX.js +11209 -0
  5. package/dist/cli-ui-jszip.min-DUghzByz.js +2324 -0
  6. package/dist/cli-ui-jszip.min-DfH6rdQD.js +26 -0
  7. package/dist/cli-ui-xlsx-CP0lJhff.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +960 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +581 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1109 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +507 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1952 -0
  149. package/src/operator.css +1952 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -0,0 +1,822 @@
1
+ import type { SubagentTranscript } from "./subagents";
2
+ import { createSseParser } from "./sse";
3
+ import { NEW_TASK, parseConvKey } from "./conversation";
4
+ import type {
5
+ BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo, Message,
6
+ BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot, WorkspaceFileBatch,
7
+ } from "./types";
8
+ import { decodeText } from "./textDecode";
9
+ import { createDocumentUploader, extractDocumentReferences, documentRequest, isTaskUploadPath, type UploadDocument, type DocumentUploadOptions, type DocumentReceipt } from "./documentUpload";
10
+ import { syncFile, type FileSyncTransport } from "../board/fileSync";
11
+ import { createFileSyncTransport } from "../board/fileSyncTransport";
12
+ import type { ExecutionConfiguration, ExecutionOptions } from "./execution";
13
+
14
+ /**
15
+ * center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
16
+ *
17
+ * 怎么连:
18
+ * · 推荐走同源代理(缺省 baseUrl = "/api/center"):Vite 代理 / nginx 把 /api/center 转到 center,
19
+ * 并在代理里注入 `Authorization: Bearer <产品线令牌>`——令牌不进浏览器。
20
+ * · 本地联调也可以直接给 token(会以 Bearer 头发出),或用 headers() 自己加头。
21
+ * center 本身不开 CORS,跨域直连需要代理。
22
+ */
23
+ export interface CenterClientOptions {
24
+ /** center 的地址前缀,缺省 "/api/center"(同源代理)。 */
25
+ baseUrl?: string;
26
+ /** 产品线令牌;有代理注入时不要给。 */
27
+ token?: string;
28
+ /**
29
+ * 产品线(line key)。以请求头 `X-CLI-Line` 发出,给代理选用哪条产品线的令牌:
30
+ * 一个代理服务多条产品线时,按这个头注入对应的 Authorization(见文档「连接中枢」)。center 本身不读这个头,
31
+ * 产品线只由令牌决定。
32
+ */
33
+ line?: string;
34
+ /** 额外请求头(每次请求时取,可放会话令牌等)。 */
35
+ headers?: () => Record<string, string>;
36
+ fetch?: typeof fetch;
37
+ /**
38
+ * 看板里的文件能不能直接用地址打开(<img src>、<iframe src>、下载链接)。缺省:没给 token / headers / line 时可以
39
+ * (同源代理注入令牌);否则浏览器直接请求带不上请求头,看板改为先 fetch 再用 blob 地址显示。
40
+ */
41
+ directFileLinks?: boolean;
42
+ /**
43
+ * 普通请求(不含实时流、文件下载)多久没有响应就放弃,缺省 30 秒。代理或网络挂住时组件不会一直「读取中」,
44
+ * 而是报「请求超时」并按各自的规则重试。打开任务浏览器另给 150 秒(节点要先启动浏览器)。
45
+ */
46
+ requestTimeoutMs?: number;
47
+ }
48
+
49
+ /** 实时流超过这么久没有任何数据(center 每 20 秒一次心跳)就当连接已断。 */
50
+ export const STREAM_SILENCE_MS = 45_000;
51
+ /** 读文件:这么久没回响应头(节点卡住、代理挂住)就放弃。 */
52
+ export const FILE_HEADER_TIMEOUT_MS = 30_000;
53
+ /** 读文件:传输中这么久一个字节都没收到就当断了(大文件慢慢传不算,只要还在动)。 */
54
+ export const FILE_STALL_MS = 60_000;
55
+
56
+ export class CenterError extends Error {
57
+ constructor(message: string, readonly code: string, readonly status: number, readonly detail?: unknown) {
58
+ super(message);
59
+ this.name = "CenterError";
60
+ }
61
+ }
62
+
63
+ /** 读改动(git diff)最多等多久:比中枢等节点的 50 秒、节点自己的 40 秒都长。 */
64
+ const DIFF_TIMEOUT_MS = 65_000;
65
+
66
+ /** center 的错误码翻成看得懂、知道怎么办的中文。 */
67
+ const ERROR_TEXT: Record<string, string> = {
68
+ conversation_closed: "这个对话已经结束,不能再发消息;需要继续请新建对话",
69
+ conversation_queue_full: "排队的消息太多了,请等前面的回复后再发",
70
+ business_id_conflict: "消息编号冲突,请重新发送",
71
+ not_a_conversation: "这个任务不是对话任务",
72
+ conversation_not_found: "这个对话不在对应的对话任务里",
73
+ too_many_conversations: "这个对话任务里开的对话太多了,请新建对话任务",
74
+ not_found: "对话不存在,或不属于当前产品线",
75
+ unauthorized: "没有权限访问任务中心(产品线令牌缺失或失效)",
76
+ conversation_requires_one_task: "对话任务只能有一条首轮消息",
77
+ conversation_prompt_required: "第一条消息不能为空",
78
+ not_dispatched: "任务还没派发到执行节点",
79
+ node_unreachable: "执行节点暂时连不上,稍后会自动重试",
80
+ node_outdated: "执行节点的 Operator 版本较旧,不支持这个功能,请升级节点",
81
+ too_many_viewers: "同时观看这个浏览器的人太多,请稍后再试",
82
+ viewer_not_found: "画面连接已断开,正在重连",
83
+ viewer_not_ready: "画面连接还没就绪,正在重连",
84
+ link_expired: "文件链接已失效,请刷新",
85
+ task_not_found: "这个子任务不在对应的任务里",
86
+ task_already_finished: "当前子任务已经结束,请刷新状态后继续",
87
+ task_not_paused: "当前子任务没有暂停,请刷新状态后重试",
88
+ job_already_paused: "整个任务已暂停,请先恢复整个任务",
89
+ file_watch_unsupported: "当前服务不支持文件事件",
90
+ browser_required: "传递浏览器 YAML 手册或操作间隔时,需要同时启用 browser: true",
91
+ invalid_browser_playbook: "浏览器 YAML 手册校验失败,请检查格式和步骤配置",
92
+ invalid_browser_pacing: "浏览器操作间隔无效,请检查最小值和最大值",
93
+ conversation_browser_fields_fixed: "浏览器手册和操作间隔在创建对话时确定,追加消息不能修改",
94
+ };
95
+
96
+ /**
97
+ * 组件用到的数据后端。界面只依赖这个接口,不直接依赖 center 的地址和字段:
98
+ * · 现在的实现是 center 的对话任务接口(createCenterClient);
99
+ * · 以后把组件放进 Operator 自己的界面时,换一个实现这套接口的 Operator 后端即可,界面不用改。
100
+ * 带 ? 的是可选能力:后端不提供,界面就按 Operator 在「没有这个能力」时的样子显示(例如提问的回答改走下一条消息)。
101
+ */
102
+ export interface ConversationBackend {
103
+ /** 无 key 时读取新对话可用设置;有 key 时读取该对话的已保存设置。 */
104
+ getExecutionConfig?(key?: string | null): Promise<ExecutionConfiguration>;
105
+ /** 保存成功才切换界面;服务端用 revision 防止并发覆盖。 */
106
+ setExecutionConfig?(key: string, changes: ExecutionOptions, expectedRevision: number): Promise<ExecutionConfiguration>;
107
+ /** Upload original HTML/Markdown without embedding document bytes in a message. */
108
+ uploadDocument?: UploadDocument;
109
+ baseUrl: string;
110
+ getJob(jobId: string): Promise<CenterJob>;
111
+ /** taskId:只要对话任务里这一个对话的轮次。 */
112
+ listTurns(jobId: string, query?: { afterSeq?: number; limit?: number; taskId?: string }): Promise<{ job_id: string; turns: CenterTurn[] }>;
113
+ listTasks(): Promise<{ tasks: CenterTask[] }>;
114
+ /** 原生子代理的公开运行记录;before 用于读取更早的一页。 */
115
+ subagent?(jobId: string, remote: string, childId: string, generation: number, before?: string): Promise<SubagentTranscript>;
116
+ /**
117
+ * 更早的节点转录(往上翻):remote 是流里给的节点会话,before 是手里最早那条消息的 id。
118
+ * 返回这一页(旧的在前)和再往前还剩几条。旧版中枢没有这个接口(实时流也就给的是全量,用不到)。
119
+ */
120
+ history?(jobId: string, remote: string, before: string, limit?: number): Promise<{ messages: Message[]; older: number }>;
121
+ createConversation(firstMessage: string, opts?: NewConversationOptions & { businessId?: string }): Promise<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>;
122
+ /** taskId:发给对话任务里的哪个对话,不给 = 第一个。 */
123
+ sendMessage(jobId: string, text: string, opts?: { businessId?: string; timeoutSeconds?: number; taskId?: string; expectedExecutionRevision?: number }): Promise<SendResult>;
124
+ /**
125
+ * 一次性任务里的对话式子任务接着聊:续派一条 mode=conversation、continue_from 这个子任务的子任务(接在同一个 Agent 会话里)。
126
+ * 返回新子任务的 id。
127
+ */
128
+ continueTask?(jobId: string, taskId: string, text: string, opts?: { title?: string; businessId?: string }): Promise<{ task_id: string }>;
129
+ /** 暂停 / 继续一次性任务中的单个子任务。继续会重新派发该子任务。 */
130
+ pauseTask?(taskId: string): Promise<CenterTask>;
131
+ resumeTask?(taskId: string): Promise<CenterTask>;
132
+ /** 在对话任务里新开一个对话(同一节点、同一工作区),text 是它的第一条消息。 */
133
+ openConversation?(jobId: string, text: string, opts?: { title?: string; agent?: string; executionConfig?: ExecutionOptions; browser?: boolean; businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
134
+ cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
135
+ closeConversation(jobId: string): Promise<{ id: string; status: string }>;
136
+ /** 只关掉对话任务里的一个对话(别的对话和对话任务照常)。 */
137
+ closeConversationTask?(jobId: string, taskId: string): Promise<{ job_id: string; task_id: string; status: string; closed: boolean }>;
138
+ /** taskId:看对话任务里的哪个对话,不给 = 第一个。 */
139
+ stream(jobId: string, handlers: StreamHandlers, intervalMs?: number, taskId?: string | null): () => void;
140
+ /**
141
+ * 把回答直接交给正停在提问上的回合(中枢 POST /api/jobs/:id/answer → 节点 /answer)。
142
+ * resolved=false 表示没有在等的回合:对话任务改用「下一条消息」发回答(中枢在 Agent 提问时已经结束那一轮);
143
+ * 一次性任务的子任务提问时回合开着等,答案直接交过去。
144
+ */
145
+ answerAsk?(jobId: string, askId: string, answers: string[][]): Promise<{ resolved: boolean }>;
146
+ /**
147
+ * 提交浏览器输入卡片(验证码、账号、密码、选项;中枢 POST /api/jobs/:id/input → 节点 /input),返回错误文案或 null。
148
+ * 密码只交给节点上的浏览器脚本,不进模型、不进转录、中枢不落库。
149
+ */
150
+ submitInput?(jobId: string, inputId: string, values: string[]): Promise<string | null>;
151
+ /** 浏览器交接后交还给 AI(Operator 的 POST /api/tasks/:id/browser),返回错误文案或 null。center 不提供。 */
152
+ handback?(jobId: string): Promise<string | null>;
153
+ /** 右侧看板用到的能力(cli-board)。后端没有时看板显示「不支持」。 */
154
+ board?: BoardBackend;
155
+ }
156
+
157
+ /**
158
+ * 右侧看板(cli-board):工作区文件、改动、任务浏览器。全部经 center 转发,浏览器不直连节点。
159
+ * 方法里的 jobId 也可以是对话键("job_x#task_y"):看对话任务里那个对话的文件、改动和浏览器。
160
+ */
161
+ export interface BoardBackend {
162
+ /** Save an original file to this conversation without sending a message or starting the agent. */
163
+ uploadFile?(jobId: string, file: File, options: DocumentUploadOptions): Promise<DocumentReceipt>;
164
+ /** Authorized input directory of this task; browsing it never starts an agent turn. */
165
+ uploadDirectory?(jobId: string, signal?: AbortSignal): Promise<string | null>;
166
+ /** Immutable revisions and content-addressed blocks; refreshes reuse validated local blocks. */
167
+ fileSync?: FileSyncTransport;
168
+ /** One directory page only. No recursive scan or file body prefetch. */
169
+ listFiles?(jobId: string, dir: string, options?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
170
+ pollFiles?(jobId: string, scope: { paths: string[]; dirs: string[]; cursor?: string }, signal?: AbortSignal): Promise<WorkspaceFileBatch & { cursor?: string; directories: Array<{ path: string; revision: string | null }> }>;
171
+ /** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
172
+ directLinks: boolean;
173
+ info(jobId: string): Promise<BoardInfo>;
174
+ /** 精准文件事件流;没有时组件明确降级为目录快照比对。 */
175
+ watchFiles?(jobId: string, handlers: { onEvent: (event: import("./types").WorkspaceFileBatch) => void; onClose: (error: CenterError | null) => void }): () => void;
176
+ /** 目录(缺省根目录)或文件的信息。 */
177
+ stat(jobId: string, path?: string): Promise<DirListing | FileInfo>;
178
+ /** 文件地址(给 <img> / <iframe> / 下载链接用)。相对路径用路径式地址,HTML 里的相对资源能跟着解析。 */
179
+ fileUrl(jobId: string, path: string, opts?: { download?: boolean }): string;
180
+ /**
181
+ * 浏览器能直接打开的文件地址前缀(…/files/raw,后面接相对路径或 ?path=):directLinks 时就是 fileUrl 的前缀;
182
+ * 否则向中枢换一个短期令牌放进路径(<img> / <iframe> / 下载都不需要请求头,大文件流式下载、不进浏览器内存),
183
+ * 快过期时自动换新的。中枢不支持时返回 null(看板退回先 fetch 再显示)。
184
+ */
185
+ fileLinkBase?(jobId: string): Promise<string | null>;
186
+ /**
187
+ * 读文本(最多 maxBytes,超过的截断)。只向中枢要这一段(Range),超大文件不整个传过来。
188
+ * tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
189
+ * 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
190
+ */
191
+ readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
192
+ /**
193
+ * 读一段原始字节:从 start 开始(缺省 0)最多 maxBytes;tail = 末尾 maxBytes。返回这段在文件里的起点和文件总大小。
194
+ * 看板用它在文件变长时只取新增的部分(日志、还在写的报告),不整个重读。没有这个方法的后端每次整个重读。
195
+ */
196
+ readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
197
+ /**
198
+ * 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
199
+ * 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
200
+ */
201
+ readBuffer(jobId: string, path: string, opts?: { signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<ArrayBuffer>;
202
+ /** fresh:不与已在途的同一请求共用结果(轮末刷新要的是这一刻之后的改动,不是之前发出的那次)。 */
203
+ diff(jobId: string, options?: { fresh?: boolean }): Promise<DiffResult>;
204
+ browserStatus(jobId: string): Promise<BrowserStatus>;
205
+ browserAction(jobId: string, action: "open" | "close" | "takeover" | "handback"): Promise<BrowserStatus>;
206
+ /** 浏览器状态实时流;返回停止函数。onClose 在断开时调用(error 为 null 表示正常结束)。 */
207
+ browserEvents(jobId: string, handlers: { onStatus: (status: BrowserStatus) => void; onClose?: (error: CenterError | null) => void; /** Recoverable transport disruption; subscription remains active. */ onError?: (error: CenterError) => void }): () => void;
208
+ /** 浏览器实时画面;onViewer 给出发输入要用的 viewer id。 */
209
+ browserFrames(jobId: string, handlers: { onViewer: (viewer: string) => void; onMessage: (message: BrowserMessage) => void; onEnd?: (end: { reason: string; detail?: string }) => void; onClose?: (error: CenterError | null) => void }): () => void;
210
+ browserInput(jobId: string, viewer: string, events: Array<Record<string, unknown>>): Promise<void>;
211
+ /**
212
+ * 浏览器的一条实时连接(状态 + 画面 + 弹窗 + 复制出的文字,见中枢 board-live.ts)。新版中枢才有:
213
+ * 旧版中枢回 404(路由不存在,error 为 "Not Found"),组件退回 browserEvents + browserFrames 两条。
214
+ */
215
+ browserLive?(jobId: string, handlers: BrowserLiveHandlers): () => void;
216
+ }
217
+
218
+ /** readBytes 读到的一段。 */
219
+ export interface ByteChunk {
220
+ bytes: Uint8Array<ArrayBuffer>;
221
+ /** 这段在文件里从第几个字节开始。 */
222
+ start: number;
223
+ /** 文件总大小。 */
224
+ size: number;
225
+ }
226
+
227
+ export interface TextChunk {
228
+ text: string;
229
+ /** 只读到了一部分。 */
230
+ truncated: boolean;
231
+ /** 文件总大小。 */
232
+ size: number;
233
+ contentType: string;
234
+ /** 读的是末尾那一段。 */
235
+ tail?: boolean;
236
+ encoding?: string;
237
+ }
238
+
239
+ export interface BrowserLiveHandlers {
240
+ /** 这条连接的 viewer id;ready = 输入能送到节点(画面连接已建好)。 */
241
+ onViewer: (viewer: string, ready: boolean) => void;
242
+ onStatus: (status: BrowserStatus) => void;
243
+ onMessage: (message: BrowserMessage) => void;
244
+ onFrames?: (state: BrowserFramesState) => void;
245
+ /** 不会再有数据(节点拒绝、中枢停机)。 */
246
+ onEnd?: (end: { reason: string; status?: number; detail?: string }) => void;
247
+ onClose?: (error: CenterError | null) => void;
248
+ }
249
+
250
+ /** 旧版中枢没有这个接口:Fastify 的「路由不存在」404(和「任务不存在」的 not_found 区分开)。 */
251
+ export const isMissingRoute = (error: CenterError | null | undefined) => !!error && error.status === 404 && error.code === "Not Found";
252
+
253
+ /** 由 fileLinkBase 给的前缀拼出文件地址。 */
254
+ export function fileUrlFromBase(linkBase: string, path: string, download = false) {
255
+ const clean = path.replace(/^\.\//, "");
256
+ if (clean.startsWith("/") || clean.startsWith("~") || /^[a-z]:[\\/]/i.test(clean)) return `${linkBase}?path=${encodeURIComponent(clean)}${download ? "&download=1" : ""}`;
257
+ return `${linkBase}/${clean.split("/").filter(Boolean).map(encodeURIComponent).join("/")}${download ? "?download=1" : ""}`;
258
+ }
259
+
260
+ export function createCenterClient(options: CenterClientOptions = {}): ConversationBackend {
261
+ const base = (options.baseUrl ?? "/api/center").replace(/\/+$/, "");
262
+ const doFetch = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
263
+
264
+ function headers(extra: Record<string, string> = {}) {
265
+ const result: Record<string, string> = { ...(options.headers?.() ?? {}), ...extra };
266
+ if (options.line) result["X-CLI-Line"] = options.line;
267
+ if (options.token) result.Authorization = `Bearer ${options.token}`;
268
+ return result;
269
+ }
270
+
271
+ // 同一时刻对同一地址的 GET 只发一次,后来的共用结果:列表、对话、看板经常同时要同一份数据(同一个任务的详情、看板概况、目录),
272
+ // 浏览器对一个域名的连接有限,重复请求会把真正要紧的(点开文件、发消息)挤到后面排队。
273
+ const inflight = new Map<string, Promise<unknown>>();
274
+ // fresh:不搭在途的那次(它可能在数据变化之前发出),自己发一次;之后来的共用这一次。
275
+ function request<T>(path: string, init: RequestInit = {}, timeoutMs = options.requestTimeoutMs ?? 30_000, fresh = false): Promise<T> {
276
+ if ((init.method ?? "GET") !== "GET" || init.body) return send<T>(path, init, timeoutMs);
277
+ const shared = fresh ? undefined : inflight.get(path);
278
+ if (shared) return shared as Promise<T>;
279
+ const p: Promise<T> = send<T>(path, init, timeoutMs).finally(() => { if (inflight.get(path) === p) inflight.delete(path); });
280
+ inflight.set(path, p);
281
+ return p;
282
+ }
283
+ async function send<T>(path: string, init: RequestInit, timeoutMs: number): Promise<T> {
284
+ let response: Response;
285
+ const ctl = new AbortController();
286
+ let timedOut = false;
287
+ const timer = setTimeout(() => { timedOut = true; ctl.abort(); }, timeoutMs);
288
+ try {
289
+ response = await doFetch(`${base}${path}`, {
290
+ ...init,
291
+ signal: ctl.signal,
292
+ headers: headers(init.body ? { "Content-Type": "application/json" } : {}),
293
+ });
294
+ } catch (error) {
295
+ clearTimeout(timer);
296
+ if (timedOut) throw new CenterError(`任务中心 ${Math.round(timeoutMs / 1000)} 秒没有响应,请稍后重试`, "NETWORK", 0);
297
+ throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
298
+ }
299
+ // 响应体也算在时限里(代理把响应头发了、正文却挂住)。
300
+ let text: string | null = null;
301
+ let readError: unknown = null;
302
+ try { text = await response.text(); } catch (error) { readError = error; } finally { clearTimeout(timer); }
303
+ // 空正文(204、只回状态的接口)照旧当 null;有内容却不是 JSON(代理 / 单点登录的 200 网页)单独报错。
304
+ let body: Record<string, unknown> | null = null;
305
+ let unreadable = false;
306
+ if (text?.trim()) {
307
+ try { body = JSON.parse(text) as Record<string, unknown> | null; } catch { unreadable = true; }
308
+ }
309
+ if (!response.ok) {
310
+ // 413:消息 / 请求体超过中枢上限(中枢回的是 Fastify 的英文说明)。
311
+ if (response.status === 413) throw new CenterError("内容太大,超过任务中心单次请求的上限,请删减或分几条发送", "payload_too_large", 413, body);
312
+ const code = typeof body?.error === "string" ? body.error : `HTTP_${response.status}`;
313
+ const detail = typeof body?.detail === "string" ? body.detail : undefined;
314
+ throw new CenterError(ERROR_TEXT[code] ?? detail ?? `任务中心返回错误(HTTP ${response.status})`, code, response.status, body);
315
+ }
316
+ // 成功状态却没读完正文 / 读到的不是数据:明确失败,不能当成「没有内容」让界面一直停在读取中。
317
+ if (readError) {
318
+ if (timedOut) throw new CenterError(`任务中心 ${Math.round(timeoutMs / 1000)} 秒没有响应,请稍后重试`, "NETWORK", 0);
319
+ throw new CenterError(`读取任务中心的响应时连接中断:${readError instanceof Error ? readError.message : String(readError)}`, "NETWORK", 0);
320
+ }
321
+ if (unreadable) {
322
+ throw new CenterError("任务中心返回的不是数据(可能是登录页或代理错误页),请刷新页面或重新登录后重试", "bad_response", response.status);
323
+ }
324
+ return body as T;
325
+ }
326
+
327
+ const post = <T>(path: string, body: unknown = {}) => request<T>(path, { method: "POST", body: JSON.stringify(body) });
328
+ const id = encodeURIComponent;
329
+ const executionPath = (key?: string | null) => {
330
+ if (!key) return "/api/execution-config";
331
+ const ref = parseConvKey(key);
332
+ return `/api/jobs/${id(ref.jobId)}/execution-config${ref.taskId && ref.taskId !== NEW_TASK ? `?task_id=${id(ref.taskId)}` : ""}`;
333
+ };
334
+
335
+ return {
336
+ baseUrl: base,
337
+ getExecutionConfig: key => request<ExecutionConfiguration>(executionPath(key)),
338
+ setExecutionConfig: (key, changes, expectedRevision) => request<ExecutionConfiguration>(executionPath(key), {
339
+ method: "PATCH", body: JSON.stringify({ ...changes, expected_revision: expectedRevision }),
340
+ }),
341
+ uploadDocument: createDocumentUploader(doFetch, `${base}/api/file-sync`, headers),
342
+ getJob: (jobId: string) => request<CenterJob>(`/api/jobs/${id(jobId)}`),
343
+ listTurns: (jobId: string, query: { afterSeq?: number; limit?: number; taskId?: string } = {}) => {
344
+ const params = new URLSearchParams();
345
+ if (query.afterSeq !== undefined) params.set("after_seq", String(query.afterSeq));
346
+ if (query.limit !== undefined) params.set("limit", String(query.limit));
347
+ if (query.taskId) params.set("task", query.taskId);
348
+ const qs = params.toString();
349
+ return request<{ job_id: string; turns: CenterTurn[] }>(`/api/jobs/${id(jobId)}/turns${qs ? `?${qs}` : ""}`);
350
+ },
351
+ /** 列表用:本产品线全部子任务(对话任务每个对话一条),用来发现对话。 */
352
+ listTasks: () => request<{ tasks: CenterTask[] }>("/api/tasks"),
353
+ async answerAsk(jobId: string, askId: string, answers: string[][]) {
354
+ try {
355
+ return await request<{ resolved: boolean }>(boardPath(jobId, "answer"), { method: "POST", body: JSON.stringify({ ask_id: askId, answers }) });
356
+ } catch (error) {
357
+ // 旧版中枢没有这个接口:当作没有在等的回合(对话任务照旧走下一条消息)。
358
+ if (error instanceof CenterError && isMissingRoute(error)) return { resolved: false };
359
+ throw error;
360
+ }
361
+ },
362
+ async submitInput(jobId: string, inputId: string, values: string[]) {
363
+ try {
364
+ await request(boardPath(jobId, "input"), { method: "POST", body: JSON.stringify({ input_id: inputId, values }) });
365
+ return null;
366
+ } catch (error) {
367
+ if (error instanceof CenterError && isMissingRoute(error)) return "当前任务中心版本不支持在这里填写,请升级任务中心";
368
+ return error instanceof Error ? error.message : String(error);
369
+ }
370
+ },
371
+ subagent: (jobId, remote, childId, generation, before) => request<SubagentTranscript>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/subagents/${id(childId)}?remote=${id(remote)}&generation=${generation}&limit=200${before ? `&before=${id(before)}` : ""}`),
372
+ history: (jobId: string, remote: string, before: string, limit = 200) =>
373
+ request<{ messages: Message[]; older: number }>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/history?remote=${id(remote)}&before=${id(before)}&limit=${limit}`),
374
+ createConversation: (firstMessage: string, opts: NewConversationOptions & { businessId?: string } = {}) => {
375
+ const title = (opts.title || firstLine(firstMessage)).slice(0, 120);
376
+ return post<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>("/api/jobs", {
377
+ title,
378
+ mode: "conversation",
379
+ ...(opts.workspace ? { workspace: opts.workspace } : {}),
380
+ ...(opts.callback ? { callback: opts.callback } : {}),
381
+ tasks: [{
382
+ title,
383
+ prompt: extractDocumentReferences(firstMessage).text,
384
+ ...(extractDocumentReferences(firstMessage).attachments ? { attachments: extractDocumentReferences(firstMessage).attachments } : {}),
385
+ ...(opts.businessId ? { business_id: opts.businessId } : {}),
386
+ ...(opts.agent ? { agent: opts.agent } : {}),
387
+ ...(opts.executionConfig ? { execution_config: opts.executionConfig } : {}),
388
+ ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
389
+ ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
390
+ ...(opts.browserPlaybook !== undefined ? { browser_playbook: opts.browserPlaybook } : {}),
391
+ ...(opts.browserPacing !== undefined ? { browser_pacing: opts.browserPacing } : {}),
392
+ }],
393
+ });
394
+ },
395
+ /** 带 business_id 时 center 按它去重,网络超时可以放心重发同一条。 */
396
+ sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number; taskId?: string; expectedExecutionRevision?: number } = {}) =>
397
+ post<SendResult>(`/api/jobs/${id(jobId)}/messages`, {
398
+ ...extractDocumentReferences(text),
399
+ ...(opts.taskId ? { task_id: opts.taskId } : {}),
400
+ ...(opts.expectedExecutionRevision !== undefined ? { expected_execution_revision: opts.expectedExecutionRevision } : {}),
401
+ ...(opts.businessId ? { business_id: opts.businessId } : {}),
402
+ ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
403
+ }),
404
+ continueTask: async (jobId: string, taskId: string, text: string, opts: { title?: string; businessId?: string } = {}) => {
405
+ const result = await post<{ task_ids: string[] }>(`/api/jobs`, {
406
+ job_id: jobId, mode: "conversation",
407
+ 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 } : {}) }],
408
+ });
409
+ return { task_id: result.task_ids[0] };
410
+ },
411
+ pauseTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/pause`, {}),
412
+ resumeTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/resume`, {}),
413
+ /** 同一个 business_id 重发只开一个对话。 */
414
+ openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; executionConfig?: ExecutionOptions; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
415
+ post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
416
+ ...extractDocumentReferences(text),
417
+ ...(opts.title ? { title: opts.title } : {}),
418
+ ...(opts.agent ? { agent: opts.agent } : {}),
419
+ ...(opts.executionConfig ? { execution_config: opts.executionConfig } : {}),
420
+ ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
421
+ ...(opts.businessId ? { business_id: opts.businessId } : {}),
422
+ ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
423
+ }),
424
+ /** 取消一轮:排队中的直接撤回;正在执行的会中止节点上的回合。 */
425
+ cancelTurn: (jobId: string, turnId: string) => post<CenterTurn>(`/api/jobs/${id(jobId)}/turns/${id(turnId)}/cancel`),
426
+ closeConversation: (jobId: string) => post<{ id: string; status: string }>(`/api/jobs/${id(jobId)}/close`),
427
+ closeConversationTask: (jobId: string, taskId: string) =>
428
+ post<{ job_id: string; task_id: string; status: string; closed: boolean }>(`/api/jobs/${id(jobId)}/conversations/${id(taskId)}/close`),
429
+ /**
430
+ * 订阅对话的实时流(GET /api/jobs/:id/stream)。返回关闭函数。
431
+ * 不自动重连:重连策略(退避、结束后不再连)由调用方决定。
432
+ */
433
+ board: createBoardClient(base, headers, doFetch, request, options.directFileLinks ?? (!options.token && !options.headers && !options.line),
434
+ // 改动:节点限时 40 秒、中枢等 50 秒;这里等得更久,让它们的答复(超时也有明确原因)先到。
435
+ Math.max(options.requestTimeoutMs ?? 30_000, DIFF_TIMEOUT_MS)),
436
+ stream(jobId: string, handlers: StreamHandlers, intervalMs = 300, taskId?: string | null): () => void {
437
+ const ctl = new AbortController();
438
+ let stopped = false;
439
+ // 心跳看门狗:center 每 20 秒发一次 `: ping`。代理 / 负载均衡在上游断掉时可能把连接挂着不关,
440
+ // 这时一直收不到数据却也不报错——超过 STREAM_SILENCE_MS 没有任何字节就当断线,交给调用方重连。
441
+ let lastByte = Date.now();
442
+ let silent = false;
443
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
444
+ const silenceError = () => new CenterError("实时连接长时间没有数据,正在重连", "NETWORK", 0);
445
+ // 中止请求之外还要直接取消读取:有的环境里中止信号不会让挂着的 read() 结束。
446
+ const cut = () => { ctl.abort(); reader?.cancel().catch(() => undefined); };
447
+ const watchdog = setInterval(() => {
448
+ if (Date.now() - lastByte > STREAM_SILENCE_MS) { silent = true; cut(); }
449
+ }, 5_000);
450
+ void (async () => {
451
+ try {
452
+ const response = await doFetch(`${base}/api/jobs/${id(jobId)}/stream?interval_ms=${intervalMs}${taskId ? `&task=${id(taskId)}` : ""}`, {
453
+ headers: headers({ Accept: "text/event-stream" }),
454
+ signal: ctl.signal,
455
+ });
456
+ if (!response.ok || !response.body) {
457
+ const body = (await response.json().catch(() => null)) as { error?: string } | null;
458
+ const code = body?.error ?? `HTTP_${response.status}`;
459
+ throw new CenterError(ERROR_TEXT[code] ?? `实时流连接失败(HTTP ${response.status})`, code, response.status);
460
+ }
461
+ handlers.onOpen?.();
462
+ const parser = createSseParser(({ event, data }) => {
463
+ let payload: unknown;
464
+ try { payload = JSON.parse(data); } catch { return; }
465
+ if (event === "snapshot") handlers.onSnapshot?.(payload as StreamSnapshot | BatchStreamSnapshot);
466
+ else if (event === "chunk") handlers.onChunk?.((payload as { events?: StreamItem[] }).events ?? []);
467
+ else if (event === "end") handlers.onEnd?.(payload as StreamEnd);
468
+ });
469
+ reader = response.body.getReader();
470
+ const decoder = new TextDecoder();
471
+ for (;;) {
472
+ const { done, value } = await reader.read();
473
+ if (done) break;
474
+ lastByte = Date.now();
475
+ parser.push(decoder.decode(value, { stream: true }));
476
+ }
477
+ parser.end();
478
+ if (stopped) return;
479
+ if (silent) return handlers.onClose?.(silenceError());
480
+ if (!ctl.signal.aborted) handlers.onClose?.(null);
481
+ } catch (error) {
482
+ if (stopped) return;
483
+ if (silent) return handlers.onClose?.(silenceError());
484
+ if (ctl.signal.aborted) return;
485
+ handlers.onClose?.(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
486
+ } finally {
487
+ clearInterval(watchdog);
488
+ }
489
+ })();
490
+ return () => { stopped = true; clearInterval(watchdog); cut(); };
491
+ },
492
+ };
493
+ }
494
+
495
+ export interface StreamHandlers {
496
+ onOpen?: () => void;
497
+ /** 对话任务是 StreamSnapshot;一次性任务(含续派)是 BatchStreamSnapshot(见 isBatchSnapshot)。 */
498
+ onSnapshot?: (snapshot: StreamSnapshot | BatchStreamSnapshot) => void;
499
+ onChunk?: (events: StreamItem[]) => void;
500
+ onEnd?: (end: StreamEnd) => void;
501
+ /** 流断了(error 为 null = 服务端正常关闭)。onEnd 之后也会触发一次。 */
502
+ onClose?: (error: CenterError | null) => void;
503
+ }
504
+
505
+ /** 兼容旧名字。 */
506
+ export type CenterClient = ConversationBackend;
507
+
508
+ export function firstLine(text: string, max = 40) {
509
+ const line = text.split("\n").map((value) => value.trim()).find(Boolean) ?? "新对话";
510
+ return line.length > max ? `${line.slice(0, max)}…` : line;
511
+ }
512
+
513
+ /** 读一条 SSE(fetch 读流,能带鉴权头),按事件名回调。返回停止函数。 */
514
+ function openSse(
515
+ doFetch: typeof fetch,
516
+ url: string,
517
+ headers: Record<string, string>,
518
+ onEvent: (event: string, data: unknown) => void,
519
+ onClose?: (error: CenterError | null) => void,
520
+ ): () => void {
521
+ const ctl = new AbortController();
522
+ // 静默看门狗:center 每 20 秒发一次心跳,超过 STREAM_SILENCE_MS 什么都没收到(网络静默断开、代理挂住)就当断线。
523
+ let silent = false;
524
+ let watchdog: ReturnType<typeof setTimeout> | undefined;
525
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
526
+ const arm = () => {
527
+ if (watchdog) clearTimeout(watchdog);
528
+ // 同时取消读取:宿主自定义的 fetch 不一定理会 signal。
529
+ watchdog = setTimeout(() => { silent = true; ctl.abort(); void reader?.cancel().catch(() => undefined); }, STREAM_SILENCE_MS);
530
+ };
531
+ void (async () => {
532
+ try {
533
+ arm();
534
+ const response = await doFetch(url, { headers: { ...headers, Accept: "text/event-stream" }, signal: ctl.signal });
535
+ // A host-supplied fetch may resolve after cancellation despite signal.
536
+ // Retire its body before acquiring a reader or restarting the watchdog.
537
+ if (ctl.signal.aborted) {
538
+ void response.body?.cancel().catch(() => undefined);
539
+ if (silent) onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0));
540
+ return;
541
+ }
542
+ if (!response.ok || !response.body) {
543
+ const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
544
+ throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? `连接失败(HTTP ${response.status})`, body?.error ?? `HTTP_${response.status}`, response.status);
545
+ }
546
+ const parser = createSseParser(({ event, data }) => {
547
+ try { onEvent(event, JSON.parse(data)); } catch { /* 不是 JSON 的忽略 */ }
548
+ });
549
+ reader = response.body.getReader();
550
+ const decoder = new TextDecoder();
551
+ for (;;) {
552
+ const { done, value } = await reader.read();
553
+ if (done) break;
554
+ arm();
555
+ parser.push(decoder.decode(value, { stream: true }));
556
+ }
557
+ parser.end();
558
+ if (watchdog) clearTimeout(watchdog);
559
+ if (silent) { onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0)); return; }
560
+ if (!ctl.signal.aborted) onClose?.(null);
561
+ } catch (error) {
562
+ if (watchdog) clearTimeout(watchdog);
563
+ if (silent) { onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0)); return; }
564
+ if (ctl.signal.aborted) return;
565
+ onClose?.(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
566
+ }
567
+ })();
568
+ return () => { if (watchdog) clearTimeout(watchdog); ctl.abort(); void reader?.cancel().catch(() => undefined); };
569
+ }
570
+
571
+ /** 看板请求的对象:对话键 → 对话任务 + 子任务(「新开」的占位不算,看对话任务缺省的那个)。 */
572
+ function boardTarget(key: string) {
573
+ const { jobId, taskId } = parseConvKey(key);
574
+ return { job: encodeURIComponent(jobId), task: taskId && taskId !== NEW_TASK ? encodeURIComponent(taskId) : null };
575
+ }
576
+
577
+ /**
578
+ * 相对路径 → 路径式地址(各段编码);绝对路径 / ~ 开头 → ?path=。jobId 可以是对话键:
579
+ * 指定了子任务时路径式地址带上它(/tasks/<子任务>/files/raw/…),HTML 里的相对资源也落在同一个对话上。
580
+ */
581
+ export function boardFileUrl(base: string, jobId: string, path: string, download = false) {
582
+ const { job: id, task } = boardTarget(jobId);
583
+ const clean = path.replace(/^\.\//, "");
584
+ const absolute = clean.startsWith("/") || clean.startsWith("~") || /^[a-z]:[\\/]/i.test(clean);
585
+ if (absolute) return `${base}/api/jobs/${id}/files/raw?path=${encodeURIComponent(clean)}${task ? `&task=${task}` : ""}${download ? "&download=1" : ""}`;
586
+ const segs = clean.split("/").filter(Boolean).map(encodeURIComponent).join("/");
587
+ return `${base}/api/jobs/${id}${task ? `/tasks/${task}` : ""}/files/raw/${segs}${download ? "?download=1" : ""}`;
588
+ }
589
+
590
+ /** 看板接口地址:/api/jobs/<job>/<rest>,指定了子任务时加 task 参数。 */
591
+ function boardPath(key: string, rest: string, query = "") {
592
+ const { job, task } = boardTarget(key);
593
+ const params = [query, task ? `task=${task}` : ""].filter(Boolean).join("&");
594
+ return `/api/jobs/${job}/${rest}${params ? `?${params}` : ""}`;
595
+ }
596
+
597
+ function createBoardClient(
598
+ base: string,
599
+ headers: (extra?: Record<string, string>) => Record<string, string>,
600
+ doFetch: typeof fetch,
601
+ request: <T>(path: string, init?: RequestInit, timeoutMs?: number, fresh?: boolean) => Promise<T>,
602
+ directLinks: boolean,
603
+ diffTimeoutMs = DIFF_TIMEOUT_MS,
604
+ ): BoardBackend {
605
+ /**
606
+ * 取文件:响应头限时 FILE_HEADER_TIMEOUT_MS;body() 边读边看门(FILE_STALL_MS 没数据就断),
607
+ * limit 读够就停(大文件只要开头)。外面给的 signal 取消时一并中止。
608
+ */
609
+ const fileFetch = async (jobId: string, path: string, extra: Record<string, string> = {}, signal?: AbortSignal) => {
610
+ const name = path.split("/").pop() || path;
611
+ const aborted = () => new DOMException("已取消", "AbortError");
612
+ if (signal?.aborted) throw aborted();
613
+ const ctl = new AbortController();
614
+ const cancel = () => ctl.abort();
615
+ signal?.addEventListener("abort", cancel, { once: true });
616
+ let timedOut = false;
617
+ const timer = setTimeout(() => { timedOut = true; ctl.abort(); }, FILE_HEADER_TIMEOUT_MS);
618
+ const release = () => { clearTimeout(timer); signal?.removeEventListener("abort", cancel); };
619
+ let response: Response;
620
+ try {
621
+ // 和中止赛跑:宿主自定义的 fetch 不一定理会 signal,取消 / 超时也要马上生效。
622
+ const stopped = new Promise<never>((_r, reject) => {
623
+ if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
624
+ ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
625
+ });
626
+ const fetching = doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" });
627
+ // Host fetch adapters can ignore abort. Dispose a response that arrives
628
+ // after the caller already cancelled or timed out.
629
+ void fetching.then(late => { if (ctl.signal.aborted) void late.body?.cancel().catch(() => undefined); }, () => undefined);
630
+ response = await Promise.race([fetching, stopped]);
631
+ } catch (error) {
632
+ release();
633
+ if (signal?.aborted) throw aborted();
634
+ if (timedOut) throw new CenterError(`读取「${name}」${FILE_HEADER_TIMEOUT_MS / 1000} 秒没有响应(执行节点忙或网络慢),请稍后重试`, "NETWORK", 0);
635
+ throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
636
+ }
637
+ clearTimeout(timer);
638
+ const stallError = () => new CenterError(`读取「${name}」时 ${FILE_STALL_MS / 1000} 秒没有收到数据,传输已中断,请重试`, "NETWORK", 0);
639
+ /** 读响应体:limit 读够就停;onBytes 报进度;FILE_STALL_MS 没数据当断线。 */
640
+ const body = async (opts: { limit?: number; onBytes?: (bytes: number) => void } = {}): Promise<Uint8Array<ArrayBuffer>> => {
641
+ if (signal?.aborted) { release(); void response.body?.cancel().catch(() => undefined); throw aborted(); }
642
+ // 空文件要「前 N 字节」:节点回 416,就是空的。
643
+ if (response.status === 416) { release(); return new Uint8Array(0); }
644
+ const reader = response.body?.getReader();
645
+ if (!reader) {
646
+ try { return new Uint8Array(await response.arrayBuffer()); } finally { release(); }
647
+ }
648
+ const cancelBody = () => { void reader.cancel().catch(() => undefined); };
649
+ ctl.signal.addEventListener("abort", cancelBody, { once: true });
650
+ if (ctl.signal.aborted) cancelBody();
651
+ const chunks: Uint8Array[] = [];
652
+ let got = 0;
653
+ let stalled = false;
654
+ let stall: ReturnType<typeof setTimeout> | undefined;
655
+ const arm = () => {
656
+ clearTimeout(stall);
657
+ stall = setTimeout(() => { stalled = true; ctl.abort(); void reader.cancel().catch(() => undefined); }, FILE_STALL_MS);
658
+ };
659
+ try {
660
+ arm();
661
+ for (;;) {
662
+ const { done, value } = await reader.read();
663
+ if (stalled) throw stallError();
664
+ if (done) break;
665
+ arm();
666
+ chunks.push(value);
667
+ got += value.length;
668
+ opts.onBytes?.(got);
669
+ if (opts.limit !== undefined && got >= opts.limit) { await reader.cancel().catch(() => undefined); break; }
670
+ }
671
+ } catch (error) {
672
+ if (signal?.aborted) throw aborted();
673
+ if (stalled) throw stallError();
674
+ if (error instanceof CenterError) throw error;
675
+ throw new CenterError(`读取「${name}」中断:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
676
+ } finally {
677
+ clearTimeout(stall);
678
+ ctl.signal.removeEventListener("abort", cancelBody);
679
+ // Progress callbacks may throw too; no abandoned response should keep
680
+ // consuming the mobile connection after the read has failed.
681
+ void reader.cancel().catch(() => undefined);
682
+ try { reader.releaseLock(); } catch { /* already released */ }
683
+ release();
684
+ }
685
+ if (signal?.aborted) throw aborted();
686
+ const size = opts.limit !== undefined ? Math.min(got, opts.limit) : got;
687
+ const all = new Uint8Array(size);
688
+ let offset = 0;
689
+ for (const chunk of chunks) {
690
+ const take = Math.min(chunk.length, size - offset);
691
+ all.set(chunk.subarray(0, take), offset);
692
+ offset += take;
693
+ if (offset >= size) break;
694
+ }
695
+ return all;
696
+ };
697
+ if (!response.ok && response.status !== 416) {
698
+ // Error bodies need the same abort/stall bounds as file data. A proxy
699
+ // returning 401 headers and then hanging must not park a read forever.
700
+ const bytes = await body({ limit: 8192 });
701
+ let problem: { error?: string; detail?: string } | null = null;
702
+ try { problem = JSON.parse(new TextDecoder().decode(bytes)); } catch { /* use the HTTP fallback */ }
703
+ const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : problem?.error ?? `读取失败(HTTP ${response.status})`;
704
+ throw new CenterError(problem?.detail ?? ERROR_TEXT[problem?.error ?? ""] ?? fallback, problem?.error ?? `HTTP_${response.status}`, response.status);
705
+ }
706
+ return { response, body };
707
+ };
708
+ /**
709
+ * 读一段:开头 / 从 start 起 maxBytes,或者末尾 maxBytes(超大日志)。只向中枢要这一段(Range);
710
+ * 不支持 Range 的节点回整个文件(200),这里边读边截,大文件不整个拉进浏览器。
711
+ */
712
+ const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }) => {
713
+ const from = Math.max(0, opts.start ?? 0);
714
+ const { maxBytes } = opts;
715
+ const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` }, opts.signal);
716
+ const contentType = response.headers.get("content-type") ?? "text/plain";
717
+ const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
718
+ // 416:要的起点不在文件里(空文件,或文件变短了)。
719
+ if (response.status === 416) { await body(); return { bytes: new Uint8Array(0), start: from, size: total ? Number(total) : 0, contentType }; }
720
+ const length = Number(response.headers.get("content-length") ?? 0);
721
+ if (response.status === 206) {
722
+ // 节点给的一段可能比要的长(不严格按 Range):也只留 maxBytes。
723
+ const bytes = await body({ limit: maxBytes, onBytes: opts.onBytes });
724
+ const start = Number(/bytes (\d+)-/.exec(response.headers.get("content-range") ?? "")?.[1] ?? 0);
725
+ return { bytes, start, size: total ? Number(total) : start + bytes.length, contentType };
726
+ }
727
+ // 回了整个文件:跳过 from 之前的,留 maxBytes。
728
+ const all = await body({ limit: from + maxBytes, onBytes: opts.onBytes });
729
+ const size = length || all.length;
730
+ return { bytes: all.slice(Math.min(from, all.length)), start: Math.min(from, all.length), size, contentType };
731
+ };
732
+ // 文件链接令牌:按对话键缓存,离过期不到 5 分钟就换新的;同一时刻只换一次。
733
+ const links = new Map<string, { base: string; until: number }>();
734
+ const minting = new Map<string, Promise<string | null>>();
735
+ let linksUnsupported = false;
736
+ const LINK_REFRESH_MS = 5 * 60_000;
737
+ const uploads = new Map<string, UploadDocument>();
738
+ const fileSync = createFileSyncTransport(doFetch, (jobId, operation) => `${base}${boardPath(jobId, `file-sync/${operation}`)}`, headers);
739
+ return {
740
+ directLinks,
741
+ uploadFile(jobId, file, options) {
742
+ let upload = uploads.get(jobId);
743
+ if (!upload) {
744
+ const { job, task } = boardTarget(jobId);
745
+ upload = createDocumentUploader(doFetch, `${base}/api/jobs/${job}${task ? `/tasks/${task}` : ""}/file-sync`, headers);
746
+ uploads.set(jobId, upload);
747
+ }
748
+ return upload(file, options);
749
+ },
750
+ async uploadDirectory(jobId, signal) {
751
+ const capabilities = await documentRequest<{ uploadsPath?: string }>(doFetch, `${base}${boardPath(jobId, "file-sync/capabilities")}`, { signal }, headers);
752
+ return typeof capabilities.uploadsPath === "string" ? capabilities.uploadsPath : null;
753
+ },
754
+ fileSync,
755
+ 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),
756
+ info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
757
+ 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),
758
+ stat: (jobId, path = ".") => documentRequest<DirListing | FileInfo>(doFetch, `${base}${boardPath(jobId, "file-sync/stat", `path=${encodeURIComponent(path)}`)}`, {}, headers),
759
+ fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
760
+ async fileLinkBase(jobId) {
761
+ const { job, task } = boardTarget(jobId);
762
+ if (directLinks) return `${base}/api/jobs/${job}${task ? `/tasks/${task}` : ""}/files/raw`;
763
+ if (linksUnsupported) return null;
764
+ const cached = links.get(jobId);
765
+ if (cached && cached.until - Date.now() > LINK_REFRESH_MS) return cached.base;
766
+ const running = minting.get(jobId);
767
+ if (running) return running;
768
+ const work = request<{ base?: unknown; expires_at?: string }>(boardPath(jobId, "files/token"), { method: "POST", body: "{}" }).then((r) => {
769
+ if (typeof r?.base !== "string") { linksUnsupported = true; return null; }
770
+ const linkBase = `${base}${r.base}`;
771
+ links.set(jobId, { base: linkBase, until: Date.parse(r.expires_at ?? "") || Date.now() + 30 * 60_000 });
772
+ return linkBase;
773
+ }, (error: unknown) => {
774
+ if (error instanceof CenterError && isMissingRoute(error)) { linksUnsupported = true; return null; }
775
+ throw error;
776
+ }).finally(() => minting.delete(jobId));
777
+ minting.set(jobId, work);
778
+ return work;
779
+ },
780
+ async readText(jobId, path, maxBytes = 512 * 1024, opts = {}) {
781
+ const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, signal: opts.signal, onBytes: opts.onBytes });
782
+ if (!size && !bytes.length) return { text: "", truncated: false, size: 0, contentType };
783
+ const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
784
+ return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
785
+ },
786
+ async readBytes(jobId, path, opts) {
787
+ const { bytes, start, size } = await readRange(jobId, path, opts);
788
+ return { bytes, start, size };
789
+ },
790
+ async readBuffer(jobId, path, opts = {}) {
791
+ if (isTaskUploadPath(path)) {
792
+ const file = await syncFile(fileSync, jobId, path, { signal: opts.signal, decode: false,
793
+ onProgress: progress => opts.onBytes?.(progress.loadedBytes) });
794
+ return file.bytes.buffer as ArrayBuffer;
795
+ }
796
+ const { body } = await fileFetch(jobId, path, {}, opts.signal);
797
+ return (await body({ onBytes: opts.onBytes })).buffer;
798
+ },
799
+ diff: (jobId, options) => request<DiffResult>(boardPath(jobId, "diff"), {}, diffTimeoutMs, options?.fresh),
800
+ browserStatus: (jobId) => request<BrowserStatus>(boardPath(jobId, "browser")),
801
+ browserAction: (jobId, action) => request<BrowserStatus>(boardPath(jobId, "browser"), { method: "POST", body: JSON.stringify({ action }) }, 150_000),
802
+ browserEvents: (jobId, handlers) => openSse(doFetch, `${base}${boardPath(jobId, "browser/events")}`, headers(), (event, data) => {
803
+ if (event === "status") handlers.onStatus(data as BrowserStatus);
804
+ }, handlers.onClose),
805
+ browserFrames: (jobId, handlers) => openSse(doFetch, `${base}${boardPath(jobId, "browser/frames")}`, headers(), (event, data) => {
806
+ if (event === "viewer") handlers.onViewer((data as { viewer: string }).viewer);
807
+ else if (event === "message") handlers.onMessage(data as BrowserMessage);
808
+ else if (event === "end") handlers.onEnd?.(data as { reason: string; detail?: string });
809
+ }, handlers.onClose),
810
+ async browserInput(jobId, viewer, events) {
811
+ await request(boardPath(jobId, "browser/input"), { method: "POST", body: JSON.stringify({ viewer, events }) });
812
+ },
813
+ browserLive: (jobId, h) => openSse(doFetch, `${base}${boardPath(jobId, "browser/live")}`, headers(), (event, data) => {
814
+ const d = data as Record<string, unknown>;
815
+ if (event === "viewer") h.onViewer(String(d.viewer), d.ready === true);
816
+ else if (event === "status") h.onStatus(data as BrowserStatus);
817
+ else if (event === "message") h.onMessage(data as BrowserMessage);
818
+ else if (event === "frames") h.onFrames?.(data as BrowserFramesState);
819
+ else if (event === "end") h.onEnd?.(data as { reason: string; status?: number; detail?: string });
820
+ }, h.onClose),
821
+ };
822
+ }