@spzxg/cli-ui 0.6.1 → 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 (133) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +145 -13
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-BKHhqcoa.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 +113 -53
  26. package/src/chat/Composer.tsx +68 -57
  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 +16 -6
  35. package/src/core/config.ts +11 -15
  36. package/src/core/conversation.ts +4 -4
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +24 -14
  40. package/src/core/store.ts +83 -26
  41. package/src/core/types.ts +34 -0
  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/msglist/CliMsgList.tsx +10 -10
  47. package/src/operator/branchFlow.ts +35 -0
  48. package/src/operator/index.ts +9 -0
  49. package/src/operator/liveStream.ts +1 -0
  50. package/src/operator/node/AgentConnect.tsx +624 -0
  51. package/src/operator/node/AppearancePanel.tsx +35 -0
  52. package/src/operator/node/BranchHistory.tsx +67 -0
  53. package/src/operator/node/BranchNavigator.tsx +36 -0
  54. package/src/operator/node/BrowserPane.tsx +128 -0
  55. package/src/operator/node/BrowserPopup.tsx +22 -0
  56. package/src/operator/node/Chat.tsx +517 -0
  57. package/src/operator/node/CommandPalette.tsx +213 -0
  58. package/src/operator/node/Composer.tsx +287 -0
  59. package/src/operator/node/InsightsView.tsx +831 -0
  60. package/src/operator/node/Layout.tsx +190 -0
  61. package/src/operator/node/Markdown.tsx +61 -0
  62. package/src/operator/node/Modal.tsx +179 -0
  63. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  64. package/src/operator/node/OnboardingWizard.tsx +250 -0
  65. package/src/operator/node/Orchestrator.tsx +634 -0
  66. package/src/operator/node/ProjectLanding.tsx +86 -0
  67. package/src/operator/node/ProjectsColumn.tsx +137 -0
  68. package/src/operator/node/ProviderAuth.tsx +561 -0
  69. package/src/operator/node/RunControls.tsx +2 -0
  70. package/src/operator/node/RunSwitch.tsx +113 -0
  71. package/src/operator/node/Services.tsx +245 -0
  72. package/src/operator/node/SessionRail.tsx +260 -0
  73. package/src/operator/node/SessionView.tsx +288 -0
  74. package/src/operator/node/SettingsView.tsx +690 -0
  75. package/src/operator/node/TaskBoard.tsx +352 -0
  76. package/src/operator/node/TaskChanges.tsx +530 -0
  77. package/src/operator/node/TaskHero.tsx +50 -0
  78. package/src/operator/node/TasksColumn.tsx +200 -0
  79. package/src/operator/node/Terminal.tsx +143 -0
  80. package/src/operator/node/Transcript.tsx +463 -0
  81. package/src/operator/node/ViewFile.tsx +79 -0
  82. package/src/operator/node/Welcome.tsx +101 -0
  83. package/src/operator/node/WorktreePrune.tsx +333 -0
  84. package/src/operator/node/agents.ts +58 -0
  85. package/src/operator/node/api.ts +40 -0
  86. package/src/operator/node/boardBackend.ts +80 -0
  87. package/src/operator/node/boardEvents.ts +22 -0
  88. package/src/operator/node/boardInteraction.ts +15 -0
  89. package/src/operator/node/boardState.ts +57 -0
  90. package/src/operator/node/boardWorkspace.ts +12 -0
  91. package/src/operator/node/branchFlow.ts +1 -0
  92. package/src/operator/node/composerDraft.ts +52 -0
  93. package/src/operator/node/fileLinks.ts +81 -0
  94. package/src/operator/node/format.ts +290 -0
  95. package/src/operator/node/github.tsx +234 -0
  96. package/src/operator/node/icons.tsx +88 -0
  97. package/src/operator/node/liveStream.ts +270 -0
  98. package/src/operator/node/modals.tsx +720 -0
  99. package/src/operator/node/navHistory.ts +70 -0
  100. package/src/operator/node/persist.ts +26 -0
  101. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  102. package/src/operator/node/protocol/agents/types.ts +394 -0
  103. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  104. package/src/operator/node/protocol/authFailure.ts +66 -0
  105. package/src/operator/node/protocol/customModels.ts +23 -0
  106. package/src/operator/node/protocol/events.ts +13 -0
  107. package/src/operator/node/protocol/features.ts +70 -0
  108. package/src/operator/node/protocol/internalUsage.ts +1 -0
  109. package/src/operator/node/protocol/promptLimits.ts +52 -0
  110. package/src/operator/node/protocol/storageRetention.ts +13 -0
  111. package/src/operator/node/protocol/types.ts +332 -0
  112. package/src/operator/node/protocol/usageLimit.ts +72 -0
  113. package/src/operator/node/shared.tsx +89 -0
  114. package/src/operator/node/transcriptModel.ts +129 -0
  115. package/src/operator/node/types.ts +296 -0
  116. package/src/operator/node/useGlobalEvents.ts +97 -0
  117. package/src/operator/node/useOrchestrator.ts +696 -0
  118. package/src/operator/node/usePrefs.ts +114 -0
  119. package/src/operator/node/useRecaps.ts +91 -0
  120. package/src/operator/node/useTaskStream.ts +279 -0
  121. package/src/operator.base.css +1798 -0
  122. package/src/operator.css +186 -16
  123. package/src/styles.css +51 -0
  124. package/src/ui/Markdown.tsx +7 -2
  125. package/src/ui/Popover.tsx +77 -0
  126. package/src/ui/TextBlock.tsx +3 -3
  127. package/src/ui/Transcript.tsx +10 -8
  128. package/src/ui/fileLinks.ts +6 -0
  129. package/src/ui/format.ts +1 -1
  130. package/src/ui/icons.tsx +1 -0
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
@@ -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)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
@@ -75,6 +75,9 @@ const ERROR_TEXT: Record<string, string> = {
75
75
  viewer_not_ready: "画面连接还没就绪,正在重连",
76
76
  link_expired: "文件链接已失效,请刷新",
77
77
  task_not_found: "这个子任务不在对应的任务里",
78
+ task_already_finished: "当前子任务已经结束,请刷新状态后继续",
79
+ task_not_paused: "当前子任务没有暂停,请刷新状态后重试",
80
+ job_already_paused: "整个任务已暂停,请先恢复整个任务",
78
81
  };
79
82
 
80
83
  /**
@@ -102,6 +105,9 @@ export interface ConversationBackend {
102
105
  * 返回新子任务的 id。
103
106
  */
104
107
  continueTask?(jobId: string, taskId: string, text: string, opts?: { title?: string; businessId?: string }): Promise<{ task_id: string }>;
108
+ /** 暂停 / 继续一次性任务中的单个子任务。继续会重新派发该子任务。 */
109
+ pauseTask?(taskId: string): Promise<CenterTask>;
110
+ resumeTask?(taskId: string): Promise<CenterTask>;
105
111
  /** 在对话任务里新开一个对话(同一节点、同一工作区),text 是它的第一条消息。 */
106
112
  openConversation?(jobId: string, text: string, opts?: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
107
113
  cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
@@ -135,6 +141,8 @@ export interface BoardBackend {
135
141
  /** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
136
142
  directLinks: boolean;
137
143
  info(jobId: string): Promise<BoardInfo>;
144
+ /** 精准文件事件流;没有时组件明确降级为目录快照比对。 */
145
+ watchFiles?(jobId: string, handlers: { onEvent: (event: import("./types").WorkspaceFileBatch) => void; onClose: (error: CenterError | null) => void }): () => void;
138
146
  /** 目录(缺省根目录)或文件的信息。 */
139
147
  stat(jobId: string, path?: string): Promise<DirListing | FileInfo>;
140
148
  /** 文件地址(给 <img> / <iframe> / 下载链接用)。相对路径用路径式地址,HTML 里的相对资源能跟着解析。 */
@@ -165,7 +173,7 @@ export interface BoardBackend {
165
173
  browserStatus(jobId: string): Promise<BrowserStatus>;
166
174
  browserAction(jobId: string, action: "open" | "close" | "takeover" | "handback"): Promise<BrowserStatus>;
167
175
  /** 浏览器状态实时流;返回停止函数。onClose 在断开时调用(error 为 null 表示正常结束)。 */
168
- 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;
169
177
  /** 浏览器实时画面;onViewer 给出发输入要用的 viewer id。 */
170
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;
171
179
  browserInput(jobId: string, viewer: string, events: Array<Record<string, unknown>>): Promise<void>;
@@ -336,6 +344,8 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
336
344
  });
337
345
  return { task_id: result.task_ids[0] };
338
346
  },
347
+ pauseTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/pause`, {}),
348
+ resumeTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/resume`, {}),
339
349
  /** 同一个 business_id 重发只开一个对话。 */
340
350
  openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
341
351
  post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
@@ -538,7 +548,7 @@ function createBoardClient(
538
548
  if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
539
549
  ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
540
550
  });
541
- 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]);
542
552
  } catch (error) {
543
553
  release();
544
554
  if (signal?.aborted) throw aborted();
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(默认文案;cli-chat 的顶部栏缺省隐藏,其余全部显示)。
2
+ * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,缺省值见下方 DEFAULT_*_CONFIG。
3
3
  *
4
4
  * <CliChat config={{ header: { title: true, status: true }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
5
5
  * <CliMsgList config={{ header: { refresh: false }, card: { turns: false } }} />
@@ -39,7 +39,7 @@ export interface CliChatConfig {
39
39
  placeholderRunning?: string;
40
40
  /** 不能发送时(对话已结束、只读)的占位文案。 */
41
41
  placeholderDisabled?: string;
42
- /** 底部「⏎ 发送 / ⇧⏎ 换行」提示。 */
42
+ /** 底部「Shift + Enter 换行」提示;发送快捷键仍为 Enter。 */
43
43
  hints?: boolean;
44
44
  /** 底部字数统计。 */
45
45
  charCount?: boolean;
@@ -47,7 +47,7 @@ export interface CliChatConfig {
47
47
  fileUpload?: boolean;
48
48
  /** 「文件」按钮的文字。 */
49
49
  fileButtonText?: string;
50
- /** 运行中的停止按钮。 */
50
+ /** 执行控制:普通对话停止当前轮次,一次性子任务暂停 / 继续。 */
51
51
  stop?: boolean;
52
52
  /** 运行中允许发消息排队;false = 运行中不能发。 */
53
53
  queue?: boolean;
@@ -60,6 +60,8 @@ export interface CliChatConfig {
60
60
  sessionLabel?: boolean;
61
61
  /** 消息时间。 */
62
62
  timestamps?: boolean;
63
+ /** 已随项目上下文发送的初始提示。缺省不渲染;true 显示原文。 */
64
+ initialPrompt?: boolean;
63
65
  /** 首条消息上的「初始提示」「已随项目上下文发送」标记。 */
64
66
  initialBadge?: boolean;
65
67
  /** 工具调用卡片(命令、读写文件……);false 只留文字往来(提问卡片照常显示)。 */
@@ -87,7 +89,7 @@ export interface ResolvedChatConfig {
87
89
  show: boolean; placeholder: string; placeholderNew: string; placeholderRunning: string; placeholderDisabled: string | null;
88
90
  hints: boolean; charCount: boolean; fileUpload: boolean; fileButtonText: string; stop: boolean; queue: boolean;
89
91
  };
90
- transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
92
+ transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialPrompt: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
91
93
  labels: { user: string; agent: string };
92
94
  empty: { show: boolean; title: string; description: string };
93
95
  /** 按种类显示 / 不显示;"*" 是没列出的种类。用 refShown 判断。 */
@@ -102,13 +104,13 @@ export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
102
104
  header: { show: true, crumb: false, export: false, title: false, agent: false, usage: false, model: false, status: false, close: false },
103
105
  composer: {
104
106
  show: true,
105
- placeholder: "在“{title}”中回复 {agent}…(可拖入 Word / Excel / Markdown 等文档)",
106
- placeholderNew: "输入第一条消息,开始新对话…(可拖入 Word / Excel / Markdown 等文档)",
107
+ placeholder: "回复 {agent}…",
108
+ placeholderNew: "输入消息,开始新对话…",
107
109
  placeholderRunning: "排队一条后续消息…(本轮结束后发送)",
108
110
  placeholderDisabled: null,
109
111
  hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
110
112
  },
111
- transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true, status: false },
113
+ transcript: { nav: true, sessionLabel: true, timestamps: true, initialPrompt: false, initialBadge: true, tools: true, runningIndicator: true, status: false },
112
114
  labels: { user: "你", agent: "智能体" },
113
115
  empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 Word、Excel、Markdown、TXT、HTML、CSV 文档,内容会转成文字一起发送。" },
114
116
  refs: { "*": true, viewing: false },
@@ -119,12 +121,6 @@ export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfi
119
121
  const header = config.header === true ? allOn(d.header) : toggle(config.header, d.header);
120
122
  const composer = toggle(config.composer, d.composer);
121
123
  const empty = toggle(config.empty, d.empty);
122
- // 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
123
- if (!composer.fileUpload) {
124
- const strip = (text: string) => text.replace(/(可拖入 Word \/ Excel \/ Markdown 等文档)$/, "");
125
- if (!isObj(config.composer) || config.composer.placeholder === undefined) composer.placeholder = strip(composer.placeholder);
126
- if (!isObj(config.composer) || config.composer.placeholderNew === undefined) composer.placeholderNew = strip(composer.placeholderNew);
127
- }
128
124
  return {
129
125
  header,
130
126
  composer,
@@ -146,7 +142,7 @@ export interface CliMsgListConfig {
146
142
  newButton?: boolean;
147
143
  /** 「+ 对话」按钮的文字。 */
148
144
  newButtonText?: string;
149
- /** 刷新按钮。 */
145
+ /** 手动刷新按钮,缺省隐藏;列表仍按 pollMs 自动更新。 */
150
146
  refresh?: boolean;
151
147
  /** 上下文说明框(需同时给 context 或 onEditContext)。 */
152
148
  context?: boolean;
@@ -192,7 +188,7 @@ export interface ResolvedMsgListConfig {
192
188
  }
193
189
 
194
190
  export const DEFAULT_MSGLIST_CONFIG: ResolvedMsgListConfig = {
195
- header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: true, context: true },
191
+ header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: false, context: true },
196
192
  search: "auto",
197
193
  searchPlaceholder: "搜索对话…",
198
194
  grouped: false,
@@ -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"]);
@@ -403,10 +403,10 @@ function batchViewMessages(conv: ConvState): Msg[] {
403
403
  }
404
404
  const generation = out.length ? out[out.length - 1].generation : 1;
405
405
  const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
406
+ for (const q of task?.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
406
407
  if (task?.conversation) {
407
408
  // 对话式:像对话一样显示——还没派发的消息是「排队中」气泡,没有「子任务」字样。
408
409
  if (CHAT_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${CHAT_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
409
- for (const q of task.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
410
410
  return out;
411
411
  }
412
412
  if (!task) {
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]);