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