@spzxg/cli-ui 0.1.0 → 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 +76 -14
- package/dist/{cli-ui-embed-BeBZKDZO.js → cli-ui-embed-hutDQhkT.js} +13316 -11962
- package/dist/{cli-ui-index-CuwqVeUk.js → cli-ui-index-C8TOk7M3.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +15 -9
- package/package.json +1 -1
- 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 +186 -63
- package/src/core/client.ts +221 -10
- package/src/core/config.ts +4 -2
- package/src/core/conversation.ts +81 -13
- package/src/core/provider.tsx +33 -19
- package/src/core/store.ts +168 -84
- package/src/core/types.ts +109 -2
- package/src/elements.tsx +51 -7
- package/src/embed.ts +15 -4
- package/src/index.ts +11 -5
- package/src/msglist/CliMsgList.tsx +119 -25
- package/src/styles.css +124 -0
- package/src/ui/Markdown.tsx +35 -6
- package/src/ui/fileLinks.ts +46 -0
- package/src/ui/transcriptModel.ts +19 -1
package/src/core/client.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createSseParser } from "./sse.js";
|
|
2
|
+
import { NEW_TASK, parseConvKey } from "./conversation.js";
|
|
2
3
|
import type {
|
|
3
|
-
|
|
4
|
+
BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo,
|
|
5
|
+
NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
|
|
4
6
|
} from "./types.js";
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -26,6 +28,11 @@ export interface CenterClientOptions {
|
|
|
26
28
|
/** 额外请求头(每次请求时取,可放会话令牌等)。 */
|
|
27
29
|
headers?: () => Record<string, string>;
|
|
28
30
|
fetch?: typeof fetch;
|
|
31
|
+
/**
|
|
32
|
+
* 看板里的文件能不能直接用地址打开(<img src>、<iframe src>、下载链接)。缺省:没给 token / headers / line 时可以
|
|
33
|
+
* (同源代理注入令牌);否则浏览器直接请求带不上请求头,看板改为先 fetch 再用 blob 地址显示。
|
|
34
|
+
*/
|
|
35
|
+
directFileLinks?: boolean;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
/** 实时流超过这么久没有任何数据(center 每 20 秒一次心跳)就当连接已断。 */
|
|
@@ -44,10 +51,18 @@ const ERROR_TEXT: Record<string, string> = {
|
|
|
44
51
|
conversation_queue_full: "排队的消息太多了,请等前面的回复后再发",
|
|
45
52
|
business_id_conflict: "消息编号冲突,请重新发送",
|
|
46
53
|
not_a_conversation: "这个任务不是对话任务",
|
|
54
|
+
conversation_not_found: "这个对话不在对应的对话任务里",
|
|
55
|
+
too_many_conversations: "这个对话任务里开的对话太多了,请新建对话任务",
|
|
47
56
|
not_found: "对话不存在,或不属于当前产品线",
|
|
48
57
|
unauthorized: "没有权限访问任务中心(产品线令牌缺失或失效)",
|
|
49
58
|
conversation_requires_one_task: "对话任务只能有一条首轮消息",
|
|
50
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: "画面连接还没就绪,正在重连",
|
|
51
66
|
};
|
|
52
67
|
|
|
53
68
|
/**
|
|
@@ -59,13 +74,20 @@ const ERROR_TEXT: Record<string, string> = {
|
|
|
59
74
|
export interface ConversationBackend {
|
|
60
75
|
baseUrl: string;
|
|
61
76
|
getJob(jobId: string): Promise<CenterJob>;
|
|
62
|
-
|
|
77
|
+
/** taskId:只要对话任务里这一个对话的轮次。 */
|
|
78
|
+
listTurns(jobId: string, query?: { afterSeq?: number; limit?: number; taskId?: string }): Promise<{ job_id: string; turns: CenterTurn[] }>;
|
|
63
79
|
listTasks(): Promise<{ tasks: CenterTask[] }>;
|
|
64
|
-
createConversation(firstMessage: string, opts?: NewConversationOptions & { businessId?: string }): Promise<{ id: string; job_id: string; status: string; turn: CenterTurn }>;
|
|
65
|
-
|
|
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>;
|
|
66
85
|
cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
|
|
67
86
|
closeConversation(jobId: string): Promise<{ id: string; status: string }>;
|
|
68
|
-
|
|
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;
|
|
69
91
|
/**
|
|
70
92
|
* 把回答直接交给正停在提问上的回合(Operator 的 POST /api/tasks/:id/answer)。
|
|
71
93
|
* resolved=false 表示没有在等的回合,调用方改用「下一条消息」发回答。center 不提供:它在 Agent 提问时已经结束那一轮。
|
|
@@ -75,6 +97,33 @@ export interface ConversationBackend {
|
|
|
75
97
|
submitInput?(jobId: string, inputId: string, values: string[]): Promise<string | null>;
|
|
76
98
|
/** 浏览器交接后交还给 AI(Operator 的 POST /api/tasks/:id/browser),返回错误文案或 null。center 不提供。 */
|
|
77
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>;
|
|
78
127
|
}
|
|
79
128
|
|
|
80
129
|
export function createCenterClient(options: CenterClientOptions = {}): ConversationBackend {
|
|
@@ -113,10 +162,11 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
113
162
|
return {
|
|
114
163
|
baseUrl: base,
|
|
115
164
|
getJob: (jobId: string) => request<CenterJob>(`/api/jobs/${id(jobId)}`),
|
|
116
|
-
listTurns: (jobId: string, query: { afterSeq?: number; limit?: number } = {}) => {
|
|
165
|
+
listTurns: (jobId: string, query: { afterSeq?: number; limit?: number; taskId?: string } = {}) => {
|
|
117
166
|
const params = new URLSearchParams();
|
|
118
167
|
if (query.afterSeq !== undefined) params.set("after_seq", String(query.afterSeq));
|
|
119
168
|
if (query.limit !== undefined) params.set("limit", String(query.limit));
|
|
169
|
+
if (query.taskId) params.set("task", query.taskId);
|
|
120
170
|
const qs = params.toString();
|
|
121
171
|
return request<{ job_id: string; turns: CenterTurn[] }>(`/api/jobs/${id(jobId)}/turns${qs ? `?${qs}` : ""}`);
|
|
122
172
|
},
|
|
@@ -124,7 +174,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
124
174
|
listTasks: () => request<{ tasks: CenterTask[] }>("/api/tasks"),
|
|
125
175
|
createConversation: (firstMessage: string, opts: NewConversationOptions & { businessId?: string } = {}) => {
|
|
126
176
|
const title = (opts.title || firstLine(firstMessage)).slice(0, 120);
|
|
127
|
-
return post<{ id: string; job_id: string; status: string; turn: CenterTurn }>("/api/jobs", {
|
|
177
|
+
return post<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>("/api/jobs", {
|
|
128
178
|
title,
|
|
129
179
|
mode: "conversation",
|
|
130
180
|
...(opts.workspace ? { workspace: opts.workspace } : {}),
|
|
@@ -140,20 +190,34 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
140
190
|
});
|
|
141
191
|
},
|
|
142
192
|
/** 带 business_id 时 center 按它去重,网络超时可以放心重发同一条。 */
|
|
143
|
-
sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number } = {}) =>
|
|
193
|
+
sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number; taskId?: string } = {}) =>
|
|
144
194
|
post<SendResult>(`/api/jobs/${id(jobId)}/messages`, {
|
|
145
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 } : {}),
|
|
146
207
|
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
147
208
|
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
148
209
|
}),
|
|
149
210
|
/** 取消一轮:排队中的直接撤回;正在执行的会中止节点上的回合。 */
|
|
150
211
|
cancelTurn: (jobId: string, turnId: string) => post<CenterTurn>(`/api/jobs/${id(jobId)}/turns/${id(turnId)}/cancel`),
|
|
151
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`),
|
|
152
215
|
/**
|
|
153
216
|
* 订阅对话的实时流(GET /api/jobs/:id/stream)。返回关闭函数。
|
|
154
217
|
* 不自动重连:重连策略(退避、结束后不再连)由调用方决定。
|
|
155
218
|
*/
|
|
156
|
-
|
|
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 {
|
|
157
221
|
const ctl = new AbortController();
|
|
158
222
|
let stopped = false;
|
|
159
223
|
// 心跳看门狗:center 每 20 秒发一次 `: ping`。代理 / 负载均衡在上游断掉时可能把连接挂着不关,
|
|
@@ -169,7 +233,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
169
233
|
}, 5_000);
|
|
170
234
|
void (async () => {
|
|
171
235
|
try {
|
|
172
|
-
const response = await doFetch(`${base}/api/jobs/${id(jobId)}/stream?interval_ms=${intervalMs}`, {
|
|
236
|
+
const response = await doFetch(`${base}/api/jobs/${id(jobId)}/stream?interval_ms=${intervalMs}${taskId ? `&task=${id(taskId)}` : ""}`, {
|
|
173
237
|
headers: headers({ Accept: "text/event-stream" }),
|
|
174
238
|
signal: ctl.signal,
|
|
175
239
|
});
|
|
@@ -228,3 +292,150 @@ export function firstLine(text: string, max = 40) {
|
|
|
228
292
|
const line = text.split("\n").map((value) => value.trim()).find(Boolean) ?? "新对话";
|
|
229
293
|
return line.length > max ? `${line.slice(0, max)}…` : line;
|
|
230
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
|
+
}
|
package/src/core/config.ts
CHANGED
|
@@ -150,6 +150,8 @@ export interface CliMsgListConfig {
|
|
|
150
150
|
activity?: boolean;
|
|
151
151
|
/** 「N 轮对话」。 */
|
|
152
152
|
turns?: boolean;
|
|
153
|
+
/** 「+」按钮:在这个任务(对话任务)里新开一个对话,共用同一个工作区。 */
|
|
154
|
+
newInJob?: boolean;
|
|
153
155
|
};
|
|
154
156
|
/** 分组标题;写 false 隐藏这一组。 */
|
|
155
157
|
groups?: { needs?: string | false; active?: string | false; done?: string | false; cancelled?: string | false };
|
|
@@ -162,7 +164,7 @@ export interface ResolvedMsgListConfig {
|
|
|
162
164
|
header: { show: boolean; title: boolean; newButton: boolean; newButtonText: string; refresh: boolean; context: boolean };
|
|
163
165
|
search: boolean | "auto";
|
|
164
166
|
searchPlaceholder: string;
|
|
165
|
-
card: { status: boolean; agent: boolean; description: boolean; activity: boolean; turns: boolean };
|
|
167
|
+
card: { status: boolean; agent: boolean; description: boolean; activity: boolean; turns: boolean; newInJob: boolean };
|
|
166
168
|
groups: { needs: string | false; active: string | false; done: string | false; cancelled: string | false };
|
|
167
169
|
emptyTitle: string;
|
|
168
170
|
emptyDescription: string;
|
|
@@ -172,7 +174,7 @@ export const DEFAULT_MSGLIST_CONFIG: ResolvedMsgListConfig = {
|
|
|
172
174
|
header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: true, context: true },
|
|
173
175
|
search: "auto",
|
|
174
176
|
searchPlaceholder: "搜索对话…",
|
|
175
|
-
card: { status: true, agent: true, description: true, activity: true, turns: true },
|
|
177
|
+
card: { status: true, agent: true, description: true, activity: true, turns: true, newInJob: true },
|
|
176
178
|
groups: { needs: "需要你处理", active: "进行中", done: "已结束", cancelled: "已取消" },
|
|
177
179
|
emptyTitle: "还没有对话",
|
|
178
180
|
emptyDescription: "点「对话」开始一个新的对话。",
|
package/src/core/conversation.ts
CHANGED
|
@@ -6,13 +6,41 @@
|
|
|
6
6
|
* · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
|
|
7
7
|
* 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
|
|
8
8
|
*/
|
|
9
|
-
import type { AskAnswers, AskQuestion, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, JobStatus, Message, Msg, NodeEvent, StreamItem, StreamSnapshot, ToolData, TurnUsage } from "./types.js";
|
|
9
|
+
import type { AskAnswers, AskQuestion, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, JobStatus, Message, Msg, NodeEvent, StreamItem, StreamSnapshot, StreamTask, ToolData, TurnUsage } from "./types.js";
|
|
10
10
|
import { indexFromEnd, mergeSnapshot, toolDataOf } from "../ui/transcriptModel.js";
|
|
11
11
|
import { formatAnswersText } from "../ui/format.js";
|
|
12
12
|
|
|
13
13
|
export const TERMINAL_TURN = new Set(["succeeded", "failed", "cancelled", "merged"]);
|
|
14
14
|
export const ACTIVE_TURN = new Set(["queued", "dispatching", "running"]);
|
|
15
15
|
export const TERMINAL_JOB = new Set(["succeeded", "failed", "cancelled"]);
|
|
16
|
+
|
|
17
|
+
// ── 对话键 ─────────────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// 一个对话任务(job)里可以有几个对话(几个子任务,各自一个 Agent 会话,共用节点和工作区)。
|
|
20
|
+
// 组件里「一个对话」用对话键标识:
|
|
21
|
+
// · "job_x" 对话任务的第一个对话(只有一个对话时就是它,和以前一样);
|
|
22
|
+
// · "job_x#task_y" 对话任务里的某个对话;
|
|
23
|
+
// · "job_x#new" 在这个对话任务里新开一个对话(还没发第一条消息)。
|
|
24
|
+
|
|
25
|
+
/** 「在这个对话任务里新开一个对话」的子任务占位。 */
|
|
26
|
+
export const NEW_TASK = "new";
|
|
27
|
+
|
|
28
|
+
export function convKey(jobId: string, taskId?: string | null): string {
|
|
29
|
+
return taskId ? `${jobId}#${taskId}` : jobId;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseConvKey(key: string): { jobId: string; taskId: string | null } {
|
|
33
|
+
const at = key.indexOf("#");
|
|
34
|
+
return at < 0 ? { jobId: key, taskId: null } : { jobId: key.slice(0, at), taskId: key.slice(at + 1) || null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 对话键里的真实子任务(「新开」的占位不算)。 */
|
|
38
|
+
export function taskOfKey(key: string): string | null {
|
|
39
|
+
const { taskId } = parseConvKey(key);
|
|
40
|
+
return taskId && taskId !== NEW_TASK ? taskId : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const isNewInJob = (key: string | null | undefined) => !!key && parseConvKey(key).taskId === NEW_TASK;
|
|
16
44
|
/** Operator 首轮固定的启动语句:正文其实在任务描述里(= 中枢第 1 轮的 input)。 */
|
|
17
45
|
export const OPERATOR_INITIAL_PROMPT = "Start working on the task described in the task context.";
|
|
18
46
|
|
|
@@ -24,8 +52,14 @@ export interface ConvJob {
|
|
|
24
52
|
}
|
|
25
53
|
|
|
26
54
|
export interface ConvState {
|
|
55
|
+
/** 对话键(见 convKey)。 */
|
|
56
|
+
key: string;
|
|
27
57
|
jobId: string;
|
|
58
|
+
/** 对话键里指定的子任务;只有 job_id 时为 null(看第一个对话)。 */
|
|
59
|
+
taskId: string | null;
|
|
28
60
|
job: ConvJob | null;
|
|
61
|
+
/** 实时流看的这个对话(子任务)的状态;关闭了 closed=true。旧版 center 没有。 */
|
|
62
|
+
task: StreamTask | null;
|
|
29
63
|
/** 中枢轮次,按 seq 升序。 */
|
|
30
64
|
turns: CenterTurn[];
|
|
31
65
|
/** 节点转录(Operator 消息)。 */
|
|
@@ -38,13 +72,14 @@ export interface ConvState {
|
|
|
38
72
|
stream: "idle" | "connecting" | "live" | "reconnecting" | "ended" | "error";
|
|
39
73
|
streamError: string | null;
|
|
40
74
|
endReason: string | null;
|
|
75
|
+
/** 接续这个对话的新对话的键(对话任务结束后再发消息开出来的)。 */
|
|
41
76
|
continuedBy: string | null;
|
|
42
77
|
/** 本页提交过的提问回答(askId → 答案)。节点上那一轮已被中枢结束,不会回 ask_answered,只能本地记。 */
|
|
43
78
|
localAnswers: Record<string, AskAnswers>;
|
|
44
79
|
}
|
|
45
80
|
|
|
46
|
-
export const emptyConv = (
|
|
47
|
-
|
|
81
|
+
export const emptyConv = (key: string): ConvState => ({
|
|
82
|
+
key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, nodeRunning: false, resolvedModel: null,
|
|
48
83
|
liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
|
|
49
84
|
});
|
|
50
85
|
|
|
@@ -55,17 +90,30 @@ export function upsertTurns(turns: CenterTurn[], incoming: CenterTurn[]): Center
|
|
|
55
90
|
return [...byId.values()].sort((a, b) => a.seq - b.seq);
|
|
56
91
|
}
|
|
57
92
|
|
|
93
|
+
/**
|
|
94
|
+
* 只补上还不认识的轮次(发消息 / 撤回的接口响应用)。接口响应是请求那一刻的快照,可能比实时流晚到:
|
|
95
|
+
* 实时流已经报了「执行中 / 已结束」,再拿响应里的「排队中」去盖,这一轮就会一直显示排队(之后也不会再有更新)。
|
|
96
|
+
* 已有的轮次只认实时流——状态可能合法地往回走(节点忙时中枢会把派发中的轮次放回队列),不能按状态大小判断新旧。
|
|
97
|
+
*/
|
|
98
|
+
export function addTurnsIfAbsent(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
|
|
99
|
+
const known = new Set(turns.map((turn) => turn.turn_id));
|
|
100
|
+
const fresh = incoming.filter((turn) => !known.has(turn.turn_id));
|
|
101
|
+
return fresh.length ? upsertTurns(turns, fresh) : turns;
|
|
102
|
+
}
|
|
103
|
+
|
|
58
104
|
export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot): ConvState {
|
|
59
105
|
const { job } = snapshot;
|
|
60
106
|
return {
|
|
61
107
|
...conv,
|
|
62
108
|
job: { status: job.status, closed_reason: job.closed_reason, node_id: job.node_id, title: job.title },
|
|
109
|
+
task: snapshot.task ?? conv.task,
|
|
63
110
|
turns: upsertTurns([], snapshot.turns),
|
|
64
111
|
resolvedModel: conv.resolvedModel ?? job.conversation?.last_turn?.resolved_model ?? null,
|
|
65
112
|
};
|
|
66
113
|
}
|
|
67
114
|
|
|
68
|
-
|
|
115
|
+
// 缺字段时兜底:generation 缺省 1(按它分会话,缺了会把顺序排乱);时间缺就不显示。
|
|
116
|
+
const toMsg = (m: Message): Msg => ({ id: m.id, role: m.role, content: m.content, generation: typeof m.generation === "number" ? m.generation : 1, ...(typeof m.created_at === "number" ? { ts: m.created_at } : {}) });
|
|
69
117
|
|
|
70
118
|
export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
|
|
71
119
|
let next = conv;
|
|
@@ -77,9 +125,15 @@ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
|
|
|
77
125
|
} else if (item.type === "node") {
|
|
78
126
|
next = applyNodeEvent(next, item.event);
|
|
79
127
|
} else if (item.type === "turn") {
|
|
80
|
-
|
|
128
|
+
const turns = upsertTurns(next.turns, [item.turn]);
|
|
129
|
+
// 中枢只在节点上的回合真的停下后才把执行过的一轮收尾,所以一轮结束、又没有别的轮次在排队或执行时,
|
|
130
|
+
// 节点一定没在跑了——不只靠节点的 turn_end 事件(重连的空档里可能错过,界面就会一直显示「运行中」)。
|
|
131
|
+
const settled = TERMINAL_TURN.has(item.turn.status) && !turns.some((turn) => ACTIVE_TURN.has(turn.status));
|
|
132
|
+
next = { ...next, turns, resolvedModel: item.turn.resolved_model ?? next.resolvedModel, ...(settled ? { nodeRunning: false } : {}) };
|
|
81
133
|
} else if (item.type === "job") {
|
|
82
134
|
next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason, node_id: item.job.node_id } };
|
|
135
|
+
} else if (item.type === "task") {
|
|
136
|
+
next = { ...next, task: item.task };
|
|
83
137
|
}
|
|
84
138
|
}
|
|
85
139
|
return next;
|
|
@@ -167,6 +221,9 @@ export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
|
|
|
167
221
|
}
|
|
168
222
|
}
|
|
169
223
|
|
|
224
|
+
/** 这个对话结束了:整个对话任务结束,或者只是这一个对话被关闭。 */
|
|
225
|
+
export const convEnded = (conv: Pick<ConvState, "job" | "task">) => !!conv.task?.closed || (!!conv.job && TERMINAL_JOB.has(conv.job.status));
|
|
226
|
+
|
|
170
227
|
/** 正在执行的那一轮(派发中或执行中)。 */
|
|
171
228
|
export const runningTurn = (conv: ConvState) => conv.turns.find((turn) => turn.status === "running" || turn.status === "dispatching") ?? null;
|
|
172
229
|
|
|
@@ -183,13 +240,20 @@ export function viewMessages(conv: ConvState): Msg[] {
|
|
|
183
240
|
// 有节点消息就以节点为准:通常先收到全量转录,但新对话可能先来增量事件(user / assistant / tool),也算。
|
|
184
241
|
if (conv.msgs.length) {
|
|
185
242
|
// 节点转录只有当前这个 Operator 会话:它的开场是固定的启动语句,正文其实是开出这个会话的那一轮——
|
|
186
|
-
//
|
|
187
|
-
|
|
243
|
+
// 这个对话的第一轮,或者节点会话重建过(context_reset)时最近重建的那一轮。
|
|
244
|
+
// 轮次号在整个对话任务里统一编号:对话任务里后开的对话,第一轮不是 1,取这个对话最早的那一轮。
|
|
245
|
+
const opener = [...conv.turns].reverse().find((turn) => turn.context_reset && turn.started_at) ?? conv.turns[0];
|
|
246
|
+
// 认开场语句:Operator 一个会话的第一条用户消息总是固定的启动语句(忽略请求正文),所以按位置认——
|
|
247
|
+
// 转录里的第一条用户消息、内容又不是开出会话那一轮的原文,就是它;措辞以后变了也认得出。
|
|
248
|
+
// 之后的消息里再出现这句固定语句(例如会话被重启),按原文匹配。
|
|
249
|
+
const openerText = opener ? opener.sent_input ?? opener.input : null;
|
|
188
250
|
let replaced = false;
|
|
189
251
|
for (const m of conv.msgs) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
252
|
+
const firstUser = !replaced && m.role === "user";
|
|
253
|
+
if (firstUser) replaced = true;
|
|
254
|
+
if (openerText && m.role === "user" && m.content.trim() !== openerText.trim()
|
|
255
|
+
&& ((firstUser && m.id === conv.msgs.find((x) => x.role === "user")?.id) || m.content.trim() === OPERATOR_INITIAL_PROMPT)) {
|
|
256
|
+
out.push({ ...m, content: openerText });
|
|
193
257
|
} else out.push(m);
|
|
194
258
|
}
|
|
195
259
|
} else {
|
|
@@ -212,7 +276,9 @@ export function viewMessages(conv: ConvState): Msg[] {
|
|
|
212
276
|
const ts = new Date(turn.ended_at ?? turn.created_at).getTime();
|
|
213
277
|
const reason = turn.last_error ? `:${describeTurnError(turn.last_error)}` : "";
|
|
214
278
|
if ((turn.status === "failed" || turn.status === "cancelled") && !turn.started_at) {
|
|
215
|
-
|
|
279
|
+
// 用户自己撤回的不用再写原因(「已撤回:已取消」是同一件事);中枢取消的(例如对话结束时还在排队)写明原因。
|
|
280
|
+
const byCaller = turn.status === "cancelled" && /cancelled by caller/i.test(turn.last_error ?? "");
|
|
281
|
+
notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 第 ${turn.seq} 条消息${turn.status === "cancelled" ? "已撤回" : "没有执行"}${byCaller ? "" : reason}`, generation, ts });
|
|
216
282
|
} else if (turn.status === "failed") {
|
|
217
283
|
notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 这一轮没有完成${reason}`, generation, ts });
|
|
218
284
|
}
|
|
@@ -362,8 +428,10 @@ export function closedCallback(conv: ConvState): ConversationClosedCallback {
|
|
|
362
428
|
mode: "conversation",
|
|
363
429
|
callback_seq: turns + 1,
|
|
364
430
|
job_id: conv.jobId,
|
|
365
|
-
|
|
366
|
-
|
|
431
|
+
// 只关掉了这一个对话(对话任务还开着):带上是哪个,原因写 conversation_closed。
|
|
432
|
+
...(conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? { task_id: conv.task.id } : {}),
|
|
433
|
+
status: conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? "succeeded" : conv.job?.status ?? "succeeded",
|
|
434
|
+
closed_reason: conv.task?.closed && !TERMINAL_JOB.has(conv.job?.status ?? "") ? "conversation_closed" : conv.job?.closed_reason ?? null,
|
|
367
435
|
turns,
|
|
368
436
|
continued_by_job_id: conv.continuedBy,
|
|
369
437
|
occurred_at: new Date().toISOString(),
|
package/src/core/provider.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
2
2
|
import { createCliStore, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store.js";
|
|
3
|
-
import type { ConversationCallback } from "./types.js";
|
|
3
|
+
import type { ConversationCallback, ConversationEndPayload, TurnEndPayload } from "./types.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库:
|
|
@@ -15,30 +15,43 @@ import type { ConversationCallback } from "./types.js";
|
|
|
15
15
|
*/
|
|
16
16
|
const StoreContext = createContext<CliStore | null>(null);
|
|
17
17
|
|
|
18
|
-
export interface CliCenterProviderProps extends CliStoreOptions {
|
|
18
|
+
export interface CliCenterProviderProps extends CliStoreOptions, ResultHandlers {
|
|
19
19
|
/** 外部建好的仓库(例如 Web Component 按 group 共享的);给了就不再自己建。 */
|
|
20
20
|
store?: CliStore;
|
|
21
21
|
/** 监听仓库事件:选中、一轮结束、对话结束、job 变化、错误。 */
|
|
22
22
|
onEvent?: (event: CliEvent) => void;
|
|
23
|
-
/**
|
|
24
|
-
* 前端回调:每轮结束 / 对话结束时调用,载荷与 center 投递给 callback.url 的请求体同名同义
|
|
25
|
-
* (conversation_turn / conversation_closed)。用来刷新业务界面;落库以后端回调为准。
|
|
26
|
-
*/
|
|
27
|
-
onCallback?: (payload: ConversationCallback) => void;
|
|
28
23
|
children: ReactNode;
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 结果通知:组件从实时流里看到一轮结束 / 对话结束时调用,全在浏览器里发生,**和 center 的 callback.url 无关**——
|
|
28
|
+
* 不配 callback 也照样会调。载荷字段与 center 回调体一致,方便业务后端和前端用同一套代码解析。
|
|
29
|
+
*/
|
|
30
|
+
export interface ResultHandlers {
|
|
31
|
+
/** 一轮结束(成功 / 失败 / 取消)。 */
|
|
32
|
+
onTurnEnd?: (payload: TurnEndPayload) => void;
|
|
33
|
+
/** 对话结束(关闭 / 取消 / 过期……)。 */
|
|
34
|
+
onConversationEnd?: (payload: ConversationEndPayload) => void;
|
|
35
|
+
/** @deprecated 旧名字,等同于同时监听 onTurnEnd 和 onConversationEnd(payload.type 区分)。 */
|
|
36
|
+
onCallback?: (payload: ConversationCallback) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function dispatchResult(event: CliEvent, handlers: ResultHandlers) {
|
|
40
|
+
if (event.type === "conversation_turn") { handlers.onTurnEnd?.(event.payload); handlers.onCallback?.(event.payload); }
|
|
41
|
+
else if (event.type === "conversation_closed") { handlers.onConversationEnd?.(event.payload); handlers.onCallback?.(event.payload); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function CliCenterProvider({ store: external, onEvent, onTurnEnd, onConversationEnd, onCallback, children, ...options }: CliCenterProviderProps) {
|
|
32
45
|
// 连接参数只在建仓库时用一次:中途换 baseUrl / token 请换 key 重建。
|
|
33
46
|
const optionsRef = useRef(options);
|
|
34
47
|
const store = useMemo(() => external ?? createCliStore(optionsRef.current), [external]);
|
|
35
48
|
const onEventRef = useRef(onEvent);
|
|
36
49
|
onEventRef.current = onEvent;
|
|
37
|
-
const
|
|
38
|
-
|
|
50
|
+
const resultRef = useRef<ResultHandlers>({});
|
|
51
|
+
resultRef.current = { onTurnEnd, onConversationEnd, onCallback };
|
|
39
52
|
useEffect(() => store.on((event) => {
|
|
40
53
|
onEventRef.current?.(event);
|
|
41
|
-
|
|
54
|
+
dispatchResult(event, resultRef.current);
|
|
42
55
|
}), [store]);
|
|
43
56
|
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
|
44
57
|
}
|
|
@@ -60,30 +73,31 @@ export function useCliState<T>(selector: (state: CliState) => T): T {
|
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
/** 组件外面没有 Provider 时,用组件自己的 props 包一层。 */
|
|
63
|
-
export function withOwnProvider<P extends object>(props: P & CliStoreOptions & { onEvent?: (event: CliEvent) => void;
|
|
76
|
+
export function withOwnProvider<P extends object>(props: P & CliStoreOptions & ResultHandlers & { onEvent?: (event: CliEvent) => void; store?: CliStore }, inner: ReactNode, hasContext: boolean) {
|
|
64
77
|
if (hasContext) return inner;
|
|
65
|
-
const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onCallback, store } = props;
|
|
78
|
+
const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store } = props;
|
|
66
79
|
// 连接参数变了(换了中枢地址、令牌、产品线、命名空间)就换一个新仓库重连,不沿用旧连接。
|
|
67
80
|
const connection = [baseUrl ?? "", token ?? "", line ?? "", namespace ?? ""].join("\u0000");
|
|
68
81
|
return (
|
|
69
|
-
<CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onCallback, store }}>
|
|
82
|
+
<CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store }}>
|
|
70
83
|
{inner}
|
|
71
84
|
</CliCenterProvider>
|
|
72
85
|
);
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
/**
|
|
76
|
-
* 组件自己的 onEvent /
|
|
89
|
+
* 组件自己的 onEvent / onTurnEnd / onConversationEnd:组件放在外层 Provider 里时由这里订阅
|
|
90
|
+
* (自己建 Provider 时交给那个 Provider,免得调两次)。
|
|
77
91
|
*/
|
|
78
|
-
export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefined,
|
|
92
|
+
export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefined, results: ResultHandlers, enabled: boolean) {
|
|
79
93
|
const store = useContext(StoreContext);
|
|
80
|
-
const ref = useRef({ onEvent,
|
|
81
|
-
ref.current = { onEvent,
|
|
94
|
+
const ref = useRef({ onEvent, results });
|
|
95
|
+
ref.current = { onEvent, results };
|
|
82
96
|
useEffect(() => {
|
|
83
97
|
if (!enabled || !store) return;
|
|
84
98
|
return store.on((event) => {
|
|
85
99
|
ref.current.onEvent?.(event);
|
|
86
|
-
|
|
100
|
+
dispatchResult(event, ref.current.results);
|
|
87
101
|
});
|
|
88
102
|
}, [store, enabled]);
|
|
89
103
|
}
|