@spzxg/cli-ui 0.0.0-stage → 0.3.0

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,194 @@
1
+ // 输入框。结构与 Operator 的 Composer.tsx 一致(comp-box / comp-area / send-group / comp-foot),
2
+ // 但只发文本:不收图片和其他附件。文件只收 TXT、Markdown、Word,在前端解析成纯文本,
3
+ // 和长粘贴一样变成可折叠的文本块,发送时按统一格式拼进正文(见 core/blocks.ts)。
4
+ import { useEffect, useRef, useState } from "react";
5
+ import {
6
+ ACCEPT_ATTR, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
7
+ } from "../core/blocks.js";
8
+ import { Icon } from "../ui/icons.js";
9
+ import { TextBlockCard } from "../ui/TextBlock.js";
10
+ import { fmtCount } from "../ui/format.js";
11
+
12
+ const draftKey = (key: string) => `cli-ui:draft:${key}`;
13
+ const loadDraft = (key: string) => { try { return localStorage.getItem(draftKey(key)) ?? ""; } catch { return ""; } };
14
+ const saveDraft = (key: string, value: string) => {
15
+ try {
16
+ if (value) localStorage.setItem(draftKey(key), value);
17
+ else localStorage.removeItem(draftKey(key));
18
+ } catch { /* 隐私模式 / 配额:草稿就不保存 */ }
19
+ };
20
+
21
+ type Pending = { key: string; name: string; status: "reading" | "error"; error?: string };
22
+
23
+ /** 输入框的可配置项(来自 CliChat 的 config.composer)。 */
24
+ export interface ComposerOptions {
25
+ placeholderRunning: string;
26
+ hints: boolean;
27
+ charCount: boolean;
28
+ fileUpload: boolean;
29
+ fileButtonText: string;
30
+ queue: boolean;
31
+ }
32
+
33
+ export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, options }: {
34
+ /** 草稿按这个键存在 localStorage(一般是对话 id)。 */
35
+ draft: string;
36
+ placeholder: string;
37
+ disabled: boolean;
38
+ disabledHint?: string;
39
+ running: boolean;
40
+ onSend: (text: string) => Promise<void>;
41
+ onStop?: () => Promise<void> | void;
42
+ options: ComposerOptions;
43
+ }) {
44
+ const [val, setVal] = useState(() => loadDraft(draft));
45
+ const [blocks, setBlocks] = useState<Array<TextBlock & { key: string }>>([]);
46
+ const [pending, setPending] = useState<Pending[]>([]);
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [sending, setSending] = useState(false);
49
+ const [stopping, setStopping] = useState(false);
50
+ const [dragging, setDragging] = useState(false);
51
+ const dragDepth = useRef(0);
52
+ const seq = useRef(0);
53
+ const fileRef = useRef<HTMLInputElement>(null);
54
+ const ref = useRef<HTMLTextAreaElement>(null);
55
+
56
+ useEffect(() => { if (!running) setStopping(false); }, [running]);
57
+ useEffect(() => { saveDraft(draft, val); }, [draft, val]);
58
+ const autosize = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; };
59
+ useEffect(() => { if (ref.current) autosize(ref.current); }, [draft]);
60
+
61
+ const nextKey = () => `blk-${++seq.current}`;
62
+ const addBlock = (block: TextBlock) => setBlocks((prev) => [...prev, { ...block, key: nextKey() }]);
63
+
64
+ const addFiles = (files: File[]) => {
65
+ if (disabled || !options.fileUpload) return;
66
+ for (const file of files) {
67
+ const key = nextKey();
68
+ setPending((prev) => [...prev, { key, name: file.name || "文件", status: "reading" }]);
69
+ readFileText(file)
70
+ .then((text) => {
71
+ setPending((prev) => prev.filter((p) => p.key !== key));
72
+ if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
73
+ addBlock({ name: file.name, text, kind: "file" });
74
+ })
75
+ .catch((err: unknown) => {
76
+ setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
77
+ });
78
+ }
79
+ };
80
+
81
+ const message = composeMessage(val, blocks);
82
+ const total = charCount(message);
83
+ const tooLong = total > MAX_MESSAGE_CHARS;
84
+ const reading = pending.some((p) => p.status === "reading");
85
+ // 运行中不允许排队时,等这一轮结束再发。
86
+ const canSend = !!message.trim() && !reading && !tooLong && !sending && (!running || options.queue);
87
+
88
+ const submit = async () => {
89
+ if (!canSend || disabled) return;
90
+ setSending(true);
91
+ setError(null);
92
+ const key = draft;
93
+ try {
94
+ await onSend(message);
95
+ // 新建对话时发送完成前组件就换成了新对话的输入框(key 变了),这里直接清掉发出时那份草稿。
96
+ saveDraft(key, "");
97
+ setVal("");
98
+ setBlocks([]);
99
+ setPending([]);
100
+ if (ref.current) ref.current.style.height = "auto";
101
+ } catch (err) {
102
+ // 发送失败:内容留在输入框里,可以直接再发。
103
+ setError(err instanceof Error ? err.message : String(err));
104
+ } finally {
105
+ setSending(false);
106
+ }
107
+ };
108
+
109
+ const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
110
+ const hasFileDrag = (e: React.DragEvent) => options.fileUpload && isFileDrag(e);
111
+
112
+ return (
113
+ <div className="composer">
114
+ <div className="composer-inner">
115
+ {(error || tooLong) && (
116
+ <div className="cli-comp-err">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
117
+ )}
118
+ <div
119
+ className={`comp-box${dragging ? " dropping" : ""}`}
120
+ onDragEnter={(e) => { if (!disabled && hasFileDrag(e)) { e.preventDefault(); dragDepth.current++; setDragging(true); } }}
121
+ onDragOver={(e) => { if (isFileDrag(e)) e.preventDefault(); }}
122
+ onDragLeave={() => { if (dragDepth.current > 0 && --dragDepth.current === 0) setDragging(false); }}
123
+ onDrop={(e) => {
124
+ if (!isFileDrag(e)) return;
125
+ // 文件拖进来总要拦下:不拦的话浏览器会直接打开这个文件、离开当前页面。
126
+ e.preventDefault();
127
+ dragDepth.current = 0;
128
+ setDragging(false);
129
+ if (disabled) return;
130
+ if (!options.fileUpload) { setError("这里只能发送文字"); return; }
131
+ addFiles(Array.from(e.dataTransfer.files));
132
+ }}
133
+ >
134
+ {(blocks.length > 0 || pending.length > 0) && (
135
+ <div className="cli-blocks">
136
+ {blocks.map((b) => <TextBlockCard key={b.key} block={b} onRemove={() => setBlocks((prev) => prev.filter((x) => x.key !== b.key))} />)}
137
+ {pending.map((p) => (
138
+ <div key={p.key} className={`attach-chip file ${p.status}`} title={p.error || p.name}>
139
+ <span className="attach-file">{Icon.doc()} {p.name}</span>
140
+ <span className={`attach-badge ${p.status === "error" ? "err" : ""}`}>{p.status === "reading" ? "解析中…" : p.error}</span>
141
+ {p.status === "error" && <button className="attach-x" title="移除" aria-label={`移除 ${p.name}`} onClick={() => setPending((prev) => prev.filter((x) => x.key !== p.key))}>×</button>}
142
+ </div>
143
+ ))}
144
+ </div>
145
+ )}
146
+ <div className="comp-area">
147
+ <textarea
148
+ ref={ref} rows={1} value={val} disabled={disabled}
149
+ placeholder={disabled ? disabledHint ?? "暂时不能发送" : running && options.queue ? options.placeholderRunning : placeholder}
150
+ onChange={(e) => { setVal(e.target.value); autosize(e.target); }}
151
+ onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void submit(); } }}
152
+ onPaste={(e) => {
153
+ const files = Array.from(e.clipboardData?.files ?? []);
154
+ if (files.length) {
155
+ e.preventDefault();
156
+ if (!options.fileUpload) { setError("这里只能发送文字"); return; }
157
+ if (files.some((f) => f.type.startsWith("image/"))) setError("不支持发送图片,只能发送文字,或上传 TXT / Markdown / Word 文件");
158
+ addFiles(files.filter((f) => !f.type.startsWith("image/")));
159
+ return;
160
+ }
161
+ // 大段粘贴不放进输入框(几万字的 textarea 会很卡),转成可折叠的文本块。
162
+ const text = e.clipboardData?.getData("text/plain") ?? "";
163
+ if (isLongText(text)) {
164
+ e.preventDefault();
165
+ addBlock({ name: PASTE_NAME, text, kind: "paste" });
166
+ }
167
+ }}
168
+ />
169
+ {running ? (
170
+ <div className="send-group">
171
+ {canSend && <button className="send queue" onClick={() => void submit()} title="把这条后续消息排队 —— 当前轮次结束后自动发送" disabled={disabled}>{Icon.send()}</button>}
172
+ {onStop && <button className="send stop" onClick={() => { setStopping(true); void Promise.resolve(onStop()).catch(() => setStopping(false)); }} disabled={stopping} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>}
173
+ </div>
174
+ ) : (
175
+ <button className="send" disabled={!canSend || disabled} onClick={() => void submit()}>{Icon.send()}</button>
176
+ )}
177
+ </div>
178
+ {/* 底部栏的内容都关掉时整行不占位。 */}
179
+ {(options.hints || options.charCount || options.fileUpload || tooLong) && <div className="comp-foot">
180
+ {options.hints && <span className="hint"><span className="kbd">⏎</span> 发送</span>}
181
+ {options.hints && <span className="hint comp-hint-extra"><span className="kbd">⇧⏎</span> 换行</span>}
182
+ {options.charCount && total > 0 && <span className={`hint cli-count ${tooLong ? "over" : ""}`}>{fmtCount(total)} 字</span>}
183
+ {!options.charCount && tooLong && <span className="hint cli-count over">{fmtCount(total)} 字</span>}
184
+ <span className="spacer" />
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
+ {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>
188
+ )}
189
+ </div>}
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ }
@@ -0,0 +1,34 @@
1
+ import type { CenterTurn, Msg } from "../core/types.js";
2
+ import { toolDataOf } from "../ui/transcriptModel.js";
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,132 @@
1
+ /**
2
+ * 「文本块」:上传的文件(TXT / Markdown / Word)和粘贴的大段文字。
3
+ *
4
+ * 前端先把文件解析成纯文本,再按统一格式拼进消息正文发给 center——center 只收纯文本。
5
+ * 同一个格式两头用:输入框里是可折叠的卡片,发出去之后转录里也解析回同样的卡片,
6
+ * 所以长内容在输入框和对话里都只显示开头,点开才看全部,不会把页面卡住。
7
+ *
8
+ * ----- 附件:需求说明.docx(12,345 字)-----
9
+ * ……纯文本……
10
+ * ----- 附件结束:需求说明.docx -----
11
+ *
12
+ * 对 Agent 来说这也是清楚的分隔:哪段是附件、叫什么、在哪结束。
13
+ */
14
+
15
+ export interface TextBlock {
16
+ /** 显示名:文件名,粘贴的文字为「粘贴的文本」。 */
17
+ name: string;
18
+ text: string;
19
+ kind: "file" | "paste";
20
+ }
21
+
22
+ /** 支持的文件:只有 TXT、Markdown、Word(.docx)。 */
23
+ export const ACCEPTED_EXTENSIONS = [".txt", ".md", ".markdown", ".docx"] as const;
24
+ export const ACCEPT_ATTR = ".txt,.md,.markdown,.docx,text/plain,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document";
25
+ /** 粘贴超过这个长度(或行数)就转成文本块,不放进输入框。 */
26
+ export const PASTE_BLOCK_CHARS = 2_000;
27
+ export const PASTE_BLOCK_LINES = 40;
28
+ /** center 单条消息上限(POST /api/jobs/:id/messages 的 text 最长 200,000 字符)。 */
29
+ export const MAX_MESSAGE_CHARS = 200_000;
30
+ export const PASTE_NAME = "粘贴的文本";
31
+
32
+ const OPEN = /^----- 附件:(.+?)([\d,]+ 字)-----$/;
33
+ const CLOSE = (name: string) => `----- 附件结束:${name} -----`;
34
+
35
+ export const charCount = (text: string) => Array.from(text).length;
36
+
37
+ export function serializeBlock(block: TextBlock): string {
38
+ const name = block.name.replace(/[\r\n]/g, " ").trim() || PASTE_NAME;
39
+ return `----- 附件:${name}(${charCount(block.text).toLocaleString("en-US")} 字)-----\n${block.text}\n${CLOSE(name)}`;
40
+ }
41
+
42
+ /** 输入框里的文字 + 文本块 → 发给 center 的正文。 */
43
+ export function composeMessage(typed: string, blocks: TextBlock[]): string {
44
+ return [typed.trim(), ...blocks.map(serializeBlock)].filter(Boolean).join("\n\n");
45
+ }
46
+
47
+ export type MessagePart = { kind: "text"; text: string } | { kind: "block"; block: TextBlock };
48
+
49
+ /** 正文 → 文字段和文本块(转录里渲染用)。不成对的标记原样当文字。 */
50
+ export function splitMessage(content: string): MessagePart[] {
51
+ if (!content.includes("----- 附件:")) return [{ kind: "text", text: content }];
52
+ const lines = content.split("\n");
53
+ const parts: MessagePart[] = [];
54
+ let text: string[] = [];
55
+ const flush = () => {
56
+ const value = text.join("\n").trim();
57
+ if (value) parts.push({ kind: "text", text: value });
58
+ text = [];
59
+ };
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const open = OPEN.exec(lines[i]);
62
+ const end = open ? lines.indexOf(CLOSE(open[1]), i + 1) : -1;
63
+ if (!open || end === -1) { text.push(lines[i]); continue; }
64
+ flush();
65
+ const name = open[1];
66
+ parts.push({ kind: "block", block: { name, text: lines.slice(i + 1, end).join("\n"), kind: name === PASTE_NAME ? "paste" : "file" } });
67
+ i = end;
68
+ }
69
+ flush();
70
+ return parts.length ? parts : [{ kind: "text", text: content }];
71
+ }
72
+
73
+ /** 粘贴的内容是不是该转成文本块。 */
74
+ export function isLongText(text: string) {
75
+ if (text.length > PASTE_BLOCK_CHARS) return true;
76
+ let lines = 1;
77
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10 && ++lines > PASTE_BLOCK_LINES) return true;
78
+ return false;
79
+ }
80
+
81
+ export function extensionOf(name: string) {
82
+ const dot = name.lastIndexOf(".");
83
+ return dot === -1 ? "" : name.slice(dot).toLowerCase();
84
+ }
85
+
86
+ export class FileReadError extends Error {}
87
+
88
+ /**
89
+ * 读出文件的纯文本。TXT / Markdown 直接按 UTF-8 读;Word 用 mammoth 取正文文字(不带格式、图片)。
90
+ * 老式 .doc(二进制)浏览器里读不了,提示另存为 .docx。
91
+ */
92
+ export async function readFileText(file: File): Promise<string> {
93
+ const ext = extensionOf(file.name);
94
+ if (ext === ".doc") throw new FileReadError("不支持老版 Word(.doc),请另存为 .docx 后再上传");
95
+ if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError("只支持 TXT、Markdown 和 Word(.docx)文件");
96
+ if (ext === ".docx") {
97
+ // 按需加载:mammoth 不小,只有真的传 Word 时才下载。
98
+ const mammoth = await import("mammoth");
99
+ const extract = (mammoth as unknown as { extractRawText: typeof import("mammoth").extractRawText }).extractRawText
100
+ ?? (mammoth as unknown as { default: typeof import("mammoth") }).default.extractRawText;
101
+ try {
102
+ const arrayBuffer = await readAs(file, "buffer");
103
+ // 浏览器版 mammoth 读 arrayBuffer;Node 版(测试、SSR)只认 buffer,有 Buffer 时一并给上。
104
+ const NodeBuffer = (globalThis as { Buffer?: { from(data: ArrayBuffer): unknown } }).Buffer;
105
+ const result = await extract({ arrayBuffer, ...(NodeBuffer ? { buffer: NodeBuffer.from(arrayBuffer) } : {}) } as { arrayBuffer: ArrayBuffer });
106
+ return normalizeText(result.value);
107
+ } catch {
108
+ throw new FileReadError("Word 文件解析失败,文件可能已损坏或不是 .docx 格式");
109
+ }
110
+ }
111
+ return normalizeText(await readAs(file, "text"));
112
+ }
113
+
114
+ /** Blob.text() / arrayBuffer() 老浏览器(Safari 14 以前)没有,退回 FileReader。 */
115
+ function readAs(file: Blob, as: "text"): Promise<string>;
116
+ function readAs(file: Blob, as: "buffer"): Promise<ArrayBuffer>;
117
+ function readAs(file: Blob, as: "text" | "buffer"): Promise<string | ArrayBuffer> {
118
+ if (as === "text" && typeof file.text === "function") return file.text();
119
+ if (as === "buffer" && typeof file.arrayBuffer === "function") return file.arrayBuffer();
120
+ return new Promise((resolve, reject) => {
121
+ const reader = new FileReader();
122
+ reader.onload = () => resolve(reader.result as string | ArrayBuffer);
123
+ reader.onerror = () => reject(reader.error ?? new FileReadError("文件读取失败"));
124
+ if (as === "text") reader.readAsText(file, "utf-8");
125
+ else reader.readAsArrayBuffer(file);
126
+ });
127
+ }
128
+
129
+ /** 统一换行、去掉 BOM 和首尾空行;段落间多余的空行压成一个。 */
130
+ export function normalizeText(text: string) {
131
+ return text.replace(/^/, "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
132
+ }