@spzxg/cli-ui 0.6.2 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +165 -19
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +17 -10
  11. package/src/board/BrowserPane.tsx +346 -115
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +54 -31
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +94 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/frameQuote.ts +2 -2
  23. package/src/board/panels.ts +33 -0
  24. package/src/board/textLoad.ts +9 -7
  25. package/src/board/workspace.ts +53 -14
  26. package/src/chat/CliChat.tsx +93 -40
  27. package/src/chat/Composer.tsx +44 -42
  28. package/src/chat/RunControls.tsx +99 -0
  29. package/src/chat/configuration.ts +15 -0
  30. package/src/chat/draft.ts +41 -0
  31. package/src/chat/export.ts +2 -2
  32. package/src/components.tsx +13 -0
  33. package/src/core/blocks.ts +17 -6
  34. package/src/core/callbacks.ts +8 -0
  35. package/src/core/client.ts +103 -19
  36. package/src/core/conversation.ts +4 -3
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +31 -14
  40. package/src/core/store.ts +151 -53
  41. package/src/core/types.ts +64 -9
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/layout/split.ts +67 -30
  47. package/src/msglist/CliMsgList.tsx +19 -13
  48. package/src/operator/branchFlow.ts +35 -0
  49. package/src/operator/index.ts +9 -0
  50. package/src/operator/liveStream.ts +1 -0
  51. package/src/operator/node/AgentConnect.tsx +624 -0
  52. package/src/operator/node/AppearancePanel.tsx +35 -0
  53. package/src/operator/node/BranchHistory.tsx +67 -0
  54. package/src/operator/node/BranchNavigator.tsx +36 -0
  55. package/src/operator/node/BrowserPane.tsx +128 -0
  56. package/src/operator/node/BrowserPopup.tsx +22 -0
  57. package/src/operator/node/Chat.tsx +517 -0
  58. package/src/operator/node/CommandPalette.tsx +213 -0
  59. package/src/operator/node/Composer.tsx +287 -0
  60. package/src/operator/node/InsightsView.tsx +831 -0
  61. package/src/operator/node/Layout.tsx +190 -0
  62. package/src/operator/node/Markdown.tsx +61 -0
  63. package/src/operator/node/Modal.tsx +179 -0
  64. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  65. package/src/operator/node/OnboardingWizard.tsx +250 -0
  66. package/src/operator/node/Orchestrator.tsx +634 -0
  67. package/src/operator/node/ProjectLanding.tsx +86 -0
  68. package/src/operator/node/ProjectsColumn.tsx +137 -0
  69. package/src/operator/node/ProviderAuth.tsx +561 -0
  70. package/src/operator/node/RunControls.tsx +2 -0
  71. package/src/operator/node/RunSwitch.tsx +113 -0
  72. package/src/operator/node/Services.tsx +245 -0
  73. package/src/operator/node/SessionRail.tsx +260 -0
  74. package/src/operator/node/SessionView.tsx +288 -0
  75. package/src/operator/node/SettingsView.tsx +690 -0
  76. package/src/operator/node/TaskBoard.tsx +352 -0
  77. package/src/operator/node/TaskChanges.tsx +530 -0
  78. package/src/operator/node/TaskHero.tsx +50 -0
  79. package/src/operator/node/TasksColumn.tsx +200 -0
  80. package/src/operator/node/Terminal.tsx +143 -0
  81. package/src/operator/node/Transcript.tsx +468 -0
  82. package/src/operator/node/ViewFile.tsx +79 -0
  83. package/src/operator/node/Welcome.tsx +101 -0
  84. package/src/operator/node/WorktreePrune.tsx +333 -0
  85. package/src/operator/node/agents.ts +58 -0
  86. package/src/operator/node/api.ts +40 -0
  87. package/src/operator/node/boardBackend.ts +82 -0
  88. package/src/operator/node/boardEvents.ts +22 -0
  89. package/src/operator/node/boardInteraction.ts +15 -0
  90. package/src/operator/node/boardState.ts +57 -0
  91. package/src/operator/node/boardWorkspace.ts +12 -0
  92. package/src/operator/node/branchFlow.ts +1 -0
  93. package/src/operator/node/composerDraft.ts +52 -0
  94. package/src/operator/node/fileLinks.ts +81 -0
  95. package/src/operator/node/format.ts +290 -0
  96. package/src/operator/node/github.tsx +234 -0
  97. package/src/operator/node/icons.tsx +88 -0
  98. package/src/operator/node/liveStream.ts +270 -0
  99. package/src/operator/node/modals.tsx +720 -0
  100. package/src/operator/node/navHistory.ts +70 -0
  101. package/src/operator/node/persist.ts +26 -0
  102. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  103. package/src/operator/node/protocol/agents/types.ts +394 -0
  104. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  105. package/src/operator/node/protocol/authFailure.ts +66 -0
  106. package/src/operator/node/protocol/customModels.ts +23 -0
  107. package/src/operator/node/protocol/events.ts +13 -0
  108. package/src/operator/node/protocol/features.ts +70 -0
  109. package/src/operator/node/protocol/internalUsage.ts +1 -0
  110. package/src/operator/node/protocol/promptLimits.ts +52 -0
  111. package/src/operator/node/protocol/storageRetention.ts +13 -0
  112. package/src/operator/node/protocol/types.ts +332 -0
  113. package/src/operator/node/protocol/usageLimit.ts +72 -0
  114. package/src/operator/node/shared.tsx +89 -0
  115. package/src/operator/node/transcriptModel.ts +129 -0
  116. package/src/operator/node/types.ts +296 -0
  117. package/src/operator/node/useGlobalEvents.ts +97 -0
  118. package/src/operator/node/useOrchestrator.ts +696 -0
  119. package/src/operator/node/usePrefs.ts +114 -0
  120. package/src/operator/node/useRecaps.ts +91 -0
  121. package/src/operator/node/useTaskStream.ts +280 -0
  122. package/src/operator.base.css +1806 -0
  123. package/src/operator.css +194 -16
  124. package/src/styles.css +49 -1
  125. package/src/ui/Markdown.tsx +7 -2
  126. package/src/ui/Popover.tsx +82 -0
  127. package/src/ui/TextBlock.tsx +3 -3
  128. package/src/ui/Transcript.tsx +17 -10
  129. package/src/ui/fileLinks.ts +6 -0
  130. package/src/ui/format.ts +1 -1
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -0,0 +1,41 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import type { TextBlock } from "../core/blocks";
3
+ export type PendingFile = { key: string; name: string; status: "reading" | "error"; error?: string };
4
+ export interface ComposerDraft { text: string; blocks: Array<TextBlock & { key: string }>; pending: PendingFile[]; sending: boolean; error: string | null; revision: number }
5
+ const empty = (): ComposerDraft => ({ text: "", blocks: [], pending: [], sending: false, error: null, revision: 0 });
6
+ const server = empty();
7
+ const entries = new Map<string, { state: ComposerDraft; stamp: string }>();
8
+ const listeners = new Set<() => void>();
9
+ const textKey = (id: string) => `cli-ui:draft:${id}`;
10
+ const dataKey = (id: string) => `${textKey(id)}:data`;
11
+ const stamp = (id: string) => { try { return JSON.stringify([localStorage.getItem(textKey(id)), localStorage.getItem(dataKey(id))]); } catch { return ""; } };
12
+ export function readComposerDraft(id: string): ComposerDraft {
13
+ const stored = stamp(id), cached = entries.get(id);
14
+ if (cached?.stamp === stored) return cached.state;
15
+ let state = empty();
16
+ try {
17
+ state.text = localStorage.getItem(textKey(id)) ?? "";
18
+ const data = JSON.parse(localStorage.getItem(dataKey(id)) ?? "null");
19
+ if (data?.version === 1) {
20
+ state.blocks = Array.isArray(data.blocks) ? data.blocks.filter((b: TextBlock & { key: string }) => typeof b?.text === "string" && typeof b?.key === "string") : [];
21
+ state.pending = Array.isArray(data.pending) ? data.pending.map((p: PendingFile) => ({ ...p, status: "error", error: p.error || "页面重载中断了解析,请移除后重新添加" })) : [];
22
+ }
23
+ } catch { /* Old text drafts and restricted storage remain usable. */ }
24
+ entries.set(id, { state, stamp: stored });
25
+ return state;
26
+ }
27
+ export function updateComposerDraft(id: string, update: (state: ComposerDraft) => ComposerDraft) {
28
+ const previous = readComposerDraft(id);
29
+ const state = { ...update(previous), revision: previous.revision + 1 };
30
+ try {
31
+ if (state.text) localStorage.setItem(textKey(id), state.text); else localStorage.removeItem(textKey(id));
32
+ if (state.blocks.length || state.pending.length) localStorage.setItem(dataKey(id), JSON.stringify({ version: 1, blocks: state.blocks, pending: state.pending })); else localStorage.removeItem(dataKey(id));
33
+ } catch { /* Keep the in-memory draft even if storage is full. */ }
34
+ entries.set(id, { state, stamp: stamp(id) });
35
+ listeners.forEach(listener => listener());
36
+ return state;
37
+ }
38
+ export function useComposerDraft(id: string) {
39
+ return useSyncExternalStore(listener => { listeners.add(listener); return () => listeners.delete(listener); }, () => readComposerDraft(id), () => server);
40
+ }
41
+ export function clearComposerDraft(id: string) { updateComposerDraft(id, () => empty()); }
@@ -1,5 +1,5 @@
1
- import type { CenterTurn, Msg } from "../core/types.js";
2
- import { toolDataOf } from "../ui/transcriptModel.js";
1
+ import type { CenterTurn, Msg } from "../core/types";
2
+ import { toolDataOf } from "../ui/transcriptModel";
3
3
 
4
4
  /** 把对话过程导出成 Markdown 下载(对应 Operator 的「导出执行过程」,这里在前端用已加载的转录生成)。 */
5
5
  export function buildMarkdown(title: string, jobId: string, messages: Msg[], turns: CenterTurn[]): string {
@@ -0,0 +1,13 @@
1
+ import type { ComponentProps } from "react";
2
+ import { CliMsgList as CenterMsgList, type CliMsgListProps as CenterMsgListProps } from "./msglist/CliMsgList";
3
+ import { CliChat as CenterChat, type CliChatProps as CenterChatProps } from "./chat/CliChat";
4
+ import { CliBoard as CenterBoard, type CliBoardProps as CenterBoardProps } from "./board/CliBoard";
5
+ import { TasksColumn } from "./operator/node/TasksColumn";
6
+ import { SessionView } from "./operator/node/SessionView";
7
+ import { SessionRail } from "./operator/node/SessionRail";
8
+ export interface CliMsgListProps extends CenterMsgListProps { operator?: ComponentProps<typeof TasksColumn> }
9
+ export interface CliChatProps extends CenterChatProps { operator?: ComponentProps<typeof SessionView> }
10
+ export interface CliBoardProps extends CenterBoardProps { operator?: ComponentProps<typeof SessionRail> }
11
+ export function CliMsgList(props: CliMsgListProps) { return props.operator ? <TasksColumn {...props.operator}/> : <CenterMsgList {...props}/>; }
12
+ export function CliChat(props: CliChatProps) { return props.operator ? <SessionView {...props.operator} board={{...props.operator.board,autoContext:props.operator.board?.autoContext??props.passiveContext,onFilesChange:props.operator.board?.onFilesChange??props.onFilesChange,onFilesWatch:props.operator.board?.onFilesWatch??props.onFilesWatch,onBoardContextChange:props.operator.board?.onBoardContextChange??props.onBoardContextChange}}/> : <CenterChat {...props}/>; }
13
+ export function CliBoard(props: CliBoardProps) { return props.operator ? <SessionRail key={props.operator.task.id} {...props.operator} tabs={props.operator.tabs ?? props.tabs} availableTabs={props.operator.availableTabs ?? props.availableTabs} defaultTab={props.operator.defaultTab ?? props.defaultTab} autoContext={props.operator.autoContext ?? props.autoQuoteOpenFile} watchFiles={props.operator.watchFiles ?? props.watchFiles} fileWatchIntervalMs={props.operator.fileWatchIntervalMs ?? props.fileWatchIntervalMs} onFilesChange={props.operator.onFilesChange ?? props.onFilesChange} onFilesWatch={props.operator.onFilesWatch ?? props.onFilesWatch} onBoardContextChange={props.operator.onBoardContextChange ?? props.onBoardContextChange} onEvent={props.operator.onEvent ?? props.onEvent}/> : <CenterBoard {...props}/>; }
@@ -12,7 +12,7 @@
12
12
  * 对 Agent 来说这也是清楚的分隔:哪段是附件、叫什么、在哪结束。
13
13
  */
14
14
 
15
- import { decodeText } from "./textDecode.js";
15
+ import { decodeText } from "./textDecode";
16
16
 
17
17
  export interface TextBlock {
18
18
  /** 显示名:文件名,粘贴的文字为「粘贴的文本」。 */
@@ -48,16 +48,16 @@ export function backgroundBlock(items: Array<{ title?: string; text: string }>):
48
48
  return { name: BACKGROUND_NAME, kind: "background", text: `以下是补充的项目背景信息,请在这次及之后的工作中一并参考:\n\n${body}` };
49
49
  }
50
50
 
51
- /** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、HTML、CSV / TSV。都在浏览器里转成文字。 */
52
- export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".html", ".htm", ".csv", ".tsv"] as const;
51
+ /** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、YAML、HTML、CSV / TSV。都在浏览器里转成文字。 */
52
+ export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".yaml", ".yml", ".html", ".htm", ".csv", ".tsv"] as const;
53
53
  export const ACCEPT_ATTR = [
54
54
  ...ACCEPTED_EXTENSIONS,
55
- "text/plain", "text/markdown", "text/html", "text/csv", "text/tab-separated-values",
55
+ "text/plain", "text/markdown", "text/yaml", "application/yaml", "text/html", "text/csv", "text/tab-separated-values",
56
56
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
57
57
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel.sheet.macroEnabled.12",
58
58
  ].join(",");
59
59
  /** 不支持时提示里列的格式。 */
60
- export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、HTML、CSV";
60
+ export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、YAML(.yaml / .yml)、HTML、CSV";
61
61
  /** 一些常见但读不了的文档:给出另存为什么。 */
62
62
  const SAVE_AS: Record<string, string> = {
63
63
  ".doc": "老版 Word(.doc)请另存为 .docx 后再上传",
@@ -72,6 +72,8 @@ export const PASTE_BLOCK_CHARS = 2_000;
72
72
  export const PASTE_BLOCK_LINES = 40;
73
73
  /** center 单条消息上限(POST /api/jobs/:id/messages 的 text 最长 200,000 字符)。 */
74
74
  export const MAX_MESSAGE_CHARS = 200_000;
75
+ /** Same source-file budget as the full node composer; reject before reading/parsing. */
76
+ export const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
75
77
  export const PASTE_NAME = "粘贴的文本";
76
78
 
77
79
  const OPEN = /^----- 附件:(.+?)([\d,]+ 字)-----$/;
@@ -138,11 +140,18 @@ export class FileReadError extends Error {}
138
140
  * 其他文件(图片、PDF、压缩包……)不收;老版 .doc / .xls 浏览器里读不了,提示另存为。
139
141
  */
140
142
  export async function readFileText(file: File): Promise<string> {
143
+ if (file.size > MAX_DOCUMENT_BYTES) throw new FileReadError(`“${file.name}”超过 10 MB,请缩小后重试。`);
144
+ const text = await extractFileText(file);
145
+ if (text.length > MAX_MESSAGE_CHARS) throw new FileReadError(`“${file.name}”解析后的正文超过 ${MAX_MESSAGE_CHARS.toLocaleString("en-US")} 字符,请拆分后重试。`);
146
+ return text;
147
+ }
148
+
149
+ async function extractFileText(file: File): Promise<string> {
141
150
  const ext = extensionOf(file.name);
142
151
  if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
143
152
  if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
144
153
  if (ext === ".xlsx" || ext === ".xlsm") {
145
- const { readXlsx, sheetsToText } = await import("./xlsx.js");
154
+ const { readXlsx, sheetsToText } = await import("./xlsx");
146
155
  try {
147
156
  return normalizeText(sheetsToText(await readXlsx(await readAs(file, "buffer"))));
148
157
  } catch {
@@ -165,6 +174,8 @@ export async function readFileText(file: File): Promise<string> {
165
174
  }
166
175
  }
167
176
  const { text } = decodeText(new Uint8Array(await readAs(file, "buffer")));
177
+ // YAML literal scalars depend on blank lines, indentation and the final newline.
178
+ if (ext === ".yaml" || ext === ".yml") return text;
168
179
  if (ext === ".html" || ext === ".htm") return normalizeText(text.replace(INLINE_DATA, (m) => `${m.slice(0, m.indexOf(",") + 1)}…(内嵌数据已省略)`));
169
180
  return normalizeText(text);
170
181
  }
@@ -0,0 +1,8 @@
1
+ /** A host subscriber must not interrupt state updates or another subscriber. */
2
+ export function notifyCallback<T>(callback: ((value: T) => unknown) | undefined, value: T): void {
3
+ const report = (error: unknown) => console.error("[@spzxg/cli-ui] subscriber callback failed", error);
4
+ try {
5
+ const result = callback?.(value);
6
+ if (result && typeof (result as PromiseLike<unknown>).then === "function") void Promise.resolve(result).catch(report);
7
+ } catch (error) { report(error); }
8
+ }
@@ -1,10 +1,10 @@
1
- import { createSseParser } from "./sse.js";
2
- import { NEW_TASK, parseConvKey } from "./conversation.js";
1
+ import { createSseParser } from "./sse";
2
+ import { NEW_TASK, parseConvKey } from "./conversation";
3
3
  import type {
4
4
  BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo, Message,
5
- BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
6
- } from "./types.js";
7
- import { decodeText } from "./textDecode.js";
5
+ BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot, WorkspaceFileBatch,
6
+ } from "./types";
7
+ import { decodeText } from "./textDecode";
8
8
 
9
9
  /**
10
10
  * center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
@@ -78,6 +78,11 @@ const ERROR_TEXT: Record<string, string> = {
78
78
  task_already_finished: "当前子任务已经结束,请刷新状态后继续",
79
79
  task_not_paused: "当前子任务没有暂停,请刷新状态后重试",
80
80
  job_already_paused: "整个任务已暂停,请先恢复整个任务",
81
+ file_watch_unsupported: "当前服务不支持文件事件",
82
+ browser_required: "传递浏览器 YAML 手册或操作间隔时,需要同时启用 browser: true",
83
+ invalid_browser_playbook: "浏览器 YAML 手册校验失败,请检查格式和步骤配置",
84
+ invalid_browser_pacing: "浏览器操作间隔无效,请检查最小值和最大值",
85
+ conversation_browser_fields_fixed: "浏览器手册和操作间隔在创建对话时确定,追加消息不能修改",
81
86
  };
82
87
 
83
88
  /**
@@ -141,6 +146,8 @@ export interface BoardBackend {
141
146
  /** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
142
147
  directLinks: boolean;
143
148
  info(jobId: string): Promise<BoardInfo>;
149
+ /** 精准文件事件流;没有时组件明确降级为目录快照比对。 */
150
+ watchFiles?(jobId: string, handlers: { onEvent: (event: import("./types").WorkspaceFileBatch) => void; onClose: (error: CenterError | null) => void }): () => void;
144
151
  /** 目录(缺省根目录)或文件的信息。 */
145
152
  stat(jobId: string, path?: string): Promise<DirListing | FileInfo>;
146
153
  /** 文件地址(给 <img> / <iframe> / 下载链接用)。相对路径用路径式地址,HTML 里的相对资源能跟着解析。 */
@@ -156,12 +163,12 @@ export interface BoardBackend {
156
163
  * tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
157
164
  * 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
158
165
  */
159
- readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
166
+ readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
160
167
  /**
161
168
  * 读一段原始字节:从 start 开始(缺省 0)最多 maxBytes;tail = 末尾 maxBytes。返回这段在文件里的起点和文件总大小。
162
169
  * 看板用它在文件变长时只取新增的部分(日志、还在写的报告),不整个重读。没有这个方法的后端每次整个重读。
163
170
  */
164
- readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
171
+ readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
165
172
  /**
166
173
  * 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
167
174
  * 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
@@ -171,7 +178,7 @@ export interface BoardBackend {
171
178
  browserStatus(jobId: string): Promise<BrowserStatus>;
172
179
  browserAction(jobId: string, action: "open" | "close" | "takeover" | "handback"): Promise<BrowserStatus>;
173
180
  /** 浏览器状态实时流;返回停止函数。onClose 在断开时调用(error 为 null 表示正常结束)。 */
174
- browserEvents(jobId: string, handlers: { onStatus: (status: BrowserStatus) => void; onClose?: (error: CenterError | null) => void }): () => void;
181
+ browserEvents(jobId: string, handlers: { onStatus: (status: BrowserStatus) => void; onClose?: (error: CenterError | null) => void; /** Recoverable transport disruption; subscription remains active. */ onError?: (error: CenterError) => void }): () => void;
175
182
  /** 浏览器实时画面;onViewer 给出发输入要用的 viewer id。 */
176
183
  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;
177
184
  browserInput(jobId: string, viewer: string, events: Array<Record<string, unknown>>): Promise<void>;
@@ -323,7 +330,9 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
323
330
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
324
331
  ...(opts.agent ? { agent: opts.agent } : {}),
325
332
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
326
- ...(opts.browser ? { browser: true } : {}),
333
+ ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
334
+ ...(opts.browserPlaybook !== undefined ? { browser_playbook: opts.browserPlaybook } : {}),
335
+ ...(opts.browserPacing !== undefined ? { browser_pacing: opts.browserPacing } : {}),
327
336
  }],
328
337
  });
329
338
  },
@@ -463,6 +472,13 @@ function openSse(
463
472
  try {
464
473
  arm();
465
474
  const response = await doFetch(url, { headers: { ...headers, Accept: "text/event-stream" }, signal: ctl.signal });
475
+ // A host-supplied fetch may resolve after cancellation despite signal.
476
+ // Retire its body before acquiring a reader or restarting the watchdog.
477
+ if (ctl.signal.aborted) {
478
+ void response.body?.cancel().catch(() => undefined);
479
+ if (silent) onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0));
480
+ return;
481
+ }
466
482
  if (!response.ok || !response.body) {
467
483
  const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
468
484
  throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? `连接失败(HTTP ${response.status})`, body?.error ?? `HTTP_${response.status}`, response.status);
@@ -518,6 +534,31 @@ function boardPath(key: string, rest: string, query = "") {
518
534
  return `/api/jobs/${job}/${rest}${params ? `?${params}` : ""}`;
519
535
  }
520
536
 
537
+ /** Reject a malformed manifest as a whole: silently dropping entries could invent deletions. */
538
+ function isWorkspaceFileBatch(value: unknown): value is WorkspaceFileBatch {
539
+ if (!value || typeof value !== "object") return false;
540
+ const batch = value as Record<string, unknown>;
541
+ if (typeof batch.complete !== "boolean" || (batch.revision !== undefined && typeof batch.revision !== "string")) return false;
542
+ const file = (value: unknown) => {
543
+ if (!value || typeof value !== "object") return false;
544
+ const entry = value as Record<string, unknown>;
545
+ return typeof entry.name === "string" && typeof entry.path === "string" && !!entry.path
546
+ && (entry.type === "file" || entry.type === "dir")
547
+ && typeof entry.size === "number" && Number.isFinite(entry.size) && entry.size >= 0
548
+ && typeof entry.mtime === "number" && Number.isFinite(entry.mtime)
549
+ && (entry.changeToken === undefined || typeof entry.changeToken === "string");
550
+ };
551
+ if (batch.type === "snapshot") return Array.isArray(batch.files) && batch.files.every(file);
552
+ if (batch.type === "resync") return batch.complete === false && typeof batch.reason === "string";
553
+ return batch.type === "changes" && Array.isArray(batch.changes) && batch.changes.every(value => {
554
+ if (!value || typeof value !== "object") return false;
555
+ const change = value as Record<string, unknown>;
556
+ return ["created", "modified", "deleted"].includes(String(change.kind)) && typeof change.path === "string" && !!change.path
557
+ && (change.file === undefined || (file(change.file) && (change.file as { path: string }).path === change.path))
558
+ && (change.previous === undefined || (file(change.previous) && (change.previous as { path: string }).path === change.path));
559
+ });
560
+ }
561
+
521
562
  function createBoardClient(
522
563
  base: string,
523
564
  headers: (extra?: Record<string, string>) => Record<string, string>,
@@ -546,7 +587,11 @@ function createBoardClient(
546
587
  if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
547
588
  ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
548
589
  });
549
- response = await Promise.race([doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal }), stopped]);
590
+ const fetching = doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" });
591
+ // Host fetch adapters can ignore abort. Dispose a response that arrives
592
+ // after the caller already cancelled or timed out.
593
+ void fetching.then(late => { if (ctl.signal.aborted) void late.body?.cancel().catch(() => undefined); }, () => undefined);
594
+ response = await Promise.race([fetching, stopped]);
550
595
  } catch (error) {
551
596
  release();
552
597
  if (signal?.aborted) throw aborted();
@@ -554,21 +599,19 @@ function createBoardClient(
554
599
  throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
555
600
  }
556
601
  clearTimeout(timer);
557
- if (!response.ok && response.status !== 416) {
558
- release();
559
- const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
560
- const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : body?.error ?? `读取失败(HTTP ${response.status})`;
561
- throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? fallback, body?.error ?? `HTTP_${response.status}`, response.status);
562
- }
563
602
  const stallError = () => new CenterError(`读取「${name}」时 ${FILE_STALL_MS / 1000} 秒没有收到数据,传输已中断,请重试`, "NETWORK", 0);
564
603
  /** 读响应体:limit 读够就停;onBytes 报进度;FILE_STALL_MS 没数据当断线。 */
565
604
  const body = async (opts: { limit?: number; onBytes?: (bytes: number) => void } = {}): Promise<Uint8Array<ArrayBuffer>> => {
605
+ if (signal?.aborted) { release(); void response.body?.cancel().catch(() => undefined); throw aborted(); }
566
606
  // 空文件要「前 N 字节」:节点回 416,就是空的。
567
607
  if (response.status === 416) { release(); return new Uint8Array(0); }
568
608
  const reader = response.body?.getReader();
569
609
  if (!reader) {
570
610
  try { return new Uint8Array(await response.arrayBuffer()); } finally { release(); }
571
611
  }
612
+ const cancelBody = () => { void reader.cancel().catch(() => undefined); };
613
+ ctl.signal.addEventListener("abort", cancelBody, { once: true });
614
+ if (ctl.signal.aborted) cancelBody();
572
615
  const chunks: Uint8Array[] = [];
573
616
  let got = 0;
574
617
  let stalled = false;
@@ -596,6 +639,11 @@ function createBoardClient(
596
639
  throw new CenterError(`读取「${name}」中断:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
597
640
  } finally {
598
641
  clearTimeout(stall);
642
+ ctl.signal.removeEventListener("abort", cancelBody);
643
+ // Progress callbacks may throw too; no abandoned response should keep
644
+ // consuming the mobile connection after the read has failed.
645
+ void reader.cancel().catch(() => undefined);
646
+ try { reader.releaseLock(); } catch { /* already released */ }
599
647
  release();
600
648
  }
601
649
  if (signal?.aborted) throw aborted();
@@ -610,16 +658,25 @@ function createBoardClient(
610
658
  }
611
659
  return all;
612
660
  };
661
+ if (!response.ok && response.status !== 416) {
662
+ // Error bodies need the same abort/stall bounds as file data. A proxy
663
+ // returning 401 headers and then hanging must not park a read forever.
664
+ const bytes = await body({ limit: 8192 });
665
+ let problem: { error?: string; detail?: string } | null = null;
666
+ try { problem = JSON.parse(new TextDecoder().decode(bytes)); } catch { /* use the HTTP fallback */ }
667
+ const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : problem?.error ?? `读取失败(HTTP ${response.status})`;
668
+ throw new CenterError(problem?.detail ?? ERROR_TEXT[problem?.error ?? ""] ?? fallback, problem?.error ?? `HTTP_${response.status}`, response.status);
669
+ }
613
670
  return { response, body };
614
671
  };
615
672
  /**
616
673
  * 读一段:开头 / 从 start 起 maxBytes,或者末尾 maxBytes(超大日志)。只向中枢要这一段(Range);
617
674
  * 不支持 Range 的节点回整个文件(200),这里边读边截,大文件不整个拉进浏览器。
618
675
  */
619
- const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }) => {
676
+ const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }) => {
620
677
  const from = Math.max(0, opts.start ?? 0);
621
678
  const { maxBytes } = opts;
622
- const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` });
679
+ const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` }, opts.signal);
623
680
  const contentType = response.headers.get("content-type") ?? "text/plain";
624
681
  const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
625
682
  // 416:要的起点不在文件里(空文件,或文件变短了)。
@@ -644,6 +701,33 @@ function createBoardClient(
644
701
  return {
645
702
  directLinks,
646
703
  info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
704
+ watchFiles(jobId, handlers) {
705
+ let active = true;
706
+ let stop: () => void = () => {};
707
+ const close = (error: CenterError | null) => {
708
+ if (!active) return;
709
+ active = false; stop(); handlers.onClose(error);
710
+ };
711
+ const fetchStream: typeof fetch = async (input, init) => {
712
+ const response = await doFetch(input, init);
713
+ if (response.ok && !/^text\/event-stream(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) {
714
+ await response.body?.cancel().catch(() => undefined);
715
+ throw new CenterError("文件事件接口未返回事件流,请检查服务代理配置", "INVALID_RESPONSE", 502);
716
+ }
717
+ return response;
718
+ };
719
+ stop = openSse(fetchStream, `${base}${boardPath(jobId, "files/events")}`, headers(), (event, data) => {
720
+ if (!active) return;
721
+ if (event === "files" || event === "message") {
722
+ if (!isWorkspaceFileBatch(data)) { close(new CenterError("文件事件数据不完整,正在重新同步", "INVALID_RESPONSE", 502)); return; }
723
+ handlers.onEvent(data);
724
+ } else if (event === "end") {
725
+ const end = data && typeof data === "object" ? data as Record<string, unknown> : {};
726
+ close(new CenterError(typeof end.detail === "string" ? end.detail : "文件监听连接中断,正在重连", typeof end.reason === "string" ? end.reason : "NETWORK", typeof end.status === "number" ? end.status : 0));
727
+ }
728
+ }, close);
729
+ return () => { active = false; stop(); };
730
+ },
647
731
  stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
648
732
  fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
649
733
  async fileLinkBase(jobId) {
@@ -667,7 +751,7 @@ function createBoardClient(
667
751
  return work;
668
752
  },
669
753
  async readText(jobId, path, maxBytes = 512 * 1024, opts = {}) {
670
- const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, onBytes: opts.onBytes });
754
+ const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, signal: opts.signal, onBytes: opts.onBytes });
671
755
  if (!size && !bytes.length) return { text: "", truncated: false, size: 0, contentType };
672
756
  const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
673
757
  return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
@@ -6,9 +6,9 @@
6
6
  * · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
7
7
  * 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
8
8
  */
9
- import { isBatchSnapshot, type AskAnswers, type AskQuestion, type BatchStreamSnapshot, type BatchStreamTask, type CenterTurn, type ConversationClosedCallback, type ConversationTurnCallback, type JobStatus, type Message, type Msg, type NodeEvent, type StreamItem, type StreamSnapshot, type StreamTask, type ToolData, type TurnUsage } from "./types.js";
10
- import { indexFromEnd, mergeSnapshot, prependOlder, toolDataOf } from "../ui/transcriptModel.js";
11
- import { formatAnswersText } from "../ui/format.js";
9
+ import { isBatchSnapshot, type AskAnswers, type AskQuestion, type BatchStreamSnapshot, type BatchStreamTask, type CenterTurn, type ConversationClosedCallback, type ConversationTurnCallback, type JobStatus, type Message, type Msg, type NodeEvent, type StreamItem, type StreamSnapshot, type StreamTask, type ToolData, type TurnUsage } from "./types";
10
+ import { indexFromEnd, mergeSnapshot, prependOlder, toolDataOf } from "../ui/transcriptModel";
11
+ import { formatAnswersText } from "../ui/format";
12
12
 
13
13
  export const TERMINAL_TURN = new Set(["succeeded", "failed", "cancelled", "merged"]);
14
14
  export const ACTIVE_TURN = new Set(["queued", "dispatching", "running"]);
@@ -135,6 +135,7 @@ const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
135
135
  // 对话式子任务跑完一条不算结束:还能接着聊。
136
136
  id: task.id, seq: task.seq, title: task.title, status: task.status, closed: !task.conversation && TERMINAL_TASK.has(task.status),
137
137
  remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
138
+ awaiting_input: task.awaiting_input, awaiting_input_observed_at: task.awaiting_input_observed_at,
138
139
  });
139
140
 
140
141
  /** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
package/src/core/id.ts ADDED
@@ -0,0 +1,15 @@
1
+ /** Correlation/draft IDs only: never use this fallback as a credential or token. */
2
+ let sequence = 0;
3
+ export function clientId(): string {
4
+ try { if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); } catch { /* Fall back in restricted or insecure contexts. */ }
5
+ try {
6
+ if (typeof globalThis.crypto?.getRandomValues === 'function') {
7
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
8
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
9
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
10
+ const hex = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
11
+ return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
12
+ }
13
+ } catch { /* Drafts still work when Web Crypto is unavailable. */ }
14
+ return `${Date.now().toString(36)}-${(++sequence).toString(36)}-${Math.random().toString(36).slice(2,10)}`;
15
+ }
@@ -0,0 +1,33 @@
1
+ import type { CenterClientOptions } from './client';
2
+ import { clientId } from './id';
3
+ // A synchronous SHA-256 fingerprint partitions persistent drafts without putting
4
+ // credentials in localStorage keys. It is never used for authentication.
5
+ const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
6
+ const rotate = (x:number,n:number)=>(x>>>n)|(x<<(32-n));
7
+ export function connectionFingerprint(text:string):string {
8
+ const raw=new TextEncoder().encode(text),data=new Uint8Array(Math.ceil((raw.length+9)/64)*64);data.set(raw);data[raw.length]=128;
9
+ const view=new DataView(data.buffer);view.setUint32(data.length-8,Math.floor(raw.length/0x20000000));view.setUint32(data.length-4,(raw.length*8)>>>0);
10
+ const hash=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],w=new Int32Array(64);
11
+ for(let offset=0;offset<data.length;offset+=64){
12
+ for(let i=0;i<16;i++)w[i]=view.getInt32(offset+i*4);
13
+ for(let i=16;i<64;i++){const x=w[i-15],y=w[i-2];w[i]=(w[i-16]+(rotate(x,7)^rotate(x,18)^(x>>>3))+w[i-7]+(rotate(y,17)^rotate(y,19)^(y>>>10)))|0;}
14
+ let [a,b,c,d,e,f,g,h]=hash;
15
+ for(let i=0;i<64;i++){const t1=(h+(rotate(e,6)^rotate(e,11)^rotate(e,25))+((e&f)^(~e&g))+K[i]+w[i])|0,t2=((rotate(a,2)^rotate(a,13)^rotate(a,22))+((a&b)^(a&c)^(b&c)))|0;
16
+ h=g;g=f;f=e;e=(d+t1)|0;d=c;c=b;b=a;a=(t1+t2)|0;}
17
+ [a,b,c,d,e,f,g,h].forEach((value,i)=>{hash[i]=(hash[i]+value)|0;});
18
+ }
19
+ return hash.map(value=>(value>>>0).toString(16).padStart(8,'0')).join('');
20
+ }
21
+ const customScopes=new WeakMap<object,string>();
22
+ export function connectionDraftScope(options:CenterClientOptions&{namespace?:string|null;client?:object}):string {
23
+ // Custom transports may hide cookies or credentials. Isolate by object identity
24
+ // rather than pretending their URL proves the account identity.
25
+ const headers = options.headers?.() ?? {};
26
+ const signature=JSON.stringify([options.baseUrl?.replace(/\/+$/,'')??'',options.token??'',options.line??'',options.namespace??'',Object.keys(headers).sort().map(key=>[key,headers[key]])]);
27
+ const transport=options.client;
28
+ if(transport){let scope=customScopes.get(transport);if(!scope){scope=clientId();customScopes.set(transport,scope);}return `custom-${scope}:${connectionFingerprint(signature)}`;}
29
+ // Preserve the historical default same-origin draft keys. Cookie-auth hosts
30
+ // must supply an account-specific namespace and remount after account changes.
31
+ if(!options.baseUrl&&!options.token&&!options.line&&!options.namespace&&!Object.keys(headers).length)return '';
32
+ return connectionFingerprint(signature);
33
+ }
@@ -1,6 +1,8 @@
1
- import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
2
- import { createCliStore, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store.js";
3
- import type { ConversationCallback, ConversationEndPayload, TurnEndPayload } from "./types.js";
1
+ import { notifyCallback } from "./callbacks";
2
+ import { Fragment, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
3
+ import { connectionDraftScope } from "./identity";
4
+ import { createCliStore, cloneWorkspaceEvent, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store";
5
+ import type { ConversationCallback, ConversationEndPayload, TurnEndPayload } from "./types";
4
6
 
5
7
  /**
6
8
  * 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库,切换时一起变:
@@ -36,7 +38,12 @@ export interface CliCenterProviderProps extends CliStoreOptions, ResultHandlers
36
38
  * 结果通知:组件从实时流里看到一轮结束 / 对话结束时调用,全在浏览器里发生,**和 center 的 callback.url 无关**——
37
39
  * 不配 callback 也照样会调。载荷字段与 center 回调体一致,方便业务后端和前端用同一套代码解析。
38
40
  */
39
- export interface ResultHandlers {
41
+ export interface WorkspaceHandlers {
42
+ onFilesChange?: (event: import("./types").WorkspaceChangeEvent) => void;
43
+ onFilesWatch?: (event: import("./types").WorkspaceWatchStatus) => void;
44
+ onBoardContextChange?: (event: Extract<CliEvent,{type:"board_context"}>) => void;
45
+ }
46
+ export interface ResultHandlers extends WorkspaceHandlers {
40
47
  /** 一轮结束(成功 / 失败 / 取消)。 */
41
48
  onTurnEnd?: (payload: TurnEndPayload) => void;
42
49
  /** 对话结束(关闭 / 取消 / 过期……)。 */
@@ -46,11 +53,14 @@ export interface ResultHandlers {
46
53
  }
47
54
 
48
55
  export function dispatchResult(event: CliEvent, handlers: ResultHandlers) {
49
- if (event.type === "conversation_turn") { handlers.onTurnEnd?.(event.payload); handlers.onCallback?.(event.payload); }
50
- else if (event.type === "conversation_closed") { handlers.onConversationEnd?.(event.payload); handlers.onCallback?.(event.payload); }
56
+ if (event.type === "conversation_turn") { notifyCallback(handlers.onTurnEnd, event.payload); notifyCallback(handlers.onCallback, event.payload); }
57
+ else if (event.type === "conversation_closed") { notifyCallback(handlers.onConversationEnd, event.payload); notifyCallback(handlers.onCallback, event.payload); }
58
+ else if(event.type === "files_changed") notifyCallback(handlers.onFilesChange,event);
59
+ else if(event.type === "files_watch") notifyCallback(handlers.onFilesWatch,event);
60
+ else if(event.type === "board_context") notifyCallback(handlers.onBoardContextChange,event);
51
61
  }
52
62
 
53
- export function CliCenterProvider({ store: external, selectedId, onEvent, onTurnEnd, onConversationEnd, onCallback, children, ...options }: CliCenterProviderProps) {
63
+ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, children, ...options }: CliCenterProviderProps) {
54
64
  // 连接参数只在建仓库时用一次:中途换 baseUrl / token 请换 key 重建。
55
65
  const optionsRef = useRef(options);
56
66
  const applied = useRef<string | null | undefined>(undefined);
@@ -60,6 +70,13 @@ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurn
60
70
  if (selectedId !== undefined) { created.select(selectedId); applied.current = selectedId; }
61
71
  return created;
62
72
  }, [external]); // eslint-disable-line react-hooks/exhaustive-deps
73
+ // Creation defaults can arrive after a file/configuration load. Update only
74
+ // these defaults on commit; the connection and workspace state stay stable.
75
+ useLayoutEffect(() => {
76
+ optionsRef.current.newConversation = options.newConversation;
77
+ optionsRef.current.createConversation = options.createConversation;
78
+ optionsRef.current.businessIdFor = options.businessIdFor;
79
+ }, [options.newConversation, options.createConversation, options.businessIdFor]);
63
80
  // 之后宿主改了 selectedId:一起切过去(在绘制前,免得闪一下旧的)。
64
81
  useLayoutEffect(() => {
65
82
  if (selectedId === undefined || selectedId === applied.current) return;
@@ -69,12 +86,12 @@ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurn
69
86
  const onEventRef = useRef(onEvent);
70
87
  onEventRef.current = onEvent;
71
88
  const resultRef = useRef<ResultHandlers>({});
72
- resultRef.current = { onTurnEnd, onConversationEnd, onCallback };
89
+ resultRef.current = { onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange };
73
90
  useEffect(() => store.on((event) => {
74
- onEventRef.current?.(event);
91
+ notifyCallback(onEventRef.current, cloneWorkspaceEvent(event));
75
92
  dispatchResult(event, resultRef.current);
76
93
  }), [store]);
77
- return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
94
+ return <StoreContext.Provider value={store}><Fragment key={store.draftScope}>{children}</Fragment></StoreContext.Provider>;
78
95
  }
79
96
 
80
97
  export function useCliStoreContext(): CliStore | null {
@@ -96,11 +113,11 @@ export function useCliState<T>(selector: (state: CliState) => T): T {
96
113
  /** 组件外面没有 Provider 时,用组件自己的 props 包一层。 */
97
114
  export function withOwnProvider<P extends object>(props: P & CliStoreOptions & ResultHandlers & { onEvent?: (event: CliEvent) => void; store?: CliStore }, inner: ReactNode, hasContext: boolean) {
98
115
  if (hasContext) return inner;
99
- const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store } = props;
116
+ const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, store } = props;
100
117
  // 连接参数变了(换了中枢地址、令牌、产品线、命名空间)就换一个新仓库重连,不沿用旧连接。
101
- const connection = [baseUrl ?? "", token ?? "", line ?? "", namespace ?? ""].join("\u0000");
118
+ const connection = connectionDraftScope({baseUrl,token,line,namespace,headers,fetch,client});
102
119
  return (
103
- <CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store }}>
120
+ <CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, store }}>
104
121
  {inner}
105
122
  </CliCenterProvider>
106
123
  );
@@ -117,7 +134,7 @@ export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefi
117
134
  useEffect(() => {
118
135
  if (!enabled || !store) return;
119
136
  return store.on((event) => {
120
- ref.current.onEvent?.(event);
137
+ notifyCallback(ref.current.onEvent, cloneWorkspaceEvent(event));
121
138
  dispatchResult(event, ref.current.results);
122
139
  });
123
140
  }, [store, enabled]);