@xgspz/cli-ui 0.0.0-stage → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +512 -2
  3. package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
  4. package/dist/cli-ui-index-C34qFb4u.js +11209 -0
  5. package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
  6. package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
  7. package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +932 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +577 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1103 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +505 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1949 -0
  149. package/src/operator.css +1949 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -0,0 +1,34 @@
1
+ import type { CenterTurn, Msg } from "../core/types";
2
+ import { toolDataOf } from "../ui/transcriptModel";
3
+
4
+ /** 把对话过程导出成 Markdown 下载(对应 Operator 的「导出执行过程」,这里在前端用已加载的转录生成)。 */
5
+ export function buildMarkdown(title: string, jobId: string, messages: Msg[], turns: CenterTurn[]): string {
6
+ const tokens = turns.reduce((sum, turn) => sum + (turn.total_tokens || 0), 0);
7
+ const lines = [`# ${title}`, "", `- 对话:${jobId}`, `- 轮次:${turns.length}`, `- tokens:${tokens.toLocaleString()}`, `- 导出时间:${new Date().toLocaleString()}`, ""];
8
+ for (const m of messages) {
9
+ const time = m.ts ? ` · ${new Date(m.ts).toLocaleString()}` : "";
10
+ if (m.role === "user" || m.role === "queued") lines.push(`## 你${m.role === "queued" ? "(排队中)" : ""}${time}`, "", m.content, "");
11
+ else if (m.role === "assistant") lines.push(`## 智能体${time}`, "", m.content, "");
12
+ else if (m.role === "system") lines.push(`> ${m.content.replace(/\n/g, "\n> ")}`, "");
13
+ else if (m.role === "tool") {
14
+ const data = toolDataOf(m);
15
+ lines.push(`<details><summary>工具:${data.title}${data.isError ? "(失败)" : ""}</summary>`, "");
16
+ if (data.detail) lines.push("```", data.detail, "```", "");
17
+ if (data.result !== undefined) lines.push("结果:", "", "```", data.result, "```", "");
18
+ lines.push("</details>", "");
19
+ }
20
+ }
21
+ return lines.join("\n");
22
+ }
23
+
24
+ export function exportMarkdown(title: string, jobId: string, messages: Msg[], turns: CenterTurn[]) {
25
+ const blob = new Blob([buildMarkdown(title, jobId, messages, turns)], { type: "text/markdown;charset=utf-8" });
26
+ const url = URL.createObjectURL(blob);
27
+ const link = document.createElement("a");
28
+ link.href = url;
29
+ link.download = `${title.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 60) || "对话"}.md`;
30
+ document.body.appendChild(link);
31
+ link.click();
32
+ link.remove();
33
+ setTimeout(() => URL.revokeObjectURL(url), 0);
34
+ }
@@ -0,0 +1,27 @@
1
+ // 编辑排队消息:把排队的正文拆回输入框——输入的文字 + 文本块(粘贴的长文本、文件原件、引用、业务背景)。
2
+ // 只去掉发送时会自动重新附带的部分:「看板正在看」的被动背景、带原件时自动加的那句提示。
3
+ import { ORIGINALS_NOTE, splitMessage, type TextBlock } from "../core/blocks";
4
+ import { clientId } from "../core/id";
5
+ import { updateComposerDraft } from "./draft";
6
+
7
+ export function queuedToDraft(content: string): { text: string; blocks: TextBlock[] } {
8
+ const texts: string[] = [];
9
+ const blocks: TextBlock[] = [];
10
+ for (const part of splitMessage(content)) {
11
+ if (part.kind === "text") texts.push(part.text);
12
+ else if (part.block.kind !== "viewing") blocks.push(part.block);
13
+ }
14
+ const text = texts.join("\n\n").split("\n").filter((line) => line.trim() !== ORIGINALS_NOTE).join("\n").replace(/\n{3,}/g, "\n\n").trim();
15
+ return { text, blocks };
16
+ }
17
+
18
+ /** 放回输入框:排队的内容在前,输入框里还没发的草稿接在后面——两边都不丢。 */
19
+ export function restoreQueuedToDraft(draftId: string, content: string) {
20
+ const restored = queuedToDraft(content);
21
+ updateComposerDraft(draftId, (current) => ({
22
+ ...current,
23
+ text: [restored.text, current.text].filter((value) => value.trim()).join("\n\n"),
24
+ blocks: [...restored.blocks.map((block) => ({ ...block, key: clientId() })), ...current.blocks],
25
+ error: null,
26
+ }));
27
+ }
@@ -0,0 +1,93 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { CenterError, type ConversationBackend } from "../core/client";
3
+ import type { ExecutionConfiguration, ExecutionOptions } from "../core/execution";
4
+ import { isConfigurationBusy, useConfigurationBusy } from "./configuration";
5
+ import { readComposerDraft, updateComposerDraft, useComposerDraft } from "./draft";
6
+
7
+ const errorText = (error: unknown) => error instanceof CenterError && [404, 405, 501].includes(error.status)
8
+ ? "当前服务尚未支持执行设置,请升级配套服务。"
9
+ : error instanceof Error ? error.message : "执行设置读取失败,请重试。";
10
+
11
+ function validate(result: ExecutionConfiguration) {
12
+ if (!result || typeof result.agent !== "string" || !Number.isSafeInteger(result.revision) || result.revision < 0 || !Array.isArray(result.agents)
13
+ || result.agents.some(agent => typeof agent?.id !== "string" || typeof agent.label !== "string" || !agent.capabilities
14
+ || [agent.capabilities.models, agent.capabilities.reasoningOptions, agent.capabilities.permissionModes].some(options => !Array.isArray(options)
15
+ || options.some(option => typeof option?.value !== "string" || typeof option.label !== "string")))) {
16
+ throw new Error("服务未返回有效的执行设置,请检查配套服务版本。");
17
+ }
18
+ }
19
+
20
+ /** 按对话隔离读取与保存,不轮询;换对话后的迟到响应不覆盖当前选择。 */
21
+ export function useExecutionConfig({ client, scope, target, enabled, ready = true, initial, running }: {
22
+ client: ConversationBackend; scope: string; target: string | null; enabled: boolean;
23
+ initial?: ExecutionOptions; running: boolean; ready?: boolean;
24
+ }) {
25
+ const [view, setView] = useState<{ scope: string; config?: ExecutionConfiguration; error?: string; loading: boolean; saveFailed?: boolean }>({ scope, loading: false });
26
+ const current = useRef(scope);
27
+ const request = useRef(0);
28
+ const mounted = useRef(true);
29
+ useLayoutEffect(() => { current.current = scope; request.current++; }, [scope]);
30
+ useEffect(() => { mounted.current = true; return () => { mounted.current = false; request.current++; }; }, []);
31
+ const draft = useComposerDraft(scope);
32
+ const busy = useConfigurationBusy(scope);
33
+ const config = view.scope === scope ? view.config : undefined;
34
+ const reload = useCallback(async () => {
35
+ const serial = ++request.current;
36
+ const active = () => mounted.current && current.current === scope && request.current === serial;
37
+ if (!ready || (!enabled && !readComposerDraft(scope).executionUncertain)) return;
38
+ if (!client.getExecutionConfig) {
39
+ setView({ scope, loading: false, error: "当前接入尚未提供执行设置。" });
40
+ return;
41
+ }
42
+ setView(value => ({ ...(value.scope === scope ? value : {}), scope, loading: true }));
43
+ try {
44
+ const result = await client.getExecutionConfig(target);
45
+ validate(result);
46
+ if (active()) {
47
+ if (!isConfigurationBusy(scope) && readComposerDraft(scope).executionUncertain) updateComposerDraft(scope, draft => ({ ...draft, executionUncertain: false }));
48
+ setView({ scope, config: result, loading: false });
49
+ }
50
+ } catch (error) {
51
+ if (active()) setView(value => ({ ...(value.scope === scope ? value : {}), scope, error: errorText(error), loading: false }));
52
+ }
53
+ }, [client, scope, target, enabled, ready]);
54
+ useEffect(() => { void reload(); return () => { request.current++; }; }, [reload]);
55
+ const wasRunning = useRef(running);
56
+ useEffect(() => {
57
+ if (wasRunning.current && !running && !isConfigurationBusy(scope)) void reload();
58
+ wasRunning.current = running;
59
+ }, [running, reload, scope]);
60
+
61
+ const local = target === null ? { ...initial, ...draft.executionConfig } : undefined;
62
+ const selected = config ? { ...config, ...local } : undefined;
63
+ const save = async (changes: ExecutionOptions) => {
64
+ if (!config || !enabled || running) throw new Error("当前无法修改执行设置,请稍后重试。");
65
+ if (target === null) {
66
+ updateComposerDraft(scope, value => ({ ...value, executionConfig: { agent: config.agent, ...initial, ...value.executionConfig, ...changes } }));
67
+ return;
68
+ }
69
+ if (!client.setExecutionConfig) throw new Error("当前接入尚未提供保存执行设置的接口。");
70
+ // An earlier GET must not overwrite the response to this save.
71
+ const serial = ++request.current;
72
+ updateComposerDraft(scope, draft => ({ ...draft, executionUncertain: true }));
73
+ try {
74
+ const result = await client.setExecutionConfig(target, changes, config.revision);
75
+ validate(result);
76
+ updateComposerDraft(scope, draft => ({ ...draft, executionUncertain: false }));
77
+ if (mounted.current && current.current === scope && request.current === serial) setView({ scope, config: result, loading: false });
78
+ } catch (error) {
79
+ if (mounted.current && current.current === scope && request.current === serial) setView({ scope, config, loading: false, saveFailed: true, error: `${errorText(error)} 请重新读取设置后再发送。` });
80
+ throw error;
81
+ }
82
+ };
83
+ return {
84
+ config: selected, busy, loading: view.scope === scope && view.loading,
85
+ error: view.scope === scope && (enabled || view.saveFailed || draft.executionUncertain) ? view.error ?? (draft.executionUncertain && !busy ? "执行设置尚未确认,请重新读取后再发送。" : undefined) : undefined,
86
+ blocked: busy || !!draft.executionUncertain || (view.scope === scope && !!view.saveFailed), reload, save,
87
+ /** Read at submit time, including changes committed before React paints. */
88
+ prepareSend: () => {
89
+ if (isConfigurationBusy(scope) || readComposerDraft(scope).executionUncertain || (view.scope === scope && view.saveFailed)) throw new Error("请等待执行设置保存或重新读取设置后再发送。");
90
+ return target === null ? { executionConfig: readComposerDraft(scope).executionConfig } : { expectedExecutionRevision: config?.revision };
91
+ },
92
+ };
93
+ }
@@ -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} composer={props.config?.composer ?? props.operator.composer} 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}/>; }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * 「文本块」:上传的文档(Word / Excel / Markdown / TXT / HTML / CSV)和粘贴的大段文字。
3
+ *
4
+ * 前端先把文件解析成纯文本,再按统一格式拼进消息正文发给 center——center 只收纯文本。
5
+ * 同一个格式两头用:输入框里是可折叠的卡片,发出去之后转录里也解析回同样的卡片,
6
+ * 所以长内容在输入框和对话里都只显示开头,点开才看全部,不会把页面卡住。
7
+ *
8
+ * ----- 附件:需求说明.docx(12,345 字)-----
9
+ * ……纯文本……
10
+ * ----- 附件结束:需求说明.docx -----
11
+ *
12
+ * 对 Agent 来说这也是清楚的分隔:哪段是附件、叫什么、在哪结束。
13
+ */
14
+
15
+ import { decodeText } from "./textDecode";
16
+
17
+ export interface TextBlock {
18
+ /** 显示名:文件名,粘贴的文字为「粘贴的文本」。 */
19
+ name: string;
20
+ text: string;
21
+ kind: "file" | "paste" | "context" | "background" | "viewing";
22
+ }
23
+
24
+ /** 看板引用合成的块的名字(转录里按它认出来,显示成「看板上下文」卡片)。 */
25
+ export const BOARD_CONTEXT_NAME = "看板上下文";
26
+
27
+ /** 看板引用 → 发给智能体的块。 */
28
+ export function boardContextBlock(lines: string[]): TextBlock {
29
+ return { name: BOARD_CONTEXT_NAME, kind: "context", text: ["用户在右侧看板里引用了下面这些,供这条消息参考:", ...lines.map((l) => `- ${l}`)].join("\n") };
30
+ }
31
+
32
+ /** 看板上正在看的东西(缺省随消息带上、不显示)合成的块的名字:和明确引用的分开,转录里可以单独不显示。 */
33
+ export const VIEWING_NAME = "看板正在看";
34
+
35
+ /** 看板上正在看的东西 → 发给智能体的块。 */
36
+ export function viewingBlock(line: string): TextBlock {
37
+ return { name: VIEWING_NAME, kind: "viewing", text: `${line}。用户没有另外说明时,消息里说的「这个」「这里」「改一下」等就是指它。` };
38
+ }
39
+
40
+ /** 补充背景合成的块的名字(转录里显示成「补充背景」卡片)。 */
41
+ export const BACKGROUND_NAME = "补充背景";
42
+
43
+ /** 补充背景(store.appendContext)→ 发给智能体的块。每条有标题时写成「【标题】」。 */
44
+ export function backgroundBlock(items: Array<{ title?: string; text: string }>): TextBlock {
45
+ // 内容里碰巧有附件分隔行时错开一个空格,免得把块提前截断。
46
+ const safe = (t: string) => t.replace(/^(----- 附件)/gm, " $1");
47
+ const body = items.map((i) => (i.title ? `【${i.title}】\n${safe(i.text)}` : safe(i.text))).join("\n\n");
48
+ return { name: BACKGROUND_NAME, kind: "background", text: `以下是补充的项目背景信息,请在这次及之后的工作中一并参考:\n\n${body}` };
49
+ }
50
+
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
+ export const ACCEPT_ATTR = [
54
+ ...ACCEPTED_EXTENSIONS,
55
+ "text/plain", "text/markdown", "text/yaml", "application/yaml", "text/html", "text/csv", "text/tab-separated-values",
56
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
57
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel.sheet.macroEnabled.12",
58
+ ].join(",");
59
+ /** 不支持时提示里列的格式。 */
60
+ export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、YAML(.yaml / .yml)、HTML、CSV";
61
+ /** 一些常见但读不了的文档:给出另存为什么。 */
62
+ const SAVE_AS: Record<string, string> = {
63
+ ".doc": "老版 Word(.doc)请另存为 .docx 后再上传",
64
+ ".xls": "老版 Excel(.xls)请另存为 .xlsx 或 CSV 后再上传",
65
+ ".wps": "WPS 文字请另存为 .docx 后再上传",
66
+ ".et": "WPS 表格请另存为 .xlsx 后再上传",
67
+ };
68
+ /** HTML 里内嵌的大段 data: 数据(图片、字体):换成占位,免得一张图就把消息撑爆。 */
69
+ const INLINE_DATA = /data:[\w.+-]+\/[\w.+-]+(?:;[\w=.+-]+)*,[^"')\s]{200,}/g;
70
+ /** 粘贴超过这个长度(或行数)就转成文本块,不放进输入框。 */
71
+ export const PASTE_BLOCK_CHARS = 2_000;
72
+ export const PASTE_BLOCK_LINES = 40;
73
+ /** center 单条消息上限(POST /api/jobs/:id/messages 的 text 最长 200,000 字符)。 */
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;
77
+ export const PASTE_NAME = "粘贴的文本";
78
+
79
+ const OPEN = /^----- 附件:(.+?)([\d,]+ 字)-----$/;
80
+ const CLOSE = (name: string) => `----- 附件结束:${name} -----`;
81
+
82
+ export const charCount = (text: string) => Array.from(text).length;
83
+
84
+ export function serializeBlock(block: TextBlock): string {
85
+ const name = block.name.replace(/[\r\n]/g, " ").trim() || PASTE_NAME;
86
+ return `----- 附件:${name}(${charCount(block.text).toLocaleString("en-US")} 字)-----\n${block.text}\n${CLOSE(name)}`;
87
+ }
88
+
89
+ /** 带原件附件时自动加在正文后的一句(编辑排队消息放回输入框时去掉,发送时会重新加上)。 */
90
+ export const ORIGINALS_NOTE = "请先读取以下附件原件,再处理本条消息。";
91
+
92
+ /** 输入框里的文字 + 文本块 → 发给 center 的正文。 */
93
+ export function composeMessage(typed: string, blocks: TextBlock[]): string {
94
+ const originals = blocks.some(block => block.kind === "file" && (/^\[Attached (?:file|image): /.test(block.text) || /^\[文件原件:.*\]\(cli-upload:\/\//.test(block.text)));
95
+ return [typed.trim(), originals ? ORIGINALS_NOTE : "", ...blocks.map(serializeBlock)].filter(Boolean).join("\n\n");
96
+ }
97
+
98
+ export type MessagePart = { kind: "text"; text: string } | { kind: "block"; block: TextBlock };
99
+
100
+ /** 正文 → 文字段和文本块(转录里渲染用)。不成对的标记原样当文字。 */
101
+ export function splitMessage(content: string): MessagePart[] {
102
+ if (!content.includes("----- 附件:")) return [{ kind: "text", text: content }];
103
+ const lines = content.split("\n");
104
+ const parts: MessagePart[] = [];
105
+ let text: string[] = [];
106
+ const flush = () => {
107
+ const value = text.join("\n").trim();
108
+ if (value) parts.push({ kind: "text", text: value });
109
+ text = [];
110
+ };
111
+ for (let i = 0; i < lines.length; i++) {
112
+ const open = OPEN.exec(lines[i]);
113
+ const end = open ? lines.indexOf(CLOSE(open[1]), i + 1) : -1;
114
+ if (!open || end === -1) { text.push(lines[i]); continue; }
115
+ flush();
116
+ const name = open[1];
117
+ parts.push({ kind: "block", block: { name, text: lines.slice(i + 1, end).join("\n"), kind: name === PASTE_NAME ? "paste" : name === BOARD_CONTEXT_NAME ? "context" : name === VIEWING_NAME ? "viewing" : name === BACKGROUND_NAME ? "background" : "file" } });
118
+ i = end;
119
+ }
120
+ flush();
121
+ return parts.length ? parts : [{ kind: "text", text: content }];
122
+ }
123
+
124
+ /** 粘贴的内容是不是该转成文本块。 */
125
+ export function isLongText(text: string) {
126
+ if (text.length > PASTE_BLOCK_CHARS) return true;
127
+ let lines = 1;
128
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10 && ++lines > PASTE_BLOCK_LINES) return true;
129
+ return false;
130
+ }
131
+
132
+ export function extensionOf(name: string) {
133
+ const dot = name.lastIndexOf(".");
134
+ return dot === -1 ? "" : name.slice(dot).toLowerCase();
135
+ }
136
+
137
+ export class FileReadError extends Error {}
138
+
139
+ /**
140
+ * 读出文件的纯文本:
141
+ * · TXT / Markdown / CSV / TSV / HTML —— 按编码解(UTF-8,导出的 GBK 文件按 GB18030),HTML 保留源码、内嵌的大段 data: 数据换成占位;
142
+ * · Word —— mammoth 取正文文字(不带格式、图片);
143
+ * · Excel —— 每个工作表一段 CSV(单元格的值,日期写成 2026-08-10),见 xlsx.ts。
144
+ * 其他文件(图片、PDF、压缩包……)不收;老版 .doc / .xls 浏览器里读不了,提示另存为。
145
+ */
146
+ export async function readFileText(file: File): Promise<string> {
147
+ if (file.size > MAX_DOCUMENT_BYTES) throw new FileReadError(`“${file.name}”超过 10 MB,请缩小后重试。`);
148
+ const text = await extractFileText(file);
149
+ if (text.length > MAX_MESSAGE_CHARS) throw new FileReadError(`“${file.name}”解析后的正文超过 ${MAX_MESSAGE_CHARS.toLocaleString("en-US")} 字符,请拆分后重试。`);
150
+ return text;
151
+ }
152
+
153
+ async function extractFileText(file: File): Promise<string> {
154
+ const ext = extensionOf(file.name);
155
+ if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
156
+ if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
157
+ if (ext === ".xlsx" || ext === ".xlsm") {
158
+ const { readXlsx, sheetsToText } = await import("./xlsx");
159
+ try {
160
+ return normalizeText(sheetsToText(await readXlsx(await readAs(file, "buffer"))));
161
+ } catch {
162
+ throw new FileReadError("Excel 文件解析失败,文件可能已损坏、加了密码,或不是 .xlsx 格式");
163
+ }
164
+ }
165
+ if (ext === ".docx") {
166
+ // 按需加载:mammoth 不小,只有真的传 Word 时才下载。
167
+ const mammoth = await import("mammoth");
168
+ const extract = (mammoth as unknown as { extractRawText: typeof import("mammoth").extractRawText }).extractRawText
169
+ ?? (mammoth as unknown as { default: typeof import("mammoth") }).default.extractRawText;
170
+ try {
171
+ const arrayBuffer = await readAs(file, "buffer");
172
+ // 浏览器版 mammoth 读 arrayBuffer;Node 版(测试、SSR)只认 buffer,有 Buffer 时一并给上。
173
+ const NodeBuffer = (globalThis as { Buffer?: { from(data: ArrayBuffer): unknown } }).Buffer;
174
+ const result = await extract({ arrayBuffer, ...(NodeBuffer ? { buffer: NodeBuffer.from(arrayBuffer) } : {}) } as { arrayBuffer: ArrayBuffer });
175
+ return normalizeText(result.value);
176
+ } catch {
177
+ throw new FileReadError("Word 文件解析失败,文件可能已损坏或不是 .docx 格式");
178
+ }
179
+ }
180
+ const { text } = decodeText(new Uint8Array(await readAs(file, "buffer")));
181
+ // YAML literal scalars depend on blank lines, indentation and the final newline.
182
+ if (ext === ".yaml" || ext === ".yml") return text;
183
+ if (ext === ".html" || ext === ".htm") return normalizeText(text.replace(INLINE_DATA, (m) => `${m.slice(0, m.indexOf(",") + 1)}…(内嵌数据已省略)`));
184
+ return normalizeText(text);
185
+ }
186
+
187
+ /** Blob.text() / arrayBuffer() 老浏览器(Safari 14 以前)没有,退回 FileReader。 */
188
+ function readAs(file: Blob, as: "text"): Promise<string>;
189
+ function readAs(file: Blob, as: "buffer"): Promise<ArrayBuffer>;
190
+ function readAs(file: Blob, as: "text" | "buffer"): Promise<string | ArrayBuffer> {
191
+ if (as === "text" && typeof file.text === "function") return file.text();
192
+ if (as === "buffer" && typeof file.arrayBuffer === "function") return file.arrayBuffer();
193
+ return new Promise((resolve, reject) => {
194
+ const reader = new FileReader();
195
+ reader.onload = () => resolve(reader.result as string | ArrayBuffer);
196
+ reader.onerror = () => reject(reader.error ?? new FileReadError("文件读取失败"));
197
+ if (as === "text") reader.readAsText(file, "utf-8");
198
+ else reader.readAsArrayBuffer(file);
199
+ });
200
+ }
201
+
202
+ /** 统一换行、去掉 BOM 和首尾空行;段落间多余的空行压成一个。 */
203
+ export function normalizeText(text: string) {
204
+ return text.replace(/^/, "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
205
+ }
@@ -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("[@xgspz/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
+ }