@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.
- package/README.md +163 -2
- package/dist/cli-ui-embed-hutDQhkT.js +37841 -0
- package/dist/cli-ui-index-C8TOk7M3.js +13516 -0
- package/dist/cli-ui.css +1 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +49 -4
- package/src/board/BrowserPane.tsx +418 -0
- package/src/board/ChangesPane.tsx +142 -0
- package/src/board/CliBoard.tsx +246 -0
- package/src/board/FileViewer.tsx +257 -0
- package/src/board/FilesPane.tsx +109 -0
- package/src/board/blocks.tsx +96 -0
- package/src/board/fileKind.ts +81 -0
- package/src/chat/CliChat.tsx +639 -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 +441 -0
- package/src/core/config.ts +222 -0
- package/src/core/conversation.ts +439 -0
- package/src/core/provider.tsx +103 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +575 -0
- package/src/core/types.ts +385 -0
- package/src/elements.tsx +166 -0
- package/src/embed.ts +58 -0
- package/src/index.ts +40 -0
- package/src/msglist/CliMsgList.tsx +342 -0
- package/src/operator.css +1628 -0
- package/src/styles.css +174 -0
- package/src/ui/Markdown.tsx +55 -0
- package/src/ui/TextBlock.tsx +49 -0
- package/src/ui/Transcript.tsx +408 -0
- package/src/ui/fileLinks.ts +46 -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 +94 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { createSseParser } from "./sse.js";
|
|
2
|
+
import { NEW_TASK, parseConvKey } from "./conversation.js";
|
|
3
|
+
import type {
|
|
4
|
+
BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo,
|
|
5
|
+
NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
|
|
10
|
+
*
|
|
11
|
+
* 怎么连:
|
|
12
|
+
* · 推荐走同源代理(缺省 baseUrl = "/api/center"):Vite 代理 / nginx 把 /api/center 转到 center,
|
|
13
|
+
* 并在代理里注入 `Authorization: Bearer <产品线令牌>`——令牌不进浏览器。
|
|
14
|
+
* · 本地联调也可以直接给 token(会以 Bearer 头发出),或用 headers() 自己加头。
|
|
15
|
+
* center 本身不开 CORS,跨域直连需要代理。
|
|
16
|
+
*/
|
|
17
|
+
export interface CenterClientOptions {
|
|
18
|
+
/** center 的地址前缀,缺省 "/api/center"(同源代理)。 */
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
/** 产品线令牌;有代理注入时不要给。 */
|
|
21
|
+
token?: string;
|
|
22
|
+
/**
|
|
23
|
+
* 产品线(line key)。以请求头 `X-CLI-Line` 发出,给代理选用哪条产品线的令牌:
|
|
24
|
+
* 一个代理服务多条产品线时,按这个头注入对应的 Authorization(见文档「连接中枢」)。center 本身不读这个头,
|
|
25
|
+
* 产品线只由令牌决定。
|
|
26
|
+
*/
|
|
27
|
+
line?: string;
|
|
28
|
+
/** 额外请求头(每次请求时取,可放会话令牌等)。 */
|
|
29
|
+
headers?: () => Record<string, string>;
|
|
30
|
+
fetch?: typeof fetch;
|
|
31
|
+
/**
|
|
32
|
+
* 看板里的文件能不能直接用地址打开(<img src>、<iframe src>、下载链接)。缺省:没给 token / headers / line 时可以
|
|
33
|
+
* (同源代理注入令牌);否则浏览器直接请求带不上请求头,看板改为先 fetch 再用 blob 地址显示。
|
|
34
|
+
*/
|
|
35
|
+
directFileLinks?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 实时流超过这么久没有任何数据(center 每 20 秒一次心跳)就当连接已断。 */
|
|
39
|
+
export const STREAM_SILENCE_MS = 45_000;
|
|
40
|
+
|
|
41
|
+
export class CenterError extends Error {
|
|
42
|
+
constructor(message: string, readonly code: string, readonly status: number, readonly detail?: unknown) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "CenterError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** center 的错误码翻成看得懂、知道怎么办的中文。 */
|
|
49
|
+
const ERROR_TEXT: Record<string, string> = {
|
|
50
|
+
conversation_closed: "这个对话已经结束,不能再发消息;需要继续请新建对话",
|
|
51
|
+
conversation_queue_full: "排队的消息太多了,请等前面的回复后再发",
|
|
52
|
+
business_id_conflict: "消息编号冲突,请重新发送",
|
|
53
|
+
not_a_conversation: "这个任务不是对话任务",
|
|
54
|
+
conversation_not_found: "这个对话不在对应的对话任务里",
|
|
55
|
+
too_many_conversations: "这个对话任务里开的对话太多了,请新建对话任务",
|
|
56
|
+
not_found: "对话不存在,或不属于当前产品线",
|
|
57
|
+
unauthorized: "没有权限访问任务中心(产品线令牌缺失或失效)",
|
|
58
|
+
conversation_requires_one_task: "对话任务只能有一条首轮消息",
|
|
59
|
+
conversation_prompt_required: "第一条消息不能为空",
|
|
60
|
+
not_dispatched: "任务还没派发到执行节点",
|
|
61
|
+
node_unreachable: "执行节点暂时连不上,稍后会自动重试",
|
|
62
|
+
node_outdated: "执行节点的 Operator 版本较旧,不支持这个功能,请升级节点",
|
|
63
|
+
too_many_viewers: "同时观看这个浏览器的人太多,请稍后再试",
|
|
64
|
+
viewer_not_found: "画面连接已断开,正在重连",
|
|
65
|
+
viewer_not_ready: "画面连接还没就绪,正在重连",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 组件用到的数据后端。界面只依赖这个接口,不直接依赖 center 的地址和字段:
|
|
70
|
+
* · 现在的实现是 center 的对话任务接口(createCenterClient);
|
|
71
|
+
* · 以后把组件放进 Operator 自己的界面时,换一个实现这套接口的 Operator 后端即可,界面不用改。
|
|
72
|
+
* 带 ? 的是可选能力:后端不提供,界面就按 Operator 在「没有这个能力」时的样子显示(例如提问的回答改走下一条消息)。
|
|
73
|
+
*/
|
|
74
|
+
export interface ConversationBackend {
|
|
75
|
+
baseUrl: string;
|
|
76
|
+
getJob(jobId: string): Promise<CenterJob>;
|
|
77
|
+
/** taskId:只要对话任务里这一个对话的轮次。 */
|
|
78
|
+
listTurns(jobId: string, query?: { afterSeq?: number; limit?: number; taskId?: string }): Promise<{ job_id: string; turns: CenterTurn[] }>;
|
|
79
|
+
listTasks(): Promise<{ tasks: CenterTask[] }>;
|
|
80
|
+
createConversation(firstMessage: string, opts?: NewConversationOptions & { businessId?: string }): Promise<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>;
|
|
81
|
+
/** taskId:发给对话任务里的哪个对话,不给 = 第一个。 */
|
|
82
|
+
sendMessage(jobId: string, text: string, opts?: { businessId?: string; timeoutSeconds?: number; taskId?: string }): Promise<SendResult>;
|
|
83
|
+
/** 在对话任务里新开一个对话(同一节点、同一工作区),text 是它的第一条消息。 */
|
|
84
|
+
openConversation?(jobId: string, text: string, opts?: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
|
|
85
|
+
cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
|
|
86
|
+
closeConversation(jobId: string): Promise<{ id: string; status: string }>;
|
|
87
|
+
/** 只关掉对话任务里的一个对话(别的对话和对话任务照常)。 */
|
|
88
|
+
closeConversationTask?(jobId: string, taskId: string): Promise<{ job_id: string; task_id: string; status: string; closed: boolean }>;
|
|
89
|
+
/** taskId:看对话任务里的哪个对话,不给 = 第一个。 */
|
|
90
|
+
stream(jobId: string, handlers: StreamHandlers, intervalMs?: number, taskId?: string | null): () => void;
|
|
91
|
+
/**
|
|
92
|
+
* 把回答直接交给正停在提问上的回合(Operator 的 POST /api/tasks/:id/answer)。
|
|
93
|
+
* resolved=false 表示没有在等的回合,调用方改用「下一条消息」发回答。center 不提供:它在 Agent 提问时已经结束那一轮。
|
|
94
|
+
*/
|
|
95
|
+
answerAsk?(jobId: string, askId: string, answers: string[][]): Promise<{ resolved: boolean }>;
|
|
96
|
+
/** 提交浏览器输入卡片(Operator 的 POST /api/tasks/:id/input),返回错误文案或 null。center 不提供。 */
|
|
97
|
+
submitInput?(jobId: string, inputId: string, values: string[]): Promise<string | null>;
|
|
98
|
+
/** 浏览器交接后交还给 AI(Operator 的 POST /api/tasks/:id/browser),返回错误文案或 null。center 不提供。 */
|
|
99
|
+
handback?(jobId: string): Promise<string | null>;
|
|
100
|
+
/** 右侧看板用到的能力(cli-board)。后端没有时看板显示「不支持」。 */
|
|
101
|
+
board?: BoardBackend;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 右侧看板(cli-board):工作区文件、改动、任务浏览器。全部经 center 转发,浏览器不直连节点。
|
|
106
|
+
* 方法里的 jobId 也可以是对话键("job_x#task_y"):看对话任务里那个对话的文件、改动和浏览器。
|
|
107
|
+
*/
|
|
108
|
+
export interface BoardBackend {
|
|
109
|
+
/** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
|
|
110
|
+
directLinks: boolean;
|
|
111
|
+
info(jobId: string): Promise<BoardInfo>;
|
|
112
|
+
/** 目录(缺省根目录)或文件的信息。 */
|
|
113
|
+
stat(jobId: string, path?: string): Promise<DirListing | FileInfo>;
|
|
114
|
+
/** 文件地址(给 <img> / <iframe> / 下载链接用)。相对路径用路径式地址,HTML 里的相对资源能跟着解析。 */
|
|
115
|
+
fileUrl(jobId: string, path: string, opts?: { download?: boolean }): string;
|
|
116
|
+
/** 读文本(最多 maxBytes,超过的截断)。 */
|
|
117
|
+
readText(jobId: string, path: string, maxBytes?: number): Promise<{ text: string; truncated: boolean; size: number; contentType: string }>;
|
|
118
|
+
readBuffer(jobId: string, path: string): Promise<ArrayBuffer>;
|
|
119
|
+
diff(jobId: string): Promise<DiffResult>;
|
|
120
|
+
browserStatus(jobId: string): Promise<BrowserStatus>;
|
|
121
|
+
browserAction(jobId: string, action: "open" | "close" | "takeover" | "handback"): Promise<BrowserStatus>;
|
|
122
|
+
/** 浏览器状态实时流;返回停止函数。onClose 在断开时调用(error 为 null 表示正常结束)。 */
|
|
123
|
+
browserEvents(jobId: string, handlers: { onStatus: (status: BrowserStatus) => void; onClose?: (error: CenterError | null) => void }): () => void;
|
|
124
|
+
/** 浏览器实时画面;onViewer 给出发输入要用的 viewer id。 */
|
|
125
|
+
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;
|
|
126
|
+
browserInput(jobId: string, viewer: string, events: Array<Record<string, unknown>>): Promise<void>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createCenterClient(options: CenterClientOptions = {}): ConversationBackend {
|
|
130
|
+
const base = (options.baseUrl ?? "/api/center").replace(/\/+$/, "");
|
|
131
|
+
const doFetch = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
|
|
132
|
+
|
|
133
|
+
function headers(extra: Record<string, string> = {}) {
|
|
134
|
+
const result: Record<string, string> = { ...(options.headers?.() ?? {}), ...extra };
|
|
135
|
+
if (options.line) result["X-CLI-Line"] = options.line;
|
|
136
|
+
if (options.token) result.Authorization = `Bearer ${options.token}`;
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
141
|
+
let response: Response;
|
|
142
|
+
try {
|
|
143
|
+
response = await doFetch(`${base}${path}`, {
|
|
144
|
+
...init,
|
|
145
|
+
headers: headers(init.body ? { "Content-Type": "application/json" } : {}),
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
149
|
+
}
|
|
150
|
+
const body = (await response.json().catch(() => null)) as Record<string, unknown> | null;
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
const code = typeof body?.error === "string" ? body.error : `HTTP_${response.status}`;
|
|
153
|
+
const detail = typeof body?.detail === "string" ? body.detail : undefined;
|
|
154
|
+
throw new CenterError(ERROR_TEXT[code] ?? detail ?? `任务中心返回错误(HTTP ${response.status})`, code, response.status, body);
|
|
155
|
+
}
|
|
156
|
+
return body as T;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const post = <T>(path: string, body: unknown = {}) => request<T>(path, { method: "POST", body: JSON.stringify(body) });
|
|
160
|
+
const id = encodeURIComponent;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
baseUrl: base,
|
|
164
|
+
getJob: (jobId: string) => request<CenterJob>(`/api/jobs/${id(jobId)}`),
|
|
165
|
+
listTurns: (jobId: string, query: { afterSeq?: number; limit?: number; taskId?: string } = {}) => {
|
|
166
|
+
const params = new URLSearchParams();
|
|
167
|
+
if (query.afterSeq !== undefined) params.set("after_seq", String(query.afterSeq));
|
|
168
|
+
if (query.limit !== undefined) params.set("limit", String(query.limit));
|
|
169
|
+
if (query.taskId) params.set("task", query.taskId);
|
|
170
|
+
const qs = params.toString();
|
|
171
|
+
return request<{ job_id: string; turns: CenterTurn[] }>(`/api/jobs/${id(jobId)}/turns${qs ? `?${qs}` : ""}`);
|
|
172
|
+
},
|
|
173
|
+
/** 列表用:本产品线全部子任务(对话任务每个对话一条),用来发现对话。 */
|
|
174
|
+
listTasks: () => request<{ tasks: CenterTask[] }>("/api/tasks"),
|
|
175
|
+
createConversation: (firstMessage: string, opts: NewConversationOptions & { businessId?: string } = {}) => {
|
|
176
|
+
const title = (opts.title || firstLine(firstMessage)).slice(0, 120);
|
|
177
|
+
return post<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>("/api/jobs", {
|
|
178
|
+
title,
|
|
179
|
+
mode: "conversation",
|
|
180
|
+
...(opts.workspace ? { workspace: opts.workspace } : {}),
|
|
181
|
+
...(opts.callback ? { callback: opts.callback } : {}),
|
|
182
|
+
tasks: [{
|
|
183
|
+
title,
|
|
184
|
+
prompt: firstMessage,
|
|
185
|
+
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
186
|
+
...(opts.agent ? { agent: opts.agent } : {}),
|
|
187
|
+
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
188
|
+
...(opts.browser ? { browser: true } : {}),
|
|
189
|
+
}],
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
/** 带 business_id 时 center 按它去重,网络超时可以放心重发同一条。 */
|
|
193
|
+
sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number; taskId?: string } = {}) =>
|
|
194
|
+
post<SendResult>(`/api/jobs/${id(jobId)}/messages`, {
|
|
195
|
+
text,
|
|
196
|
+
...(opts.taskId ? { task_id: opts.taskId } : {}),
|
|
197
|
+
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
198
|
+
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
199
|
+
}),
|
|
200
|
+
/** 同一个 business_id 重发只开一个对话。 */
|
|
201
|
+
openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
|
|
202
|
+
post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
|
|
203
|
+
text,
|
|
204
|
+
...(opts.title ? { title: opts.title } : {}),
|
|
205
|
+
...(opts.agent ? { agent: opts.agent } : {}),
|
|
206
|
+
...(opts.browser !== undefined ? { browser: opts.browser } : {}),
|
|
207
|
+
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
208
|
+
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
209
|
+
}),
|
|
210
|
+
/** 取消一轮:排队中的直接撤回;正在执行的会中止节点上的回合。 */
|
|
211
|
+
cancelTurn: (jobId: string, turnId: string) => post<CenterTurn>(`/api/jobs/${id(jobId)}/turns/${id(turnId)}/cancel`),
|
|
212
|
+
closeConversation: (jobId: string) => post<{ id: string; status: string }>(`/api/jobs/${id(jobId)}/close`),
|
|
213
|
+
closeConversationTask: (jobId: string, taskId: string) =>
|
|
214
|
+
post<{ job_id: string; task_id: string; status: string; closed: boolean }>(`/api/jobs/${id(jobId)}/conversations/${id(taskId)}/close`),
|
|
215
|
+
/**
|
|
216
|
+
* 订阅对话的实时流(GET /api/jobs/:id/stream)。返回关闭函数。
|
|
217
|
+
* 不自动重连:重连策略(退避、结束后不再连)由调用方决定。
|
|
218
|
+
*/
|
|
219
|
+
board: createBoardClient(base, headers, doFetch, request, options.directFileLinks ?? (!options.token && !options.headers && !options.line)),
|
|
220
|
+
stream(jobId: string, handlers: StreamHandlers, intervalMs = 300, taskId?: string | null): () => void {
|
|
221
|
+
const ctl = new AbortController();
|
|
222
|
+
let stopped = false;
|
|
223
|
+
// 心跳看门狗:center 每 20 秒发一次 `: ping`。代理 / 负载均衡在上游断掉时可能把连接挂着不关,
|
|
224
|
+
// 这时一直收不到数据却也不报错——超过 STREAM_SILENCE_MS 没有任何字节就当断线,交给调用方重连。
|
|
225
|
+
let lastByte = Date.now();
|
|
226
|
+
let silent = false;
|
|
227
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
228
|
+
const silenceError = () => new CenterError("实时连接长时间没有数据,正在重连", "NETWORK", 0);
|
|
229
|
+
// 中止请求之外还要直接取消读取:有的环境里中止信号不会让挂着的 read() 结束。
|
|
230
|
+
const cut = () => { ctl.abort(); reader?.cancel().catch(() => undefined); };
|
|
231
|
+
const watchdog = setInterval(() => {
|
|
232
|
+
if (Date.now() - lastByte > STREAM_SILENCE_MS) { silent = true; cut(); }
|
|
233
|
+
}, 5_000);
|
|
234
|
+
void (async () => {
|
|
235
|
+
try {
|
|
236
|
+
const response = await doFetch(`${base}/api/jobs/${id(jobId)}/stream?interval_ms=${intervalMs}${taskId ? `&task=${id(taskId)}` : ""}`, {
|
|
237
|
+
headers: headers({ Accept: "text/event-stream" }),
|
|
238
|
+
signal: ctl.signal,
|
|
239
|
+
});
|
|
240
|
+
if (!response.ok || !response.body) {
|
|
241
|
+
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
|
242
|
+
const code = body?.error ?? `HTTP_${response.status}`;
|
|
243
|
+
throw new CenterError(ERROR_TEXT[code] ?? `实时流连接失败(HTTP ${response.status})`, code, response.status);
|
|
244
|
+
}
|
|
245
|
+
handlers.onOpen?.();
|
|
246
|
+
const parser = createSseParser(({ event, data }) => {
|
|
247
|
+
let payload: unknown;
|
|
248
|
+
try { payload = JSON.parse(data); } catch { return; }
|
|
249
|
+
if (event === "snapshot") handlers.onSnapshot?.(payload as StreamSnapshot);
|
|
250
|
+
else if (event === "chunk") handlers.onChunk?.((payload as { events?: StreamItem[] }).events ?? []);
|
|
251
|
+
else if (event === "end") handlers.onEnd?.(payload as StreamEnd);
|
|
252
|
+
});
|
|
253
|
+
reader = response.body.getReader();
|
|
254
|
+
const decoder = new TextDecoder();
|
|
255
|
+
for (;;) {
|
|
256
|
+
const { done, value } = await reader.read();
|
|
257
|
+
if (done) break;
|
|
258
|
+
lastByte = Date.now();
|
|
259
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
260
|
+
}
|
|
261
|
+
parser.end();
|
|
262
|
+
if (stopped) return;
|
|
263
|
+
if (silent) return handlers.onClose?.(silenceError());
|
|
264
|
+
if (!ctl.signal.aborted) handlers.onClose?.(null);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (stopped) return;
|
|
267
|
+
if (silent) return handlers.onClose?.(silenceError());
|
|
268
|
+
if (ctl.signal.aborted) return;
|
|
269
|
+
handlers.onClose?.(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
|
|
270
|
+
} finally {
|
|
271
|
+
clearInterval(watchdog);
|
|
272
|
+
}
|
|
273
|
+
})();
|
|
274
|
+
return () => { stopped = true; clearInterval(watchdog); cut(); };
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface StreamHandlers {
|
|
280
|
+
onOpen?: () => void;
|
|
281
|
+
onSnapshot?: (snapshot: StreamSnapshot) => void;
|
|
282
|
+
onChunk?: (events: StreamItem[]) => void;
|
|
283
|
+
onEnd?: (end: StreamEnd) => void;
|
|
284
|
+
/** 流断了(error 为 null = 服务端正常关闭)。onEnd 之后也会触发一次。 */
|
|
285
|
+
onClose?: (error: CenterError | null) => void;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 兼容旧名字。 */
|
|
289
|
+
export type CenterClient = ConversationBackend;
|
|
290
|
+
|
|
291
|
+
export function firstLine(text: string, max = 40) {
|
|
292
|
+
const line = text.split("\n").map((value) => value.trim()).find(Boolean) ?? "新对话";
|
|
293
|
+
return line.length > max ? `${line.slice(0, max)}…` : line;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 读一条 SSE(fetch 读流,能带鉴权头),按事件名回调。返回停止函数。 */
|
|
297
|
+
function openSse(
|
|
298
|
+
doFetch: typeof fetch,
|
|
299
|
+
url: string,
|
|
300
|
+
headers: Record<string, string>,
|
|
301
|
+
onEvent: (event: string, data: unknown) => void,
|
|
302
|
+
onClose?: (error: CenterError | null) => void,
|
|
303
|
+
): () => void {
|
|
304
|
+
const ctl = new AbortController();
|
|
305
|
+
// 静默看门狗:center 每 20 秒发一次心跳,超过 STREAM_SILENCE_MS 什么都没收到(网络静默断开、代理挂住)就当断线。
|
|
306
|
+
let silent = false;
|
|
307
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
308
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
309
|
+
const arm = () => {
|
|
310
|
+
if (watchdog) clearTimeout(watchdog);
|
|
311
|
+
// 同时取消读取:宿主自定义的 fetch 不一定理会 signal。
|
|
312
|
+
watchdog = setTimeout(() => { silent = true; ctl.abort(); void reader?.cancel().catch(() => undefined); }, STREAM_SILENCE_MS);
|
|
313
|
+
};
|
|
314
|
+
void (async () => {
|
|
315
|
+
try {
|
|
316
|
+
arm();
|
|
317
|
+
const response = await doFetch(url, { headers: { ...headers, Accept: "text/event-stream" }, signal: ctl.signal });
|
|
318
|
+
if (!response.ok || !response.body) {
|
|
319
|
+
const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
|
|
320
|
+
throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? `连接失败(HTTP ${response.status})`, body?.error ?? `HTTP_${response.status}`, response.status);
|
|
321
|
+
}
|
|
322
|
+
const parser = createSseParser(({ event, data }) => {
|
|
323
|
+
try { onEvent(event, JSON.parse(data)); } catch { /* 不是 JSON 的忽略 */ }
|
|
324
|
+
});
|
|
325
|
+
reader = response.body.getReader();
|
|
326
|
+
const decoder = new TextDecoder();
|
|
327
|
+
for (;;) {
|
|
328
|
+
const { done, value } = await reader.read();
|
|
329
|
+
if (done) break;
|
|
330
|
+
arm();
|
|
331
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
332
|
+
}
|
|
333
|
+
parser.end();
|
|
334
|
+
if (watchdog) clearTimeout(watchdog);
|
|
335
|
+
if (silent) { onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0)); return; }
|
|
336
|
+
if (!ctl.signal.aborted) onClose?.(null);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (watchdog) clearTimeout(watchdog);
|
|
339
|
+
if (silent) { onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0)); return; }
|
|
340
|
+
if (ctl.signal.aborted) return;
|
|
341
|
+
onClose?.(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
|
|
342
|
+
}
|
|
343
|
+
})();
|
|
344
|
+
return () => { if (watchdog) clearTimeout(watchdog); ctl.abort(); void reader?.cancel().catch(() => undefined); };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 看板请求的对象:对话键 → 对话任务 + 子任务(「新开」的占位不算,看对话任务缺省的那个)。 */
|
|
348
|
+
function boardTarget(key: string) {
|
|
349
|
+
const { jobId, taskId } = parseConvKey(key);
|
|
350
|
+
return { job: encodeURIComponent(jobId), task: taskId && taskId !== NEW_TASK ? encodeURIComponent(taskId) : null };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 相对路径 → 路径式地址(各段编码);绝对路径 / ~ 开头 → ?path=。jobId 可以是对话键:
|
|
355
|
+
* 指定了子任务时路径式地址带上它(/tasks/<子任务>/files/raw/…),HTML 里的相对资源也落在同一个对话上。
|
|
356
|
+
*/
|
|
357
|
+
export function boardFileUrl(base: string, jobId: string, path: string, download = false) {
|
|
358
|
+
const { job: id, task } = boardTarget(jobId);
|
|
359
|
+
const clean = path.replace(/^\.\//, "");
|
|
360
|
+
const absolute = clean.startsWith("/") || clean.startsWith("~") || /^[a-z]:[\\/]/i.test(clean);
|
|
361
|
+
if (absolute) return `${base}/api/jobs/${id}/files/raw?path=${encodeURIComponent(clean)}${task ? `&task=${task}` : ""}${download ? "&download=1" : ""}`;
|
|
362
|
+
const segs = clean.split("/").filter(Boolean).map(encodeURIComponent).join("/");
|
|
363
|
+
return `${base}/api/jobs/${id}${task ? `/tasks/${task}` : ""}/files/raw/${segs}${download ? "?download=1" : ""}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** 看板接口地址:/api/jobs/<job>/<rest>,指定了子任务时加 task 参数。 */
|
|
367
|
+
function boardPath(key: string, rest: string, query = "") {
|
|
368
|
+
const { job, task } = boardTarget(key);
|
|
369
|
+
const params = [query, task ? `task=${task}` : ""].filter(Boolean).join("&");
|
|
370
|
+
return `/api/jobs/${job}/${rest}${params ? `?${params}` : ""}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function createBoardClient(
|
|
374
|
+
base: string,
|
|
375
|
+
headers: (extra?: Record<string, string>) => Record<string, string>,
|
|
376
|
+
doFetch: typeof fetch,
|
|
377
|
+
request: <T>(path: string, init?: RequestInit) => Promise<T>,
|
|
378
|
+
directLinks: boolean,
|
|
379
|
+
): BoardBackend {
|
|
380
|
+
const fileFetch = async (jobId: string, path: string) => {
|
|
381
|
+
let response: Response;
|
|
382
|
+
try {
|
|
383
|
+
response = await doFetch(boardFileUrl(base, jobId, path), { headers: headers() });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
386
|
+
}
|
|
387
|
+
if (!response.ok) {
|
|
388
|
+
const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
|
|
389
|
+
throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? body?.error ?? `读取失败(HTTP ${response.status})`, body?.error ?? `HTTP_${response.status}`, response.status);
|
|
390
|
+
}
|
|
391
|
+
return response;
|
|
392
|
+
};
|
|
393
|
+
return {
|
|
394
|
+
directLinks,
|
|
395
|
+
info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
|
|
396
|
+
stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
|
|
397
|
+
fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
|
|
398
|
+
async readText(jobId, path, maxBytes = 512 * 1024) {
|
|
399
|
+
const response = await fileFetch(jobId, path);
|
|
400
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
401
|
+
const size = Number(response.headers.get("content-length") ?? 0);
|
|
402
|
+
// 只读前 maxBytes:大文件不整个拉进浏览器。
|
|
403
|
+
const reader = response.body?.getReader();
|
|
404
|
+
if (!reader) return { text: await response.text(), truncated: false, size, contentType };
|
|
405
|
+
const chunks: Uint8Array[] = [];
|
|
406
|
+
let got = 0;
|
|
407
|
+
let truncated = false;
|
|
408
|
+
for (;;) {
|
|
409
|
+
const { done, value } = await reader.read();
|
|
410
|
+
if (done) break;
|
|
411
|
+
chunks.push(value);
|
|
412
|
+
got += value.length;
|
|
413
|
+
if (got >= maxBytes) { truncated = true; await reader.cancel().catch(() => undefined); break; }
|
|
414
|
+
}
|
|
415
|
+
const all = new Uint8Array(Math.min(got, maxBytes));
|
|
416
|
+
let offset = 0;
|
|
417
|
+
for (const chunk of chunks) {
|
|
418
|
+
const take = Math.min(chunk.length, all.length - offset);
|
|
419
|
+
all.set(chunk.subarray(0, take), offset);
|
|
420
|
+
offset += take;
|
|
421
|
+
if (offset >= all.length) break;
|
|
422
|
+
}
|
|
423
|
+
return { text: new TextDecoder().decode(all), truncated, size: size || got, contentType };
|
|
424
|
+
},
|
|
425
|
+
readBuffer: async (jobId, path) => (await fileFetch(jobId, path)).arrayBuffer(),
|
|
426
|
+
diff: (jobId) => request<DiffResult>(boardPath(jobId, "diff")),
|
|
427
|
+
browserStatus: (jobId) => request<BrowserStatus>(boardPath(jobId, "browser")),
|
|
428
|
+
browserAction: (jobId, action) => request<BrowserStatus>(boardPath(jobId, "browser"), { method: "POST", body: JSON.stringify({ action }) }),
|
|
429
|
+
browserEvents: (jobId, handlers) => openSse(doFetch, `${base}${boardPath(jobId, "browser/events")}`, headers(), (event, data) => {
|
|
430
|
+
if (event === "status") handlers.onStatus(data as BrowserStatus);
|
|
431
|
+
}, handlers.onClose),
|
|
432
|
+
browserFrames: (jobId, handlers) => openSse(doFetch, `${base}${boardPath(jobId, "browser/frames")}`, headers(), (event, data) => {
|
|
433
|
+
if (event === "viewer") handlers.onViewer((data as { viewer: string }).viewer);
|
|
434
|
+
else if (event === "message") handlers.onMessage(data as BrowserMessage);
|
|
435
|
+
else if (event === "end") handlers.onEnd?.(data as { reason: string; detail?: string });
|
|
436
|
+
}, handlers.onClose),
|
|
437
|
+
async browserInput(jobId, viewer, events) {
|
|
438
|
+
await request(boardPath(jobId, "browser/input"), { method: "POST", body: JSON.stringify({ viewer, events }) });
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(全部显示、默认文案)。
|
|
3
|
+
*
|
|
4
|
+
* <CliChat config={{ header: { export: false, close: false }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
|
|
5
|
+
* <CliMsgList config={{ header: { refresh: false }, card: { turns: false } }} />
|
|
6
|
+
*
|
|
7
|
+
* 开关类的模块写 false 就整块隐藏,写对象就逐项开关;文案里可以用 {title}(对话标题)、{agent}(智能体名)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── cli-chat ─────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface CliChatConfig {
|
|
13
|
+
/** 顶部栏。false = 整栏隐藏;对象 = 逐项开关。 */
|
|
14
|
+
header?: boolean | {
|
|
15
|
+
/** 面包屑:色块 + 名称 / 对话。 */
|
|
16
|
+
crumb?: boolean;
|
|
17
|
+
/** 「导出执行过程」按钮。 */
|
|
18
|
+
export?: boolean;
|
|
19
|
+
/** 对话标题。 */
|
|
20
|
+
title?: boolean;
|
|
21
|
+
/** 智能体标签(Codex / Claude Code)。 */
|
|
22
|
+
agent?: boolean;
|
|
23
|
+
/** token 用量。 */
|
|
24
|
+
usage?: boolean;
|
|
25
|
+
/** 模型(默认 + 实际模型)。 */
|
|
26
|
+
model?: boolean;
|
|
27
|
+
/** 对话状态(运行中 / 需要你处理 / 已结束)。 */
|
|
28
|
+
status?: boolean;
|
|
29
|
+
/** 「结束对话」按钮。 */
|
|
30
|
+
close?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** 底部输入框。false = 不显示输入框(只读);对象 = 逐项配置。 */
|
|
33
|
+
composer?: boolean | {
|
|
34
|
+
/** 回复时的占位文案,可用 {title} {agent}。 */
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
/** 新对话(还没有 jobId)时的占位文案。 */
|
|
37
|
+
placeholderNew?: string;
|
|
38
|
+
/** 智能体正在执行、发的消息会排队时的占位文案。 */
|
|
39
|
+
placeholderRunning?: string;
|
|
40
|
+
/** 不能发送时(对话已结束、只读)的占位文案。 */
|
|
41
|
+
placeholderDisabled?: string;
|
|
42
|
+
/** 底部「⏎ 发送 / ⇧⏎ 换行」提示。 */
|
|
43
|
+
hints?: boolean;
|
|
44
|
+
/** 底部字数统计。 */
|
|
45
|
+
charCount?: boolean;
|
|
46
|
+
/** 文件上传(按钮、拖拽、粘贴文件)。只收 TXT / Markdown / Word。 */
|
|
47
|
+
fileUpload?: boolean;
|
|
48
|
+
/** 「文件」按钮的文字。 */
|
|
49
|
+
fileButtonText?: string;
|
|
50
|
+
/** 运行中的停止按钮。 */
|
|
51
|
+
stop?: boolean;
|
|
52
|
+
/** 运行中允许发消息排队;false = 运行中不能发。 */
|
|
53
|
+
queue?: boolean;
|
|
54
|
+
};
|
|
55
|
+
/** 对话区。 */
|
|
56
|
+
transcript?: {
|
|
57
|
+
/** 右下角的上一条 / 下一条 / 跳到最新按钮。 */
|
|
58
|
+
nav?: boolean;
|
|
59
|
+
/** 「第 1 次会话 · 当前」分隔线。 */
|
|
60
|
+
sessionLabel?: boolean;
|
|
61
|
+
/** 消息时间。 */
|
|
62
|
+
timestamps?: boolean;
|
|
63
|
+
/** 首条消息上的「初始提示」「已随项目上下文发送」标记。 */
|
|
64
|
+
initialBadge?: boolean;
|
|
65
|
+
/** 工具调用卡片(命令、读写文件……);false 只留文字往来(提问卡片照常显示)。 */
|
|
66
|
+
tools?: boolean;
|
|
67
|
+
/** 「智能体 · 运行中 0:12」那一行。 */
|
|
68
|
+
runningIndicator?: boolean;
|
|
69
|
+
};
|
|
70
|
+
/** 消息头上的称呼。 */
|
|
71
|
+
labels?: { user?: string; agent?: string };
|
|
72
|
+
/** 新对话的空白页。false = 不显示。 */
|
|
73
|
+
empty?: boolean | { title?: string; description?: string };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ResolvedChatConfig {
|
|
77
|
+
header: { show: boolean; crumb: boolean; export: boolean; title: boolean; agent: boolean; usage: boolean; model: boolean; status: boolean; close: boolean };
|
|
78
|
+
composer: {
|
|
79
|
+
show: boolean; placeholder: string; placeholderNew: string; placeholderRunning: string; placeholderDisabled: string | null;
|
|
80
|
+
hints: boolean; charCount: boolean; fileUpload: boolean; fileButtonText: string; stop: boolean; queue: boolean;
|
|
81
|
+
};
|
|
82
|
+
transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean };
|
|
83
|
+
labels: { user: string; agent: string };
|
|
84
|
+
empty: { show: boolean; title: string; description: string };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
|
|
88
|
+
header: { show: true, crumb: true, export: true, title: true, agent: true, usage: true, model: true, status: true, close: true },
|
|
89
|
+
composer: {
|
|
90
|
+
show: true,
|
|
91
|
+
placeholder: "在“{title}”中回复 {agent}…(可拖入 TXT / Markdown / Word 文件)",
|
|
92
|
+
placeholderNew: "输入第一条消息,开始新对话…(可拖入 TXT / Markdown / Word 文件)",
|
|
93
|
+
placeholderRunning: "排队一条后续消息…(本轮结束后发送)",
|
|
94
|
+
placeholderDisabled: null,
|
|
95
|
+
hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
|
|
96
|
+
},
|
|
97
|
+
transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true },
|
|
98
|
+
labels: { user: "你", agent: "智能体" },
|
|
99
|
+
empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 TXT、Markdown 或 Word 文件,内容会转成文字一起发送。" },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfig {
|
|
103
|
+
const d = DEFAULT_CHAT_CONFIG;
|
|
104
|
+
const header = toggle(config.header, d.header);
|
|
105
|
+
const composer = toggle(config.composer, d.composer);
|
|
106
|
+
const empty = toggle(config.empty, d.empty);
|
|
107
|
+
// 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
|
|
108
|
+
if (!composer.fileUpload) {
|
|
109
|
+
const strip = (text: string) => text.replace(/(可拖入 TXT \/ Markdown \/ Word 文件)$/, "");
|
|
110
|
+
if (!isObj(config.composer) || config.composer.placeholder === undefined) composer.placeholder = strip(composer.placeholder);
|
|
111
|
+
if (!isObj(config.composer) || config.composer.placeholderNew === undefined) composer.placeholderNew = strip(composer.placeholderNew);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
header,
|
|
115
|
+
composer,
|
|
116
|
+
transcript: { ...d.transcript, ...defined(config.transcript) },
|
|
117
|
+
labels: { ...d.labels, ...defined(config.labels) },
|
|
118
|
+
empty,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── cli-msglist ──────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export interface CliMsgListConfig {
|
|
125
|
+
/** 顶部栏。false = 整栏隐藏;对象 = 逐项开关。 */
|
|
126
|
+
header?: boolean | {
|
|
127
|
+
/** 色块 + 名称。 */
|
|
128
|
+
title?: boolean;
|
|
129
|
+
/** 「+ 对话」按钮。 */
|
|
130
|
+
newButton?: boolean;
|
|
131
|
+
/** 「+ 对话」按钮的文字。 */
|
|
132
|
+
newButtonText?: string;
|
|
133
|
+
/** 刷新按钮。 */
|
|
134
|
+
refresh?: boolean;
|
|
135
|
+
/** 上下文说明框(需同时给 context 或 onEditContext)。 */
|
|
136
|
+
context?: boolean;
|
|
137
|
+
};
|
|
138
|
+
/** 搜索框:true 总是显示,false 不显示,"auto"(缺省)对话数 ≥ 6 时显示。 */
|
|
139
|
+
search?: boolean | "auto";
|
|
140
|
+
searchPlaceholder?: string;
|
|
141
|
+
/** 卡片上显示哪些内容。 */
|
|
142
|
+
card?: {
|
|
143
|
+
/** 状态点和状态文字。 */
|
|
144
|
+
status?: boolean;
|
|
145
|
+
/** 智能体标签。 */
|
|
146
|
+
agent?: boolean;
|
|
147
|
+
/** 第一条消息摘要。 */
|
|
148
|
+
description?: boolean;
|
|
149
|
+
/** 底部活动(运行中 · 3 分钟前……)。 */
|
|
150
|
+
activity?: boolean;
|
|
151
|
+
/** 「N 轮对话」。 */
|
|
152
|
+
turns?: boolean;
|
|
153
|
+
/** 「+」按钮:在这个任务(对话任务)里新开一个对话,共用同一个工作区。 */
|
|
154
|
+
newInJob?: boolean;
|
|
155
|
+
};
|
|
156
|
+
/** 分组标题;写 false 隐藏这一组。 */
|
|
157
|
+
groups?: { needs?: string | false; active?: string | false; done?: string | false; cancelled?: string | false };
|
|
158
|
+
/** 没有对话时的提示。 */
|
|
159
|
+
emptyTitle?: string;
|
|
160
|
+
emptyDescription?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ResolvedMsgListConfig {
|
|
164
|
+
header: { show: boolean; title: boolean; newButton: boolean; newButtonText: string; refresh: boolean; context: boolean };
|
|
165
|
+
search: boolean | "auto";
|
|
166
|
+
searchPlaceholder: string;
|
|
167
|
+
card: { status: boolean; agent: boolean; description: boolean; activity: boolean; turns: boolean; newInJob: boolean };
|
|
168
|
+
groups: { needs: string | false; active: string | false; done: string | false; cancelled: string | false };
|
|
169
|
+
emptyTitle: string;
|
|
170
|
+
emptyDescription: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const DEFAULT_MSGLIST_CONFIG: ResolvedMsgListConfig = {
|
|
174
|
+
header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: true, context: true },
|
|
175
|
+
search: "auto",
|
|
176
|
+
searchPlaceholder: "搜索对话…",
|
|
177
|
+
card: { status: true, agent: true, description: true, activity: true, turns: true, newInJob: true },
|
|
178
|
+
groups: { needs: "需要你处理", active: "进行中", done: "已结束", cancelled: "已取消" },
|
|
179
|
+
emptyTitle: "还没有对话",
|
|
180
|
+
emptyDescription: "点「对话」开始一个新的对话。",
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export function resolveMsgListConfig(config: CliMsgListConfig = {}): ResolvedMsgListConfig {
|
|
184
|
+
const d = DEFAULT_MSGLIST_CONFIG;
|
|
185
|
+
return {
|
|
186
|
+
header: toggle(config.header, d.header),
|
|
187
|
+
search: config.search ?? d.search,
|
|
188
|
+
searchPlaceholder: config.searchPlaceholder ?? d.searchPlaceholder,
|
|
189
|
+
card: { ...d.card, ...defined(config.card) },
|
|
190
|
+
groups: { ...d.groups, ...defined(config.groups) },
|
|
191
|
+
emptyTitle: config.emptyTitle ?? d.emptyTitle,
|
|
192
|
+
emptyDescription: config.emptyDescription ?? d.emptyDescription,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── 小工具 ───────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/** 文案模板:{title}、{agent}。 */
|
|
199
|
+
export function fillTemplate(template: string, vars: Record<string, string>) {
|
|
200
|
+
return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in vars ? vars[key] : match));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const isObj = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object";
|
|
204
|
+
|
|
205
|
+
/** 去掉值为 undefined 的键,免得盖掉缺省值。 */
|
|
206
|
+
function defined<T extends object>(value: T | undefined): Partial<T> {
|
|
207
|
+
if (!value) return {};
|
|
208
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 开关型模块:false → 全关(show=false 且布尔项全 false,文案保留);true / 不给 → 缺省;对象 → 合并缺省。
|
|
213
|
+
*/
|
|
214
|
+
function toggle<T extends { show: boolean }>(value: boolean | object | undefined, defaults: T): T {
|
|
215
|
+
if (value === false) {
|
|
216
|
+
const off = { ...defaults, show: false } as Record<string, unknown>;
|
|
217
|
+
for (const key of Object.keys(off)) if (typeof off[key] === "boolean") off[key] = false;
|
|
218
|
+
return off as T;
|
|
219
|
+
}
|
|
220
|
+
if (!isObj(value)) return { ...defaults };
|
|
221
|
+
return { ...defaults, ...defined(value as Partial<T>), show: true };
|
|
222
|
+
}
|