@spzxg/cli-ui 0.6.0 → 0.6.1

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.
@@ -0,0 +1,99 @@
1
+ // 预览里文本文件(代码、日志、Markdown、CSV)的读取。
2
+ //
3
+ // 能读多大:一次最多 max 字节(预览里是 32 MB),更大的只看开头或末尾。
4
+ // 文件在变(任务在写日志、在改报告)时不整个重读:后端有 readBytes 时,从上次读到的地方往前多取一小段(OVERLAP),
5
+ // 这一小段和手里的末尾一模一样就说明文件只是往后追加了,只取新增的部分接上;对不上(文件被改写、变短了)才整个重读。
6
+ // 只对 UTF-8 文件这样做(GBK 的字节边界不好对齐,整个重读)。
7
+
8
+ import type { BoardBackend } from "../core/client.js";
9
+ import { decodeText } from "../core/textDecode.js";
10
+
11
+ /** 往前多取这么多字节,用来确认文件只是追加。 */
12
+ export const OVERLAP = 256;
13
+ /** 追加的段落多了合成一段(每段是 <pre> 里的一个文字节点)。 */
14
+ const MAX_PARTS = 200;
15
+
16
+ export interface LoadedText {
17
+ text: string;
18
+ /** 分段的文字(追加的每次一段):<pre> 里按段渲染,追加时前面的不用重排。 */
19
+ parts: string[];
20
+ /** 文件比读到的大(只读了开头或末尾)。 */
21
+ truncated: boolean;
22
+ /** 文件总大小。 */
23
+ size: number;
24
+ /** 读的是末尾。 */
25
+ tail: boolean;
26
+ encoding?: string;
27
+ /** 读到的内容在文件里的字节范围 [start, end)。 */
28
+ start: number;
29
+ end: number;
30
+ /** end 之前的最后 OVERLAP 个字节(只有 UTF-8、后端有 readBytes 时有),用来确认文件只是追加。 */
31
+ tailBytes: Uint8Array | null;
32
+ }
33
+
34
+ /** 末尾有几个字节是没写完的 UTF-8 字(0 = 完整)。 */
35
+ export function incompleteUtf8Tail(bytes: Uint8Array) {
36
+ const len = bytes.length;
37
+ for (let i = len - 1; i >= 0 && i >= len - 4; i--) {
38
+ const b = bytes[i];
39
+ if ((b & 0xc0) === 0x80) continue;
40
+ const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
41
+ const have = len - i;
42
+ return have < need ? have : 0;
43
+ }
44
+ return 0;
45
+ }
46
+
47
+ const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((x, i) => x === b[i]);
48
+
49
+ /** 第一次读:开头(或末尾)最多 max 字节。 */
50
+ export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<LoadedText> {
51
+ if (!backend.readBytes) {
52
+ const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes });
53
+ return { text: r.text, parts: [r.text], truncated: r.truncated, size: r.size, tail: !!r.tail, encoding: r.encoding, start: 0, end: 0, tailBytes: null };
54
+ }
55
+ const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes });
56
+ const { text, encoding } = decodeText(chunk.bytes, { fromMiddle: chunk.start > 0 });
57
+ const k = encoding === "utf-8" ? incompleteUtf8Tail(chunk.bytes) : 0;
58
+ const end = chunk.start + chunk.bytes.length - k;
59
+ const tailBytes = encoding === "utf-8" ? chunk.bytes.slice(Math.max(0, chunk.bytes.length - k - OVERLAP), chunk.bytes.length - k) : null;
60
+ return {
61
+ text, parts: [text], truncated: chunk.start + chunk.bytes.length < chunk.size || chunk.start > 0, size: chunk.size,
62
+ tail: chunk.start > 0, encoding, start: chunk.start, end, tailBytes,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * 文件变了:能接上就只取新增的部分,返回新的内容;返回 null = 接不上(被改写 / 变短 / 不是 UTF-8 / 后端不支持),调用方整个重读。
68
+ * 只看开头且文件比 max 大时,开头没变就不用重读(追加的在后面,本来就不显示)。
69
+ */
70
+ export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number): Promise<LoadedText | null> {
71
+ if (!backend.readBytes || !prev.tailBytes) return null;
72
+ const overlap = prev.tailBytes.length;
73
+ const from = prev.end - overlap;
74
+ const headOnly = !prev.tail && prev.truncated;
75
+ // 末尾模式攒多了(超过 1.5 倍):整个重读末尾,免得越攒越大。
76
+ if (prev.tail && prev.end - prev.start > max * 1.5) return null;
77
+ const room = headOnly ? 0 : prev.tail ? max : Math.max(0, max - (prev.end - prev.start));
78
+ const chunk = await backend.readBytes(jobId, path, { start: from, maxBytes: overlap + room });
79
+ if (chunk.size < prev.end || chunk.start !== from || chunk.bytes.length < overlap) return null;
80
+ if (!sameBytes(chunk.bytes.subarray(0, overlap), prev.tailBytes)) return null;
81
+ const readEnd = from + chunk.bytes.length;
82
+ if (headOnly) return { ...prev, size: chunk.size };
83
+ const added = chunk.bytes.subarray(overlap);
84
+ const k = incompleteUtf8Tail(added);
85
+ const delta = added.subarray(0, added.length - k);
86
+ let addedText: string;
87
+ try { addedText = new TextDecoder("utf-8", { fatal: true }).decode(delta); } catch { return null; }
88
+ const end = prev.end + delta.length;
89
+ const tailBytes = new Uint8Array(Math.min(OVERLAP, overlap + delta.length));
90
+ const joined = new Uint8Array(overlap + delta.length);
91
+ joined.set(prev.tailBytes, 0);
92
+ joined.set(delta, overlap);
93
+ tailBytes.set(joined.subarray(joined.length - tailBytes.length));
94
+ const parts = addedText ? (prev.parts.length >= MAX_PARTS ? [prev.text, addedText] : [...prev.parts, addedText]) : prev.parts;
95
+ return {
96
+ ...prev, text: addedText ? prev.text + addedText : prev.text, parts, size: chunk.size, end, tailBytes,
97
+ truncated: prev.tail || chunk.size > readEnd,
98
+ };
99
+ }
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
5
5
  import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
6
6
  import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
7
7
  import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
8
- import { boardContextBlock, serializeBlock } from "../core/blocks.js";
8
+ import { boardContextBlock, serializeBlock, viewingBlock } from "../core/blocks.js";
9
9
  import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
10
10
  import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
11
11
  import { Icon } from "../ui/icons.js";
@@ -14,7 +14,7 @@ import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.j
14
14
  import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
15
15
  import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
16
16
  import { Composer, type ComposerOptions } from "./Composer.js";
17
- import { fillTemplate, resolveChatConfig, type CliChatConfig } from "../core/config.js";
17
+ import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config.js";
18
18
  import { exportMarkdown } from "./export.js";
19
19
  import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
20
20
  import { FileViewer } from "../board/FileViewer.js";
@@ -263,12 +263,15 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
263
263
  // 一次性任务:发给正在看的那个子任务(接着它聊)。
264
264
  const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
265
265
  // 看板里引用到这个对话的东西:合成一个「看板上下文」块跟着这条消息发出去;发失败就放回去。
266
- // 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复)。
266
+ // 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复),单独一块「看板正在看」:转录里按 config.refs.viewing 显示或不显示。
267
267
  const refKey = latest.current.key;
268
268
  const refs = refKey ? store.takeRefs(refKey) : [];
269
269
  const viewing = refKey ? store.takeViewing(refKey) : null;
270
- const lines = [...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewing.text] : []), ...refs.map((r) => r.text)];
271
- const full = lines.length ? `${text}\n\n${serializeBlock(boardContextBlock(lines))}` : text;
270
+ const blocks = [
271
+ ...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewingBlock(viewing.text)] : []),
272
+ ...(refs.length ? [boardContextBlock(refs.map((r) => r.text))] : []),
273
+ ];
274
+ const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
272
275
  let target: string;
273
276
  try {
274
277
  target = await store.send(id ?? null, full, sendOpts.current);
@@ -280,7 +283,10 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
280
283
  }, [store, go]);
281
284
  const refs = useCliState((s) => (key ? s.refs[key] : undefined));
282
285
  const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
283
- const viewing = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
286
+ const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
287
+ // 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
288
+ const viewing = viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
289
+ const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
284
290
  // 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
285
291
  const contextItems = useCliState((s) => s.context[key ?? ""]);
286
292
  // AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
@@ -527,7 +533,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
527
533
 
528
534
  const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
529
535
  const h = cfg.header;
530
- const view = useMemo(() => ({ timestamps: cfg.transcript.timestamps, initialBadge: cfg.transcript.initialBadge, userLabel: cfg.labels.user, agentName: cfg.labels.agent }), [cfg]);
536
+ const view = useMemo(() => ({ timestamps: cfg.transcript.timestamps, initialBadge: cfg.transcript.initialBadge, userLabel: cfg.labels.user, agentName: cfg.labels.agent, viewingBlock: refShown(cfg, "viewing") }), [cfg]);
531
537
  const composerOptions = useMemo<ComposerOptions>(() => ({
532
538
  placeholderRunning: cfg.composer.placeholderRunning, hints: cfg.composer.hints, charCount: cfg.composer.charCount,
533
539
  fileUpload: cfg.composer.fileUpload, fileButtonText: cfg.composer.fileButtonText, queue: cfg.composer.queue,
@@ -703,7 +709,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
703
709
  ))}
704
710
  </div>
705
711
  )}
706
- {showComposer && (!!refs?.length || !!viewing) && (
712
+ {showComposer && (!!shownRefs?.length || !!viewing) && (
707
713
  <div className="cli-refs" aria-label="引用自看板,下一条消息会带上">
708
714
  <span className="cli-refs-t">{Icon.board()} 引用自看板</span>
709
715
  {viewing && (
@@ -713,7 +719,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
713
719
  <button className="cli-ref-x" onClick={() => key && store.dismissViewing(key)} title="这次不带上" aria-label={`不带上 ${viewing.label}`}>{Icon.x()}</button>
714
720
  </span>
715
721
  )}
716
- {(refs ?? []).map((r) => (
722
+ {(shownRefs ?? []).map((r) => (
717
723
  <span key={r.id} className={`cli-ref ${r.kind === "selection" ? "sel" : ""}`} title={r.text}>
718
724
  <span className="cli-ref-l">{r.label}</span>
719
725
  <button className="cli-ref-x" onClick={() => key && store.removeRef(key, r.id)} title="不带上" aria-label={`不带上 ${r.label}`}>{Icon.x()}</button>
@@ -1,9 +1,9 @@
1
1
  // 输入框。结构与 Operator 的 Composer.tsx 一致(comp-box / comp-area / send-group / comp-foot),
2
- // 但只发文本:不收图片和其他附件。文件只收 TXT、Markdown、Word,在前端解析成纯文本,
2
+ // 但只发文本:不收图片和其他附件。文件只收文档(Word、Excel、Markdown、TXT、HTML、CSV),在前端解析成纯文本,
3
3
  // 和长粘贴一样变成可折叠的文本块,发送时按统一格式拼进正文(见 core/blocks.ts)。
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import {
6
- ACCEPT_ATTR, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
6
+ ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
7
7
  } from "../core/blocks.js";
8
8
  import { Icon } from "../ui/icons.js";
9
9
  import { TextBlockCard } from "../ui/TextBlock.js";
@@ -154,7 +154,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
154
154
  if (files.length) {
155
155
  e.preventDefault();
156
156
  if (!options.fileUpload) { setError("这里只能发送文字"); return; }
157
- if (files.some((f) => f.type.startsWith("image/"))) setError("不支持发送图片,只能发送文字,或上传 TXT / Markdown / Word 文件");
157
+ if (files.some((f) => f.type.startsWith("image/"))) setError(`不支持发送图片,只能发送文字,或上传文档:${ACCEPTED_LABEL}`);
158
158
  addFiles(files.filter((f) => !f.type.startsWith("image/")));
159
159
  return;
160
160
  }
@@ -184,7 +184,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
184
184
  <span className="spacer" />
185
185
  {options.fileUpload && <input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />}
186
186
  {options.fileUpload && !disabled && (
187
- <button className="hint" style={{ cursor: "pointer" }} title="添加 TXT / Markdown / Word 文件(也可拖拽进来),内容会转成文字发送" onMouseDown={(e) => { e.preventDefault(); fileRef.current?.click(); }}>{Icon.clip()} {options.fileButtonText}</button>
187
+ <button className="hint" style={{ cursor: "pointer" }} title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`} onMouseDown={(e) => { e.preventDefault(); fileRef.current?.click(); }}>{Icon.clip()} {options.fileButtonText}</button>
188
188
  )}
189
189
  </div>}
190
190
  </div>
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 「文本块」:上传的文件(TXT / Markdown / Word)和粘贴的大段文字。
2
+ * 「文本块」:上传的文档(Word / Excel / Markdown / TXT / HTML / CSV)和粘贴的大段文字。
3
3
  *
4
4
  * 前端先把文件解析成纯文本,再按统一格式拼进消息正文发给 center——center 只收纯文本。
5
5
  * 同一个格式两头用:输入框里是可折叠的卡片,发出去之后转录里也解析回同样的卡片,
@@ -12,11 +12,13 @@
12
12
  * 对 Agent 来说这也是清楚的分隔:哪段是附件、叫什么、在哪结束。
13
13
  */
14
14
 
15
+ import { decodeText } from "./textDecode.js";
16
+
15
17
  export interface TextBlock {
16
18
  /** 显示名:文件名,粘贴的文字为「粘贴的文本」。 */
17
19
  name: string;
18
20
  text: string;
19
- kind: "file" | "paste" | "context" | "background";
21
+ kind: "file" | "paste" | "context" | "background" | "viewing";
20
22
  }
21
23
 
22
24
  /** 看板引用合成的块的名字(转录里按它认出来,显示成「看板上下文」卡片)。 */
@@ -27,6 +29,14 @@ export function boardContextBlock(lines: string[]): TextBlock {
27
29
  return { name: BOARD_CONTEXT_NAME, kind: "context", text: ["用户在右侧看板里引用了下面这些,供这条消息参考:", ...lines.map((l) => `- ${l}`)].join("\n") };
28
30
  }
29
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
+
30
40
  /** 补充背景合成的块的名字(转录里显示成「补充背景」卡片)。 */
31
41
  export const BACKGROUND_NAME = "补充背景";
32
42
 
@@ -38,9 +48,25 @@ export function backgroundBlock(items: Array<{ title?: string; text: string }>):
38
48
  return { name: BACKGROUND_NAME, kind: "background", text: `以下是补充的项目背景信息,请在这次及之后的工作中一并参考:\n\n${body}` };
39
49
  }
40
50
 
41
- /** 支持的文件:只有 TXT、Markdown、Word(.docx)。 */
42
- export const ACCEPTED_EXTENSIONS = [".txt", ".md", ".markdown", ".docx"] as const;
43
- export const ACCEPT_ATTR = ".txt,.md,.markdown,.docx,text/plain,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document";
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;
53
+ export const ACCEPT_ATTR = [
54
+ ...ACCEPTED_EXTENSIONS,
55
+ "text/plain", "text/markdown", "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、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;
44
70
  /** 粘贴超过这个长度(或行数)就转成文本块,不放进输入框。 */
45
71
  export const PASTE_BLOCK_CHARS = 2_000;
46
72
  export const PASTE_BLOCK_LINES = 40;
@@ -82,7 +108,7 @@ export function splitMessage(content: string): MessagePart[] {
82
108
  if (!open || end === -1) { text.push(lines[i]); continue; }
83
109
  flush();
84
110
  const name = open[1];
85
- 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 === BACKGROUND_NAME ? "background" : "file" } });
111
+ 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" } });
86
112
  i = end;
87
113
  }
88
114
  flush();
@@ -105,13 +131,24 @@ export function extensionOf(name: string) {
105
131
  export class FileReadError extends Error {}
106
132
 
107
133
  /**
108
- * 读出文件的纯文本。TXT / Markdown 直接按 UTF-8 读;Word 用 mammoth 取正文文字(不带格式、图片)。
109
- * 老式 .doc(二进制)浏览器里读不了,提示另存为 .docx。
134
+ * 读出文件的纯文本:
135
+ * · TXT / Markdown / CSV / TSV / HTML —— 按编码解(UTF-8,导出的 GBK 文件按 GB18030),HTML 保留源码、内嵌的大段 data: 数据换成占位;
136
+ * · Word —— mammoth 取正文文字(不带格式、图片);
137
+ * · Excel —— 每个工作表一段 CSV(单元格的值,日期写成 2026-08-10),见 xlsx.ts。
138
+ * 其他文件(图片、PDF、压缩包……)不收;老版 .doc / .xls 浏览器里读不了,提示另存为。
110
139
  */
111
140
  export async function readFileText(file: File): Promise<string> {
112
141
  const ext = extensionOf(file.name);
113
- if (ext === ".doc") throw new FileReadError("不支持老版 Word(.doc),请另存为 .docx 后再上传");
114
- if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError("只支持 TXT、Markdown 和 Word(.docx)文件");
142
+ if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
143
+ if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
144
+ if (ext === ".xlsx" || ext === ".xlsm") {
145
+ const { readXlsx, sheetsToText } = await import("./xlsx.js");
146
+ try {
147
+ return normalizeText(sheetsToText(await readXlsx(await readAs(file, "buffer"))));
148
+ } catch {
149
+ throw new FileReadError("Excel 文件解析失败,文件可能已损坏、加了密码,或不是 .xlsx 格式");
150
+ }
151
+ }
115
152
  if (ext === ".docx") {
116
153
  // 按需加载:mammoth 不小,只有真的传 Word 时才下载。
117
154
  const mammoth = await import("mammoth");
@@ -127,7 +164,9 @@ export async function readFileText(file: File): Promise<string> {
127
164
  throw new FileReadError("Word 文件解析失败,文件可能已损坏或不是 .docx 格式");
128
165
  }
129
166
  }
130
- return normalizeText(await readAs(file, "text"));
167
+ const { text } = decodeText(new Uint8Array(await readAs(file, "buffer")));
168
+ if (ext === ".html" || ext === ".htm") return normalizeText(text.replace(INLINE_DATA, (m) => `${m.slice(0, m.indexOf(",") + 1)}…(内嵌数据已省略)`));
169
+ return normalizeText(text);
131
170
  }
132
171
 
133
172
  /** Blob.text() / arrayBuffer() 老浏览器(Safari 14 以前)没有,退回 FileReader。 */
@@ -150,7 +150,12 @@ export interface BoardBackend {
150
150
  * tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
151
151
  * 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
152
152
  */
153
- readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean }): Promise<TextChunk>;
153
+ readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
154
+ /**
155
+ * 读一段原始字节:从 start 开始(缺省 0)最多 maxBytes;tail = 末尾 maxBytes。返回这段在文件里的起点和文件总大小。
156
+ * 看板用它在文件变长时只取新增的部分(日志、还在写的报告),不整个重读。没有这个方法的后端每次整个重读。
157
+ */
158
+ readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
154
159
  /**
155
160
  * 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
156
161
  * 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
@@ -171,6 +176,15 @@ export interface BoardBackend {
171
176
  browserLive?(jobId: string, handlers: BrowserLiveHandlers): () => void;
172
177
  }
173
178
 
179
+ /** readBytes 读到的一段。 */
180
+ export interface ByteChunk {
181
+ bytes: Uint8Array<ArrayBuffer>;
182
+ /** 这段在文件里从第几个字节开始。 */
183
+ start: number;
184
+ /** 文件总大小。 */
185
+ size: number;
186
+ }
187
+
174
188
  export interface TextChunk {
175
189
  text: string;
176
190
  /** 只读到了一部分。 */
@@ -590,6 +604,30 @@ function createBoardClient(
590
604
  };
591
605
  return { response, body };
592
606
  };
607
+ /**
608
+ * 读一段:开头 / 从 start 起 maxBytes,或者末尾 maxBytes(超大日志)。只向中枢要这一段(Range);
609
+ * 不支持 Range 的节点回整个文件(200),这里边读边截,大文件不整个拉进浏览器。
610
+ */
611
+ const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }) => {
612
+ const from = Math.max(0, opts.start ?? 0);
613
+ const { maxBytes } = opts;
614
+ const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` });
615
+ const contentType = response.headers.get("content-type") ?? "text/plain";
616
+ const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
617
+ // 416:要的起点不在文件里(空文件,或文件变短了)。
618
+ if (response.status === 416) { await body(); return { bytes: new Uint8Array(0), start: from, size: total ? Number(total) : 0, contentType }; }
619
+ const length = Number(response.headers.get("content-length") ?? 0);
620
+ if (response.status === 206) {
621
+ // 节点给的一段可能比要的长(不严格按 Range):也只留 maxBytes。
622
+ const bytes = await body({ limit: maxBytes, onBytes: opts.onBytes });
623
+ const start = Number(/bytes (\d+)-/.exec(response.headers.get("content-range") ?? "")?.[1] ?? 0);
624
+ return { bytes, start, size: total ? Number(total) : start + bytes.length, contentType };
625
+ }
626
+ // 回了整个文件:跳过 from 之前的,留 maxBytes。
627
+ const all = await body({ limit: from + maxBytes, onBytes: opts.onBytes });
628
+ const size = length || all.length;
629
+ return { bytes: all.slice(Math.min(from, all.length)), start: Math.min(from, all.length), size, contentType };
630
+ };
593
631
  // 文件链接令牌:按对话键缓存,离过期不到 5 分钟就换新的;同一时刻只换一次。
594
632
  const links = new Map<string, { base: string; until: number }>();
595
633
  const minting = new Map<string, Promise<string | null>>();
@@ -621,25 +659,14 @@ function createBoardClient(
621
659
  return work;
622
660
  },
623
661
  async readText(jobId, path, maxBytes = 512 * 1024, opts = {}) {
624
- // 只要需要的那一段:开头 maxBytes,或者末尾 maxBytes(超大日志)。不支持 Range 的节点回整个文件(200),下面边读边截。
625
- const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=0-${maxBytes - 1}` });
626
- const contentType = response.headers.get("content-type") ?? "text/plain";
627
- if (response.status === 416) { await body(); return { text: "", truncated: false, size: 0, contentType }; }
628
- const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
629
- const length = Number(response.headers.get("content-length") ?? 0);
630
- if (response.status === 206) {
631
- // 节点给的一段可能比要的长(不严格按 Range):也只留 maxBytes。
632
- const bytes = await body({ limit: maxBytes });
633
- const size = total ? Number(total) : bytes.length;
634
- const start = Number(/bytes (\d+)-/.exec(response.headers.get("content-range") ?? "")?.[1] ?? 0);
635
- const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
636
- return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
637
- }
638
- // 不支持 Range(回 200 整个文件):只读前 maxBytes,大文件不整个拉进浏览器。
639
- const all = await body({ limit: maxBytes });
640
- const truncated = all.length >= maxBytes && (!length || length > maxBytes);
641
- const { text, encoding } = decodeText(all);
642
- return { text, truncated, size: length || all.length, contentType, encoding };
662
+ const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, onBytes: opts.onBytes });
663
+ if (!size && !bytes.length) return { text: "", truncated: false, size: 0, contentType };
664
+ const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
665
+ return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
666
+ },
667
+ async readBytes(jobId, path, opts) {
668
+ const { bytes, start, size } = await readRange(jobId, path, opts);
669
+ return { bytes, start, size };
643
670
  },
644
671
  async readBuffer(jobId, path, opts = {}) {
645
672
  const { body } = await fileFetch(jobId, path, {}, opts.signal);
@@ -43,7 +43,7 @@ export interface CliChatConfig {
43
43
  hints?: boolean;
44
44
  /** 底部字数统计。 */
45
45
  charCount?: boolean;
46
- /** 文件上传(按钮、拖拽、粘贴文件)。只收 TXT / Markdown / Word。 */
46
+ /** 文件上传(按钮、拖拽、粘贴文件)。只收文档:Word、Excel、Markdown、TXT、HTML、CSV。 */
47
47
  fileUpload?: boolean;
48
48
  /** 「文件」按钮的文字。 */
49
49
  fileButtonText?: string;
@@ -73,6 +73,12 @@ export interface CliChatConfig {
73
73
  labels?: { user?: string; agent?: string };
74
74
  /** 新对话的空白页。false = 不显示。 */
75
75
  empty?: boolean | { title?: string; description?: string };
76
+ /**
77
+ * 输入框上方显示哪些看板引用(不显示的照样随下一条消息带给智能体,只是不占地方、也不能点掉)。
78
+ * 种类:viewing(看板上正在看的文件 / 浏览器页面,缺省带上)、file、selection(选中的一段)、browser、changes,或宿主自定义的种类;
79
+ * "*" = 没列出的种类。true 全显示,false 全不显示。缺省 { viewing: false }:正在看的不显示,明确引用的都显示。
80
+ */
81
+ refs?: boolean | Record<string, boolean>;
76
82
  }
77
83
 
78
84
  export interface ResolvedChatConfig {
@@ -84,22 +90,28 @@ export interface ResolvedChatConfig {
84
90
  transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
85
91
  labels: { user: string; agent: string };
86
92
  empty: { show: boolean; title: string; description: string };
93
+ /** 按种类显示 / 不显示;"*" 是没列出的种类。用 refShown 判断。 */
94
+ refs: Record<string, boolean>;
87
95
  }
88
96
 
97
+ /** 这个种类的看板引用("viewing" = 正在看的)要不要显示在输入框上方。 */
98
+ export const refShown = (cfg: Pick<ResolvedChatConfig, "refs">, kind: string) => cfg.refs[kind] ?? cfg.refs["*"] ?? true;
99
+
89
100
  export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
90
101
  // 顶部栏缺省整栏隐藏(模型、状态、用量这些对使用者是干扰),需要的项在 config.header 里打开。
91
102
  header: { show: true, crumb: false, export: false, title: false, agent: false, usage: false, model: false, status: false, close: false },
92
103
  composer: {
93
104
  show: true,
94
- placeholder: "在“{title}”中回复 {agent}…(可拖入 TXT / Markdown / Word 文件)",
95
- placeholderNew: "输入第一条消息,开始新对话…(可拖入 TXT / Markdown / Word 文件)",
105
+ placeholder: "在“{title}”中回复 {agent}…(可拖入 Word / Excel / Markdown 等文档)",
106
+ placeholderNew: "输入第一条消息,开始新对话…(可拖入 Word / Excel / Markdown 等文档)",
96
107
  placeholderRunning: "排队一条后续消息…(本轮结束后发送)",
97
108
  placeholderDisabled: null,
98
109
  hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
99
110
  },
100
111
  transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true, status: false },
101
112
  labels: { user: "你", agent: "智能体" },
102
- empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 TXT、Markdown 或 Word 文件,内容会转成文字一起发送。" },
113
+ empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 Word、Excel、Markdown、TXT、HTML、CSV 文档,内容会转成文字一起发送。" },
114
+ refs: { "*": true, viewing: false },
103
115
  };
104
116
 
105
117
  export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfig {
@@ -109,7 +121,7 @@ export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfi
109
121
  const empty = toggle(config.empty, d.empty);
110
122
  // 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
111
123
  if (!composer.fileUpload) {
112
- const strip = (text: string) => text.replace(/(可拖入 TXT \/ Markdown \/ Word 文件)$/, "");
124
+ const strip = (text: string) => text.replace(/(可拖入 Word \/ Excel \/ Markdown 等文档)$/, "");
113
125
  if (!isObj(config.composer) || config.composer.placeholder === undefined) composer.placeholder = strip(composer.placeholder);
114
126
  if (!isObj(config.composer) || config.composer.placeholderNew === undefined) composer.placeholderNew = strip(composer.placeholderNew);
115
127
  }
@@ -119,6 +131,7 @@ export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfi
119
131
  transcript: { ...d.transcript, ...defined(config.transcript) },
120
132
  labels: { ...d.labels, ...defined(config.labels) },
121
133
  empty,
134
+ refs: config.refs === true ? { "*": true } : config.refs === false ? { "*": false } : { ...d.refs, ...(defined(isObj(config.refs) ? config.refs : undefined) as Record<string, boolean>) },
122
135
  };
123
136
  }
124
137
 
@@ -0,0 +1,142 @@
1
+ // Excel(.xlsx / .xlsm)→ 纯文本:每个工作表一段 CSV,发给智能体。
2
+ //
3
+ // .xlsx 是 ZIP 里的一堆 XML:workbook.xml 列出工作表,sharedStrings.xml 是共享字符串,worksheets/sheetN.xml 是单元格。
4
+ // 只取单元格的值(公式取缓存的结果),不要格式、图片、图表;日期按单元格格式认出来写成 2026-08-10(带时间的加上时分),
5
+ // 百分比写成 0.6%(和表格里看到的一致)。
6
+ // 老版 .xls(二进制)读不了,调用方提示另存为 .xlsx。
7
+
8
+ export interface SheetText { name: string; hidden: boolean; rows: string[][] }
9
+
10
+ const NS_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
11
+ const NS_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
12
+
13
+ /** 内置的日期 / 时间数字格式编号。 */
14
+ const BUILTIN_DATE_FMTS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 30, 36, 45, 46, 47, 50, 57]);
15
+
16
+ const parseXml = (text: string) => new DOMParser().parseFromString(text, "application/xml");
17
+ const els = (root: Document | Element, tag: string) => Array.from(root.getElementsByTagNameNS(NS_MAIN, tag));
18
+
19
+ /** 「A1」→ 列号(从 0 开始)。 */
20
+ export function columnIndex(ref: string) {
21
+ let n = 0;
22
+ for (const ch of ref) {
23
+ const c = ch.toUpperCase().charCodeAt(0);
24
+ if (c < 65 || c > 90) break;
25
+ n = n * 26 + (c - 64);
26
+ }
27
+ return n - 1;
28
+ }
29
+
30
+ /** 自定义格式里有年月日时(引号里的、[颜色] 不算)就当日期。 */
31
+ function isDateFormat(code: string) {
32
+ const bare = code.replace(/"[^"]*"/g, "").replace(/\[[^\]]*\]/g, "").replace(/\\./g, "");
33
+ return /[ymdhs]/i.test(bare) && !/^[#0.,%E+\-\s]*$/i.test(bare);
34
+ }
35
+
36
+ /** Excel 序列日期 → 2026-08-10 / 2026-08-10 14:30。 */
37
+ export function excelDate(serial: number) {
38
+ const ms = Math.round((serial - 25569) * 86_400_000); // 1970-01-01 = 25569
39
+ const d = new Date(ms);
40
+ if (Number.isNaN(d.getTime())) return String(serial);
41
+ const p = (n: number) => String(n).padStart(2, "0");
42
+ const date = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
43
+ const secs = d.getUTCHours() * 3600 + d.getUTCMinutes() * 60 + d.getUTCSeconds();
44
+ if (!secs) return date;
45
+ const time = `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}${d.getUTCSeconds() ? `:${p(d.getUTCSeconds())}` : ""}`;
46
+ return serial < 1 ? time : `${date} ${time}`;
47
+ }
48
+
49
+ /** 单元格里的文字:<t> 连起来(富文本是多段 <r><t>),不要注音(<rPh>)。 */
50
+ function richText(el: Element) {
51
+ return els(el, "t").filter((t) => t.parentElement?.localName !== "rPh").map((t) => t.textContent ?? "").join("");
52
+ }
53
+
54
+ /** 读出所有工作表(按工作簿里的顺序)。 */
55
+ export async function readXlsx(data: ArrayBuffer): Promise<SheetText[]> {
56
+ // 按需加载(mammoth 也用它,不额外增加体积)。
57
+ const mod = (await import("jszip")) as unknown as { default?: typeof import("jszip") } & typeof import("jszip");
58
+ const JSZip = mod.default ?? mod;
59
+ const zip = await JSZip.loadAsync(data);
60
+ const read = async (path: string) => { const f = zip.file(path); return f ? f.async("string") : null; };
61
+
62
+ const workbook = await read("xl/workbook.xml");
63
+ if (!workbook) throw new Error("不是 Excel 工作簿");
64
+ const rels = new Map<string, string>();
65
+ const relsXml = await read("xl/_rels/workbook.xml.rels");
66
+ if (relsXml) {
67
+ for (const r of Array.from(parseXml(relsXml).getElementsByTagName("Relationship"))) {
68
+ const target = r.getAttribute("Target") ?? "";
69
+ rels.set(r.getAttribute("Id") ?? "", target.startsWith("/") ? target.slice(1) : `xl/${target}`);
70
+ }
71
+ }
72
+ const shared: string[] = [];
73
+ const sharedXml = await read("xl/sharedStrings.xml");
74
+ if (sharedXml) for (const si of els(parseXml(sharedXml), "si")) shared.push(richText(si));
75
+
76
+ // 哪些样式是日期 / 百分比:cellXfs 第 i 个 → numFmtId → 内置格式或自定义格式。
77
+ const dateStyles = new Set<number>();
78
+ const percentStyles = new Set<number>();
79
+ const stylesXml = await read("xl/styles.xml");
80
+ if (stylesXml) {
81
+ const styles = parseXml(stylesXml);
82
+ const custom = new Map(els(styles, "numFmt").map((f) => [Number(f.getAttribute("numFmtId")), f.getAttribute("formatCode") ?? ""]));
83
+ const xfs = els(styles, "cellXfs")[0];
84
+ if (xfs) {
85
+ Array.from(xfs.children).filter((x) => x.localName === "xf").forEach((xf, i) => {
86
+ const id = Number(xf.getAttribute("numFmtId") ?? 0);
87
+ const code = custom.get(id);
88
+ if (BUILTIN_DATE_FMTS.has(id) || (code !== undefined && isDateFormat(code))) dateStyles.add(i);
89
+ else if (id === 9 || id === 10 || (code !== undefined && code.replace(/"[^"]*"/g, "").includes("%"))) percentStyles.add(i);
90
+ });
91
+ }
92
+ }
93
+
94
+ const sheets: SheetText[] = [];
95
+ for (const s of els(parseXml(workbook), "sheet")) {
96
+ const name = s.getAttribute("name") ?? `Sheet${sheets.length + 1}`;
97
+ const path = rels.get(s.getAttributeNS(NS_REL, "id") ?? s.getAttribute("r:id") ?? "") ?? `xl/worksheets/sheet${sheets.length + 1}.xml`;
98
+ const xml = await read(path);
99
+ if (!xml) continue;
100
+ const rows: string[][] = [];
101
+ for (const row of els(parseXml(xml), "row")) {
102
+ const r = Number(row.getAttribute("r")) || rows.length + 1;
103
+ const cells: string[] = [];
104
+ let next = 0;
105
+ for (const c of Array.from(row.children).filter((x) => x.localName === "c")) {
106
+ const ref = c.getAttribute("r");
107
+ const col = ref ? columnIndex(ref) : next;
108
+ next = col + 1;
109
+ const type = c.getAttribute("t");
110
+ const v = els(c, "v")[0]?.textContent ?? "";
111
+ let value: string;
112
+ if (type === "s") value = shared[Number(v)] ?? "";
113
+ else if (type === "inlineStr") value = richText(c);
114
+ else if (type === "b") value = v === "1" ? "TRUE" : v === "0" ? "FALSE" : v;
115
+ else if (type === "str" || type === "e" || !v) value = v;
116
+ else if (dateStyles.has(Number(c.getAttribute("s") ?? -1)) && Number.isFinite(Number(v))) value = excelDate(Number(v));
117
+ else if (percentStyles.has(Number(c.getAttribute("s") ?? -1)) && Number.isFinite(Number(v))) value = `${Number((Number(v) * 100).toPrecision(12))}%`;
118
+ else value = v;
119
+ cells[col] = value;
120
+ }
121
+ rows[r - 1] = Array.from(cells, (x) => x ?? "");
122
+ }
123
+ const dense = Array.from(rows, (x) => x ?? []);
124
+ // 去掉末尾的空行、每行末尾的空格子。
125
+ while (dense.length && dense[dense.length - 1].every((x) => !x)) dense.pop();
126
+ for (const row of dense) while (row.length && !row[row.length - 1]) row.pop();
127
+ sheets.push({ name, hidden: (s.getAttribute("state") ?? "visible") !== "visible", rows: dense });
128
+ }
129
+ return sheets;
130
+ }
131
+
132
+ const csvCell = (v: string) => (/[",\n\r]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
133
+
134
+ /** 工作表 → 发给智能体的文字:每个工作表一个标题 + CSV。 */
135
+ export function sheetsToText(sheets: SheetText[]) {
136
+ return sheets
137
+ .map((s) => {
138
+ const head = `## 工作表:${s.name}${s.hidden ? "(隐藏)" : ""}(${s.rows.length} 行)`;
139
+ return s.rows.length ? `${head}\n${s.rows.map((r) => r.map(csvCell).join(",")).join("\n")}` : `${head}\n(空)`;
140
+ })
141
+ .join("\n\n");
142
+ }