@xgspz/cli-ui 0.0.0-stage → 0.6.14

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 +156 -0
  2. package/README.md +512 -2
  3. package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
  4. package/dist/cli-ui-index-C34qFb4u.js +11209 -0
  5. package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
  6. package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
  7. package/dist/cli-ui-xlsx-JVLtx0qR.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 +932 -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 +577 -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 +1103 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +505 -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 +1949 -0
  149. package/src/operator.css +1949 -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,1103 @@
1
+ /**
2
+ * 组件共享的状态仓库(纯 TS,无 React)。积木之间靠它拼起来:
3
+ *
4
+ * · 选中哪个对话(cli-msglist 点一下,cli-chat 跟着切);
5
+ * · 列表里有哪些对话任务(宿主给 jobIds,或本地记住 + 从 center 发现);
6
+ * · 每个对话的实时状态:一个对话只开一条实时流,几个组件同时看也只连一次(引用计数)。
7
+ *
8
+ * 「一个对话」用对话键标识(见 conversation.ts convKey):一个对话任务里可以有几个对话,"job_x#task_y" 是其中一个,
9
+ * 只有 "job_x" 是它的第一个(和以前一样);"job_x#new" 是在这个对话任务里新开一个对话。jobs / jobIds 仍按对话任务。
10
+ *
11
+ * 单独用某个组件时它会自己建一个仓库;放进同一个 <CliCenterProvider>(或同一个 group 的 Web Component)就共享一个。
12
+ */
13
+ import { clientId } from "./id";
14
+ import { connectionDraftScope } from "./identity";
15
+ import { notifyCallback } from "./callbacks";
16
+ import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client";
17
+ import { addTurnsIfAbsent, applyItems, applyOlderPage, applySnapshot, closedCallback, convEnded, convKey, emptyConv, isChatTask, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation";
18
+ import { formatAnswersText } from "../ui/format";
19
+ import { backgroundBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "./blocks";
20
+ import { cloneBoardView, reportBoardOwner, activateBoardOwner, selectedBoardOwner, mergeBoardHistory, rememberBoardFile } from "../board/context";
21
+ import { cloneWorkspaceChange, subscribeWorkspaceFiles, retryWorkspaceFiles, type WorkspaceWatchOptions } from "../board/fileWatch";
22
+ import { retainFileScope } from "../board/fileScope";
23
+ import type { WorkspaceChangeEvent, WorkspaceWatchStatus } from "./types";
24
+ import { createWorkspace, type BoardView, type CliWorkspace } from "../board/workspace";
25
+ import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types";
26
+
27
+ export interface JobEntry {
28
+ job: CenterJob | null;
29
+ /** 第 1 轮的消息(列表卡片的描述)。 */
30
+ firstInput: string | null;
31
+ loading: boolean;
32
+ error: string | null;
33
+ fetchedAt: number;
34
+ }
35
+
36
+ export interface CliState {
37
+ /** 选中的对话(对话键)。 */
38
+ selected: string | null;
39
+ /** 列表里的对话任务(job_id)。 */
40
+ jobIds: string[];
41
+ /** 按 job_id。 */
42
+ jobs: Record<string, JobEntry>;
43
+ /** 按对话键。 */
44
+ convs: Record<string, ConvState>;
45
+ listLoading: boolean;
46
+ listError: string | null;
47
+ /** 看板里引用到对话的东西(按对话键),下一条消息发出时一起带上(见 BoardRef)。 */
48
+ refs: Record<string, BoardRef[]>;
49
+ /**
50
+ * 看板上正在看的东西(按对话键,现在是打开着的文件):缺省跟着下一条消息带上一次,对话输入框上方显示成虚线卡片;
51
+ * off = 这一个已经带过 / 用户点了不带。换了看的东西重新算。
52
+ */
53
+ viewing: Record<string, { ref: BoardRef; off: boolean }>;
54
+ /** 看板现在的样子(按对话键):正在看的文件、开着的文件页签、当前页签。没有看板时没有。 */
55
+ boards: Record<string, BoardView>;
56
+ /** 当前对话是否有展开且可见的外置看板;Chat 内嵌看板不计入。 */
57
+ externalBoards: Record<string, boolean>;
58
+ /** 同一工作区中是否有展开且可见的对话列表。 */
59
+ listVisible: boolean;
60
+ /** 外置看板的模块和只读限制,内嵌视图沿用同一约束。 */
61
+ externalBoardOptions: Record<string, { availableTabs: ("files" | "changes" | "browser")[]; readOnly: boolean }>;
62
+ /** 最近看板背景,收起/卸载后保留,供下一条消息被动参考。 */
63
+ boardContext: Record<string, BoardView>;
64
+ /** 业务方补充的项目背景(按对话键;"" = 还没建的新对话),随下一条消息发出(见 appendContext)。 */
65
+ context: Record<string, ContextItem[]>;
66
+ }
67
+
68
+ /** 一条补充背景。 */
69
+ export interface ContextItem {
70
+ /** 同一个 id 再追加会替换。 */
71
+ id: string;
72
+ /** 标题(输入框上方的小卡片、发给智能体的块里都显示)。 */
73
+ title?: string;
74
+ text: string;
75
+ }
76
+ /** 一条补充背景最长多少字(和创建任务时 workspace.context 的上限一致)。 */
77
+ export const CONTEXT_ITEM_MAX = 20_000;
78
+
79
+ /**
80
+ * 从看板引用到对话的一样东西:一个文件、浏览器当前页面、一处改动,或宿主自己加的页签里的内容(终端输出……)。
81
+ * 对话组件把它们显示在输入框上方;下一条消息发出时合成一个「看板上下文」块带给智能体
82
+ * (转录里显示成一张可展开的卡片,不占对话篇幅),发完清空。
83
+ */
84
+ export interface BoardRef {
85
+ /** 同一个 id 再引用一次会替换(例如浏览器换了页面)。 */
86
+ id: string;
87
+ /** file / browser / changes,或宿主自定义的种类(terminal …)。 */
88
+ kind: string;
89
+ /** 输入框上方小卡片里显示的名字。 */
90
+ label: string;
91
+ /** 带给智能体的那一行(说清是什么、在哪)。 */
92
+ text: string;
93
+ }
94
+
95
+ /** 与 center 回调同名同义的事件,方便宿主把前端事件和后端回调对上。 */
96
+ export type CliEvent =
97
+ /** jobId 是选中的对话键(只有 job_id,或 "job_id#task_id")。 */
98
+ | { type: "select"; jobId: string | null }
99
+ /** 一轮结束。payload 与 center 回调 conversation_turn 的请求体同名同义。key 是这个对话的对话键。 */
100
+ | { type: "conversation_turn"; jobId: string; key: string; turn: CenterTurn; payload: ConversationTurnCallback }
101
+ /** 对话结束(整个对话任务结束,或只关掉了这一个对话)。payload 与 center 回调 conversation_closed 的请求体同名同义。 */
102
+ | { type: "conversation_closed"; jobId: string; key: string; reason: string | null; continuedBy: string | null; payload: ConversationClosedCallback }
103
+ /**
104
+ * 新建了对话(新对话任务,或在对话任务里新开的对话),或旧对话结束后消息开了新对话(job_id 变了)。
105
+ * taskId / key:新的那个对话;previousKey:之前的对话键(新建时是 null 或 "job#new")。
106
+ */
107
+ | { type: "job_change"; jobId: string; previousJobId: string | null; taskId: string | null; key: string; previousKey: string | null }
108
+ | { type: "error"; jobId: string | null; error: CenterError }
109
+ /**
110
+ * 有人要在看板里打开东西(对话里点了本地文件链接、「查看浏览器」,或宿主调了 store.openInBoard)。
111
+ * handled = 有看板接住了;没有时对话组件自己弹窗打开。宿主可以据此展开右侧面板。
112
+ */
113
+ | { type: "open"; jobId: string; target: OpenTarget; handled: boolean }
114
+ /** 看板里引用 / 取消引用了东西(refs 是这个对话现在的全部引用),宿主可以据此做自己的提示。 */
115
+ | { type: "board_ref"; key: string; refs: BoardRef[] }
116
+ /** 看板上切了页签、打开 / 关掉了文件(view 为 null = 看板关了)。 */
117
+ | { type: "board_change"; key: string; view: BoardView | null }
118
+ | { type: "board_context"; jobId: string; view: BoardView }
119
+ | WorkspaceChangeEvent
120
+ | WorkspaceWatchStatus
121
+ /**
122
+ * 补充背景变了:追加 / 去掉(pending 是还没发出的),或随消息发出了(sent 是这次发出的)。
123
+ * key 是对话键;新对话还没建时是 ""。
124
+ */
125
+ | { type: "context"; key: string; pending: ContextItem[]; sent?: ContextItem[] };
126
+
127
+ /** Public workspace metadata must not be shared between business listeners. */
128
+ export function cloneWorkspaceEvent(event:CliEvent):CliEvent {
129
+ if(event.type==='files_changed')return cloneWorkspaceChange(event);
130
+ if(event.type==='files_watch')return {...event};
131
+ if(event.type==='board_ref')return {...event,refs:event.refs.map(ref=>({...ref}))};
132
+ if(event.type==='context')return {...event,pending:event.pending.map(item=>({...item})),...(event.sent?{sent:event.sent.map(item=>({...item}))}:{})};
133
+ if(event.type==='open')return {...event,target:{...event.target}};
134
+ if(event.type==='board_context')return {...event,view:cloneBoardView(event.view)};
135
+ if(event.type==='board_change')return {...event,view:event.view?cloneBoardView(event.view):null};
136
+ return event;
137
+ }
138
+
139
+ /** 看板要打开的东西。 */
140
+ export type OpenTarget = { kind: "file"; path: string } | { kind: "browser" } | { kind: "changes" } | { kind: "files"; path?: string }
141
+ /** 宿主用 extraTabs 加的页签。 */
142
+ | { kind: "tab"; id: string };
143
+ /** 看板登记的接收函数:接得住(显示的是这个对话)就返回 true。 */
144
+ export type BoardSurface = (jobId: string, target: OpenTarget) => boolean;
145
+
146
+ export interface CliStoreOptions extends CenterClientOptions {
147
+ client?: CenterClient;
148
+ /** 命名空间(不同业务 / 产品线请区分;Web Component 没写 group 时用它区分仓库)。列表不会按它在本机记住任何对话。 */
149
+ namespace?: string | null;
150
+ /**
151
+ * 新建对话时的参数(与 POST /api/jobs 对应):标题、业务 ID、工作区、回调、智能体、超时、浏览器模式。
152
+ * 带了 callback,center 每轮结束就向 callback.url 投递 conversation_turn,结束时投递 conversation_closed。
153
+ */
154
+ newConversation?: NewConversationOptions;
155
+ /** 自己建对话(例如经业务后端建,以便把回调密钥留在服务端):返回 center 的 job_id。 */
156
+ createConversation?: (firstMessage: string, options?: NewConversationOptions) => Promise<string>;
157
+ /**
158
+ * 每条消息的 business_id(center 按它去重,回调里原样带回)。缺省 `cli-<uuid>`。
159
+ * kind = "create" 是第一条(新建对话),"message" 是之后的每条。
160
+ */
161
+ businessIdFor?: (text: string, kind: "create" | "message") => string;
162
+ }
163
+
164
+ /** 发一条消息时可以单独指定的新建参数(组件自己的 props),优先于仓库的。 */
165
+ export interface SendOptions {
166
+ expectedExecutionRevision?: number;
167
+ executionConfig?: import("./execution").ExecutionOptions;
168
+ /** 新建对话后是否改仓库的选中(缺省改)。 */
169
+ select?: boolean;
170
+ newConversation?: NewConversationOptions;
171
+ createConversation?: (firstMessage: string, options?: NewConversationOptions) => Promise<string>;
172
+ businessIdFor?: (text: string, kind: "create" | "message") => string;
173
+ }
174
+
175
+ /**
176
+ * 断线后自动重连的退避(约 2 分钟内重试 7 次)。用完就停下(stream: "offline"),不再定时重试:
177
+ * 网络恢复、页面回到前台、窗口重新获得焦点、这个对话发出消息或调用 store.reconnect() 时再连。
178
+ */
179
+ const RECONNECT_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 60_000];
180
+ /**
181
+ * 断线后先悄悄重连,这么久之内重新连上(收到快照)就不提示,stream 保持原样(live / connecting);
182
+ * 超过仍没连上才变成 "reconnecting",界面显示「正在重连」。网关回收长连接、网络抖动、电脑休眠唤醒、
183
+ * 中枢重启时的一次断线大多几秒内就接上,期间漏掉的事件由重连后的快照补齐,不必打扰用户。
184
+ */
185
+ export const RECONNECT_NOTICE_MS = 15_000;
186
+ /**
187
+ * 退避时间上下浮动 ±30%:中枢重启时所有页面同时断开,不错开的话会在同一秒一起重连、一起拉快照,
188
+ * 人多时把刚起来的中枢再压垮一次。
189
+ */
190
+ export const jitter = (ms: number) => Math.round(ms * (0.7 + Math.random() * 0.6));
191
+ /**
192
+ * 标签页在后台超过这么久:先断开实时流(中枢那头每条流都在轮询数据库、连着执行节点),切回来时重连并拉一次快照补齐。
193
+ * 很多人开着页面不看时,中枢和节点不用一直为他们推数据。
194
+ */
195
+ export const PARK_HIDDEN_MS = 5 * 60_000;
196
+ /**
197
+ * 401 / 403:多半是宿主的登录会话过期了。隔 30 秒再试,最多 AUTH_RETRIES 次;之后停下等事件
198
+ * (宿主续上会话后切回页面、获得焦点或调用 store.reconnect() 即恢复),不无限重试。
199
+ */
200
+ const AUTH_RETRY_MS = 30_000;
201
+ const AUTH_RETRIES = 3;
202
+
203
+ // 全局只挂一份「网络恢复 / 切回页面」监听,转给当前有实时流的仓库。
204
+ const reconnectors = new Set<() => void>();
205
+ const parkers = new Set<() => void>();
206
+ let listening = false;
207
+ let parkTimer: ReturnType<typeof setTimeout> | null = null;
208
+ const fireReconnect = () => { for (const fn of [...reconnectors]) fn(); };
209
+ const onVisible = () => {
210
+ if (document.visibilityState === "visible") {
211
+ if (parkTimer) { clearTimeout(parkTimer); parkTimer = null; }
212
+ fireReconnect();
213
+ return;
214
+ }
215
+ parkTimer ??= setTimeout(() => { parkTimer = null; if (document.visibilityState === "hidden") for (const fn of [...parkers]) fn(); }, PARK_HIDDEN_MS);
216
+ };
217
+ function watchReconnect(fn: () => void, park: () => void) {
218
+ reconnectors.add(fn);
219
+ parkers.add(park);
220
+ if (listening || typeof window === "undefined") return;
221
+ listening = true;
222
+ window.addEventListener("online", fireReconnect);
223
+ window.addEventListener("focus", fireReconnect);
224
+ document.addEventListener("visibilitychange", onVisible);
225
+ }
226
+ function unwatchReconnect(fn: () => void, park: () => void) {
227
+ reconnectors.delete(fn);
228
+ parkers.delete(park);
229
+ if (!listening || reconnectors.size) return;
230
+ listening = false;
231
+ window.removeEventListener("online", fireReconnect);
232
+ window.removeEventListener("focus", fireReconnect);
233
+ document.removeEventListener("visibilitychange", onVisible);
234
+ }
235
+ /** 测试用:当前登记着的仓库数。 */
236
+ export const liveReconnectors = () => reconnectors.size;
237
+ const JOB_REFRESH_GAP_MS = 1_500;
238
+ /**
239
+ * 列表在后台刷新 / 发现对话时同时最多发几个请求。HTTP/1.1 下浏览器对一个域名只有 6 个连接,
240
+ * 对话实时流、看板的浏览器画面又各占着长连接;列表少占一些,点开文件、发消息这类用户在等的请求才不用排队。
241
+ */
242
+ const LIST_CONCURRENCY = 2;
243
+
244
+ export function createCliStore(options: CliStoreOptions = {}) {
245
+ const client = options.client ?? createCenterClient(options);
246
+ // 只有明确给了 namespace 才在本机记住对话列表:不给就不落任何共享状态,几个独立的组件互不影响。
247
+ // 列表只放宿主给的 jobIds 和本页里新建 / 接续出来的对话:不从 center 发现,也不从本地存储恢复——
248
+ // 不传就不加载,免得把这个产品线令牌下别的业务、别的用户的对话列出来。
249
+
250
+ let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, externalBoards: {}, listVisible: false, externalBoardOptions: {}, boardContext: {}, context: {} };
251
+ const listeners = new Set<() => void>();
252
+ const eventListeners = new Set<(event: CliEvent) => void>();
253
+ const settledTurns = new Set<string>();
254
+ const watchedTurns = new Set<string>();
255
+ const closedJobs = new Set<string>();
256
+ const seenOpen = new Set<string>();
257
+ let hostIds = false;
258
+
259
+ const set = (next: Partial<CliState>) => {
260
+ state = { ...state, ...next };
261
+ for (const fn of [...listeners]) notifyCallback(fn, undefined);
262
+ };
263
+ // Business callbacks may synchronously navigate or register more listeners.
264
+ // Preserve emission order and keep a fixed audience for each notification.
265
+ const eventQueue:Array<{event:CliEvent;listeners:Array<(event:CliEvent)=>void>}>=[];
266
+ let dispatching=false;
267
+ const emit = (event:CliEvent) => {
268
+ eventQueue.push({event,listeners:[...eventListeners]});if(dispatching)return;
269
+ dispatching=true;
270
+ try{while(eventQueue.length){const next=eventQueue.shift()!;for(const fn of next.listeners)notifyCallback(fn,cloneWorkspaceEvent(next.event));}}
271
+ finally{dispatching=false;}
272
+ };
273
+ const patchConv = (key: string, fn: (conv: ConvState) => ConvState) => {
274
+ const prev = state.convs[key] ?? emptyConv(key);
275
+ const next = fn(prev);
276
+ if (next !== prev) {
277
+ noticeTransitions(key, prev, next);
278
+ set({ convs: { ...state.convs, [key]: next } });
279
+ }
280
+ };
281
+ const patchJob = (jobId: string, patch: Partial<JobEntry>) => {
282
+ const prev = state.jobs[jobId] ?? { job: null, firstInput: null, loading: false, error: null, fetchedAt: 0 };
283
+ set({ jobs: { ...state.jobs, [jobId]: { ...prev, ...patch } } });
284
+ };
285
+
286
+ // 一轮从未结束变成结束 → conversation_turn;对话变成结束 → conversation_closed。
287
+ function noticeTransitions(key: string, prev: ConvState, next: ConvState) {
288
+ const { jobId } = next;
289
+ if (prev.turns !== next.turns) {
290
+ const before = new Map(prev.turns.map((turn) => [turn.turn_id, turn.status]));
291
+ // 有轮次开始执行 / 新排队:列表卡片的「运行中」「排队中」也要跟上,不等下一次轮询。
292
+ if (next.turns.some((turn) => before.get(turn.turn_id) !== turn.status && !TERMINAL_TURN.has(turn.status))) void refreshJob(jobId);
293
+ // 本页看着还没结束过的轮次才发结果(打开页面时就已结束的历史轮次不发)。
294
+ for (const turn of next.turns) if (!TERMINAL_TURN.has(turn.status)) watchedTurns.add(turn.turn_id);
295
+ // 按 seq 顺序发,与 center 投递 callback 的顺序一致:前面还有没结束的轮次,后面结束了的先等着
296
+ // (例如第 2 条排队消息被撤回时第 1 轮还在跑,要等第 1 轮结束后先发 1、再发 2)。
297
+ for (const turn of next.turns) {
298
+ if (!TERMINAL_TURN.has(turn.status)) break;
299
+ if (settledTurns.has(turn.turn_id)) continue;
300
+ settledTurns.add(turn.turn_id);
301
+ // 被合并进别的轮次的消息不单独发(center 也不单独投递),结果在那一轮的 messages 里。
302
+ if (!watchedTurns.has(turn.turn_id) || turn.status === "merged") continue;
303
+ const continuedFrom = state.jobs[jobId]?.job?.conversation?.continued_from_job_id ?? null;
304
+ emit({ type: "conversation_turn", jobId, key, turn, payload: turnCallback(next, turn, continuedFrom) });
305
+ void refreshJob(jobId, true);
306
+ }
307
+ }
308
+ // 对话结束(整个对话任务结束,或只关掉了这一个对话):只对本页看着还开着的对话发(打开时就已结束的不算);
309
+ // 而且要等所有轮次都收尾之后再发,保证 conversation_closed 排在这个对话最后一个 conversation_turn 之后(与 center 的 callback_seq 一致)。
310
+ if (next.job && !convEnded(next)) seenOpen.add(key);
311
+ // 一次性任务(只读查看)没有对话的结果通知。
312
+ if (next.job && convEnded(next) && seenOpen.has(key) && !closedJobs.has(key) && !next.batch) {
313
+ const settled = next.turns.every((turn) => TERMINAL_TURN.has(turn.status));
314
+ if (settled || next.stream === "ended") {
315
+ closedJobs.add(key);
316
+ const payload = closedCallback(next);
317
+ emit({ type: "conversation_closed", jobId, key, reason: payload.closed_reason, continuedBy: next.continuedBy, payload });
318
+ }
319
+ }
320
+ // 一次性任务里看着的子任务换了状态(开跑、结束):列表里那一行马上跟上,不等列表的定时刷新。
321
+ if (next.batchTask && (prev.batchTask?.id !== next.batchTask.id || prev.batchTask?.status !== next.batchTask.status)) void refreshJob(jobId);
322
+ // 实时流里的对话状态同步给列表。
323
+ if (next.job && prev.job?.status !== next.job.status) {
324
+ const entry = state.jobs[jobId];
325
+ if (entry?.job) patchJob(jobId, { job: { ...entry.job, status: next.job.status } });
326
+ }
327
+ }
328
+
329
+ // ── 列表 ─────────────────────────────────────────────
330
+
331
+ function setJobIds(ids: string[]) {
332
+ hostIds = true;
333
+ const unique = [...new Set(ids.filter(Boolean))];
334
+ if (unique.join() === state.jobIds.join()) return;
335
+ set({ jobIds: unique });
336
+ }
337
+
338
+ function addJob(jobId: string) {
339
+ if (state.jobIds[0] === jobId) return;
340
+ const ids = [jobId, ...state.jobIds.filter((id) => id !== jobId)];
341
+ set({ jobIds: ids });
342
+ }
343
+
344
+ function removeJob(jobId: string) {
345
+ const ids = state.jobIds.filter((id) => id !== jobId);
346
+ set({ jobIds: ids, selected: state.selected && parseConvKey(state.selected).jobId === jobId ? null : state.selected });
347
+ }
348
+
349
+ /** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。oldId / newId 可以是对话键。 */
350
+ function replaceJob(oldId: string, newId: string) {
351
+ if (oldId === newId) return;
352
+ const from = parseConvKey(oldId);
353
+ const to = parseConvKey(newId);
354
+ const ids = from.jobId === to.jobId ? state.jobIds
355
+ : state.jobIds.includes(from.jobId) ? state.jobIds.map((id) => (id === from.jobId ? to.jobId : id)) : [to.jobId, ...state.jobIds];
356
+ set({ jobIds: [...new Set(ids)], selected: state.selected === oldId ? newId : state.selected });
357
+ emit({ type: "job_change", jobId: to.jobId, previousJobId: from.jobId, taskId: to.taskId, key: newId, previousKey: oldId });
358
+ void refreshJob(to.jobId, true);
359
+ }
360
+
361
+ const refreshAgain = new Set<string>();
362
+ async function refreshJob(jobId: string, force = false) {
363
+ const entry = state.jobs[jobId];
364
+ // 正在拉:拉完再补一次,免得错过这期间的变化(例如轮次刚从排队变成执行中)。
365
+ if (entry?.loading) { refreshAgain.add(jobId); return; }
366
+ if (!force && entry && Date.now() - entry.fetchedAt < JOB_REFRESH_GAP_MS) return;
367
+ patchJob(jobId, { loading: true });
368
+ try {
369
+ const job = await client.getJob(jobId);
370
+ let firstInput = state.jobs[jobId]?.firstInput ?? null;
371
+ if (firstInput === null && job.mode === "conversation") {
372
+ // 卡片描述取第一个对话的第一条消息(按 seq 最早的那一轮)。
373
+ const first = await client.listTurns(jobId, { limit: 1 }).catch(() => null);
374
+ firstInput = first?.turns[0]?.input ?? "";
375
+ }
376
+ patchJob(jobId, { job, firstInput, loading: false, error: null, fetchedAt: Date.now() });
377
+ // 新数据里已包含实时用量,清零累计(这个对话任务里的每个对话)。
378
+ for (const conv of Object.values(state.convs)) if (conv.jobId === jobId) patchConv(conv.key, (c) => ({ ...c, liveUsage: { fresh: 0, cacheRead: 0 } }));
379
+ } catch (error) {
380
+ patchJob(jobId, { loading: false, error: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() });
381
+ // 本地记住的对话在 center 已经不存在(被删、换了环境):从列表里拿掉。宿主给的 jobIds 不动,由宿主决定。
382
+ if (!hostIds && error instanceof CenterError && error.status === 404 && (!state.selected || parseConvKey(state.selected).jobId !== jobId)) {
383
+ refreshAgain.delete(jobId);
384
+ removeJob(jobId);
385
+ return;
386
+ }
387
+ }
388
+ if (refreshAgain.delete(jobId)) void refreshJob(jobId, true);
389
+ }
390
+
391
+ /**
392
+ * 刷新列表。缺省只刷还没结束的对话(结束了的不会再变,轮询不必每次都拉);all = true 全部重拉(首次加载、手动刷新)。
393
+ */
394
+ async function refreshAll(opts: { all?: boolean } = {}) {
395
+ set({ listLoading: true, listError: null });
396
+ try {
397
+ const ids = opts.all ? state.jobIds : state.jobIds.filter((id) => {
398
+ const status = state.jobs[id]?.job?.status;
399
+ return !status || !TERMINAL_JOB.has(status);
400
+ });
401
+ await runLimited(ids, LIST_CONCURRENCY, (id) => refreshJob(id, true));
402
+ set({ listLoading: false });
403
+ } catch (error) {
404
+ set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
405
+ }
406
+ }
407
+
408
+ // ── 实时流(引用计数 + 断线重连) ─────────────────────
409
+
410
+ // synced:这个流已经收到过快照;之后的快照是断线重连后的补齐。
411
+ type StreamEntry = {
412
+ refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number; parked?: boolean; synced?: boolean;
413
+ /** 这次断线从什么时候开始(还没恢复);恢复 = 新连接收到快照或数据。 */
414
+ droppedAt?: number;
415
+ /** 到 RECONNECT_NOTICE_MS 还没恢复时把状态改成 reconnecting 的定时器。 */
416
+ notice?: ReturnType<typeof setTimeout> | null;
417
+ lastError?: string | null;
418
+ };
419
+ const streams = new Map<string, StreamEntry>();
420
+ const clearNotice = (entry: StreamEntry) => { if (entry.notice) { clearTimeout(entry.notice); entry.notice = null; } };
421
+ /** 不再算「断线中」:恢复了,或者转成了别的明确状态(停下、结束、出错、页面停放)。 */
422
+ const settleDrop = (entry: StreamEntry) => { clearNotice(entry); entry.droppedAt = undefined; entry.lastError = null; };
423
+
424
+ function connect(key: string) {
425
+ const entry = streams.get(key);
426
+ if (!entry || entry.refs <= 0) return;
427
+ const previous = entry.stop;
428
+ entry.stop = null;
429
+ previous?.();
430
+ if (streams.get(key) !== entry || entry.refs <= 0) return;
431
+ const { jobId, taskId } = parseConvKey(key);
432
+ let ended = false, retired = false, snapshotReceived = false;
433
+ let endReason: string | null = null;
434
+ let cleanup: (() => void) | null = null;
435
+ const owns = () => streams.get(key) === entry && entry.refs > 0 && entry.stop === retire;
436
+ const active = () => !retired && owns();
437
+ const refreshFiles = () => { if (active() && client.board) retryWorkspaceFiles(client.board, key); };
438
+ const retire = () => {
439
+ if (retired) return;
440
+ retired = true;
441
+ if (entry.stop === retire) entry.stop = null;
442
+ const dispose = cleanup; cleanup = null;
443
+ notifyCallback(dispose ?? undefined, undefined);
444
+ };
445
+ // 新连接真正送来了数据:断线算恢复,退避次数重新计。只连上响应头就又断的不算,
446
+ // 否则「连上即断」会把重试次数一直清零、变成无限重连。
447
+ const recovered = () => { entry.attempt = 0; settleDrop(entry); };
448
+ // 断线:先悄悄重连;到 RECONNECT_NOTICE_MS 还没恢复才显示「正在重连」(见 RECONNECT_NOTICE_MS)。
449
+ const noteDrop = (current: StreamEntry, error: CenterError | null) => {
450
+ current.lastError = error?.message ?? null;
451
+ current.droppedAt ??= Date.now();
452
+ const show = () => patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: current.lastError ?? null }));
453
+ const waited = Date.now() - current.droppedAt;
454
+ if (waited >= RECONNECT_NOTICE_MS) { clearNotice(current); show(); return; }
455
+ if (current.notice) return;
456
+ current.notice = setTimeout(() => {
457
+ current.notice = null;
458
+ if (streams.get(key) !== current || current.refs <= 0 || current.parked || current.droppedAt === undefined) return;
459
+ show();
460
+ }, RECONNECT_NOTICE_MS - waited);
461
+ };
462
+ // 重试用完:停下等事件(见 RECONNECT_MS),对话照常可以看、可以发消息。
463
+ const goOffline = (current: NonNullable<ReturnType<typeof streams.get>>, error: CenterError | null) => {
464
+ settleDrop(current);
465
+ current.parked = true;
466
+ patchConv(key, (conv) => ({ ...conv, stream: "offline", streamError: error?.message ?? null }));
467
+ emit({ type: "error", jobId: key, error: error ?? new CenterError("实时连接已中断", "NETWORK", 0) });
468
+ };
469
+ const close = (error: CenterError | null) => {
470
+ if (!active()) return;
471
+ retire();
472
+ const current = streams.get(key);
473
+ if (current !== entry || current.refs <= 0 || current.stop) return;
474
+ // 对话结束(end)不再连;中枢停机(server_shutdown)按断线处理,稍后重连。
475
+ if (ended && endReason !== "server_shutdown") return;
476
+ // 404:对话不存在,重连也没用。
477
+ if (error && error.status === 404) {
478
+ settleDrop(current);
479
+ patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
480
+ emit({ type: "error", jobId: key, error });
481
+ return;
482
+ }
483
+ // 401 / 403:多半是宿主的登录会话过期。报出来(宿主可以据此续会话),之后隔 30 秒再试几次,会话续上了自己恢复。
484
+ if (error && (error.status === 401 || error.status === 403)) {
485
+ settleDrop(current);
486
+ if (current.attempt === 0) emit({ type: "error", jobId: key, error });
487
+ if (current.attempt >= AUTH_RETRIES) { goOffline(current, error); return; }
488
+ patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
489
+ current.attempt += 1;
490
+ current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(AUTH_RETRY_MS));
491
+ return;
492
+ }
493
+ if (current.attempt >= RECONNECT_MS.length) { goOffline(current, error); return; }
494
+ const delay = RECONNECT_MS[current.attempt];
495
+ current.attempt += 1;
496
+ noteDrop(current, error);
497
+ current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(delay));
498
+ };
499
+ entry.stop = retire;
500
+ // 断线后的重连尝试不改状态(悄悄进行,或已经在显示「正在重连」);只有首次连接显示 connecting。
501
+ if (entry.droppedAt === undefined) patchConv(key, (conv) => ({ ...conv, stream: "connecting", streamError: null }));
502
+ if (!active()) return;
503
+ try {
504
+ cleanup = client.stream(jobId, {
505
+ onOpen: () => { if (!active()) return; clearNotice(entry); patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
506
+ onSnapshot: (snapshot) => {
507
+ if (!active()) return;
508
+ // 重连后的快照:断线期间可能有轮次结束,算一次收尾(首次连上不算,看板挂上时自己会读)。
509
+ const catchUp = !snapshotReceived && !!entry.synced;
510
+ entry.synced = true;
511
+ recovered();
512
+ patchConv(key, (conv) => { const next = applySnapshot(conv, snapshot); return catchUp ? { ...next, settled: next.settled + 1 } : next; });
513
+ // Each connection catches up once, including rounds completed while disconnected.
514
+ if (!snapshotReceived) { snapshotReceived = true; refreshFiles(); }
515
+ },
516
+ onChunk: (items) => {
517
+ if (!active()) return;
518
+ recovered();
519
+ let refresh = false;
520
+ patchConv(key, (conv) => {
521
+ let next = conv;
522
+ let settled = false;
523
+ for (const item of items) {
524
+ const previous = next;
525
+ next = applyItems(next, [item]);
526
+ // applyItems also rejects events from another batch task's node session.
527
+ if (next === previous) continue;
528
+ // Observe every event: end + next user in one chunk can leave running=true.
529
+ if (item.type === "node" && item.event.type === "turn_end" || item.type === "transcript") refresh = true;
530
+ // 有轮次的对话以中枢把这一轮标成结束为准(节点已停、轮末保存已做);没有轮次的看节点 turn_end。
531
+ if (item.type === "node" && item.event.type === "turn_end" && !next.turns.length) settled = true;
532
+ if (item.type === "turn" && item.turn.status !== "merged" && TERMINAL_TURN.has(item.turn.status)
533
+ && !TERMINAL_TURN.has(previous.turns.find((turn) => turn.turn_id === item.turn.turn_id)?.status ?? "")) { refresh = true; settled = true; }
534
+ }
535
+ return settled ? { ...next, settled: next.settled + 1 } : next;
536
+ });
537
+ if (refresh) refreshFiles();
538
+ },
539
+ onEnd: (end) => {
540
+ if (!active() || ended) return;
541
+ ended = true;
542
+ endReason = end.reason;
543
+ // 中枢停机:连接随后会断,按断线悄悄重连,不显示成「已结束」。
544
+ if (end.reason === "server_shutdown") return;
545
+ settleDrop(entry);
546
+ const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
547
+ patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false, settled: conv.settled + 1 }));
548
+ refreshFiles();
549
+ void refreshJob(jobId, true);
550
+ },
551
+ onClose: close,
552
+ }, undefined, taskId);
553
+ // Some custom transports close synchronously before returning cleanup.
554
+ if (retired || !owns()) {
555
+ const dispose = cleanup; cleanup = null;
556
+ notifyCallback(dispose ?? undefined, undefined);
557
+ }
558
+ } catch (error) {
559
+ close(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
560
+ }
561
+ }
562
+
563
+ // 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
564
+ // 只在有实时流时登记到全局(见 watchReconnect):组件卸载、流都关了就撤掉,宿主单页应用里反复挂载组件不会越积越多。
565
+ function reconnectNow() {
566
+ for (const [jobId, entry] of streams) {
567
+ if (entry.parked && entry.refs > 0) { entry.parked = false; entry.attempt = 0; settleDrop(entry); connect(jobId); continue; }
568
+ if (!entry.timer) continue;
569
+ clearTimeout(entry.timer);
570
+ entry.timer = null;
571
+ entry.attempt = 0;
572
+ connect(jobId);
573
+ }
574
+ }
575
+ /** 手动重连:给了对话键只连这一个,否则所有停下的 / 正在退避等待的都立刻再连(重试次数重新计)。 */
576
+ function reconnect(key?: string) {
577
+ if (!key) { reconnectNow(); return; }
578
+ const entry = streams.get(key);
579
+ if (!entry || entry.refs <= 0 || (!entry.parked && !entry.timer)) return;
580
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
581
+ entry.parked = false; entry.attempt = 0;
582
+ connect(key);
583
+ }
584
+ // 页面在后台太久:停掉实时流(主动停止不算断线,不会触发重连),切回来由 reconnectNow 接上。
585
+ function parkAll() {
586
+ for (const entry of streams.values()) {
587
+ // 只停连着的和正在等重连的;对话已结束、不再连的不动。
588
+ if (entry.refs <= 0 || (!entry.stop && !entry.timer)) continue;
589
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
590
+ settleDrop(entry);
591
+ entry.stop?.();
592
+ entry.stop = null;
593
+ entry.parked = true;
594
+ }
595
+ }
596
+ const syncLive = () => { if (streams.size) watchReconnect(reconnectNow, parkAll); else unwatchReconnect(reconnectNow, parkAll); };
597
+
598
+ /**
599
+ * 开始看一个对话(对话键;返回停止函数)。同一对话多处同时看只连一次。
600
+ * "job#new"(还没开出来的对话)没有实时流,只拉对话任务的信息。
601
+ */
602
+ function open(key: string): () => void {
603
+ const { jobId, taskId } = parseConvKey(key);
604
+ if (taskId === NEW_TASK) {
605
+ void refreshJob(jobId);
606
+ return () => undefined;
607
+ }
608
+ let entry = streams.get(key);
609
+ if (!entry) {
610
+ entry = { refs: 0, stop: null, timer: null, attempt: 0 };
611
+ streams.set(key, entry);
612
+ syncLive();
613
+ }
614
+ entry.refs += 1;
615
+ if (entry.refs === 1) {
616
+ void refreshJob(jobId);
617
+ connect(key);
618
+ }
619
+ const owned = entry;
620
+ let active = true;
621
+ return () => {
622
+ if (!active) return;
623
+ active = false;
624
+ const current = streams.get(key);
625
+ if (current !== owned) return;
626
+ current.refs -= 1;
627
+ if (current.refs > 0) return;
628
+ // Retire the registry before calling host cleanup: it may synchronously
629
+ // acquire a new lease for the same task.
630
+ streams.delete(key);
631
+ if (current.timer) { clearTimeout(current.timer); current.timer = null; }
632
+ settleDrop(current);
633
+ syncLive();
634
+ current.stop?.();
635
+ if (!streams.has(key)) patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
636
+ };
637
+ }
638
+
639
+ // ── 发消息 / 新建 / 撤回 / 结束 ───────────────────────
640
+
641
+ /**
642
+ * 发一条消息。key 是对话键:null 时新建对话任务(这条就是第一轮);"job#new" 时在这个对话任务里新开一个对话。
643
+ * 返回实际接收消息的对话键。带 business_id 发:网络抖动时重发一次也只会跑一轮。
644
+ */
645
+ // 同一个对话的消息严格按发出的顺序送到 center(输入框、提问卡片、重试可能同时发):
646
+ // center 按到达顺序给轮次编号,并发发出去可能后发先到。每个对话一条队列,前一条失败不挡后面的。
647
+ const sendQueues = new Map<string, Promise<unknown>>();
648
+ function send(jobId: string | null, text: string, overrides: SendOptions = {}): Promise<string> {
649
+ const key = jobId ?? "\u0000new";
650
+ const prev = sendQueues.get(key) ?? Promise.resolve();
651
+ const run = prev.catch(() => undefined).then(() => sendNow(jobId, text, overrides));
652
+ const tail = run.catch(() => undefined);
653
+ sendQueues.set(key, tail);
654
+ void tail.then(() => { if (sendQueues.get(key) === tail) sendQueues.delete(key); });
655
+ return run;
656
+ }
657
+
658
+ async function sendNow(key: string | null, typed: string, overrides: SendOptions): Promise<string> {
659
+ // 业务方补充的背景:随这条消息带上(发出前取走,失败放回去)。超过单条上限就不发,背景留着。
660
+ const ck = ctxKey(key);
661
+ const ctx = state.context[ck] ?? [];
662
+ let text = typed;
663
+ if (ctx.length) {
664
+ text = `${typed}\n\n${serializeBlock(backgroundBlock(ctx))}`;
665
+ if (charCount(text) > MAX_MESSAGE_CHARS) {
666
+ const err = new CenterError(`补充背景加上这条消息超过单条上限(${MAX_MESSAGE_CHARS.toLocaleString()} 字),请精简补充背景`, "too_long", 0);
667
+ emit({ type: "error", jobId: key, error: err });
668
+ throw err;
669
+ }
670
+ setContext(ck, []);
671
+ }
672
+ try {
673
+ const target = await sendWithText(key, text, overrides);
674
+ if (ctx.length) emit({ type: "context", key: target, pending: state.context[ck] ?? [], sent: ctx });
675
+ // 实时流因重试用完停下了:消息已发出,马上再连,回复才能实时显示。
676
+ reconnect(target);
677
+ return target;
678
+ } catch (error) {
679
+ if (ctx.length) {
680
+ const have = new Set((state.context[ck] ?? []).map((c) => c.id));
681
+ setContext(ck, [...ctx.filter((c) => !have.has(c.id)), ...(state.context[ck] ?? [])]);
682
+ }
683
+ throw error;
684
+ }
685
+ }
686
+
687
+ async function sendWithText(key: string | null, text: string, overrides: SendOptions): Promise<string> {
688
+ // 发消息的组件自己的参数优先(同一个仓库里可以有几个对话组件,各带各的新建参数),没有再用仓库的。
689
+ const originalOptions = overrides.newConversation ?? options.newConversation;
690
+ const newConversation = overrides.executionConfig ? { ...originalOptions, agent: overrides.executionConfig.agent ?? originalOptions?.agent, executionConfig: overrides.executionConfig } : originalOptions;
691
+ const createConversation = overrides.createConversation ?? options.createConversation;
692
+ const businessIdFor = overrides.businessIdFor ?? options.businessIdFor;
693
+ const ref = key ? parseConvKey(key) : null;
694
+ const businessId = businessIdFor?.(text, ref && ref.taskId !== NEW_TASK ? "message" : "create") || `cli-${uid()}`;
695
+ try {
696
+ if (ref?.taskId === NEW_TASK) {
697
+ // 在对话任务里新开一个对话:标题取第一行,智能体 / 浏览器模式沿用对话任务的第一个对话(center 决定)。
698
+ if (!client.openConversation) throw new CenterError("当前后端不支持在对话任务里新开对话", "UNSUPPORTED", 0);
699
+ const result = await retryOnce(() => client.openConversation!(ref.jobId, text, { title: firstLine(text, 60), businessId, agent: newConversation?.agent, executionConfig: newConversation?.executionConfig, browser: newConversation?.browser, timeoutSeconds: newConversation?.timeoutSeconds }));
700
+ const target = convKey(result.job_id, result.task_id ?? result.turn.task_id);
701
+ patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
702
+ addJobIfMissing(result.job_id);
703
+ if (overrides.select !== false && state.selected === key) set({ selected: target });
704
+ emit({ type: "job_change", jobId: result.job_id, previousJobId: result.job_id !== ref.jobId ? ref.jobId : null, taskId: parseConvKey(target).taskId, key: target, previousKey: key });
705
+ void refreshJob(result.job_id, true);
706
+ return target;
707
+ }
708
+ if (!key || !ref) {
709
+ const selectedBeforeCreate = state.selected;
710
+ let newId: string;
711
+ if (createConversation) {
712
+ newId = newConversation?.executionConfig ? await createConversation(text, newConversation) : await createConversation(text);
713
+ } else {
714
+ // 新建只发一次:center 的 POST /api/jobs 没有幂等,建好了却没收到响应时重发会建出第二个对话。
715
+ const created = await client.createConversation(text, { ...newConversation, title: newConversation?.title || firstLine(text), businessId: newConversation?.businessId || businessId });
716
+ newId = created.id;
717
+ patchConv(newId, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, created.turn ? [created.turn] : []) }));
718
+ }
719
+ addJob(newId);
720
+ // 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
721
+ if (overrides.select !== false && state.selected === selectedBeforeCreate) set({ selected: newId });
722
+ emit({ type: "job_change", jobId: newId, previousJobId: null, taskId: null, key: newId, previousKey: null });
723
+ void refreshJob(newId, true);
724
+ return newId;
725
+ }
726
+ const shown = state.convs[key];
727
+ if (ref.taskId && (isChatTask(shown) || shown?.batch || state.convs[ref.jobId]?.batch)) {
728
+ // 一次性任务里的子任务(对话式的,或已经结束的普通子任务):续派一条接着它的对话(中枢接到这个对话最新的那条后面),对话键不变。
729
+ // 续派没有按 business_id 去重,不重发。
730
+ if (!client.continueTask) throw new CenterError("当前后端不支持接着对话式子任务聊", "UNSUPPORTED", 0);
731
+ const { task_id } = await client.continueTask(ref.jobId, ref.taskId, text, { businessId });
732
+ // 先显示成排队中,不用等实时流的下一次轮询。
733
+ patchConv(key, (conv) => conv.batchTask && !conv.batchTask.queued?.some((q) => q.id === task_id)
734
+ ? { ...conv, batchTask: { ...conv.batchTask, queued: [...(conv.batchTask.queued ?? []), { id: task_id, prompt: text, created_at: new Date().toISOString() }] } }
735
+ : conv);
736
+ // 大任务结束时旧版中枢会关流:重新连上,接着看这一条。
737
+ const entry = streams.get(key);
738
+ if (entry && entry.refs > 0 && !entry.stop && !entry.timer) { entry.attempt = 0; connect(key); }
739
+ void refreshJob(ref.jobId, true);
740
+ return key;
741
+ }
742
+ const result = await retryOnce(() => client.sendMessage(ref.jobId, text, { businessId, ...(ref.taskId ? { taskId: ref.taskId } : {}), ...(overrides.expectedExecutionRevision !== undefined ? { expectedExecutionRevision: overrides.expectedExecutionRevision } : {}) }));
743
+ const jobTarget = result.job_id || ref.jobId;
744
+ // 指定了对话的:接续后跟到新对话任务里对应的那个对话;只有 job_id 的照旧只换 job。
745
+ const target = ref.taskId ? convKey(jobTarget, result.task_id ?? result.turn.task_id ?? ref.taskId) : jobTarget;
746
+ if (target !== key) replaceJob(key, target);
747
+ // 先把这一轮放进状态:排队气泡立刻出现,不用等实时流的下一个块。
748
+ patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
749
+ if (jobTarget === ref.jobId) addJobIfMissing(ref.jobId);
750
+ return target;
751
+ } catch (error) {
752
+ const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
753
+ emit({ type: "error", jobId: key, error: err });
754
+ throw err;
755
+ }
756
+ }
757
+
758
+ function addJobIfMissing(jobId: string) {
759
+ if (!state.jobIds.includes(jobId)) addJob(jobId);
760
+ }
761
+
762
+ /**
763
+ * 回答 Agent 的提问(AskUserQuestion 卡片)。center 在 Agent 提问时已经结束了那一轮(asked=true),
764
+ * 所以回答作为下一条消息发出——与 Operator 在没有等待中的回合时(/answer 返回 resolved=false)的做法一致;
765
+ * 卡片先在本地标成已答(Operator 同样是先乐观更新)。
766
+ */
767
+ /** fallback = false:只交给正在等的回合,不改发消息(一次性任务不能发消息);没有在等的回合时报错。 */
768
+ async function answerAsk(key: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions & { fallback?: boolean } = {}): Promise<string> {
769
+ // 与 Operator 的 answerQuestion 同一流程:先乐观标成已答 → 交给正在等的回合 → 没有在等的回合就当普通回复发出。
770
+ patchConv(key, (conv) => ({ ...conv, localAnswers: { ...conv.localAnswers, [askId]: answers } }));
771
+ try {
772
+ if (askId && client.answerAsk) {
773
+ const { resolved } = await client.answerAsk(key, askId, answers);
774
+ if (resolved) return key;
775
+ }
776
+ if (overrides.fallback === false) throw new CenterError("这个提问已经没有在等回答了(回合已经结束)", "ASK_EXPIRED", 0);
777
+ return await send(key, formatAnswersText(questions, answers), overrides);
778
+ } catch (error) {
779
+ // 没发出去:卡片恢复成可回答。
780
+ patchConv(key, (conv) => {
781
+ const { [askId]: _drop, ...rest } = conv.localAnswers;
782
+ return { ...conv, localAnswers: rest };
783
+ });
784
+ throw error;
785
+ }
786
+ }
787
+
788
+ async function cancelTurn(key: string, turnId: string) {
789
+ try {
790
+ const turn = await client.cancelTurn(parseConvKey(key).jobId, turnId);
791
+ // 撤回排队的消息会立刻收尾(cancelled):响应里已经结束、而我们还没收到的,先用上;其余以实时流为准。
792
+ patchConv(key, (conv) => {
793
+ const known = conv.turns.find((t) => t.turn_id === turn.turn_id);
794
+ if (known && (TERMINAL_TURN.has(known.status) || !TERMINAL_TURN.has(turn.status))) return conv;
795
+ return { ...conv, turns: upsertTurns(conv.turns, [turn]) };
796
+ });
797
+ } catch (error) {
798
+ const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
799
+ emit({ type: "error", jobId: key, error: err });
800
+ throw err;
801
+ }
802
+ }
803
+
804
+ /** 结束对话:指定了对话("job#task")的只关掉这一个;只有 job_id 的结束整个对话任务(和以前一样)。 */
805
+ async function close(key: string) {
806
+ const { jobId, taskId } = parseConvKey(key);
807
+ try {
808
+ if (taskId && taskId !== NEW_TASK) {
809
+ if (!client.closeConversationTask) throw new CenterError("当前后端不支持单独关闭对话", "UNSUPPORTED", 0);
810
+ await client.closeConversationTask(jobId, taskId);
811
+ } else {
812
+ await client.closeConversation(jobId);
813
+ }
814
+ await refreshJob(jobId, true);
815
+ } catch (error) {
816
+ const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
817
+ emit({ type: "error", jobId: key, error: err });
818
+ throw err;
819
+ }
820
+ }
821
+
822
+ /**
823
+ * 往上翻:取这个对话更早的一页节点消息,接在最前面。实时流只给最新一段(older 是更早还有几条)。
824
+ * 同一个对话同一时刻只取一页。返回取到没有。
825
+ */
826
+ const loadingOlder = new Set<string>();
827
+ async function loadOlder(key: string): Promise<boolean> {
828
+ const conv = state.convs[key];
829
+ // 节点会话:一次性任务是正在看的那个对话,对话任务是流里这个对话的远端任务。
830
+ const remote = conv ? conv.remoteTaskId ?? conv.task?.remote_task_id ?? null : null;
831
+ const first = conv?.msgs[0];
832
+ if (!conv || conv.older <= 0 || !remote || !first || !client.history || loadingOlder.has(key)) return false;
833
+ loadingOlder.add(key);
834
+ try {
835
+ const page = await client.history(key, remote, first.id, 200);
836
+ // 取的过程中换了节点会话(重建、续派切过去):这一页作废。
837
+ patchConv(key, (c) => ((c.remoteTaskId ?? c.task?.remote_task_id ?? null) === remote && c.msgs[0]?.id === first.id ? applyOlderPage(c, page.messages ?? [], page.older) : c));
838
+ return true;
839
+ } catch (error) {
840
+ emit({ type: "error", jobId: key, error: error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0) });
841
+ return false;
842
+ } finally {
843
+ loadingOlder.delete(key);
844
+ }
845
+ }
846
+
847
+ // 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
848
+ const surfaces: { surface: BoardSurface; owner?: string; inline?: boolean; priority?: () => number }[] = [];
849
+ const visibleLists = new Set<string>();
850
+ function reportListPresence(owner: string, visible: boolean) {
851
+ if (visible) visibleLists.add(owner); else visibleLists.delete(owner);
852
+ const listVisible = visibleLists.size > 0;
853
+ if (state.listVisible !== listVisible) set({ listVisible });
854
+ }
855
+ const externalBoards = new Map<string, Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>>();
856
+ function reportBoardPresence(key: string, owner: string, visible: boolean | null, options: { availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean } = {}) {
857
+ const owners = externalBoards.get(key) ?? new Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>();
858
+ if (visible === null) owners.delete(owner); else owners.set(owner, { visible, ...options });
859
+ if (owners.size) externalBoards.set(key, owners); else externalBoards.delete(key);
860
+ const values = [...owners.values()];
861
+ const shown = values.some(value => value.visible);
862
+ const limits = owners.size ? { availableTabs: (["files", "changes", "browser"] as const).filter(tab => values.every(value => !value.availableTabs || value.availableTabs.includes(tab))), readOnly: values.some(value => value.readOnly) } : undefined;
863
+ if (!!state.externalBoards[key] === shown && JSON.stringify(state.externalBoardOptions[key]) === JSON.stringify(limits)) return;
864
+ const next = { ...state.externalBoards };
865
+ if (shown) next[key] = true; else delete next[key];
866
+ const configurations = { ...state.externalBoardOptions };
867
+ if (limits) configurations[key] = limits; else delete configurations[key];
868
+ set({ externalBoards: next, externalBoardOptions: configurations });
869
+ }
870
+ // ── 看板 → 对话的引用 ─────────────────────────────
871
+
872
+ function setRefs(key: string, refs: BoardRef[]) {
873
+ const next = { ...state.refs };
874
+ if (refs.length) next[key] = refs.map(ref => ({ ...ref })); else delete next[key];
875
+ set({ refs: next });
876
+ emit({ type: "board_ref", key, refs });
877
+ }
878
+ /** 把看板里的东西引用到这个对话的下一条消息(同 id 替换)。 */
879
+ function addRef(key: string, ref: BoardRef) {
880
+ const list = (state.refs[key] ?? []).filter((r) => r.id !== ref.id);
881
+ // 引用是「指向看板里的什么」,不是把内容整段塞进消息:每条截到 REF_TEXT_MAX,最多留 REF_MAX 条,消息不会因此超长。
882
+ const text = ref.text.length > REF_TEXT_MAX ? `${ref.text.slice(0, REF_TEXT_MAX)}…(已截断,共 ${ref.text.length} 字)` : ref.text;
883
+ setRefs(key, [...list, { ...ref, text }].slice(-REF_MAX));
884
+ }
885
+ function removeRef(key: string, id: string) {
886
+ const list = state.refs[key] ?? [];
887
+ if (list.some((r) => r.id === id)) setRefs(key, list.filter((r) => r.id !== id));
888
+ }
889
+ /** 取走这个对话的引用(发消息时用;发失败时用 restoreRefs 放回去)。 */
890
+ function takeRefs(key: string): BoardRef[] {
891
+ const list = state.refs[key] ?? [];
892
+ if (list.length) setRefs(key, []);
893
+ return list;
894
+ }
895
+ function restoreRefs(key: string, refs: BoardRef[]) {
896
+ if (!refs.length) return;
897
+ const have = new Set((state.refs[key] ?? []).map((r) => r.id));
898
+ setRefs(key, [...refs.filter((r) => !have.has(r.id)), ...(state.refs[key] ?? [])]);
899
+ }
900
+
901
+ // ── 看板上正在看的东西(缺省带进下一条消息一次)────────
902
+ function setViewingState(key: string, value: { ref: BoardRef; off: boolean } | null) {
903
+ const next = { ...state.viewing };
904
+ if (value) next[key] = value; else delete next[key];
905
+ set({ viewing: next });
906
+ }
907
+ /**
908
+ * 看板报告这个对话正在看什么(null = 没在看)。同一个东西重复报告不变。
909
+ * owner:哪个看板报的。同一个对话开着两个看板(常驻的 + 弹出的)时,一个撤掉只撤自己报的,不清掉另一个的。
910
+ */
911
+ // 每个看板各自报告(按 owner 记),生效的是最近报告、还开着的那个;一个看板撤掉只撤它自己的。
912
+ const viewSources = new Map<string, Map<string, BoardRef>>();
913
+ const boardSources = new Map<string, Map<string, BoardView>>();
914
+ /** 记下 owner 的报告,返回现在生效的那个(最近报告的)。 */
915
+ function latestReport<T>(sources: Map<string, Map<string, T>>, key: string, owner: string, value: T | null): T | null {
916
+ const m = sources.get(key) ?? new Map<string, T>();
917
+ m.delete(owner);
918
+ if (value) m.set(owner, value);
919
+ if (m.size) sources.set(key, m); else sources.delete(key);
920
+ return m.size ? [...m.values()][m.size - 1] : null;
921
+ }
922
+ function setViewing(key: string, ref: BoardRef | null, owner = "default") {
923
+ const top = latestReport(viewSources, key, owner, ref);
924
+ const cur = state.viewing[key];
925
+ if (!top) { if (cur) setViewingState(key, null); return; }
926
+ if (cur && cur.ref.id === top.id && cur.ref.text === top.text) return;
927
+ setViewingState(key, { ref: top, off: cur?.ref.id === top.id ? cur.off : false });
928
+ }
929
+
930
+ // ── 看板现在的样子(给业务方:当前打开的文件等)────────
931
+ function applyBoardSelection(key:string,top:BoardView|null,history:import("../board/workspace").BoardFile[]) {
932
+ const current=state.boards[key],previous=state.boardContext[key];
933
+ const boardChanged=JSON.stringify(top)!==JSON.stringify(current??null);
934
+ const retained=top?{...cloneBoardView(top),history,visible:top.visible!==false}:previous?{...cloneBoardView(previous),history,visible:false}:null;
935
+ const contextChanged=!!retained&&JSON.stringify(previous)!==JSON.stringify(retained);
936
+ if(!boardChanged&&!contextChanged)return;
937
+ const boards={...state.boards};if(top)boards[key]=cloneBoardView(top);else delete boards[key];
938
+ // Commit both public views before subscribers can read them or navigate again.
939
+ set({...(boardChanged?{boards}:{}),...(contextChanged?{boardContext:{...state.boardContext,[key]:retained!}}:{})});
940
+ if(boardChanged&&state.boards[key]===boards[key])emit({type:"board_change",key,view:top?cloneBoardView(top):null});
941
+ // A reentrant callback may already have selected a newer file. Do not emit
942
+ // or restore the older automatic context over that newer selection.
943
+ if(contextChanged&&state.boardContext[key]===retained)emit({type:"board_context",jobId:key,view:cloneBoardView(retained!)});
944
+ }
945
+ function reportBoard(key:string,view:BoardView|null,owner="default") {
946
+ const sources=boardSources.get(key)??new Map<string,BoardView>();
947
+ let history=state.boardContext[key]?.history??[];
948
+ if(view){history=mergeBoardHistory(history,sources.get(owner),view);reportBoardOwner(sources,owner,cloneBoardView(view));}
949
+ else sources.delete(owner);
950
+ if(sources.size)boardSources.set(key,sources);else boardSources.delete(key);
951
+ const top=selectedBoardOwner(sources)?.[1]??null;applyBoardSelection(key,top,history);
952
+ }
953
+ function activateBoard(key:string,owner:string) {
954
+ const sources=boardSources.get(key);if(!sources)return;
955
+ const top=activateBoardOwner(sources,owner)?.[1];if(!top)return;
956
+ const file=top.current??(top.file?{path:top.file,sourceId:key}:null);
957
+ const history=file?rememberBoardFile(state.boardContext[key]?.history??[],file):state.boardContext[key]?.history??[];
958
+ applyBoardSelection(key,top,history);
959
+ }
960
+ const fileSubscriptions = new Map<string,{listeners:Set<{change?:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']}>;stop:()=>void;status?:WorkspaceWatchStatus}>();
961
+ function watchWorkspace(key:string,listener?:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}) {
962
+ if(!client.board) throw new Error("当前后端不支持工作区文件订阅");
963
+ const releaseScope=options.paths?.length||options.dirs?.length?retainFileScope(client.board,key,{paths:[...options.paths??[]],dirs:[...options.dirs??[]]}):()=>{};
964
+ const item={change:listener,status:options.onStatus};let subscription=fileSubscriptions.get(key);
965
+ if(!subscription){
966
+ subscription={listeners:new Set([item]),stop:()=>{}};fileSubscriptions.set(key,subscription);const owned=subscription;
967
+ owned.stop=subscribeWorkspaceFiles(client.board,key,event=>{emit(event);for(const next of [...owned.listeners])notifyCallback(next.change,cloneWorkspaceChange(event));},{intervalMs:options.intervalMs,onStatus:status=>{owned.status=status;emit(status);for(const next of [...owned.listeners])notifyCallback(next.status,{...status});}});
968
+ }else{subscription.listeners.add(item);if(subscription.status)notifyCallback(item.status,{...subscription.status});retryWorkspaceFiles(client.board,key);}
969
+ const owned=subscription;let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(item);if(!owned.listeners.size){if(fileSubscriptions.get(key)===owned)fileSubscriptions.delete(key);owned.stop();}releaseScope();};
970
+ }
971
+
972
+ // ── 补充背景:业务方随时追加,随下一条消息发给智能体 ────────
973
+ const ctxKey = (key: string | null) => key ?? "";
974
+ function setContext(key: string, items: ContextItem[], sent?: ContextItem[]) {
975
+ const next = { ...state.context };
976
+ if (items.length) next[key] = items; else delete next[key];
977
+ set({ context: next });
978
+ emit({ type: "context", key, pending: items, ...(sent ? { sent } : {}) });
979
+ }
980
+ /**
981
+ * 给这个对话追加一段项目背景(null = 还没建的新对话,随第一条消息带上)。
982
+ * 下一条消息发出时一起带给智能体(对话里显示成一张「补充背景」卡片),发出后智能体在这个对话里一直记得。
983
+ * 同一个 id 再追加会替换。返回这条的 id。
984
+ */
985
+ function appendContext(key: string | null, text: string, opts: { title?: string; id?: string } = {}): string {
986
+ const body = String(text ?? "").trim();
987
+ if (!body) throw new Error("补充背景不能为空");
988
+ if (charCount(body) > CONTEXT_ITEM_MAX) throw new Error(`一条补充背景最长 ${CONTEXT_ITEM_MAX.toLocaleString()} 字,请拆成几条或精简`);
989
+ const k = ctxKey(key);
990
+ const id = opts.id ?? `ctx-${uid()}`;
991
+ const list = (state.context[k] ?? []).filter((c) => c.id !== id);
992
+ setContext(k, [...list, { id, text: body, ...(opts.title ? { title: opts.title.trim().slice(0, 60) } : {}) }]);
993
+ return id;
994
+ }
995
+ function removeContext(key: string | null, id?: string) {
996
+ const k = ctxKey(key);
997
+ const list = state.context[k] ?? [];
998
+ const next = id ? list.filter((c) => c.id !== id) : [];
999
+ if (next.length !== list.length) setContext(k, next);
1000
+ }
1001
+ /** 还没发出的补充背景。 */
1002
+ const pendingContext = (key: string | null): ContextItem[] => (state.context[ctxKey(key)] ?? []).map(item => ({ ...item }));
1003
+
1004
+ function workspace(key?: string | null): CliWorkspace {
1005
+ const k = key ?? state.selected;
1006
+ if (!k) throw new Error("没有指定对话:请传 jobId,或先选中一个对话");
1007
+ return createWorkspace({
1008
+ backend: client.board,
1009
+ key: k,
1010
+ view: () => state.boards[k],
1011
+ context: () => state.boardContext[k] ? cloneBoardView(state.boardContext[k]) : null,
1012
+ watch: (listener, options) => watchWorkspace(k,listener,options),
1013
+ open: (path) => openInBoard(k, { kind: "file", path }),
1014
+ });
1015
+ }
1016
+ /** 这次不带上正在看的东西。 */
1017
+ function dismissViewing(key: string) {
1018
+ const cur = state.viewing[key];
1019
+ if (cur && !cur.off) setViewingState(key, { ...cur, off: true });
1020
+ }
1021
+ /** 发消息时取走(取走后同一个东西不再重复带;看了别的才再带)。 */
1022
+ function takeViewing(key: string): BoardRef | null {
1023
+ const cur = state.viewing[key];
1024
+ if (!cur || cur.off) return null;
1025
+ setViewingState(key, { ...cur, off: true });
1026
+ return cur.ref;
1027
+ }
1028
+ function restoreViewing(key: string, ref: BoardRef | null) {
1029
+ const cur = state.viewing[key];
1030
+ if (ref && cur && cur.ref.id === ref.id && cur.off) setViewingState(key, { ...cur, off: false });
1031
+ }
1032
+
1033
+ function openInBoard(jobId: string, target: OpenTarget, preferredInlineOwner?: string): boolean {
1034
+ let handled = false;
1035
+ const available = [...surfaces].reverse().filter(item => !preferredInlineOwner || !item.inline || item.owner === preferredInlineOwner)
1036
+ .sort((a, b) => (b.priority?.() ?? 2) - (a.priority?.() ?? 2));
1037
+ for (const { surface } of available) {
1038
+ try { handled = surface(jobId, { ...target }); }
1039
+ catch (error) { console.error("[@xgspz/cli-ui] board surface callback failed", error); }
1040
+ if (handled) break;
1041
+ }
1042
+ emit({ type: "open", jobId, target, handled });
1043
+ return handled;
1044
+ }
1045
+
1046
+ return {
1047
+ client,
1048
+ getNewConversationOptions: () => options.newConversation,
1049
+ draftScope: connectionDraftScope(options),
1050
+ getState: () => state,
1051
+ /** 在看板里打开文件 / 浏览器 / 改动。返回有没有看板接住。 */
1052
+ openInBoard,
1053
+ /** 看板登记自己;返回注销函数。 */
1054
+ registerBoard(surface: BoardSurface, options: { owner?: string; inline?: boolean; priority?: () => number } = {}) {
1055
+ const entry = { surface, ...options };
1056
+ surfaces.push(entry);
1057
+ return () => { const i = surfaces.indexOf(entry); if (i >= 0) surfaces.splice(i, 1); };
1058
+ },
1059
+ subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
1060
+ on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
1061
+ select(jobId: string | null) {
1062
+ if (state.selected === jobId) return;
1063
+ set({ selected: jobId });
1064
+ emit({ type: "select", jobId });
1065
+ },
1066
+ setJobIds, addJob, removeJob, replaceJob, refreshJob, refreshAll,
1067
+ addRef, removeRef, takeRefs, restoreRefs,
1068
+ setViewing, dismissViewing, takeViewing, restoreViewing,
1069
+ /** 看板报告自己的样子(看板组件用)。 */
1070
+ reportBoard, activateBoard, reportBoardPresence, reportListPresence,
1071
+ /** 业务方追加项目背景:随下一条消息发给智能体。 */
1072
+ appendContext, removeContext, pendingContext,
1073
+ /** 工作区文件接口(不显示看板也能用):列文件、读文件、下载、打包。不传 = 当前选中的对话。 */
1074
+ workspace, watchWorkspace,
1075
+ open, send, answerAsk, cancelTurn, close, loadOlder, reconnect,
1076
+ };
1077
+ }
1078
+
1079
+ export type CliStore = ReturnType<typeof createCliStore>;
1080
+
1081
+
1082
+ async function runLimited<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>) {
1083
+ let index = 0;
1084
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1085
+ while (index < items.length) await fn(items[index++]);
1086
+ });
1087
+ await Promise.all(workers);
1088
+ }
1089
+
1090
+ const REF_MAX = 20;
1091
+ const REF_TEXT_MAX = 4_000;
1092
+
1093
+ /** 只有网络层失败(没收到响应)才重发:带 business_id,center 会去重。 */
1094
+ async function retryOnce<T>(fn: () => Promise<T>): Promise<T> {
1095
+ try {
1096
+ return await fn();
1097
+ } catch (error) {
1098
+ if (error instanceof CenterError && error.code === "NETWORK") return fn();
1099
+ throw error;
1100
+ }
1101
+ }
1102
+
1103
+ const uid = clientId;