@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.
- package/README.md +17 -5
- package/dist/{cli-ui-embed-CXdmrwo1.js → cli-ui-embed-6IJ_jRVh.js} +11479 -11152
- package/dist/cli-ui-index-BY5i3wId.js +11209 -0
- package/dist/cli-ui-jszip.min-BZsUwI-s.js +2324 -0
- package/dist/cli-ui-jszip.min-m01fexHW.js +26 -0
- package/dist/cli-ui-xlsx-CpWicJLT.js +93 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +2 -1
- package/src/board/CliBoard.tsx +2 -2
- package/src/board/FileViewer.tsx +168 -35
- package/src/board/fileKind.ts +31 -0
- package/src/board/frameQuote.ts +50 -0
- package/src/board/textLoad.ts +99 -0
- package/src/chat/CliChat.tsx +56 -33
- package/src/chat/Composer.tsx +36 -25
- package/src/core/blocks.ts +50 -11
- package/src/core/client.ts +55 -20
- package/src/core/config.ts +26 -17
- package/src/core/conversation.ts +1 -1
- package/src/core/xlsx.ts +142 -0
- package/src/styles.css +31 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/Transcript.tsx +10 -5
- package/src/ui/icons.tsx +1 -0
- package/dist/cli-ui-index-BZppwn-v.js +0 -13528
package/src/core/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
/**
|
|
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: "
|
|
95
|
-
placeholderNew: "
|
|
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、
|
|
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:
|
|
191
|
+
header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: false, context: true },
|
|
183
192
|
search: "auto",
|
|
184
193
|
searchPlaceholder: "搜索对话…",
|
|
185
194
|
grouped: false,
|
package/src/core/conversation.ts
CHANGED
|
@@ -403,10 +403,10 @@ function batchViewMessages(conv: ConvState): Msg[] {
|
|
|
403
403
|
}
|
|
404
404
|
const generation = out.length ? out[out.length - 1].generation : 1;
|
|
405
405
|
const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
|
|
406
|
+
for (const q of task?.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
|
|
406
407
|
if (task?.conversation) {
|
|
407
408
|
// 对话式:像对话一样显示——还没派发的消息是「排队中」气泡,没有「子任务」字样。
|
|
408
409
|
if (CHAT_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${CHAT_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
|
|
409
|
-
for (const q of task.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
|
|
410
410
|
return out;
|
|
411
411
|
}
|
|
412
412
|
if (!task) {
|
package/src/core/xlsx.ts
ADDED
|
@@ -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
|
|
package/src/ui/TextBlock.tsx
CHANGED
|
@@ -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" />
|
package/src/ui/Transcript.tsx
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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),
|