@spzxg/cli-ui 0.0.0-stage → 0.1.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.
- package/README.md +101 -2
- package/dist/cli-ui-embed-BeBZKDZO.js +36487 -0
- package/dist/cli-ui-index-CuwqVeUk.js +13516 -0
- package/dist/cli-ui.css +1 -0
- package/dist/cli-ui.js +11 -0
- package/package.json +49 -4
- package/src/chat/CliChat.tsx +516 -0
- package/src/chat/Composer.tsx +194 -0
- package/src/chat/export.ts +34 -0
- package/src/core/blocks.ts +132 -0
- package/src/core/client.ts +230 -0
- package/src/core/config.ts +220 -0
- package/src/core/conversation.ts +371 -0
- package/src/core/provider.tsx +89 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +491 -0
- package/src/core/types.ts +278 -0
- package/src/elements.tsx +122 -0
- package/src/embed.ts +47 -0
- package/src/index.ts +34 -0
- package/src/msglist/CliMsgList.tsx +248 -0
- package/src/operator.css +1628 -0
- package/src/styles.css +50 -0
- package/src/ui/Markdown.tsx +26 -0
- package/src/ui/TextBlock.tsx +49 -0
- package/src/ui/Transcript.tsx +408 -0
- package/src/ui/format.ts +101 -0
- package/src/ui/icons.tsx +89 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +76 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createSseParser } from "./sse.js";
|
|
2
|
+
import type {
|
|
3
|
+
CenterJob, CenterTask, CenterTurn, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
|
|
4
|
+
} from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
|
|
8
|
+
*
|
|
9
|
+
* 怎么连:
|
|
10
|
+
* · 推荐走同源代理(缺省 baseUrl = "/api/center"):Vite 代理 / nginx 把 /api/center 转到 center,
|
|
11
|
+
* 并在代理里注入 `Authorization: Bearer <产品线令牌>`——令牌不进浏览器。
|
|
12
|
+
* · 本地联调也可以直接给 token(会以 Bearer 头发出),或用 headers() 自己加头。
|
|
13
|
+
* center 本身不开 CORS,跨域直连需要代理。
|
|
14
|
+
*/
|
|
15
|
+
export interface CenterClientOptions {
|
|
16
|
+
/** center 的地址前缀,缺省 "/api/center"(同源代理)。 */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/** 产品线令牌;有代理注入时不要给。 */
|
|
19
|
+
token?: string;
|
|
20
|
+
/**
|
|
21
|
+
* 产品线(line key)。以请求头 `X-CLI-Line` 发出,给代理选用哪条产品线的令牌:
|
|
22
|
+
* 一个代理服务多条产品线时,按这个头注入对应的 Authorization(见文档「连接中枢」)。center 本身不读这个头,
|
|
23
|
+
* 产品线只由令牌决定。
|
|
24
|
+
*/
|
|
25
|
+
line?: string;
|
|
26
|
+
/** 额外请求头(每次请求时取,可放会话令牌等)。 */
|
|
27
|
+
headers?: () => Record<string, string>;
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 实时流超过这么久没有任何数据(center 每 20 秒一次心跳)就当连接已断。 */
|
|
32
|
+
export const STREAM_SILENCE_MS = 45_000;
|
|
33
|
+
|
|
34
|
+
export class CenterError extends Error {
|
|
35
|
+
constructor(message: string, readonly code: string, readonly status: number, readonly detail?: unknown) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "CenterError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** center 的错误码翻成看得懂、知道怎么办的中文。 */
|
|
42
|
+
const ERROR_TEXT: Record<string, string> = {
|
|
43
|
+
conversation_closed: "这个对话已经结束,不能再发消息;需要继续请新建对话",
|
|
44
|
+
conversation_queue_full: "排队的消息太多了,请等前面的回复后再发",
|
|
45
|
+
business_id_conflict: "消息编号冲突,请重新发送",
|
|
46
|
+
not_a_conversation: "这个任务不是对话任务",
|
|
47
|
+
not_found: "对话不存在,或不属于当前产品线",
|
|
48
|
+
unauthorized: "没有权限访问任务中心(产品线令牌缺失或失效)",
|
|
49
|
+
conversation_requires_one_task: "对话任务只能有一条首轮消息",
|
|
50
|
+
conversation_prompt_required: "第一条消息不能为空",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 组件用到的数据后端。界面只依赖这个接口,不直接依赖 center 的地址和字段:
|
|
55
|
+
* · 现在的实现是 center 的对话任务接口(createCenterClient);
|
|
56
|
+
* · 以后把组件放进 Operator 自己的界面时,换一个实现这套接口的 Operator 后端即可,界面不用改。
|
|
57
|
+
* 带 ? 的是可选能力:后端不提供,界面就按 Operator 在「没有这个能力」时的样子显示(例如提问的回答改走下一条消息)。
|
|
58
|
+
*/
|
|
59
|
+
export interface ConversationBackend {
|
|
60
|
+
baseUrl: string;
|
|
61
|
+
getJob(jobId: string): Promise<CenterJob>;
|
|
62
|
+
listTurns(jobId: string, query?: { afterSeq?: number; limit?: number }): Promise<{ job_id: string; turns: CenterTurn[] }>;
|
|
63
|
+
listTasks(): Promise<{ tasks: CenterTask[] }>;
|
|
64
|
+
createConversation(firstMessage: string, opts?: NewConversationOptions & { businessId?: string }): Promise<{ id: string; job_id: string; status: string; turn: CenterTurn }>;
|
|
65
|
+
sendMessage(jobId: string, text: string, opts?: { businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
|
|
66
|
+
cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
|
|
67
|
+
closeConversation(jobId: string): Promise<{ id: string; status: string }>;
|
|
68
|
+
stream(jobId: string, handlers: StreamHandlers, intervalMs?: number): () => void;
|
|
69
|
+
/**
|
|
70
|
+
* 把回答直接交给正停在提问上的回合(Operator 的 POST /api/tasks/:id/answer)。
|
|
71
|
+
* resolved=false 表示没有在等的回合,调用方改用「下一条消息」发回答。center 不提供:它在 Agent 提问时已经结束那一轮。
|
|
72
|
+
*/
|
|
73
|
+
answerAsk?(jobId: string, askId: string, answers: string[][]): Promise<{ resolved: boolean }>;
|
|
74
|
+
/** 提交浏览器输入卡片(Operator 的 POST /api/tasks/:id/input),返回错误文案或 null。center 不提供。 */
|
|
75
|
+
submitInput?(jobId: string, inputId: string, values: string[]): Promise<string | null>;
|
|
76
|
+
/** 浏览器交接后交还给 AI(Operator 的 POST /api/tasks/:id/browser),返回错误文案或 null。center 不提供。 */
|
|
77
|
+
handback?(jobId: string): Promise<string | null>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createCenterClient(options: CenterClientOptions = {}): ConversationBackend {
|
|
81
|
+
const base = (options.baseUrl ?? "/api/center").replace(/\/+$/, "");
|
|
82
|
+
const doFetch = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
|
|
83
|
+
|
|
84
|
+
function headers(extra: Record<string, string> = {}) {
|
|
85
|
+
const result: Record<string, string> = { ...(options.headers?.() ?? {}), ...extra };
|
|
86
|
+
if (options.line) result["X-CLI-Line"] = options.line;
|
|
87
|
+
if (options.token) result.Authorization = `Bearer ${options.token}`;
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
92
|
+
let response: Response;
|
|
93
|
+
try {
|
|
94
|
+
response = await doFetch(`${base}${path}`, {
|
|
95
|
+
...init,
|
|
96
|
+
headers: headers(init.body ? { "Content-Type": "application/json" } : {}),
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
100
|
+
}
|
|
101
|
+
const body = (await response.json().catch(() => null)) as Record<string, unknown> | null;
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const code = typeof body?.error === "string" ? body.error : `HTTP_${response.status}`;
|
|
104
|
+
const detail = typeof body?.detail === "string" ? body.detail : undefined;
|
|
105
|
+
throw new CenterError(ERROR_TEXT[code] ?? detail ?? `任务中心返回错误(HTTP ${response.status})`, code, response.status, body);
|
|
106
|
+
}
|
|
107
|
+
return body as T;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const post = <T>(path: string, body: unknown = {}) => request<T>(path, { method: "POST", body: JSON.stringify(body) });
|
|
111
|
+
const id = encodeURIComponent;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
baseUrl: base,
|
|
115
|
+
getJob: (jobId: string) => request<CenterJob>(`/api/jobs/${id(jobId)}`),
|
|
116
|
+
listTurns: (jobId: string, query: { afterSeq?: number; limit?: number } = {}) => {
|
|
117
|
+
const params = new URLSearchParams();
|
|
118
|
+
if (query.afterSeq !== undefined) params.set("after_seq", String(query.afterSeq));
|
|
119
|
+
if (query.limit !== undefined) params.set("limit", String(query.limit));
|
|
120
|
+
const qs = params.toString();
|
|
121
|
+
return request<{ job_id: string; turns: CenterTurn[] }>(`/api/jobs/${id(jobId)}/turns${qs ? `?${qs}` : ""}`);
|
|
122
|
+
},
|
|
123
|
+
/** 列表用:本产品线全部子任务(对话任务每个对话一条),用来发现对话。 */
|
|
124
|
+
listTasks: () => request<{ tasks: CenterTask[] }>("/api/tasks"),
|
|
125
|
+
createConversation: (firstMessage: string, opts: NewConversationOptions & { businessId?: string } = {}) => {
|
|
126
|
+
const title = (opts.title || firstLine(firstMessage)).slice(0, 120);
|
|
127
|
+
return post<{ id: string; job_id: string; status: string; turn: CenterTurn }>("/api/jobs", {
|
|
128
|
+
title,
|
|
129
|
+
mode: "conversation",
|
|
130
|
+
...(opts.workspace ? { workspace: opts.workspace } : {}),
|
|
131
|
+
...(opts.callback ? { callback: opts.callback } : {}),
|
|
132
|
+
tasks: [{
|
|
133
|
+
title,
|
|
134
|
+
prompt: firstMessage,
|
|
135
|
+
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
136
|
+
...(opts.agent ? { agent: opts.agent } : {}),
|
|
137
|
+
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
138
|
+
...(opts.browser ? { browser: true } : {}),
|
|
139
|
+
}],
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
/** 带 business_id 时 center 按它去重,网络超时可以放心重发同一条。 */
|
|
143
|
+
sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number } = {}) =>
|
|
144
|
+
post<SendResult>(`/api/jobs/${id(jobId)}/messages`, {
|
|
145
|
+
text,
|
|
146
|
+
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
147
|
+
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
148
|
+
}),
|
|
149
|
+
/** 取消一轮:排队中的直接撤回;正在执行的会中止节点上的回合。 */
|
|
150
|
+
cancelTurn: (jobId: string, turnId: string) => post<CenterTurn>(`/api/jobs/${id(jobId)}/turns/${id(turnId)}/cancel`),
|
|
151
|
+
closeConversation: (jobId: string) => post<{ id: string; status: string }>(`/api/jobs/${id(jobId)}/close`),
|
|
152
|
+
/**
|
|
153
|
+
* 订阅对话的实时流(GET /api/jobs/:id/stream)。返回关闭函数。
|
|
154
|
+
* 不自动重连:重连策略(退避、结束后不再连)由调用方决定。
|
|
155
|
+
*/
|
|
156
|
+
stream(jobId: string, handlers: StreamHandlers, intervalMs = 300): () => void {
|
|
157
|
+
const ctl = new AbortController();
|
|
158
|
+
let stopped = false;
|
|
159
|
+
// 心跳看门狗:center 每 20 秒发一次 `: ping`。代理 / 负载均衡在上游断掉时可能把连接挂着不关,
|
|
160
|
+
// 这时一直收不到数据却也不报错——超过 STREAM_SILENCE_MS 没有任何字节就当断线,交给调用方重连。
|
|
161
|
+
let lastByte = Date.now();
|
|
162
|
+
let silent = false;
|
|
163
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
164
|
+
const silenceError = () => new CenterError("实时连接长时间没有数据,正在重连", "NETWORK", 0);
|
|
165
|
+
// 中止请求之外还要直接取消读取:有的环境里中止信号不会让挂着的 read() 结束。
|
|
166
|
+
const cut = () => { ctl.abort(); reader?.cancel().catch(() => undefined); };
|
|
167
|
+
const watchdog = setInterval(() => {
|
|
168
|
+
if (Date.now() - lastByte > STREAM_SILENCE_MS) { silent = true; cut(); }
|
|
169
|
+
}, 5_000);
|
|
170
|
+
void (async () => {
|
|
171
|
+
try {
|
|
172
|
+
const response = await doFetch(`${base}/api/jobs/${id(jobId)}/stream?interval_ms=${intervalMs}`, {
|
|
173
|
+
headers: headers({ Accept: "text/event-stream" }),
|
|
174
|
+
signal: ctl.signal,
|
|
175
|
+
});
|
|
176
|
+
if (!response.ok || !response.body) {
|
|
177
|
+
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
|
178
|
+
const code = body?.error ?? `HTTP_${response.status}`;
|
|
179
|
+
throw new CenterError(ERROR_TEXT[code] ?? `实时流连接失败(HTTP ${response.status})`, code, response.status);
|
|
180
|
+
}
|
|
181
|
+
handlers.onOpen?.();
|
|
182
|
+
const parser = createSseParser(({ event, data }) => {
|
|
183
|
+
let payload: unknown;
|
|
184
|
+
try { payload = JSON.parse(data); } catch { return; }
|
|
185
|
+
if (event === "snapshot") handlers.onSnapshot?.(payload as StreamSnapshot);
|
|
186
|
+
else if (event === "chunk") handlers.onChunk?.((payload as { events?: StreamItem[] }).events ?? []);
|
|
187
|
+
else if (event === "end") handlers.onEnd?.(payload as StreamEnd);
|
|
188
|
+
});
|
|
189
|
+
reader = response.body.getReader();
|
|
190
|
+
const decoder = new TextDecoder();
|
|
191
|
+
for (;;) {
|
|
192
|
+
const { done, value } = await reader.read();
|
|
193
|
+
if (done) break;
|
|
194
|
+
lastByte = Date.now();
|
|
195
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
196
|
+
}
|
|
197
|
+
parser.end();
|
|
198
|
+
if (stopped) return;
|
|
199
|
+
if (silent) return handlers.onClose?.(silenceError());
|
|
200
|
+
if (!ctl.signal.aborted) handlers.onClose?.(null);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (stopped) return;
|
|
203
|
+
if (silent) return handlers.onClose?.(silenceError());
|
|
204
|
+
if (ctl.signal.aborted) return;
|
|
205
|
+
handlers.onClose?.(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
|
|
206
|
+
} finally {
|
|
207
|
+
clearInterval(watchdog);
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
210
|
+
return () => { stopped = true; clearInterval(watchdog); cut(); };
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface StreamHandlers {
|
|
216
|
+
onOpen?: () => void;
|
|
217
|
+
onSnapshot?: (snapshot: StreamSnapshot) => void;
|
|
218
|
+
onChunk?: (events: StreamItem[]) => void;
|
|
219
|
+
onEnd?: (end: StreamEnd) => void;
|
|
220
|
+
/** 流断了(error 为 null = 服务端正常关闭)。onEnd 之后也会触发一次。 */
|
|
221
|
+
onClose?: (error: CenterError | null) => void;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 兼容旧名字。 */
|
|
225
|
+
export type CenterClient = ConversationBackend;
|
|
226
|
+
|
|
227
|
+
export function firstLine(text: string, max = 40) {
|
|
228
|
+
const line = text.split("\n").map((value) => value.trim()).find(Boolean) ?? "新对话";
|
|
229
|
+
return line.length > max ? `${line.slice(0, max)}…` : line;
|
|
230
|
+
}
|