@spzxg/cli-ui 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -10
- package/dist/cli-ui-embed-Dk2QGHxn.js +41146 -0
- package/dist/cli-ui-index-BFpRZXn4.js +11209 -0
- package/dist/cli-ui-jszip.min-B9tqMA-e.js +2324 -0
- package/dist/cli-ui-jszip.min-DIavJzUG.js +26 -0
- package/dist/cli-ui-xlsx-BKHhqcoa.js +93 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +387 -73
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileKind.ts +31 -0
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/frameQuote.ts +50 -0
- package/src/board/textLoad.ts +99 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +122 -23
- package/src/chat/Composer.tsx +4 -4
- package/src/core/blocks.ts +69 -11
- package/src/core/client.ts +285 -41
- package/src/core/config.ts +18 -5
- package/src/core/conversation.ts +17 -7
- package/src/core/store.ts +340 -71
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +21 -6
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +106 -11
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +50 -22
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/Transcript.tsx +10 -5
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-D7xtcvGG.js +0 -37967
- package/dist/cli-ui-index-O0krpZBg.js +0 -13516
package/src/core/blocks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 「文本块」:上传的文档(Word / Excel / Markdown / TXT / HTML / CSV)和粘贴的大段文字。
|
|
3
3
|
*
|
|
4
4
|
* 前端先把文件解析成纯文本,再按统一格式拼进消息正文发给 center——center 只收纯文本。
|
|
5
5
|
* 同一个格式两头用:输入框里是可折叠的卡片,发出去之后转录里也解析回同样的卡片,
|
|
@@ -12,16 +12,61 @@
|
|
|
12
12
|
* 对 Agent 来说这也是清楚的分隔:哪段是附件、叫什么、在哪结束。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { decodeText } from "./textDecode.js";
|
|
16
|
+
|
|
15
17
|
export interface TextBlock {
|
|
16
18
|
/** 显示名:文件名,粘贴的文字为「粘贴的文本」。 */
|
|
17
19
|
name: string;
|
|
18
20
|
text: string;
|
|
19
|
-
kind: "file" | "paste";
|
|
21
|
+
kind: "file" | "paste" | "context" | "background" | "viewing";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 看板引用合成的块的名字(转录里按它认出来,显示成「看板上下文」卡片)。 */
|
|
25
|
+
export const BOARD_CONTEXT_NAME = "看板上下文";
|
|
26
|
+
|
|
27
|
+
/** 看板引用 → 发给智能体的块。 */
|
|
28
|
+
export function boardContextBlock(lines: string[]): TextBlock {
|
|
29
|
+
return { name: BOARD_CONTEXT_NAME, kind: "context", text: ["用户在右侧看板里引用了下面这些,供这条消息参考:", ...lines.map((l) => `- ${l}`)].join("\n") };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 看板上正在看的东西(缺省随消息带上、不显示)合成的块的名字:和明确引用的分开,转录里可以单独不显示。 */
|
|
33
|
+
export const VIEWING_NAME = "看板正在看";
|
|
34
|
+
|
|
35
|
+
/** 看板上正在看的东西 → 发给智能体的块。 */
|
|
36
|
+
export function viewingBlock(line: string): TextBlock {
|
|
37
|
+
return { name: VIEWING_NAME, kind: "viewing", text: `${line}。用户没有另外说明时,消息里说的「这个」「这里」「改一下」等就是指它。` };
|
|
20
38
|
}
|
|
21
39
|
|
|
22
|
-
/**
|
|
23
|
-
export const
|
|
24
|
-
|
|
40
|
+
/** 补充背景合成的块的名字(转录里显示成「补充背景」卡片)。 */
|
|
41
|
+
export const BACKGROUND_NAME = "补充背景";
|
|
42
|
+
|
|
43
|
+
/** 补充背景(store.appendContext)→ 发给智能体的块。每条有标题时写成「【标题】」。 */
|
|
44
|
+
export function backgroundBlock(items: Array<{ title?: string; text: string }>): TextBlock {
|
|
45
|
+
// 内容里碰巧有附件分隔行时错开一个空格,免得把块提前截断。
|
|
46
|
+
const safe = (t: string) => t.replace(/^(----- 附件)/gm, " $1");
|
|
47
|
+
const body = items.map((i) => (i.title ? `【${i.title}】\n${safe(i.text)}` : safe(i.text))).join("\n\n");
|
|
48
|
+
return { name: BACKGROUND_NAME, kind: "background", text: `以下是补充的项目背景信息,请在这次及之后的工作中一并参考:\n\n${body}` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、HTML、CSV / TSV。都在浏览器里转成文字。 */
|
|
52
|
+
export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".html", ".htm", ".csv", ".tsv"] as const;
|
|
53
|
+
export const ACCEPT_ATTR = [
|
|
54
|
+
...ACCEPTED_EXTENSIONS,
|
|
55
|
+
"text/plain", "text/markdown", "text/html", "text/csv", "text/tab-separated-values",
|
|
56
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
57
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
58
|
+
].join(",");
|
|
59
|
+
/** 不支持时提示里列的格式。 */
|
|
60
|
+
export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、HTML、CSV";
|
|
61
|
+
/** 一些常见但读不了的文档:给出另存为什么。 */
|
|
62
|
+
const SAVE_AS: Record<string, string> = {
|
|
63
|
+
".doc": "老版 Word(.doc)请另存为 .docx 后再上传",
|
|
64
|
+
".xls": "老版 Excel(.xls)请另存为 .xlsx 或 CSV 后再上传",
|
|
65
|
+
".wps": "WPS 文字请另存为 .docx 后再上传",
|
|
66
|
+
".et": "WPS 表格请另存为 .xlsx 后再上传",
|
|
67
|
+
};
|
|
68
|
+
/** HTML 里内嵌的大段 data: 数据(图片、字体):换成占位,免得一张图就把消息撑爆。 */
|
|
69
|
+
const INLINE_DATA = /data:[\w.+-]+\/[\w.+-]+(?:;[\w=.+-]+)*,[^"')\s]{200,}/g;
|
|
25
70
|
/** 粘贴超过这个长度(或行数)就转成文本块,不放进输入框。 */
|
|
26
71
|
export const PASTE_BLOCK_CHARS = 2_000;
|
|
27
72
|
export const PASTE_BLOCK_LINES = 40;
|
|
@@ -63,7 +108,7 @@ export function splitMessage(content: string): MessagePart[] {
|
|
|
63
108
|
if (!open || end === -1) { text.push(lines[i]); continue; }
|
|
64
109
|
flush();
|
|
65
110
|
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" } });
|
|
111
|
+
parts.push({ kind: "block", block: { name, text: lines.slice(i + 1, end).join("\n"), kind: name === PASTE_NAME ? "paste" : name === BOARD_CONTEXT_NAME ? "context" : name === VIEWING_NAME ? "viewing" : name === BACKGROUND_NAME ? "background" : "file" } });
|
|
67
112
|
i = end;
|
|
68
113
|
}
|
|
69
114
|
flush();
|
|
@@ -86,13 +131,24 @@ export function extensionOf(name: string) {
|
|
|
86
131
|
export class FileReadError extends Error {}
|
|
87
132
|
|
|
88
133
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
134
|
+
* 读出文件的纯文本:
|
|
135
|
+
* · TXT / Markdown / CSV / TSV / HTML —— 按编码解(UTF-8,导出的 GBK 文件按 GB18030),HTML 保留源码、内嵌的大段 data: 数据换成占位;
|
|
136
|
+
* · Word —— mammoth 取正文文字(不带格式、图片);
|
|
137
|
+
* · Excel —— 每个工作表一段 CSV(单元格的值,日期写成 2026-08-10),见 xlsx.ts。
|
|
138
|
+
* 其他文件(图片、PDF、压缩包……)不收;老版 .doc / .xls 浏览器里读不了,提示另存为。
|
|
91
139
|
*/
|
|
92
140
|
export async function readFileText(file: File): Promise<string> {
|
|
93
141
|
const ext = extensionOf(file.name);
|
|
94
|
-
if (ext
|
|
95
|
-
if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(
|
|
142
|
+
if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
|
|
143
|
+
if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
|
|
144
|
+
if (ext === ".xlsx" || ext === ".xlsm") {
|
|
145
|
+
const { readXlsx, sheetsToText } = await import("./xlsx.js");
|
|
146
|
+
try {
|
|
147
|
+
return normalizeText(sheetsToText(await readXlsx(await readAs(file, "buffer"))));
|
|
148
|
+
} catch {
|
|
149
|
+
throw new FileReadError("Excel 文件解析失败,文件可能已损坏、加了密码,或不是 .xlsx 格式");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
96
152
|
if (ext === ".docx") {
|
|
97
153
|
// 按需加载:mammoth 不小,只有真的传 Word 时才下载。
|
|
98
154
|
const mammoth = await import("mammoth");
|
|
@@ -108,7 +164,9 @@ export async function readFileText(file: File): Promise<string> {
|
|
|
108
164
|
throw new FileReadError("Word 文件解析失败,文件可能已损坏或不是 .docx 格式");
|
|
109
165
|
}
|
|
110
166
|
}
|
|
111
|
-
|
|
167
|
+
const { text } = decodeText(new Uint8Array(await readAs(file, "buffer")));
|
|
168
|
+
if (ext === ".html" || ext === ".htm") return normalizeText(text.replace(INLINE_DATA, (m) => `${m.slice(0, m.indexOf(",") + 1)}…(内嵌数据已省略)`));
|
|
169
|
+
return normalizeText(text);
|
|
112
170
|
}
|
|
113
171
|
|
|
114
172
|
/** Blob.text() / arrayBuffer() 老浏览器(Safari 14 以前)没有,退回 FileReader。 */
|
package/src/core/client.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createSseParser } from "./sse.js";
|
|
2
2
|
import { NEW_TASK, parseConvKey } from "./conversation.js";
|
|
3
3
|
import type {
|
|
4
|
-
BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo,
|
|
5
|
-
BatchStreamSnapshot, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
|
|
4
|
+
BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo, Message,
|
|
5
|
+
BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
|
|
6
6
|
} from "./types.js";
|
|
7
|
+
import { decodeText } from "./textDecode.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
|
|
@@ -33,10 +34,19 @@ export interface CenterClientOptions {
|
|
|
33
34
|
* (同源代理注入令牌);否则浏览器直接请求带不上请求头,看板改为先 fetch 再用 blob 地址显示。
|
|
34
35
|
*/
|
|
35
36
|
directFileLinks?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* 普通请求(不含实时流、文件下载)多久没有响应就放弃,缺省 30 秒。代理或网络挂住时组件不会一直「读取中」,
|
|
39
|
+
* 而是报「请求超时」并按各自的规则重试。打开任务浏览器另给 150 秒(节点要先启动浏览器)。
|
|
40
|
+
*/
|
|
41
|
+
requestTimeoutMs?: number;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/** 实时流超过这么久没有任何数据(center 每 20 秒一次心跳)就当连接已断。 */
|
|
39
45
|
export const STREAM_SILENCE_MS = 45_000;
|
|
46
|
+
/** 读文件:这么久没回响应头(节点卡住、代理挂住)就放弃。 */
|
|
47
|
+
export const FILE_HEADER_TIMEOUT_MS = 30_000;
|
|
48
|
+
/** 读文件:传输中这么久一个字节都没收到就当断了(大文件慢慢传不算,只要还在动)。 */
|
|
49
|
+
export const FILE_STALL_MS = 60_000;
|
|
40
50
|
|
|
41
51
|
export class CenterError extends Error {
|
|
42
52
|
constructor(message: string, readonly code: string, readonly status: number, readonly detail?: unknown) {
|
|
@@ -63,6 +73,8 @@ const ERROR_TEXT: Record<string, string> = {
|
|
|
63
73
|
too_many_viewers: "同时观看这个浏览器的人太多,请稍后再试",
|
|
64
74
|
viewer_not_found: "画面连接已断开,正在重连",
|
|
65
75
|
viewer_not_ready: "画面连接还没就绪,正在重连",
|
|
76
|
+
link_expired: "文件链接已失效,请刷新",
|
|
77
|
+
task_not_found: "这个子任务不在对应的任务里",
|
|
66
78
|
};
|
|
67
79
|
|
|
68
80
|
/**
|
|
@@ -77,6 +89,11 @@ export interface ConversationBackend {
|
|
|
77
89
|
/** taskId:只要对话任务里这一个对话的轮次。 */
|
|
78
90
|
listTurns(jobId: string, query?: { afterSeq?: number; limit?: number; taskId?: string }): Promise<{ job_id: string; turns: CenterTurn[] }>;
|
|
79
91
|
listTasks(): Promise<{ tasks: CenterTask[] }>;
|
|
92
|
+
/**
|
|
93
|
+
* 更早的节点转录(往上翻):remote 是流里给的节点会话,before 是手里最早那条消息的 id。
|
|
94
|
+
* 返回这一页(旧的在前)和再往前还剩几条。旧版中枢没有这个接口(实时流也就给的是全量,用不到)。
|
|
95
|
+
*/
|
|
96
|
+
history?(jobId: string, remote: string, before: string, limit?: number): Promise<{ messages: Message[]; older: number }>;
|
|
80
97
|
createConversation(firstMessage: string, opts?: NewConversationOptions & { businessId?: string }): Promise<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>;
|
|
81
98
|
/** taskId:发给对话任务里的哪个对话,不给 = 第一个。 */
|
|
82
99
|
sendMessage(jobId: string, text: string, opts?: { businessId?: string; timeoutSeconds?: number; taskId?: string }): Promise<SendResult>;
|
|
@@ -94,11 +111,15 @@ export interface ConversationBackend {
|
|
|
94
111
|
/** taskId:看对话任务里的哪个对话,不给 = 第一个。 */
|
|
95
112
|
stream(jobId: string, handlers: StreamHandlers, intervalMs?: number, taskId?: string | null): () => void;
|
|
96
113
|
/**
|
|
97
|
-
*
|
|
98
|
-
* resolved=false
|
|
114
|
+
* 把回答直接交给正停在提问上的回合(中枢 POST /api/jobs/:id/answer → 节点 /answer)。
|
|
115
|
+
* resolved=false 表示没有在等的回合:对话任务改用「下一条消息」发回答(中枢在 Agent 提问时已经结束那一轮);
|
|
116
|
+
* 一次性任务的子任务提问时回合开着等,答案直接交过去。
|
|
99
117
|
*/
|
|
100
118
|
answerAsk?(jobId: string, askId: string, answers: string[][]): Promise<{ resolved: boolean }>;
|
|
101
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* 提交浏览器输入卡片(验证码、账号、密码、选项;中枢 POST /api/jobs/:id/input → 节点 /input),返回错误文案或 null。
|
|
121
|
+
* 密码只交给节点上的浏览器脚本,不进模型、不进转录、中枢不落库。
|
|
122
|
+
*/
|
|
102
123
|
submitInput?(jobId: string, inputId: string, values: string[]): Promise<string | null>;
|
|
103
124
|
/** 浏览器交接后交还给 AI(Operator 的 POST /api/tasks/:id/browser),返回错误文案或 null。center 不提供。 */
|
|
104
125
|
handback?(jobId: string): Promise<string | null>;
|
|
@@ -118,9 +139,28 @@ export interface BoardBackend {
|
|
|
118
139
|
stat(jobId: string, path?: string): Promise<DirListing | FileInfo>;
|
|
119
140
|
/** 文件地址(给 <img> / <iframe> / 下载链接用)。相对路径用路径式地址,HTML 里的相对资源能跟着解析。 */
|
|
120
141
|
fileUrl(jobId: string, path: string, opts?: { download?: boolean }): string;
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
142
|
+
/**
|
|
143
|
+
* 浏览器能直接打开的文件地址前缀(…/files/raw,后面接相对路径或 ?path=):directLinks 时就是 fileUrl 的前缀;
|
|
144
|
+
* 否则向中枢换一个短期令牌放进路径(<img> / <iframe> / 下载都不需要请求头,大文件流式下载、不进浏览器内存),
|
|
145
|
+
* 快过期时自动换新的。中枢不支持时返回 null(看板退回先 fetch 再显示)。
|
|
146
|
+
*/
|
|
147
|
+
fileLinkBase?(jobId: string): Promise<string | null>;
|
|
148
|
+
/**
|
|
149
|
+
* 读文本(最多 maxBytes,超过的截断)。只向中枢要这一段(Range),超大文件不整个传过来。
|
|
150
|
+
* tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
|
|
151
|
+
* 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
|
|
152
|
+
*/
|
|
153
|
+
readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
|
|
154
|
+
/**
|
|
155
|
+
* 读一段原始字节:从 start 开始(缺省 0)最多 maxBytes;tail = 末尾 maxBytes。返回这段在文件里的起点和文件总大小。
|
|
156
|
+
* 看板用它在文件变长时只取新增的部分(日志、还在写的报告),不整个重读。没有这个方法的后端每次整个重读。
|
|
157
|
+
*/
|
|
158
|
+
readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
|
|
159
|
+
/**
|
|
160
|
+
* 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
|
|
161
|
+
* 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
|
|
162
|
+
*/
|
|
163
|
+
readBuffer(jobId: string, path: string, opts?: { signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<ArrayBuffer>;
|
|
124
164
|
diff(jobId: string): Promise<DiffResult>;
|
|
125
165
|
browserStatus(jobId: string): Promise<BrowserStatus>;
|
|
126
166
|
browserAction(jobId: string, action: "open" | "close" | "takeover" | "handback"): Promise<BrowserStatus>;
|
|
@@ -129,6 +169,53 @@ export interface BoardBackend {
|
|
|
129
169
|
/** 浏览器实时画面;onViewer 给出发输入要用的 viewer id。 */
|
|
130
170
|
browserFrames(jobId: string, handlers: { onViewer: (viewer: string) => void; onMessage: (message: BrowserMessage) => void; onEnd?: (end: { reason: string; detail?: string }) => void; onClose?: (error: CenterError | null) => void }): () => void;
|
|
131
171
|
browserInput(jobId: string, viewer: string, events: Array<Record<string, unknown>>): Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* 浏览器的一条实时连接(状态 + 画面 + 弹窗 + 复制出的文字,见中枢 board-live.ts)。新版中枢才有:
|
|
174
|
+
* 旧版中枢回 404(路由不存在,error 为 "Not Found"),组件退回 browserEvents + browserFrames 两条。
|
|
175
|
+
*/
|
|
176
|
+
browserLive?(jobId: string, handlers: BrowserLiveHandlers): () => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** readBytes 读到的一段。 */
|
|
180
|
+
export interface ByteChunk {
|
|
181
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
182
|
+
/** 这段在文件里从第几个字节开始。 */
|
|
183
|
+
start: number;
|
|
184
|
+
/** 文件总大小。 */
|
|
185
|
+
size: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface TextChunk {
|
|
189
|
+
text: string;
|
|
190
|
+
/** 只读到了一部分。 */
|
|
191
|
+
truncated: boolean;
|
|
192
|
+
/** 文件总大小。 */
|
|
193
|
+
size: number;
|
|
194
|
+
contentType: string;
|
|
195
|
+
/** 读的是末尾那一段。 */
|
|
196
|
+
tail?: boolean;
|
|
197
|
+
encoding?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface BrowserLiveHandlers {
|
|
201
|
+
/** 这条连接的 viewer id;ready = 输入能送到节点(画面连接已建好)。 */
|
|
202
|
+
onViewer: (viewer: string, ready: boolean) => void;
|
|
203
|
+
onStatus: (status: BrowserStatus) => void;
|
|
204
|
+
onMessage: (message: BrowserMessage) => void;
|
|
205
|
+
onFrames?: (state: BrowserFramesState) => void;
|
|
206
|
+
/** 不会再有数据(节点拒绝、中枢停机)。 */
|
|
207
|
+
onEnd?: (end: { reason: string; status?: number; detail?: string }) => void;
|
|
208
|
+
onClose?: (error: CenterError | null) => void;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 旧版中枢没有这个接口:Fastify 的「路由不存在」404(和「任务不存在」的 not_found 区分开)。 */
|
|
212
|
+
export const isMissingRoute = (error: CenterError | null | undefined) => !!error && error.status === 404 && error.code === "Not Found";
|
|
213
|
+
|
|
214
|
+
/** 由 fileLinkBase 给的前缀拼出文件地址。 */
|
|
215
|
+
export function fileUrlFromBase(linkBase: string, path: string, download = false) {
|
|
216
|
+
const clean = path.replace(/^\.\//, "");
|
|
217
|
+
if (clean.startsWith("/") || clean.startsWith("~") || /^[a-z]:[\\/]/i.test(clean)) return `${linkBase}?path=${encodeURIComponent(clean)}${download ? "&download=1" : ""}`;
|
|
218
|
+
return `${linkBase}/${clean.split("/").filter(Boolean).map(encodeURIComponent).join("/")}${download ? "?download=1" : ""}`;
|
|
132
219
|
}
|
|
133
220
|
|
|
134
221
|
export function createCenterClient(options: CenterClientOptions = {}): ConversationBackend {
|
|
@@ -142,18 +229,38 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
142
229
|
return result;
|
|
143
230
|
}
|
|
144
231
|
|
|
145
|
-
|
|
232
|
+
// 同一时刻对同一地址的 GET 只发一次,后来的共用结果:列表、对话、看板经常同时要同一份数据(同一个任务的详情、看板概况、目录),
|
|
233
|
+
// 浏览器对一个域名的连接有限,重复请求会把真正要紧的(点开文件、发消息)挤到后面排队。
|
|
234
|
+
const inflight = new Map<string, Promise<unknown>>();
|
|
235
|
+
function request<T>(path: string, init: RequestInit = {}, timeoutMs = options.requestTimeoutMs ?? 30_000): Promise<T> {
|
|
236
|
+
if ((init.method ?? "GET") !== "GET" || init.body) return send<T>(path, init, timeoutMs);
|
|
237
|
+
const shared = inflight.get(path);
|
|
238
|
+
if (shared) return shared as Promise<T>;
|
|
239
|
+
const p = send<T>(path, init, timeoutMs).finally(() => inflight.delete(path));
|
|
240
|
+
inflight.set(path, p);
|
|
241
|
+
return p;
|
|
242
|
+
}
|
|
243
|
+
async function send<T>(path: string, init: RequestInit, timeoutMs: number): Promise<T> {
|
|
146
244
|
let response: Response;
|
|
245
|
+
const ctl = new AbortController();
|
|
246
|
+
let timedOut = false;
|
|
247
|
+
const timer = setTimeout(() => { timedOut = true; ctl.abort(); }, timeoutMs);
|
|
147
248
|
try {
|
|
148
249
|
response = await doFetch(`${base}${path}`, {
|
|
149
250
|
...init,
|
|
251
|
+
signal: ctl.signal,
|
|
150
252
|
headers: headers(init.body ? { "Content-Type": "application/json" } : {}),
|
|
151
253
|
});
|
|
152
254
|
} catch (error) {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
if (timedOut) throw new CenterError(`任务中心 ${Math.round(timeoutMs / 1000)} 秒没有响应,请稍后重试`, "NETWORK", 0);
|
|
153
257
|
throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
154
258
|
}
|
|
155
|
-
|
|
259
|
+
// 响应体也算在时限里(代理把响应头发了、正文却挂住)。
|
|
260
|
+
const body = (await response.json().catch(() => null).finally(() => clearTimeout(timer))) as Record<string, unknown> | null;
|
|
156
261
|
if (!response.ok) {
|
|
262
|
+
// 413:消息 / 请求体超过中枢上限(中枢回的是 Fastify 的英文说明)。
|
|
263
|
+
if (response.status === 413) throw new CenterError("内容太大,超过任务中心单次请求的上限,请删减或分几条发送", "payload_too_large", 413, body);
|
|
157
264
|
const code = typeof body?.error === "string" ? body.error : `HTTP_${response.status}`;
|
|
158
265
|
const detail = typeof body?.detail === "string" ? body.detail : undefined;
|
|
159
266
|
throw new CenterError(ERROR_TEXT[code] ?? detail ?? `任务中心返回错误(HTTP ${response.status})`, code, response.status, body);
|
|
@@ -177,6 +284,26 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
177
284
|
},
|
|
178
285
|
/** 列表用:本产品线全部子任务(对话任务每个对话一条),用来发现对话。 */
|
|
179
286
|
listTasks: () => request<{ tasks: CenterTask[] }>("/api/tasks"),
|
|
287
|
+
async answerAsk(jobId: string, askId: string, answers: string[][]) {
|
|
288
|
+
try {
|
|
289
|
+
return await request<{ resolved: boolean }>(boardPath(jobId, "answer"), { method: "POST", body: JSON.stringify({ ask_id: askId, answers }) });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
// 旧版中枢没有这个接口:当作没有在等的回合(对话任务照旧走下一条消息)。
|
|
292
|
+
if (error instanceof CenterError && isMissingRoute(error)) return { resolved: false };
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
async submitInput(jobId: string, inputId: string, values: string[]) {
|
|
297
|
+
try {
|
|
298
|
+
await request(boardPath(jobId, "input"), { method: "POST", body: JSON.stringify({ input_id: inputId, values }) });
|
|
299
|
+
return null;
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error instanceof CenterError && isMissingRoute(error)) return "当前任务中心版本不支持在这里填写,请升级任务中心";
|
|
302
|
+
return error instanceof Error ? error.message : String(error);
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
history: (jobId: string, remote: string, before: string, limit = 200) =>
|
|
306
|
+
request<{ messages: Message[]; older: number }>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/history?remote=${id(remote)}&before=${id(before)}&limit=${limit}`),
|
|
180
307
|
createConversation: (firstMessage: string, opts: NewConversationOptions & { businessId?: string } = {}) => {
|
|
181
308
|
const title = (opts.title || firstLine(firstMessage)).slice(0, 120);
|
|
182
309
|
return post<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>("/api/jobs", {
|
|
@@ -387,58 +514,167 @@ function createBoardClient(
|
|
|
387
514
|
base: string,
|
|
388
515
|
headers: (extra?: Record<string, string>) => Record<string, string>,
|
|
389
516
|
doFetch: typeof fetch,
|
|
390
|
-
request: <T>(path: string, init?: RequestInit) => Promise<T>,
|
|
517
|
+
request: <T>(path: string, init?: RequestInit, timeoutMs?: number) => Promise<T>,
|
|
391
518
|
directLinks: boolean,
|
|
392
519
|
): BoardBackend {
|
|
393
|
-
|
|
520
|
+
/**
|
|
521
|
+
* 取文件:响应头限时 FILE_HEADER_TIMEOUT_MS;body() 边读边看门(FILE_STALL_MS 没数据就断),
|
|
522
|
+
* limit 读够就停(大文件只要开头)。外面给的 signal 取消时一并中止。
|
|
523
|
+
*/
|
|
524
|
+
const fileFetch = async (jobId: string, path: string, extra: Record<string, string> = {}, signal?: AbortSignal) => {
|
|
525
|
+
const name = path.split("/").pop() || path;
|
|
526
|
+
const aborted = () => new DOMException("已取消", "AbortError");
|
|
527
|
+
if (signal?.aborted) throw aborted();
|
|
528
|
+
const ctl = new AbortController();
|
|
529
|
+
const cancel = () => ctl.abort();
|
|
530
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
531
|
+
let timedOut = false;
|
|
532
|
+
const timer = setTimeout(() => { timedOut = true; ctl.abort(); }, FILE_HEADER_TIMEOUT_MS);
|
|
533
|
+
const release = () => { clearTimeout(timer); signal?.removeEventListener("abort", cancel); };
|
|
394
534
|
let response: Response;
|
|
395
535
|
try {
|
|
396
|
-
|
|
536
|
+
// 和中止赛跑:宿主自定义的 fetch 不一定理会 signal,取消 / 超时也要马上生效。
|
|
537
|
+
const stopped = new Promise<never>((_r, reject) => {
|
|
538
|
+
if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
|
|
539
|
+
ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
|
|
540
|
+
});
|
|
541
|
+
response = await Promise.race([doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal }), stopped]);
|
|
397
542
|
} catch (error) {
|
|
543
|
+
release();
|
|
544
|
+
if (signal?.aborted) throw aborted();
|
|
545
|
+
if (timedOut) throw new CenterError(`读取「${name}」${FILE_HEADER_TIMEOUT_MS / 1000} 秒没有响应(执行节点忙或网络慢),请稍后重试`, "NETWORK", 0);
|
|
398
546
|
throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
399
547
|
}
|
|
400
|
-
|
|
548
|
+
clearTimeout(timer);
|
|
549
|
+
if (!response.ok && response.status !== 416) {
|
|
550
|
+
release();
|
|
401
551
|
const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
|
|
402
|
-
|
|
552
|
+
const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : body?.error ?? `读取失败(HTTP ${response.status})`;
|
|
553
|
+
throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? fallback, body?.error ?? `HTTP_${response.status}`, response.status);
|
|
403
554
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
|
|
410
|
-
fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
|
|
411
|
-
async readText(jobId, path, maxBytes = 512 * 1024) {
|
|
412
|
-
const response = await fileFetch(jobId, path);
|
|
413
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
414
|
-
const size = Number(response.headers.get("content-length") ?? 0);
|
|
415
|
-
// 只读前 maxBytes:大文件不整个拉进浏览器。
|
|
555
|
+
const stallError = () => new CenterError(`读取「${name}」时 ${FILE_STALL_MS / 1000} 秒没有收到数据,传输已中断,请重试`, "NETWORK", 0);
|
|
556
|
+
/** 读响应体:limit 读够就停;onBytes 报进度;FILE_STALL_MS 没数据当断线。 */
|
|
557
|
+
const body = async (opts: { limit?: number; onBytes?: (bytes: number) => void } = {}): Promise<Uint8Array<ArrayBuffer>> => {
|
|
558
|
+
// 空文件要「前 N 字节」:节点回 416,就是空的。
|
|
559
|
+
if (response.status === 416) { release(); return new Uint8Array(0); }
|
|
416
560
|
const reader = response.body?.getReader();
|
|
417
|
-
if (!reader)
|
|
561
|
+
if (!reader) {
|
|
562
|
+
try { return new Uint8Array(await response.arrayBuffer()); } finally { release(); }
|
|
563
|
+
}
|
|
418
564
|
const chunks: Uint8Array[] = [];
|
|
419
565
|
let got = 0;
|
|
420
|
-
let
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
566
|
+
let stalled = false;
|
|
567
|
+
let stall: ReturnType<typeof setTimeout> | undefined;
|
|
568
|
+
const arm = () => {
|
|
569
|
+
clearTimeout(stall);
|
|
570
|
+
stall = setTimeout(() => { stalled = true; ctl.abort(); void reader.cancel().catch(() => undefined); }, FILE_STALL_MS);
|
|
571
|
+
};
|
|
572
|
+
try {
|
|
573
|
+
arm();
|
|
574
|
+
for (;;) {
|
|
575
|
+
const { done, value } = await reader.read();
|
|
576
|
+
if (stalled) throw stallError();
|
|
577
|
+
if (done) break;
|
|
578
|
+
arm();
|
|
579
|
+
chunks.push(value);
|
|
580
|
+
got += value.length;
|
|
581
|
+
opts.onBytes?.(got);
|
|
582
|
+
if (opts.limit !== undefined && got >= opts.limit) { await reader.cancel().catch(() => undefined); break; }
|
|
583
|
+
}
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (signal?.aborted) throw aborted();
|
|
586
|
+
if (stalled) throw stallError();
|
|
587
|
+
if (error instanceof CenterError) throw error;
|
|
588
|
+
throw new CenterError(`读取「${name}」中断:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
589
|
+
} finally {
|
|
590
|
+
clearTimeout(stall);
|
|
591
|
+
release();
|
|
427
592
|
}
|
|
428
|
-
|
|
593
|
+
if (signal?.aborted) throw aborted();
|
|
594
|
+
const size = opts.limit !== undefined ? Math.min(got, opts.limit) : got;
|
|
595
|
+
const all = new Uint8Array(size);
|
|
429
596
|
let offset = 0;
|
|
430
597
|
for (const chunk of chunks) {
|
|
431
|
-
const take = Math.min(chunk.length,
|
|
598
|
+
const take = Math.min(chunk.length, size - offset);
|
|
432
599
|
all.set(chunk.subarray(0, take), offset);
|
|
433
600
|
offset += take;
|
|
434
|
-
if (offset >=
|
|
601
|
+
if (offset >= size) break;
|
|
435
602
|
}
|
|
436
|
-
return
|
|
603
|
+
return all;
|
|
604
|
+
};
|
|
605
|
+
return { response, body };
|
|
606
|
+
};
|
|
607
|
+
/**
|
|
608
|
+
* 读一段:开头 / 从 start 起 maxBytes,或者末尾 maxBytes(超大日志)。只向中枢要这一段(Range);
|
|
609
|
+
* 不支持 Range 的节点回整个文件(200),这里边读边截,大文件不整个拉进浏览器。
|
|
610
|
+
*/
|
|
611
|
+
const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }) => {
|
|
612
|
+
const from = Math.max(0, opts.start ?? 0);
|
|
613
|
+
const { maxBytes } = opts;
|
|
614
|
+
const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` });
|
|
615
|
+
const contentType = response.headers.get("content-type") ?? "text/plain";
|
|
616
|
+
const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
|
|
617
|
+
// 416:要的起点不在文件里(空文件,或文件变短了)。
|
|
618
|
+
if (response.status === 416) { await body(); return { bytes: new Uint8Array(0), start: from, size: total ? Number(total) : 0, contentType }; }
|
|
619
|
+
const length = Number(response.headers.get("content-length") ?? 0);
|
|
620
|
+
if (response.status === 206) {
|
|
621
|
+
// 节点给的一段可能比要的长(不严格按 Range):也只留 maxBytes。
|
|
622
|
+
const bytes = await body({ limit: maxBytes, onBytes: opts.onBytes });
|
|
623
|
+
const start = Number(/bytes (\d+)-/.exec(response.headers.get("content-range") ?? "")?.[1] ?? 0);
|
|
624
|
+
return { bytes, start, size: total ? Number(total) : start + bytes.length, contentType };
|
|
625
|
+
}
|
|
626
|
+
// 回了整个文件:跳过 from 之前的,留 maxBytes。
|
|
627
|
+
const all = await body({ limit: from + maxBytes, onBytes: opts.onBytes });
|
|
628
|
+
const size = length || all.length;
|
|
629
|
+
return { bytes: all.slice(Math.min(from, all.length)), start: Math.min(from, all.length), size, contentType };
|
|
630
|
+
};
|
|
631
|
+
// 文件链接令牌:按对话键缓存,离过期不到 5 分钟就换新的;同一时刻只换一次。
|
|
632
|
+
const links = new Map<string, { base: string; until: number }>();
|
|
633
|
+
const minting = new Map<string, Promise<string | null>>();
|
|
634
|
+
let linksUnsupported = false;
|
|
635
|
+
const LINK_REFRESH_MS = 5 * 60_000;
|
|
636
|
+
return {
|
|
637
|
+
directLinks,
|
|
638
|
+
info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
|
|
639
|
+
stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
|
|
640
|
+
fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
|
|
641
|
+
async fileLinkBase(jobId) {
|
|
642
|
+
const { job, task } = boardTarget(jobId);
|
|
643
|
+
if (directLinks) return `${base}/api/jobs/${job}${task ? `/tasks/${task}` : ""}/files/raw`;
|
|
644
|
+
if (linksUnsupported) return null;
|
|
645
|
+
const cached = links.get(jobId);
|
|
646
|
+
if (cached && cached.until - Date.now() > LINK_REFRESH_MS) return cached.base;
|
|
647
|
+
const running = minting.get(jobId);
|
|
648
|
+
if (running) return running;
|
|
649
|
+
const work = request<{ base?: unknown; expires_at?: string }>(boardPath(jobId, "files/token"), { method: "POST", body: "{}" }).then((r) => {
|
|
650
|
+
if (typeof r?.base !== "string") { linksUnsupported = true; return null; }
|
|
651
|
+
const linkBase = `${base}${r.base}`;
|
|
652
|
+
links.set(jobId, { base: linkBase, until: Date.parse(r.expires_at ?? "") || Date.now() + 30 * 60_000 });
|
|
653
|
+
return linkBase;
|
|
654
|
+
}, (error: unknown) => {
|
|
655
|
+
if (error instanceof CenterError && isMissingRoute(error)) { linksUnsupported = true; return null; }
|
|
656
|
+
throw error;
|
|
657
|
+
}).finally(() => minting.delete(jobId));
|
|
658
|
+
minting.set(jobId, work);
|
|
659
|
+
return work;
|
|
660
|
+
},
|
|
661
|
+
async readText(jobId, path, maxBytes = 512 * 1024, opts = {}) {
|
|
662
|
+
const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, onBytes: opts.onBytes });
|
|
663
|
+
if (!size && !bytes.length) return { text: "", truncated: false, size: 0, contentType };
|
|
664
|
+
const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
|
|
665
|
+
return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
|
|
666
|
+
},
|
|
667
|
+
async readBytes(jobId, path, opts) {
|
|
668
|
+
const { bytes, start, size } = await readRange(jobId, path, opts);
|
|
669
|
+
return { bytes, start, size };
|
|
670
|
+
},
|
|
671
|
+
async readBuffer(jobId, path, opts = {}) {
|
|
672
|
+
const { body } = await fileFetch(jobId, path, {}, opts.signal);
|
|
673
|
+
return (await body({ onBytes: opts.onBytes })).buffer;
|
|
437
674
|
},
|
|
438
|
-
readBuffer: async (jobId, path) => (await fileFetch(jobId, path)).arrayBuffer(),
|
|
439
675
|
diff: (jobId) => request<DiffResult>(boardPath(jobId, "diff")),
|
|
440
676
|
browserStatus: (jobId) => request<BrowserStatus>(boardPath(jobId, "browser")),
|
|
441
|
-
browserAction: (jobId, action) => request<BrowserStatus>(boardPath(jobId, "browser"), { method: "POST", body: JSON.stringify({ action }) }),
|
|
677
|
+
browserAction: (jobId, action) => request<BrowserStatus>(boardPath(jobId, "browser"), { method: "POST", body: JSON.stringify({ action }) }, 150_000),
|
|
442
678
|
browserEvents: (jobId, handlers) => openSse(doFetch, `${base}${boardPath(jobId, "browser/events")}`, headers(), (event, data) => {
|
|
443
679
|
if (event === "status") handlers.onStatus(data as BrowserStatus);
|
|
444
680
|
}, handlers.onClose),
|
|
@@ -450,5 +686,13 @@ function createBoardClient(
|
|
|
450
686
|
async browserInput(jobId, viewer, events) {
|
|
451
687
|
await request(boardPath(jobId, "browser/input"), { method: "POST", body: JSON.stringify({ viewer, events }) });
|
|
452
688
|
},
|
|
689
|
+
browserLive: (jobId, h) => openSse(doFetch, `${base}${boardPath(jobId, "browser/live")}`, headers(), (event, data) => {
|
|
690
|
+
const d = data as Record<string, unknown>;
|
|
691
|
+
if (event === "viewer") h.onViewer(String(d.viewer), d.ready === true);
|
|
692
|
+
else if (event === "status") h.onStatus(data as BrowserStatus);
|
|
693
|
+
else if (event === "message") h.onMessage(data as BrowserMessage);
|
|
694
|
+
else if (event === "frames") h.onFrames?.(data as BrowserFramesState);
|
|
695
|
+
else if (event === "end") h.onEnd?.(data as { reason: string; status?: number; detail?: string });
|
|
696
|
+
}, h.onClose),
|
|
453
697
|
};
|
|
454
698
|
}
|
package/src/core/config.ts
CHANGED
|
@@ -43,7 +43,7 @@ export interface CliChatConfig {
|
|
|
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;
|
|
@@ -73,6 +73,12 @@ export interface CliChatConfig {
|
|
|
73
73
|
labels?: { user?: string; agent?: string };
|
|
74
74
|
/** 新对话的空白页。false = 不显示。 */
|
|
75
75
|
empty?: boolean | { title?: string; description?: string };
|
|
76
|
+
/**
|
|
77
|
+
* 输入框上方显示哪些看板引用(不显示的照样随下一条消息带给智能体,只是不占地方、也不能点掉)。
|
|
78
|
+
* 种类:viewing(看板上正在看的文件 / 浏览器页面,缺省带上)、file、selection(选中的一段)、browser、changes,或宿主自定义的种类;
|
|
79
|
+
* "*" = 没列出的种类。true 全显示,false 全不显示。缺省 { viewing: false }:正在看的不显示,明确引用的都显示。
|
|
80
|
+
*/
|
|
81
|
+
refs?: boolean | Record<string, boolean>;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
export interface ResolvedChatConfig {
|
|
@@ -84,22 +90,28 @@ export interface ResolvedChatConfig {
|
|
|
84
90
|
transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
|
|
85
91
|
labels: { user: string; agent: string };
|
|
86
92
|
empty: { show: boolean; title: string; description: string };
|
|
93
|
+
/** 按种类显示 / 不显示;"*" 是没列出的种类。用 refShown 判断。 */
|
|
94
|
+
refs: Record<string, boolean>;
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
/** 这个种类的看板引用("viewing" = 正在看的)要不要显示在输入框上方。 */
|
|
98
|
+
export const refShown = (cfg: Pick<ResolvedChatConfig, "refs">, kind: string) => cfg.refs[kind] ?? cfg.refs["*"] ?? true;
|
|
99
|
+
|
|
89
100
|
export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
|
|
90
101
|
// 顶部栏缺省整栏隐藏(模型、状态、用量这些对使用者是干扰),需要的项在 config.header 里打开。
|
|
91
102
|
header: { show: true, crumb: false, export: false, title: false, agent: false, usage: false, model: false, status: false, close: false },
|
|
92
103
|
composer: {
|
|
93
104
|
show: true,
|
|
94
|
-
placeholder: "在“{title}”中回复 {agent}…(可拖入
|
|
95
|
-
placeholderNew: "输入第一条消息,开始新对话…(可拖入
|
|
105
|
+
placeholder: "在“{title}”中回复 {agent}…(可拖入 Word / Excel / Markdown 等文档)",
|
|
106
|
+
placeholderNew: "输入第一条消息,开始新对话…(可拖入 Word / Excel / Markdown 等文档)",
|
|
96
107
|
placeholderRunning: "排队一条后续消息…(本轮结束后发送)",
|
|
97
108
|
placeholderDisabled: null,
|
|
98
109
|
hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
|
|
99
110
|
},
|
|
100
111
|
transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true, status: false },
|
|
101
112
|
labels: { user: "你", agent: "智能体" },
|
|
102
|
-
empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 TXT、
|
|
113
|
+
empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 Word、Excel、Markdown、TXT、HTML、CSV 文档,内容会转成文字一起发送。" },
|
|
114
|
+
refs: { "*": true, viewing: false },
|
|
103
115
|
};
|
|
104
116
|
|
|
105
117
|
export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfig {
|
|
@@ -109,7 +121,7 @@ export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfi
|
|
|
109
121
|
const empty = toggle(config.empty, d.empty);
|
|
110
122
|
// 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
|
|
111
123
|
if (!composer.fileUpload) {
|
|
112
|
-
const strip = (text: string) => text.replace(/(可拖入
|
|
124
|
+
const strip = (text: string) => text.replace(/(可拖入 Word \/ Excel \/ Markdown 等文档)$/, "");
|
|
113
125
|
if (!isObj(config.composer) || config.composer.placeholder === undefined) composer.placeholder = strip(composer.placeholder);
|
|
114
126
|
if (!isObj(config.composer) || config.composer.placeholderNew === undefined) composer.placeholderNew = strip(composer.placeholderNew);
|
|
115
127
|
}
|
|
@@ -119,6 +131,7 @@ export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfi
|
|
|
119
131
|
transcript: { ...d.transcript, ...defined(config.transcript) },
|
|
120
132
|
labels: { ...d.labels, ...defined(config.labels) },
|
|
121
133
|
empty,
|
|
134
|
+
refs: config.refs === true ? { "*": true } : config.refs === false ? { "*": false } : { ...d.refs, ...(defined(isObj(config.refs) ? config.refs : undefined) as Record<string, boolean>) },
|
|
122
135
|
};
|
|
123
136
|
}
|
|
124
137
|
|