@spzxg/cli-ui 0.6.0 → 0.6.2

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(默认文案;cli-chat 的顶部栏缺省隐藏,其余全部显示)。
2
+ * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,缺省值见下方 DEFAULT_*_CONFIG。
3
3
  *
4
4
  * <CliChat config={{ header: { title: true, status: true }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
5
5
  * <CliMsgList config={{ header: { refresh: false }, card: { turns: false } }} />
@@ -39,15 +39,15 @@ export interface CliChatConfig {
39
39
  placeholderRunning?: string;
40
40
  /** 不能发送时(对话已结束、只读)的占位文案。 */
41
41
  placeholderDisabled?: string;
42
- /** 底部「⏎ 发送 / ⇧⏎ 换行」提示。 */
42
+ /** 底部「Shift + Enter 换行」提示;发送快捷键仍为 Enter。 */
43
43
  hints?: boolean;
44
44
  /** 底部字数统计。 */
45
45
  charCount?: boolean;
46
- /** 文件上传(按钮、拖拽、粘贴文件)。只收 TXT / Markdown / Word。 */
46
+ /** 文件上传(按钮、拖拽、粘贴文件)。只收文档:Word、Excel、Markdown、TXT、HTML、CSV。 */
47
47
  fileUpload?: boolean;
48
48
  /** 「文件」按钮的文字。 */
49
49
  fileButtonText?: string;
50
- /** 运行中的停止按钮。 */
50
+ /** 执行控制:普通对话停止当前轮次,一次性子任务暂停 / 继续。 */
51
51
  stop?: boolean;
52
52
  /** 运行中允许发消息排队;false = 运行中不能发。 */
53
53
  queue?: boolean;
@@ -60,6 +60,8 @@ export interface CliChatConfig {
60
60
  sessionLabel?: boolean;
61
61
  /** 消息时间。 */
62
62
  timestamps?: boolean;
63
+ /** 已随项目上下文发送的初始提示。缺省不渲染;true 显示原文。 */
64
+ initialPrompt?: boolean;
63
65
  /** 首条消息上的「初始提示」「已随项目上下文发送」标记。 */
64
66
  initialBadge?: boolean;
65
67
  /** 工具调用卡片(命令、读写文件……);false 只留文字往来(提问卡片照常显示)。 */
@@ -73,6 +75,12 @@ export interface CliChatConfig {
73
75
  labels?: { user?: string; agent?: string };
74
76
  /** 新对话的空白页。false = 不显示。 */
75
77
  empty?: boolean | { title?: string; description?: string };
78
+ /**
79
+ * 输入框上方显示哪些看板引用(不显示的照样随下一条消息带给智能体,只是不占地方、也不能点掉)。
80
+ * 种类:viewing(看板上正在看的文件 / 浏览器页面,缺省带上)、file、selection(选中的一段)、browser、changes,或宿主自定义的种类;
81
+ * "*" = 没列出的种类。true 全显示,false 全不显示。缺省 { viewing: false }:正在看的不显示,明确引用的都显示。
82
+ */
83
+ refs?: boolean | Record<string, boolean>;
76
84
  }
77
85
 
78
86
  export interface ResolvedChatConfig {
@@ -81,25 +89,31 @@ export interface ResolvedChatConfig {
81
89
  show: boolean; placeholder: string; placeholderNew: string; placeholderRunning: string; placeholderDisabled: string | null;
82
90
  hints: boolean; charCount: boolean; fileUpload: boolean; fileButtonText: string; stop: boolean; queue: boolean;
83
91
  };
84
- transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
92
+ transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialPrompt: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
85
93
  labels: { user: string; agent: string };
86
94
  empty: { show: boolean; title: string; description: string };
95
+ /** 按种类显示 / 不显示;"*" 是没列出的种类。用 refShown 判断。 */
96
+ refs: Record<string, boolean>;
87
97
  }
88
98
 
99
+ /** 这个种类的看板引用("viewing" = 正在看的)要不要显示在输入框上方。 */
100
+ export const refShown = (cfg: Pick<ResolvedChatConfig, "refs">, kind: string) => cfg.refs[kind] ?? cfg.refs["*"] ?? true;
101
+
89
102
  export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
90
103
  // 顶部栏缺省整栏隐藏(模型、状态、用量这些对使用者是干扰),需要的项在 config.header 里打开。
91
104
  header: { show: true, crumb: false, export: false, title: false, agent: false, usage: false, model: false, status: false, close: false },
92
105
  composer: {
93
106
  show: true,
94
- placeholder: "在“{title}”中回复 {agent}…(可拖入 TXT / Markdown / Word 文件)",
95
- placeholderNew: "输入第一条消息,开始新对话…(可拖入 TXT / Markdown / Word 文件)",
107
+ placeholder: "回复 {agent}…",
108
+ placeholderNew: "输入消息,开始新对话…",
96
109
  placeholderRunning: "排队一条后续消息…(本轮结束后发送)",
97
110
  placeholderDisabled: null,
98
111
  hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
99
112
  },
100
- transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true, status: false },
113
+ transcript: { nav: true, sessionLabel: true, timestamps: true, initialPrompt: false, initialBadge: true, tools: true, runningIndicator: true, status: false },
101
114
  labels: { user: "你", agent: "智能体" },
102
- empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 TXT、Markdown 或 Word 文件,内容会转成文字一起发送。" },
115
+ empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 Word、Excel、Markdown、TXT、HTML、CSV 文档,内容会转成文字一起发送。" },
116
+ refs: { "*": true, viewing: false },
103
117
  };
104
118
 
105
119
  export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfig {
@@ -107,18 +121,13 @@ export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfi
107
121
  const header = config.header === true ? allOn(d.header) : toggle(config.header, d.header);
108
122
  const composer = toggle(config.composer, d.composer);
109
123
  const empty = toggle(config.empty, d.empty);
110
- // 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
111
- if (!composer.fileUpload) {
112
- const strip = (text: string) => text.replace(/(可拖入 TXT \/ Markdown \/ Word 文件)$/, "");
113
- if (!isObj(config.composer) || config.composer.placeholder === undefined) composer.placeholder = strip(composer.placeholder);
114
- if (!isObj(config.composer) || config.composer.placeholderNew === undefined) composer.placeholderNew = strip(composer.placeholderNew);
115
- }
116
124
  return {
117
125
  header,
118
126
  composer,
119
127
  transcript: { ...d.transcript, ...defined(config.transcript) },
120
128
  labels: { ...d.labels, ...defined(config.labels) },
121
129
  empty,
130
+ refs: config.refs === true ? { "*": true } : config.refs === false ? { "*": false } : { ...d.refs, ...(defined(isObj(config.refs) ? config.refs : undefined) as Record<string, boolean>) },
122
131
  };
123
132
  }
124
133
 
@@ -133,7 +142,7 @@ export interface CliMsgListConfig {
133
142
  newButton?: boolean;
134
143
  /** 「+ 对话」按钮的文字。 */
135
144
  newButtonText?: string;
136
- /** 刷新按钮。 */
145
+ /** 手动刷新按钮,缺省隐藏;列表仍按 pollMs 自动更新。 */
137
146
  refresh?: boolean;
138
147
  /** 上下文说明框(需同时给 context 或 onEditContext)。 */
139
148
  context?: boolean;
@@ -179,7 +188,7 @@ export interface ResolvedMsgListConfig {
179
188
  }
180
189
 
181
190
  export const DEFAULT_MSGLIST_CONFIG: ResolvedMsgListConfig = {
182
- header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: true, context: true },
191
+ header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: false, context: true },
183
192
  search: "auto",
184
193
  searchPlaceholder: "搜索对话…",
185
194
  grouped: false,
@@ -403,10 +403,10 @@ function batchViewMessages(conv: ConvState): Msg[] {
403
403
  }
404
404
  const generation = out.length ? out[out.length - 1].generation : 1;
405
405
  const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
406
+ for (const q of task?.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
406
407
  if (task?.conversation) {
407
408
  // 对话式:像对话一样显示——还没派发的消息是「排队中」气泡,没有「子任务」字样。
408
409
  if (CHAT_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${CHAT_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
409
- for (const q of task.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
410
410
  return out;
411
411
  }
412
412
  if (!task) {
@@ -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
+ }
package/src/styles.css CHANGED
@@ -46,6 +46,37 @@
46
46
  .cli-ui .cli-count{font-family:var(--mono);}
47
47
  .cli-ui .cli-count.over{color:var(--red);}
48
48
 
49
+ /* 组件在侧栏中也保持清晰的标题、操作和输入区。 */
50
+ .cli-ui.cli-chat{container:cli-chat / inline-size;}
51
+ .cli-ui .sess-head .sh-main{min-width:0;flex:1 1 180px;}
52
+ .cli-ui .sess-head .sh-tools{min-width:0;max-width:100%;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;}
53
+ .cli-ui .sess-head .crumb{overflow-wrap:anywhere;}
54
+ .cli-ui .cli-export{white-space:nowrap;}
55
+ .cli-ui .comp-area textarea{min-width:0;min-height:48px;}
56
+ .cli-ui .comp-foot{gap:8px;padding:8px 10px;background:transparent;border-top:0;flex-wrap:wrap;}
57
+ .cli-ui .cli-attach{display:inline-flex;align-items:center;gap:5px;min-height:32px;padding:0 8px;border-radius:7px;color:var(--ink-3);font-size:12px;}
58
+ .cli-ui .cli-attach:hover{background:var(--surface-2);color:var(--ink);}
59
+ .cli-ui .cli-attach svg{width:15px;height:15px;}
60
+ .cli-ui .send.cli-send{display:inline-flex;justify-content:center;gap:5px;width:auto;min-width:76px;padding:0 12px;font-size:12.5px;font-weight:600;}
61
+ .cli-ui .send.cli-send:disabled{background:var(--surface-2);color:var(--ink-4);box-shadow:none;opacity:1;}
62
+ .cli-ui .cli-export:focus-visible,.cli-ui .cli-attach:focus-visible,.cli-ui .send:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
63
+ .cli-ui .cli-task-control{height:34px;padding:0 10px;white-space:nowrap;}
64
+ .cli-ui .cli-task-control:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
65
+ @container cli-chat (max-width:560px){
66
+ .cli-ui .sess-head{gap:8px;padding:12px 14px;}
67
+ .cli-ui .sess-head .sh-main{flex-basis:100%;}
68
+ .cli-ui .sess-head .sh-tools{flex:1;justify-content:flex-start;margin-left:0;}
69
+ .cli-ui .sess-head .cli-export{margin-left:auto;}
70
+ .cli-ui .tw{padding:0 16px;}
71
+ .cli-ui .composer{padding:10px 12px;}
72
+ }
73
+
74
+ /* 普通文本和 URL 换行;代码、宽表格只在各自区域内横向滚动。聊天和看板预览共用。 */
75
+ .cli-ui .transcript-wrap,.cli-ui .transcript,.cli-ui .tw,.cli-ui .msg,.cli-ui .msg-body,.cli-ui .md{min-width:0;}
76
+ .cli-ui .msg-body,.cli-ui .md{overflow-wrap:anywhere;}
77
+ .cli-ui .md pre{max-width:100%;overflow-wrap:normal;}
78
+ .cli-ui .md table{display:block;max-width:100%;overflow-x:auto;}
79
+
49
80
  /* 排队气泡下的等待原因(中枢给的:节点连不上、没有可用节点……) */
50
81
  .cli-ui .cli-queued-note{margin-top:6px;font-size:12px;color:var(--amber);}
51
82
 
@@ -22,7 +22,7 @@ export const TextBlockCard = memo(function TextBlockCard({ block, onRemove }: {
22
22
  return (
23
23
  <div className={`cli-block ${open ? "open" : ""}`}>
24
24
  <div className="cli-block-h">
25
- <span className="cli-block-ic">{block.kind === "file" ? Icon.doc() : block.kind === "context" ? Icon.board() : block.kind === "background" ? Icon.flag() : Icon.clip()}</span>
25
+ <span className="cli-block-ic">{block.kind === "file" ? Icon.doc() : block.kind === "context" || block.kind === "viewing" ? Icon.board() : block.kind === "background" ? Icon.flag() : Icon.clip()}</span>
26
26
  <span className="cli-block-name" title={block.name}>{block.name}</span>
27
27
  <span className="cli-block-meta">{fmtCount(chars)} 字 · {fmtCount(lines)} 行</span>
28
28
  <span className="cli-block-sp" />
@@ -283,9 +283,10 @@ function AttachmentStrip({ items, attachmentUrl }: { items: MsgAttachment[]; att
283
283
  }
284
284
 
285
285
  /** 用户消息正文:普通文字按 markdown 渲染,附件 / 长粘贴是可折叠卡片,很长的纯文字也折叠。 */
286
- function UserBody({ content, attachmentUrl }: { content: string; attachmentUrl?: (a: MsgAttachment) => string | null }) {
286
+ function UserBody({ content, attachmentUrl, viewing = true }: { content: string; attachmentUrl?: (a: MsgAttachment) => string | null; viewing?: boolean }) {
287
287
  const { text, attachments } = splitAttachments(content);
288
- const parts = text ? splitMessage(text) : [];
288
+ // 看板上正在看的东西(缺省随消息带上的那块):配置成不显示时转录里也不显示。
289
+ const parts = (text ? splitMessage(text) : []).filter((p) => viewing || p.kind !== "block" || p.block.kind !== "viewing");
289
290
  return (
290
291
  <>
291
292
  {parts.map((part, i) => part.kind === "block"
@@ -310,7 +311,11 @@ function LongText({ text }: { text: string }) {
310
311
  }
311
312
 
312
313
  /** 消息行的显示选项(来自组件配置)。要引用稳定,否则 memo 失效。 */
313
- export interface MessageViewOptions { timestamps: boolean; initialBadge: boolean; userLabel: string; agentName: string }
314
+ export interface MessageViewOptions {
315
+ timestamps: boolean; initialBadge: boolean; userLabel: string; agentName: string;
316
+ /** 用户消息里「看板正在看」那块显示成卡片;false = 不显示(照样发给了智能体)。缺省显示。 */
317
+ viewingBlock?: boolean;
318
+ }
314
319
  const DEFAULT_VIEW: MessageViewOptions = { timestamps: true, initialBadge: true, userLabel: "你", agentName: "智能体" };
315
320
 
316
321
  export interface MessageHandlers {
@@ -340,7 +345,7 @@ export const MessageView = memo(function MessageView({ m, initial: isInitial, hi
340
345
  <div className="msg user queued">
341
346
  <div className="who"><Avatar who="user" /> {view.userLabel}<span className="badge queued-badge">排队中</span>{time}</div>
342
347
  <div className="msg-body">
343
- <UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} />
348
+ <UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} viewing={view.viewingBlock} />
344
349
  {m.note && <div className="cli-queued-note">⏳ {m.note}</div>}
345
350
  {handlers.onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => handlers.onCancelQueued!(m)}>{Icon.x()}</button>}
346
351
  </div>
@@ -388,7 +393,7 @@ export const MessageView = memo(function MessageView({ m, initial: isInitial, hi
388
393
  )}
389
394
  <div className="msg-body">
390
395
  {initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
391
- {isUser ? <UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} /> : m.content && <Markdown>{m.content}</Markdown>}
396
+ {isUser ? <UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} viewing={view.viewingBlock} /> : m.content && <Markdown>{m.content}</Markdown>}
392
397
  </div>
393
398
  </div>
394
399
  );
package/src/ui/icons.tsx CHANGED
@@ -54,6 +54,7 @@ export const Icon = {
54
54
  external: (p?: P) => S(<><path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M19 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5" /></>, p),
55
55
  copy: (p?: P) => S(<><rect x={9} y={9} width={12} height={12} rx={2} /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>, p),
56
56
  play: (p?: P) => S(<path d="M7 5l11 7-11 7z" />, p),
57
+ pause: (p?: P) => S(<><path d="M9 5v14" /><path d="M15 5v14" /></>, p),
57
58
  stop: (p?: P) => S(<rect x={6} y={6} width={12} height={12} rx={2} />, p),
58
59
  sliders: (p?: P) => S(<><line x1={4} y1={21} x2={4} y2={14} /><line x1={4} y1={10} x2={4} y2={3} /><line x1={12} y1={21} x2={12} y2={12} /><line x1={12} y1={8} x2={12} y2={3} /><line x1={20} y1={21} x2={20} y2={16} /><line x1={20} y1={12} x2={20} y2={3} /><line x1={1} y1={14} x2={7} y2={14} /><line x1={9} y1={8} x2={15} y2={8} /><line x1={17} y1={16} x2={23} y2={16} /></>, p),
59
60
  search: (p?: P) => S(<><circle cx={11} cy={11} r={7} /><line x1={21} y1={21} x2={16.65} y2={16.65} /></>, p),