@spzxg/cli-ui 0.6.2 → 0.6.5

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 (131) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +137 -11
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  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 +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +73 -30
  26. package/src/chat/Composer.tsx +41 -41
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +8 -6
  35. package/src/core/conversation.ts +3 -3
  36. package/src/core/id.ts +15 -0
  37. package/src/core/identity.ts +33 -0
  38. package/src/core/provider.tsx +24 -14
  39. package/src/core/store.ts +83 -26
  40. package/src/core/types.ts +34 -0
  41. package/src/elements.tsx +135 -29
  42. package/src/embed.ts +6 -8
  43. package/src/index.ts +24 -17
  44. package/src/layout/CliSplit.tsx +1 -1
  45. package/src/msglist/CliMsgList.tsx +10 -10
  46. package/src/operator/branchFlow.ts +35 -0
  47. package/src/operator/index.ts +9 -0
  48. package/src/operator/liveStream.ts +1 -0
  49. package/src/operator/node/AgentConnect.tsx +624 -0
  50. package/src/operator/node/AppearancePanel.tsx +35 -0
  51. package/src/operator/node/BranchHistory.tsx +67 -0
  52. package/src/operator/node/BranchNavigator.tsx +36 -0
  53. package/src/operator/node/BrowserPane.tsx +128 -0
  54. package/src/operator/node/BrowserPopup.tsx +22 -0
  55. package/src/operator/node/Chat.tsx +517 -0
  56. package/src/operator/node/CommandPalette.tsx +213 -0
  57. package/src/operator/node/Composer.tsx +287 -0
  58. package/src/operator/node/InsightsView.tsx +831 -0
  59. package/src/operator/node/Layout.tsx +190 -0
  60. package/src/operator/node/Markdown.tsx +61 -0
  61. package/src/operator/node/Modal.tsx +179 -0
  62. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  63. package/src/operator/node/OnboardingWizard.tsx +250 -0
  64. package/src/operator/node/Orchestrator.tsx +634 -0
  65. package/src/operator/node/ProjectLanding.tsx +86 -0
  66. package/src/operator/node/ProjectsColumn.tsx +137 -0
  67. package/src/operator/node/ProviderAuth.tsx +561 -0
  68. package/src/operator/node/RunControls.tsx +2 -0
  69. package/src/operator/node/RunSwitch.tsx +113 -0
  70. package/src/operator/node/Services.tsx +245 -0
  71. package/src/operator/node/SessionRail.tsx +260 -0
  72. package/src/operator/node/SessionView.tsx +288 -0
  73. package/src/operator/node/SettingsView.tsx +690 -0
  74. package/src/operator/node/TaskBoard.tsx +352 -0
  75. package/src/operator/node/TaskChanges.tsx +530 -0
  76. package/src/operator/node/TaskHero.tsx +50 -0
  77. package/src/operator/node/TasksColumn.tsx +200 -0
  78. package/src/operator/node/Terminal.tsx +143 -0
  79. package/src/operator/node/Transcript.tsx +463 -0
  80. package/src/operator/node/ViewFile.tsx +79 -0
  81. package/src/operator/node/Welcome.tsx +101 -0
  82. package/src/operator/node/WorktreePrune.tsx +333 -0
  83. package/src/operator/node/agents.ts +58 -0
  84. package/src/operator/node/api.ts +40 -0
  85. package/src/operator/node/boardBackend.ts +80 -0
  86. package/src/operator/node/boardEvents.ts +22 -0
  87. package/src/operator/node/boardInteraction.ts +15 -0
  88. package/src/operator/node/boardState.ts +57 -0
  89. package/src/operator/node/boardWorkspace.ts +12 -0
  90. package/src/operator/node/branchFlow.ts +1 -0
  91. package/src/operator/node/composerDraft.ts +52 -0
  92. package/src/operator/node/fileLinks.ts +81 -0
  93. package/src/operator/node/format.ts +290 -0
  94. package/src/operator/node/github.tsx +234 -0
  95. package/src/operator/node/icons.tsx +88 -0
  96. package/src/operator/node/liveStream.ts +270 -0
  97. package/src/operator/node/modals.tsx +720 -0
  98. package/src/operator/node/navHistory.ts +70 -0
  99. package/src/operator/node/persist.ts +26 -0
  100. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  101. package/src/operator/node/protocol/agents/types.ts +394 -0
  102. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  103. package/src/operator/node/protocol/authFailure.ts +66 -0
  104. package/src/operator/node/protocol/customModels.ts +23 -0
  105. package/src/operator/node/protocol/events.ts +13 -0
  106. package/src/operator/node/protocol/features.ts +70 -0
  107. package/src/operator/node/protocol/internalUsage.ts +1 -0
  108. package/src/operator/node/protocol/promptLimits.ts +52 -0
  109. package/src/operator/node/protocol/storageRetention.ts +13 -0
  110. package/src/operator/node/protocol/types.ts +332 -0
  111. package/src/operator/node/protocol/usageLimit.ts +72 -0
  112. package/src/operator/node/shared.tsx +89 -0
  113. package/src/operator/node/transcriptModel.ts +129 -0
  114. package/src/operator/node/types.ts +296 -0
  115. package/src/operator/node/useGlobalEvents.ts +97 -0
  116. package/src/operator/node/useOrchestrator.ts +696 -0
  117. package/src/operator/node/usePrefs.ts +114 -0
  118. package/src/operator/node/useRecaps.ts +91 -0
  119. package/src/operator/node/useTaskStream.ts +279 -0
  120. package/src/operator.base.css +1798 -0
  121. package/src/operator.css +186 -16
  122. package/src/styles.css +20 -0
  123. package/src/ui/Markdown.tsx +7 -2
  124. package/src/ui/Popover.tsx +77 -0
  125. package/src/ui/TextBlock.tsx +3 -3
  126. package/src/ui/Transcript.tsx +10 -8
  127. package/src/ui/fileLinks.ts +6 -0
  128. package/src/ui/format.ts +1 -1
  129. package/src/ui/shared.tsx +1 -1
  130. package/src/ui/transcriptModel.ts +2 -2
  131. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -1,24 +1,16 @@
1
1
  // 输入框。结构与 Operator 的 Composer.tsx 一致(comp-box / comp-area / send-group / comp-foot),
2
2
  // 但只发文本:不收图片和其他附件。文件只收文档(Word、Excel、Markdown、TXT、HTML、CSV),在前端解析成纯文本,
3
3
  // 和长粘贴一样变成可折叠的文本块,发送时按统一格式拼进正文(见 core/blocks.ts)。
4
- import { useEffect, useRef, useState } from "react";
4
+ import { useEffect, useRef, useState, type ReactNode } from "react";
5
5
  import {
6
6
  ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
7
- } from "../core/blocks.js";
8
- import { Icon } from "../ui/icons.js";
9
- import { TextBlockCard } from "../ui/TextBlock.js";
10
- import { fmtCount } from "../ui/format.js";
7
+ } from "../core/blocks";
8
+ import { Icon } from "../ui/icons";
9
+ import { clientId } from "../core/id";
10
+ import { TextBlockCard } from "../ui/TextBlock";
11
+ import { fmtCount } from "../ui/format";
11
12
 
12
- const draftKey = (key: string) => `cli-ui:draft:${key}`;
13
- const loadDraft = (key: string) => { try { return localStorage.getItem(draftKey(key)) ?? ""; } catch { return ""; } };
14
- const saveDraft = (key: string, value: string) => {
15
- try {
16
- if (value) localStorage.setItem(draftKey(key), value);
17
- else localStorage.removeItem(draftKey(key));
18
- } catch { /* 隐私模式 / 配额:草稿就不保存 */ }
19
- };
20
-
21
- type Pending = { key: string; name: string; status: "reading" | "error"; error?: string };
13
+ import { clearComposerDraft, readComposerDraft, updateComposerDraft, useComposerDraft, type PendingFile } from "./draft";
22
14
 
23
15
  /** 输入框的可配置项(来自 CliChat 的 config.composer)。 */
24
16
  export interface ComposerOptions {
@@ -30,7 +22,7 @@ export interface ComposerOptions {
30
22
  queue: boolean;
31
23
  }
32
24
 
33
- export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options }: {
25
+ export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar }: {
34
26
  /** 草稿按这个键存在 localStorage(一般是对话 id)。 */
35
27
  draft: string;
36
28
  placeholder: string;
@@ -41,38 +33,41 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
41
33
  onStop?: () => Promise<void> | void;
42
34
  taskControl?: { paused: boolean; onToggle: () => Promise<void> };
43
35
  options: ComposerOptions;
36
+ toolbar?: ReactNode;
44
37
  }) {
45
- const [val, setVal] = useState(() => loadDraft(draft));
46
- const [blocks, setBlocks] = useState<Array<TextBlock & { key: string }>>([]);
47
- const [pending, setPending] = useState<Pending[]>([]);
48
- const [error, setError] = useState<string | null>(null);
49
- const [sending, setSending] = useState(false);
38
+ const state = useComposerDraft(draft);
39
+ const { text: val, blocks, pending, error, sending } = state;
40
+ const setVal = (text: string) => updateComposerDraft(draft, s => ({ ...s, text }));
41
+ const setBlocks = (fn: (blocks: typeof state.blocks) => typeof state.blocks) => updateComposerDraft(draft, s => ({ ...s, blocks: fn(s.blocks) }));
42
+ const setPending = (fn: (pending: PendingFile[]) => PendingFile[]) => updateComposerDraft(draft, s => ({ ...s, pending: fn(s.pending) }));
43
+ const setError = (error: string | null) => updateComposerDraft(draft, s => ({ ...s, error }));
44
+ const setSending = (sending: boolean) => updateComposerDraft(draft, s => ({ ...s, sending }));
50
45
  const [stopping, setStopping] = useState(false);
51
46
  const [controlling, setControlling] = useState(false);
52
47
  const [dragging, setDragging] = useState(false);
53
48
  const dragDepth = useRef(0);
54
- const seq = useRef(0);
49
+ const stopLock = useRef(false);
50
+ const controlLock = useRef(false);
51
+ const composing = useRef(false);
55
52
  const fileRef = useRef<HTMLInputElement>(null);
56
53
  const ref = useRef<HTMLTextAreaElement>(null);
57
54
 
58
- useEffect(() => { if (!running) setStopping(false); }, [running]);
59
- useEffect(() => { saveDraft(draft, val); }, [draft, val]);
55
+ useEffect(() => { if (!running) { stopLock.current = false; setStopping(false); } }, [running]);
60
56
  const autosize = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; };
61
57
  useEffect(() => { if (ref.current) autosize(ref.current); }, [draft, disabled]);
62
58
 
63
- const nextKey = () => `blk-${++seq.current}`;
59
+ const nextKey = () => `blk-${clientId()}`;
64
60
  const addBlock = (block: TextBlock) => setBlocks((prev) => [...prev, { ...block, key: nextKey() }]);
65
61
 
66
62
  const addFiles = (files: File[]) => {
67
- if (disabled || !options.fileUpload) return;
63
+ if (disabled || sending || !options.fileUpload) return;
68
64
  for (const file of files) {
69
65
  const key = nextKey();
70
66
  setPending((prev) => [...prev, { key, name: file.name || "文件", status: "reading" }]);
71
67
  readFileText(file)
72
68
  .then((text) => {
73
- setPending((prev) => prev.filter((p) => p.key !== key));
74
69
  if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
75
- addBlock({ name: file.name, text, kind: "file" });
70
+ updateComposerDraft(draft, s => ({ ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }));
76
71
  })
77
72
  .catch((err: unknown) => {
78
73
  setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
@@ -85,20 +80,19 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
85
80
  const tooLong = total > MAX_MESSAGE_CHARS;
86
81
  const reading = pending.some((p) => p.status === "reading");
87
82
  // 运行中不允许排队时,等这一轮结束再发。
88
- const canSend = !!message.trim() && !reading && !tooLong && !sending && (!running || options.queue);
83
+ const canSend = !!message.trim() && !pending.length && !reading && !tooLong && !sending && (!running || options.queue);
89
84
 
90
85
  const submit = async () => {
91
- if (!canSend || disabled) return;
86
+ if (!canSend || disabled || readComposerDraft(draft).sending) return;
87
+ const submitted = JSON.stringify([val, blocks]);
92
88
  setSending(true);
93
89
  setError(null);
94
90
  const key = draft;
95
91
  try {
96
92
  await onSend(message);
97
93
  // 新建对话时发送完成前组件就换成了新对话的输入框(key 变了),这里直接清掉发出时那份草稿。
98
- saveDraft(key, "");
99
- setVal("");
100
- setBlocks([]);
101
- setPending([]);
94
+ const current = readComposerDraft(key);
95
+ if (JSON.stringify([current.text, current.blocks]) === submitted) clearComposerDraft(key);
102
96
  if (ref.current) ref.current.style.height = "auto";
103
97
  } catch (err) {
104
98
  // 发送失败:内容留在输入框里,可以直接再发。
@@ -111,7 +105,11 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
111
105
  const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
112
106
  const hasFileDrag = (e: React.DragEvent) => options.fileUpload && isFileDrag(e);
113
107
  const stopButton = running && onStop && (
114
- <button type="button" className="send stop" onClick={() => { setStopping(true); void Promise.resolve(onStop()).catch(() => setStopping(false)); }}
108
+ <button type="button" className="send stop" onClick={async () => {
109
+ if (stopLock.current) return;
110
+ stopLock.current = true; setStopping(true); setError(null);
111
+ try { await onStop(); } catch (error) { stopLock.current = false; setStopping(false); setError(error instanceof Error ? error.message : "停止失败,请重试"); }
112
+ }}
115
113
  disabled={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
116
114
  );
117
115
 
@@ -119,7 +117,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
119
117
  <div className="composer">
120
118
  <div className="composer-inner">
121
119
  {(error || tooLong) && (
122
- <div className="cli-comp-err">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
120
+ <div className="cli-comp-err" role="alert">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
123
121
  )}
124
122
  <div
125
123
  className={`comp-box${dragging ? " dropping" : ""}`}
@@ -151,10 +149,11 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
151
149
  )}
152
150
  <div className="comp-area">
153
151
  <textarea
154
- ref={ref} rows={2} value={val} disabled={disabled} aria-label="消息内容"
152
+ ref={ref} rows={2} value={val} disabled={disabled || sending} aria-label="消息内容"
155
153
  placeholder={disabled ? disabledHint ?? "暂时不能发送" : taskControl?.paused ? "任务已暂停,可补充消息,继续后依次执行…" : running && options.queue ? options.placeholderRunning : placeholder}
156
154
  onChange={(e) => { setVal(e.target.value); autosize(e.target); }}
157
- onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void submit(); } }}
155
+ onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
156
+ onKeyDown={(e) => { if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
158
157
  onPaste={(e) => {
159
158
  const files = Array.from(e.clipboardData?.files ?? []);
160
159
  if (files.length) {
@@ -176,9 +175,10 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
176
175
  <div className="comp-foot">
177
176
  {options.fileUpload && !disabled && <>
178
177
  <input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
179
- <button type="button" className="cli-attach" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
180
- onClick={() => fileRef.current?.click()}>{Icon.clip()} {options.fileButtonText}</button>
178
+ <button type="button" className="cli-attach" disabled={sending} aria-label="添加文档" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
179
+ onClick={() => fileRef.current?.click()}>{Icon.plus()} <span>{options.fileButtonText}</span></button>
181
180
  </>}
181
+ {toolbar}
182
182
  {options.hints && !disabled && <span className="hint comp-hint-extra">Shift + Enter 换行</span>}
183
183
  <span className="spacer" />
184
184
  {options.charCount && total > 0 && <span className={`hint cli-count ${tooLong ? "over" : ""}`}>{fmtCount(total)} 字</span>}
@@ -187,7 +187,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
187
187
  {taskControl && <button type="button" className="btn btn-line cli-task-control" disabled={controlling}
188
188
  aria-label={taskControl.paused ? "继续任务" : "暂停任务"}
189
189
  title={taskControl.paused ? "重新派发当前子任务,排队消息随后执行" : "暂停当前子任务,继续时重新派发"}
190
- onClick={async () => { setControlling(true); try { await taskControl.onToggle(); } catch { /* 错误由调用方显示,保留草稿和当前状态 */ } finally { setControlling(false); } }}>
190
+ onClick={async () => { if (controlLock.current) return; controlLock.current = true; setControlling(true); try { await taskControl.onToggle(); } catch (error) { setError(error instanceof Error ? error.message : "执行控制失败,请重试"); } finally { controlLock.current = false; setControlling(false); } }}>
191
191
  {taskControl.paused ? Icon.play() : Icon.pause()}<span>{controlling ? "处理中…" : taskControl.paused ? "继续" : "暂停"}</span>
192
192
  </button>}
193
193
  {(!running || options.queue) && <button type="button" className={`send cli-send${running ? " queue" : ""}`} disabled={disabled || !canSend}
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { Fragment, useEffect, useRef, useState } from "react";
4
+ import { Icon } from "../ui/icons";
5
+ import { Popover } from "../ui/Popover";
6
+ import { modelLabel } from "../ui/format";
7
+ import { acquireConfiguration, useConfigurationBusy } from "./configuration";
8
+ export interface RunOption { value: string; label: string; sub: string; group?: string }
9
+ export interface RunCapabilities { models: RunOption[]; reasoningOptions: RunOption[]; permissionModes: RunOption[]; supportsCustomModels?: boolean }
10
+ export interface RunTask { id: string; agent?: string; model?: string | null; reasoning?: string | null; permission_mode?: string | null; resolved_model?: string | null }
11
+ const withDefault = (options?: RunOption[]) => [{ value: null as string | null, label: "默认", sub: "沿用智能体的默认设置", group: undefined as string | undefined }, ...(options ?? [])];
12
+ const modelOptions = (caps?: RunCapabilities) => withDefault(caps?.models);
13
+ const permissionOptions = (caps?: RunCapabilities) => withDefault(caps?.permissionModes);
14
+ const reasoningOptions = (caps?: RunCapabilities) => withDefault(caps?.reasoningOptions);
15
+
16
+ export type RunControlProps = {
17
+ task: RunTask;
18
+ agents?: {id:string;label:string;disabled?:boolean}[];
19
+ onSetAgent?: (id:string)=>Promise<void>;
20
+ caps?: RunCapabilities;
21
+ defaults?: { model?: string | null; reasoning?: string | null; permission_mode?: string | null };
22
+ disabled: boolean;
23
+ onSetModel: (value: string | null) => Promise<void>;
24
+ onSetReasoning: (value: string | null) => Promise<void>;
25
+ onSetPermission: (value: string | null) => Promise<void>;
26
+ onBusyChange?: (busy: boolean) => void;
27
+ };
28
+
29
+ // Both the pre-start view and composer use the same capability-driven controls.
30
+ // Await persistence before allowing Send so the next turn uses the chosen value.
31
+ export function RunControls({ task, agents, onSetAgent, caps, defaults, disabled, onSetModel, onSetReasoning, onSetPermission, onBusyChange }: RunControlProps) {
32
+ const [open, setOpen] = useState<"model" | "permission" | null>(null);
33
+ const [custom, setCustom] = useState("");
34
+ const busy = useConfigurationBusy(task.id);
35
+ const currentTask = useRef(task.id); currentTask.current = task.id;
36
+ useEffect(() => { setOpen(null); setError(""); }, [task.id]);
37
+ const [error, setError] = useState("");
38
+ const models = modelOptions(caps);
39
+ const reasoning = reasoningOptions(caps);
40
+ const permissions = permissionOptions(caps);
41
+ const effectiveModel = task.model ?? defaults?.model ?? null;
42
+ const effectiveReasoning = task.reasoning ?? defaults?.reasoning ?? null;
43
+ const effectivePermission = task.permission_mode ?? defaults?.permission_mode ?? caps?.permissionModes[0]?.value ?? null;
44
+ const model = models.find((m) => m.value === effectiveModel)?.label ?? modelLabel(effectiveModel ?? "");
45
+ const permission = permissions.find((p) => p.value === effectivePermission);
46
+ const change = async (save: () => Promise<void>) => {
47
+ if (disabled) return;
48
+ const release = acquireConfiguration(task.id);
49
+ if (!release) return;
50
+ const targetId = task.id;
51
+ setError("");
52
+ try { onBusyChange?.(true); await save(); if(currentTask.current === targetId) setOpen(null); }
53
+ catch (e) { if(currentTask.current === targetId) setError(e instanceof Error ? e.message : "设置保存失败,请重试。"); }
54
+ finally { release(); onBusyChange?.(false); }
55
+ };
56
+ const locked = disabled || busy || !caps;
57
+ return <>
58
+ {!!caps?.permissionModes.length && <div className="comp-control">
59
+ <button className={`comp-setting permission${effectivePermission === "bypassPermissions" ? " elevated" : ""}`} disabled={locked} aria-label="权限模式" aria-expanded={open === "permission"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改权限" : permission?.sub} onClick={(e) => { e.stopPropagation(); setOpen(open === "permission" ? null : "permission"); }}>
60
+ {Icon.lock()} <span>{permission?.value ? permission.label : "默认权限"}</span>{Icon.chevDown()}
61
+ </button>
62
+ {open === "permission" && <Popover onClose={() => setOpen(null)}>
63
+ <div className="pop-sec">权限模式 · 保存后用于下一轮</div>
64
+ {permissions.map((p) => <button key={p.value ?? "default"} className="pop-item" disabled={locked} aria-pressed={(task.permission_mode ?? null) === p.value} onClick={() => void change(() => onSetPermission(p.value))}>
65
+ <span><span>{p.label}</span><span className="pi-sub">{p.sub}</span></span>{(task.permission_mode ?? null) === p.value && Icon.check()}
66
+ </button>)}
67
+ {error && <div className="control-error" role="alert">{error}</div>}
68
+ </Popover>}
69
+ </div>}
70
+ <div className="comp-control comp-model">
71
+ <button className="comp-setting" disabled={locked} aria-label="模型与推理设置" aria-expanded={open === "model"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改模型与推理" : `模型:${model}${task.resolved_model ? ` · 上次运行:${task.resolved_model}` : ""}`} onClick={(e) => { e.stopPropagation(); setOpen(open === "model" ? null : "model"); }}>
72
+ <span className="comp-model-name">{model === "默认" ? "默认模型" : model}</span>
73
+ {!!caps?.reasoningOptions.length && <span className="comp-reasoning">{reasoning.find((r) => r.value === effectiveReasoning)?.label ?? "默认"}</span>}{Icon.chevDown()}
74
+ </button>
75
+ {open === "model" && <Popover onClose={() => setOpen(null)}>
76
+ {agents && agents.length>1 && onSetAgent && <><div className="pop-sec">智能体 · 接手当前任务的历史</div>{agents.map(a=><button key={a.id} className="pop-item" disabled={locked||a.disabled} aria-pressed={task.agent===a.id} onClick={()=>void change(()=>onSetAgent(a.id))}>{a.label}{task.agent===a.id&&Icon.check()}</button>)}<div className="divider"/></>}
77
+ <div className="pop-sec">模型 · 保存后用于下一轮</div>
78
+ {models.map((m, i) => <Fragment key={m.value ?? "default"}>
79
+ {m.group && m.group !== models[i - 1]?.group && <div className="pop-sec">{m.group}</div>}
80
+ <button className="pop-item" disabled={locked} aria-pressed={(task.model ?? null) === m.value} onClick={() => void change(() => onSetModel(m.value))}>
81
+ <span><span>{m.label}</span><span className="pi-sub">{m.sub}</span></span>{(task.model ?? null) === m.value && Icon.check()}
82
+ </button>
83
+ </Fragment>)}
84
+ {caps?.supportsCustomModels && <form className="pop-custom-model" onSubmit={(e) => { e.preventDefault(); if (custom.trim()) void change(() => onSetModel(custom.trim())); }}>
85
+ <label className="pop-sec" htmlFor={`model-${task.id}`}>自定义模型</label>
86
+ <div className="pop-custom-row"><input id={`model-${task.id}`} value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="模型 ID" disabled={locked} /><button className="btn btn-sm" disabled={locked || !custom.trim()}>使用</button></div>
87
+ </form>}
88
+ {!!caps?.reasoningOptions.length && <>
89
+ <div className="divider" /><div className="pop-sec">推理强度</div>
90
+ {reasoning.map((r) => <button key={r.value ?? "default"} className="pop-item" disabled={locked} aria-pressed={(task.reasoning ?? null) === r.value} onClick={() => void change(() => onSetReasoning(r.value))}>
91
+ <span><span>{r.label}</span><span className="pi-sub">{r.sub}</span></span>{(task.reasoning ?? null) === r.value && Icon.check()}
92
+ </button>)}
93
+ </>}
94
+ {busy && <div className="pop-sec" role="status">正在保存…</div>}
95
+ {error && <div className="control-error" role="alert">{error}</div>}
96
+ </Popover>}
97
+ </div>
98
+ </>;
99
+ }
@@ -0,0 +1,15 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ // A task's setting request outlives its controls when the user changes tasks.
3
+ const pending = new Set<string>();
4
+ const listeners = new Set<() => void>();
5
+ const notify = () => listeners.forEach(listener => listener());
6
+ export const isConfigurationBusy = (taskId:string) => pending.has(taskId);
7
+ export function acquireConfiguration(taskId:string): (()=>void) | null {
8
+ if (pending.has(taskId)) return null;
9
+ pending.add(taskId); notify();
10
+ let active=true;
11
+ return () => { if(active){active=false;pending.delete(taskId);notify();} };
12
+ }
13
+ export function useConfigurationBusy(taskId:string) {
14
+ return useSyncExternalStore(listener=>{listeners.add(listener);return()=>listeners.delete(listener);},()=>isConfigurationBusy(taskId),()=>false);
15
+ }
@@ -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
  /** 显示名:文件名,粘贴的文字为「粘贴的文本」。 */
@@ -142,7 +142,7 @@ export async function readFileText(file: File): Promise<string> {
142
142
  if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
143
143
  if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
144
144
  if (ext === ".xlsx" || ext === ".xlsm") {
145
- const { readXlsx, sheetsToText } = await import("./xlsx.js");
145
+ const { readXlsx, sheetsToText } = await import("./xlsx");
146
146
  try {
147
147
  return normalizeText(sheetsToText(await readXlsx(await readAs(file, "buffer"))));
148
148
  } catch {
@@ -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
5
  BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
6
- } from "./types.js";
7
- import { decodeText } from "./textDecode.js";
6
+ } from "./types";
7
+ import { decodeText } from "./textDecode";
8
8
 
9
9
  /**
10
10
  * center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
@@ -141,6 +141,8 @@ export interface BoardBackend {
141
141
  /** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
142
142
  directLinks: boolean;
143
143
  info(jobId: string): Promise<BoardInfo>;
144
+ /** 精准文件事件流;没有时组件明确降级为目录快照比对。 */
145
+ watchFiles?(jobId: string, handlers: { onEvent: (event: import("./types").WorkspaceFileBatch) => void; onClose: (error: CenterError | null) => void }): () => void;
144
146
  /** 目录(缺省根目录)或文件的信息。 */
145
147
  stat(jobId: string, path?: string): Promise<DirListing | FileInfo>;
146
148
  /** 文件地址(给 <img> / <iframe> / 下载链接用)。相对路径用路径式地址,HTML 里的相对资源能跟着解析。 */
@@ -171,7 +173,7 @@ export interface BoardBackend {
171
173
  browserStatus(jobId: string): Promise<BrowserStatus>;
172
174
  browserAction(jobId: string, action: "open" | "close" | "takeover" | "handback"): Promise<BrowserStatus>;
173
175
  /** 浏览器状态实时流;返回停止函数。onClose 在断开时调用(error 为 null 表示正常结束)。 */
174
- browserEvents(jobId: string, handlers: { onStatus: (status: BrowserStatus) => void; onClose?: (error: CenterError | null) => void }): () => void;
176
+ 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
177
  /** 浏览器实时画面;onViewer 给出发输入要用的 viewer id。 */
176
178
  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
179
  browserInput(jobId: string, viewer: string, events: Array<Record<string, unknown>>): Promise<void>;
@@ -546,7 +548,7 @@ function createBoardClient(
546
548
  if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
547
549
  ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
548
550
  });
549
- response = await Promise.race([doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal }), stopped]);
551
+ response = await Promise.race([doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" }), stopped]);
550
552
  } catch (error) {
551
553
  release();
552
554
  if (signal?.aborted) throw aborted();
@@ -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"]);
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);
@@ -69,12 +79,12 @@ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurn
69
79
  const onEventRef = useRef(onEvent);
70
80
  onEventRef.current = onEvent;
71
81
  const resultRef = useRef<ResultHandlers>({});
72
- resultRef.current = { onTurnEnd, onConversationEnd, onCallback };
82
+ resultRef.current = { onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange };
73
83
  useEffect(() => store.on((event) => {
74
- onEventRef.current?.(event);
84
+ notifyCallback(onEventRef.current, cloneWorkspaceEvent(event));
75
85
  dispatchResult(event, resultRef.current);
76
86
  }), [store]);
77
- return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
87
+ return <StoreContext.Provider value={store}><Fragment key={store.draftScope}>{children}</Fragment></StoreContext.Provider>;
78
88
  }
79
89
 
80
90
  export function useCliStoreContext(): CliStore | null {
@@ -96,11 +106,11 @@ export function useCliState<T>(selector: (state: CliState) => T): T {
96
106
  /** 组件外面没有 Provider 时,用组件自己的 props 包一层。 */
97
107
  export function withOwnProvider<P extends object>(props: P & CliStoreOptions & ResultHandlers & { onEvent?: (event: CliEvent) => void; store?: CliStore }, inner: ReactNode, hasContext: boolean) {
98
108
  if (hasContext) return inner;
99
- const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store } = props;
109
+ const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, store } = props;
100
110
  // 连接参数变了(换了中枢地址、令牌、产品线、命名空间)就换一个新仓库重连,不沿用旧连接。
101
- const connection = [baseUrl ?? "", token ?? "", line ?? "", namespace ?? ""].join("\u0000");
111
+ const connection = connectionDraftScope({baseUrl,token,line,namespace,headers,fetch,client});
102
112
  return (
103
- <CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store }}>
113
+ <CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, store }}>
104
114
  {inner}
105
115
  </CliCenterProvider>
106
116
  );
@@ -117,7 +127,7 @@ export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefi
117
127
  useEffect(() => {
118
128
  if (!enabled || !store) return;
119
129
  return store.on((event) => {
120
- ref.current.onEvent?.(event);
130
+ notifyCallback(ref.current.onEvent, cloneWorkspaceEvent(event));
121
131
  dispatchResult(event, ref.current.results);
122
132
  });
123
133
  }, [store, enabled]);