@spzxg/cli-ui 0.1.0 → 0.3.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 +79 -14
- package/dist/{cli-ui-embed-BeBZKDZO.js → cli-ui-embed-C0F64Ln7.js} +13493 -12058
- package/dist/{cli-ui-index-CuwqVeUk.js → cli-ui-index--RVPCGIc.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 +208 -65
- package/src/core/client.ts +224 -12
- package/src/core/config.ts +4 -2
- package/src/core/conversation.ts +186 -17
- package/src/core/provider.tsx +33 -19
- package/src/core/store.ts +169 -84
- package/src/core/types.ts +142 -4
- package/src/elements.tsx +51 -7
- package/src/embed.ts +15 -4
- package/src/index.ts +11 -5
- package/src/msglist/CliMsgList.tsx +129 -26
- 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/store.ts
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
* 组件共享的状态仓库(纯 TS,无 React)。积木之间靠它拼起来:
|
|
3
3
|
*
|
|
4
4
|
* · 选中哪个对话(cli-msglist 点一下,cli-chat 跟着切);
|
|
5
|
-
* ·
|
|
5
|
+
* · 列表里有哪些对话任务(宿主给 jobIds,或本地记住 + 从 center 发现);
|
|
6
6
|
* · 每个对话的实时状态:一个对话只开一条实时流,几个组件同时看也只连一次(引用计数)。
|
|
7
7
|
*
|
|
8
|
+
* 「一个对话」用对话键标识(见 conversation.ts convKey):一个对话任务里可以有几个对话,"job_x#task_y" 是其中一个,
|
|
9
|
+
* 只有 "job_x" 是它的第一个(和以前一样);"job_x#new" 是在这个对话任务里新开一个对话。jobs / jobIds 仍按对话任务。
|
|
10
|
+
*
|
|
8
11
|
* 单独用某个组件时它会自己建一个仓库;放进同一个 <CliCenterProvider>(或同一个 group 的 Web Component)就共享一个。
|
|
9
12
|
*/
|
|
10
13
|
import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client.js";
|
|
11
|
-
import { applyItems, applySnapshot, closedCallback, emptyConv, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
|
|
14
|
+
import { addTurnsIfAbsent, applyItems, applySnapshot, closedCallback, convEnded, convKey, emptyConv, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
|
|
12
15
|
import { formatAnswersText } from "../ui/format.js";
|
|
13
16
|
import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types.js";
|
|
14
17
|
|
|
@@ -22,9 +25,13 @@ export interface JobEntry {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export interface CliState {
|
|
28
|
+
/** 选中的对话(对话键)。 */
|
|
25
29
|
selected: string | null;
|
|
30
|
+
/** 列表里的对话任务(job_id)。 */
|
|
26
31
|
jobIds: string[];
|
|
32
|
+
/** 按 job_id。 */
|
|
27
33
|
jobs: Record<string, JobEntry>;
|
|
34
|
+
/** 按对话键。 */
|
|
28
35
|
convs: Record<string, ConvState>;
|
|
29
36
|
listLoading: boolean;
|
|
30
37
|
listError: string | null;
|
|
@@ -32,14 +39,28 @@ export interface CliState {
|
|
|
32
39
|
|
|
33
40
|
/** 与 center 回调同名同义的事件,方便宿主把前端事件和后端回调对上。 */
|
|
34
41
|
export type CliEvent =
|
|
42
|
+
/** jobId 是选中的对话键(只有 job_id,或 "job_id#task_id")。 */
|
|
35
43
|
| { type: "select"; jobId: string | null }
|
|
36
|
-
/** 一轮结束。payload 与 center 回调 conversation_turn 的请求体同名同义。 */
|
|
37
|
-
| { type: "conversation_turn"; jobId: string; turn: CenterTurn; payload: ConversationTurnCallback }
|
|
38
|
-
/**
|
|
39
|
-
| { type: "conversation_closed"; jobId: string; reason: string | null; continuedBy: string | null; payload: ConversationClosedCallback }
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
/** 一轮结束。payload 与 center 回调 conversation_turn 的请求体同名同义。key 是这个对话的对话键。 */
|
|
45
|
+
| { type: "conversation_turn"; jobId: string; key: string; turn: CenterTurn; payload: ConversationTurnCallback }
|
|
46
|
+
/** 对话结束(整个对话任务结束,或只关掉了这一个对话)。payload 与 center 回调 conversation_closed 的请求体同名同义。 */
|
|
47
|
+
| { type: "conversation_closed"; jobId: string; key: string; reason: string | null; continuedBy: string | null; payload: ConversationClosedCallback }
|
|
48
|
+
/**
|
|
49
|
+
* 新建了对话(新对话任务,或在对话任务里新开的对话),或旧对话结束后消息开了新对话(job_id 变了)。
|
|
50
|
+
* taskId / key:新的那个对话;previousKey:之前的对话键(新建时是 null 或 "job#new")。
|
|
51
|
+
*/
|
|
52
|
+
| { type: "job_change"; jobId: string; previousJobId: string | null; taskId: string | null; key: string; previousKey: string | null }
|
|
53
|
+
| { type: "error"; jobId: string | null; error: CenterError }
|
|
54
|
+
/**
|
|
55
|
+
* 有人要在看板里打开东西(对话里点了本地文件链接、「查看浏览器」,或宿主调了 store.openInBoard)。
|
|
56
|
+
* handled = 有看板接住了;没有时对话组件自己弹窗打开。宿主可以据此展开右侧面板。
|
|
57
|
+
*/
|
|
58
|
+
| { type: "open"; jobId: string; target: OpenTarget; handled: boolean };
|
|
59
|
+
|
|
60
|
+
/** 看板要打开的东西。 */
|
|
61
|
+
export type OpenTarget = { kind: "file"; path: string } | { kind: "browser" } | { kind: "changes" } | { kind: "files"; path?: string };
|
|
62
|
+
/** 看板登记的接收函数:接得住(显示的是这个对话)就返回 true。 */
|
|
63
|
+
export type BoardSurface = (jobId: string, target: OpenTarget) => boolean;
|
|
43
64
|
|
|
44
65
|
export interface CliStoreOptions extends CenterClientOptions {
|
|
45
66
|
client?: CenterClient;
|
|
@@ -80,6 +101,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
80
101
|
const listeners = new Set<() => void>();
|
|
81
102
|
const eventListeners = new Set<(event: CliEvent) => void>();
|
|
82
103
|
const settledTurns = new Set<string>();
|
|
104
|
+
const watchedTurns = new Set<string>();
|
|
83
105
|
const closedJobs = new Set<string>();
|
|
84
106
|
const seenOpen = new Set<string>();
|
|
85
107
|
let hostIds = false;
|
|
@@ -89,12 +111,12 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
89
111
|
for (const fn of listeners) fn();
|
|
90
112
|
};
|
|
91
113
|
const emit = (event: CliEvent) => { for (const fn of eventListeners) fn(event); };
|
|
92
|
-
const patchConv = (
|
|
93
|
-
const prev = state.convs[
|
|
114
|
+
const patchConv = (key: string, fn: (conv: ConvState) => ConvState) => {
|
|
115
|
+
const prev = state.convs[key] ?? emptyConv(key);
|
|
94
116
|
const next = fn(prev);
|
|
95
117
|
if (next !== prev) {
|
|
96
|
-
noticeTransitions(
|
|
97
|
-
set({ convs: { ...state.convs, [
|
|
118
|
+
noticeTransitions(key, prev, next);
|
|
119
|
+
set({ convs: { ...state.convs, [key]: next } });
|
|
98
120
|
}
|
|
99
121
|
};
|
|
100
122
|
const patchJob = (jobId: string, patch: Partial<JobEntry>) => {
|
|
@@ -103,32 +125,37 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
103
125
|
};
|
|
104
126
|
|
|
105
127
|
// 一轮从未结束变成结束 → conversation_turn;对话变成结束 → conversation_closed。
|
|
106
|
-
function noticeTransitions(
|
|
128
|
+
function noticeTransitions(key: string, prev: ConvState, next: ConvState) {
|
|
129
|
+
const { jobId } = next;
|
|
107
130
|
if (prev.turns !== next.turns) {
|
|
108
131
|
const before = new Map(prev.turns.map((turn) => [turn.turn_id, turn.status]));
|
|
109
132
|
// 有轮次开始执行 / 新排队:列表卡片的「运行中」「排队中」也要跟上,不等下一次轮询。
|
|
110
133
|
if (next.turns.some((turn) => before.get(turn.turn_id) !== turn.status && !TERMINAL_TURN.has(turn.status))) void refreshJob(jobId);
|
|
134
|
+
// 本页看着还没结束过的轮次才发结果(打开页面时就已结束的历史轮次不发)。
|
|
135
|
+
for (const turn of next.turns) if (!TERMINAL_TURN.has(turn.status)) watchedTurns.add(turn.turn_id);
|
|
136
|
+
// 按 seq 顺序发,与 center 投递 callback 的顺序一致:前面还有没结束的轮次,后面结束了的先等着
|
|
137
|
+
// (例如第 2 条排队消息被撤回时第 1 轮还在跑,要等第 1 轮结束后先发 1、再发 2)。
|
|
111
138
|
for (const turn of next.turns) {
|
|
112
|
-
if (!TERMINAL_TURN.has(turn.status)
|
|
139
|
+
if (!TERMINAL_TURN.has(turn.status)) break;
|
|
140
|
+
if (settledTurns.has(turn.turn_id)) continue;
|
|
113
141
|
settledTurns.add(turn.turn_id);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
emit({ type: "conversation_turn", jobId, turn, payload: turnCallback(next, turn, continuedFrom) });
|
|
120
|
-
void refreshJob(jobId, true);
|
|
121
|
-
}
|
|
142
|
+
// 被合并进别的轮次的消息不单独发(center 也不单独投递),结果在那一轮的 messages 里。
|
|
143
|
+
if (!watchedTurns.has(turn.turn_id) || turn.status === "merged") continue;
|
|
144
|
+
const continuedFrom = state.jobs[jobId]?.job?.conversation?.continued_from_job_id ?? null;
|
|
145
|
+
emit({ type: "conversation_turn", jobId, key, turn, payload: turnCallback(next, turn, continuedFrom) });
|
|
146
|
+
void refreshJob(jobId, true);
|
|
122
147
|
}
|
|
123
148
|
}
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
if (next.job && !
|
|
127
|
-
|
|
149
|
+
// 对话结束(整个对话任务结束,或只关掉了这一个对话):只对本页看着还开着的对话发(打开时就已结束的不算);
|
|
150
|
+
// 而且要等所有轮次都收尾之后再发,保证 conversation_closed 排在这个对话最后一个 conversation_turn 之后(与 center 的 callback_seq 一致)。
|
|
151
|
+
if (next.job && !convEnded(next)) seenOpen.add(key);
|
|
152
|
+
// 一次性任务(只读查看)没有对话的结果通知。
|
|
153
|
+
if (next.job && convEnded(next) && seenOpen.has(key) && !closedJobs.has(key) && !next.batch) {
|
|
128
154
|
const settled = next.turns.every((turn) => TERMINAL_TURN.has(turn.status));
|
|
129
155
|
if (settled || next.stream === "ended") {
|
|
130
|
-
closedJobs.add(
|
|
131
|
-
|
|
156
|
+
closedJobs.add(key);
|
|
157
|
+
const payload = closedCallback(next);
|
|
158
|
+
emit({ type: "conversation_closed", jobId, key, reason: payload.closed_reason, continuedBy: next.continuedBy, payload });
|
|
132
159
|
}
|
|
133
160
|
}
|
|
134
161
|
// 实时流里的对话状态同步给列表。
|
|
@@ -165,14 +192,17 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
165
192
|
persist(ids);
|
|
166
193
|
}
|
|
167
194
|
|
|
168
|
-
/** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。 */
|
|
195
|
+
/** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。oldId / newId 可以是对话键。 */
|
|
169
196
|
function replaceJob(oldId: string, newId: string) {
|
|
170
197
|
if (oldId === newId) return;
|
|
171
|
-
const
|
|
198
|
+
const from = parseConvKey(oldId);
|
|
199
|
+
const to = parseConvKey(newId);
|
|
200
|
+
const ids = from.jobId === to.jobId ? state.jobIds
|
|
201
|
+
: state.jobIds.includes(from.jobId) ? state.jobIds.map((id) => (id === from.jobId ? to.jobId : id)) : [to.jobId, ...state.jobIds];
|
|
172
202
|
set({ jobIds: [...new Set(ids)], selected: state.selected === oldId ? newId : state.selected });
|
|
173
203
|
persist(state.jobIds);
|
|
174
|
-
emit({ type: "job_change", jobId:
|
|
175
|
-
void refreshJob(
|
|
204
|
+
emit({ type: "job_change", jobId: to.jobId, previousJobId: from.jobId, taskId: to.taskId, key: newId, previousKey: oldId });
|
|
205
|
+
void refreshJob(to.jobId, true);
|
|
176
206
|
}
|
|
177
207
|
|
|
178
208
|
const refreshAgain = new Set<string>();
|
|
@@ -186,16 +216,17 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
186
216
|
const job = await client.getJob(jobId);
|
|
187
217
|
let firstInput = state.jobs[jobId]?.firstInput ?? null;
|
|
188
218
|
if (firstInput === null && job.mode === "conversation") {
|
|
219
|
+
// 卡片描述取第一个对话的第一条消息(按 seq 最早的那一轮)。
|
|
189
220
|
const first = await client.listTurns(jobId, { limit: 1 }).catch(() => null);
|
|
190
221
|
firstInput = first?.turns[0]?.input ?? "";
|
|
191
222
|
}
|
|
192
223
|
patchJob(jobId, { job, firstInput, loading: false, error: null, fetchedAt: Date.now() });
|
|
193
|
-
//
|
|
194
|
-
|
|
224
|
+
// 新数据里已包含实时用量,清零累计(这个对话任务里的每个对话)。
|
|
225
|
+
for (const conv of Object.values(state.convs)) if (conv.jobId === jobId) patchConv(conv.key, (c) => ({ ...c, liveUsage: { fresh: 0, cacheRead: 0 } }));
|
|
195
226
|
} catch (error) {
|
|
196
227
|
patchJob(jobId, { loading: false, error: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() });
|
|
197
228
|
// 本地记住的对话在 center 已经不存在(被删、换了环境):从列表里拿掉。宿主给的 jobIds 不动,由宿主决定。
|
|
198
|
-
if (!hostIds && error instanceof CenterError && error.status === 404 && state.selected !== jobId) {
|
|
229
|
+
if (!hostIds && error instanceof CenterError && error.status === 404 && (!state.selected || parseConvKey(state.selected).jobId !== jobId)) {
|
|
199
230
|
refreshAgain.delete(jobId);
|
|
200
231
|
removeJob(jobId);
|
|
201
232
|
return;
|
|
@@ -259,38 +290,41 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
259
290
|
|
|
260
291
|
const streams = new Map<string, { refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number }>();
|
|
261
292
|
|
|
262
|
-
function connect(
|
|
263
|
-
const entry = streams.get(
|
|
293
|
+
function connect(key: string) {
|
|
294
|
+
const entry = streams.get(key);
|
|
264
295
|
if (!entry || entry.refs <= 0) return;
|
|
265
|
-
|
|
296
|
+
const { jobId, taskId } = parseConvKey(key);
|
|
297
|
+
patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
|
|
266
298
|
let ended = false;
|
|
267
299
|
entry.stop = client.stream(jobId, {
|
|
268
|
-
onOpen: () => { entry.attempt = 0; patchConv(
|
|
269
|
-
onSnapshot: (snapshot) => patchConv(
|
|
270
|
-
onChunk: (items) => patchConv(
|
|
300
|
+
onOpen: () => { entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
|
|
301
|
+
onSnapshot: (snapshot) => patchConv(key, (conv) => applySnapshot(conv, snapshot)),
|
|
302
|
+
onChunk: (items) => patchConv(key, (conv) => applyItems(conv, items)),
|
|
271
303
|
onEnd: (end) => {
|
|
272
304
|
ended = true;
|
|
273
|
-
|
|
305
|
+
// 接续的新对话:指定了对话的跟到接续它的那个对话(还没有就不跟,再发消息时 center 会开出来);只有 job_id 的照旧跟新 job。
|
|
306
|
+
const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
|
|
307
|
+
patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
|
|
274
308
|
void refreshJob(jobId, true);
|
|
275
309
|
},
|
|
276
310
|
onClose: (error) => {
|
|
277
311
|
entry.stop = null;
|
|
278
|
-
const current = streams.get(
|
|
312
|
+
const current = streams.get(key);
|
|
279
313
|
if (!current || current.refs <= 0) return;
|
|
280
314
|
// 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
|
|
281
|
-
if (ended && state.convs[
|
|
315
|
+
if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
|
|
282
316
|
// 404 / 401:对话不存在或没权限,重连也没用。
|
|
283
317
|
if (error && (error.status === 404 || error.status === 401 || error.status === 403)) {
|
|
284
|
-
patchConv(
|
|
285
|
-
emit({ type: "error", jobId, error });
|
|
318
|
+
patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
|
|
319
|
+
emit({ type: "error", jobId: key, error });
|
|
286
320
|
return;
|
|
287
321
|
}
|
|
288
322
|
const delay = RECONNECT_MS[Math.min(current.attempt, RECONNECT_MS.length - 1)];
|
|
289
323
|
current.attempt += 1;
|
|
290
|
-
patchConv(
|
|
291
|
-
current.timer = setTimeout(() => { current.timer = null; connect(
|
|
324
|
+
patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
|
|
325
|
+
current.timer = setTimeout(() => { current.timer = null; connect(key); }, delay);
|
|
292
326
|
},
|
|
293
|
-
});
|
|
327
|
+
}, undefined, taskId);
|
|
294
328
|
}
|
|
295
329
|
|
|
296
330
|
// 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
|
|
@@ -308,35 +342,43 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
308
342
|
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reconnectNow(); });
|
|
309
343
|
}
|
|
310
344
|
|
|
311
|
-
/**
|
|
312
|
-
|
|
313
|
-
|
|
345
|
+
/**
|
|
346
|
+
* 开始看一个对话(对话键;返回停止函数)。同一对话多处同时看只连一次。
|
|
347
|
+
* "job#new"(还没开出来的对话)没有实时流,只拉对话任务的信息。
|
|
348
|
+
*/
|
|
349
|
+
function open(key: string): () => void {
|
|
350
|
+
const { jobId, taskId } = parseConvKey(key);
|
|
351
|
+
if (taskId === NEW_TASK) {
|
|
352
|
+
void refreshJob(jobId);
|
|
353
|
+
return () => undefined;
|
|
354
|
+
}
|
|
355
|
+
let entry = streams.get(key);
|
|
314
356
|
if (!entry) {
|
|
315
357
|
entry = { refs: 0, stop: null, timer: null, attempt: 0 };
|
|
316
|
-
streams.set(
|
|
358
|
+
streams.set(key, entry);
|
|
317
359
|
}
|
|
318
360
|
entry.refs += 1;
|
|
319
361
|
if (entry.refs === 1) {
|
|
320
362
|
void refreshJob(jobId);
|
|
321
|
-
connect(
|
|
363
|
+
connect(key);
|
|
322
364
|
}
|
|
323
365
|
return () => {
|
|
324
|
-
const current = streams.get(
|
|
366
|
+
const current = streams.get(key);
|
|
325
367
|
if (!current) return;
|
|
326
368
|
current.refs -= 1;
|
|
327
369
|
if (current.refs > 0) return;
|
|
328
370
|
current.stop?.();
|
|
329
371
|
if (current.timer) clearTimeout(current.timer);
|
|
330
|
-
streams.delete(
|
|
331
|
-
patchConv(
|
|
372
|
+
streams.delete(key);
|
|
373
|
+
patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
|
|
332
374
|
};
|
|
333
375
|
}
|
|
334
376
|
|
|
335
377
|
// ── 发消息 / 新建 / 撤回 / 结束 ───────────────────────
|
|
336
378
|
|
|
337
379
|
/**
|
|
338
|
-
* 发一条消息。
|
|
339
|
-
*
|
|
380
|
+
* 发一条消息。key 是对话键:null 时新建对话任务(这条就是第一轮);"job#new" 时在这个对话任务里新开一个对话。
|
|
381
|
+
* 返回实际接收消息的对话键。带 business_id 发:网络抖动时重发一次也只会跑一轮。
|
|
340
382
|
*/
|
|
341
383
|
// 同一个对话的消息严格按发出的顺序送到 center(输入框、提问卡片、重试可能同时发):
|
|
342
384
|
// center 按到达顺序给轮次编号,并发发出去可能后发先到。每个对话一条队列,前一条失败不挡后面的。
|
|
@@ -351,14 +393,27 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
351
393
|
return run;
|
|
352
394
|
}
|
|
353
395
|
|
|
354
|
-
async function sendNow(
|
|
396
|
+
async function sendNow(key: string | null, text: string, overrides: SendOptions): Promise<string> {
|
|
355
397
|
// 发消息的组件自己的参数优先(同一个仓库里可以有几个对话组件,各带各的新建参数),没有再用仓库的。
|
|
356
398
|
const newConversation = overrides.newConversation ?? options.newConversation;
|
|
357
399
|
const createConversation = overrides.createConversation ?? options.createConversation;
|
|
358
400
|
const businessIdFor = overrides.businessIdFor ?? options.businessIdFor;
|
|
359
|
-
const
|
|
401
|
+
const ref = key ? parseConvKey(key) : null;
|
|
402
|
+
const businessId = businessIdFor?.(text, ref && ref.taskId !== NEW_TASK ? "message" : "create") || `cli-${uid()}`;
|
|
360
403
|
try {
|
|
361
|
-
if (
|
|
404
|
+
if (ref?.taskId === NEW_TASK) {
|
|
405
|
+
// 在对话任务里新开一个对话:标题取第一行,智能体 / 浏览器模式沿用对话任务的第一个对话(center 决定)。
|
|
406
|
+
if (!client.openConversation) throw new CenterError("当前后端不支持在对话任务里新开对话", "UNSUPPORTED", 0);
|
|
407
|
+
const result = await retryOnce(() => client.openConversation!(ref.jobId, text, { title: firstLine(text, 60), businessId }));
|
|
408
|
+
const target = convKey(result.job_id, result.task_id ?? result.turn.task_id);
|
|
409
|
+
patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
|
|
410
|
+
addJobIfMissing(result.job_id);
|
|
411
|
+
if (overrides.select !== false && state.selected === key) set({ selected: target });
|
|
412
|
+
emit({ type: "job_change", jobId: result.job_id, previousJobId: result.job_id !== ref.jobId ? ref.jobId : null, taskId: parseConvKey(target).taskId, key: target, previousKey: key });
|
|
413
|
+
void refreshJob(result.job_id, true);
|
|
414
|
+
return target;
|
|
415
|
+
}
|
|
416
|
+
if (!key || !ref) {
|
|
362
417
|
let newId: string;
|
|
363
418
|
if (createConversation) {
|
|
364
419
|
newId = await createConversation(text);
|
|
@@ -366,25 +421,27 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
366
421
|
// 新建只发一次:center 的 POST /api/jobs 没有幂等,建好了却没收到响应时重发会建出第二个对话。
|
|
367
422
|
const created = await client.createConversation(text, { ...newConversation, title: newConversation?.title || firstLine(text), businessId: newConversation?.businessId || businessId });
|
|
368
423
|
newId = created.id;
|
|
369
|
-
patchConv(newId, (conv) => ({ ...conv, turns:
|
|
424
|
+
patchConv(newId, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, created.turn ? [created.turn] : []) }));
|
|
370
425
|
}
|
|
371
426
|
addJob(newId);
|
|
372
427
|
// 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
|
|
373
428
|
if (overrides.select !== false) set({ selected: newId });
|
|
374
|
-
emit({ type: "job_change", jobId: newId, previousJobId: null });
|
|
429
|
+
emit({ type: "job_change", jobId: newId, previousJobId: null, taskId: null, key: newId, previousKey: null });
|
|
375
430
|
void refreshJob(newId, true);
|
|
376
431
|
return newId;
|
|
377
432
|
}
|
|
378
|
-
const result = await retryOnce(() => client.sendMessage(jobId, text, { businessId }));
|
|
379
|
-
const
|
|
380
|
-
|
|
433
|
+
const result = await retryOnce(() => client.sendMessage(ref.jobId, text, { businessId, ...(ref.taskId ? { taskId: ref.taskId } : {}) }));
|
|
434
|
+
const jobTarget = result.job_id || ref.jobId;
|
|
435
|
+
// 指定了对话的:接续后跟到新对话任务里对应的那个对话;只有 job_id 的照旧只换 job。
|
|
436
|
+
const target = ref.taskId ? convKey(jobTarget, result.task_id ?? result.turn.task_id ?? ref.taskId) : jobTarget;
|
|
437
|
+
if (target !== key) replaceJob(key, target);
|
|
381
438
|
// 先把这一轮放进状态:排队气泡立刻出现,不用等实时流的下一个块。
|
|
382
|
-
patchConv(target, (conv) => ({ ...conv, turns:
|
|
383
|
-
if (
|
|
439
|
+
patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
|
|
440
|
+
if (jobTarget === ref.jobId) addJobIfMissing(ref.jobId);
|
|
384
441
|
return target;
|
|
385
442
|
} catch (error) {
|
|
386
443
|
const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
|
|
387
|
-
emit({ type: "error", jobId, error: err });
|
|
444
|
+
emit({ type: "error", jobId: key, error: err });
|
|
388
445
|
throw err;
|
|
389
446
|
}
|
|
390
447
|
}
|
|
@@ -398,18 +455,18 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
398
455
|
* 所以回答作为下一条消息发出——与 Operator 在没有等待中的回合时(/answer 返回 resolved=false)的做法一致;
|
|
399
456
|
* 卡片先在本地标成已答(Operator 同样是先乐观更新)。
|
|
400
457
|
*/
|
|
401
|
-
async function answerAsk(
|
|
458
|
+
async function answerAsk(key: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions = {}): Promise<string> {
|
|
402
459
|
// 与 Operator 的 answerQuestion 同一流程:先乐观标成已答 → 交给正在等的回合 → 没有在等的回合就当普通回复发出。
|
|
403
|
-
patchConv(
|
|
460
|
+
patchConv(key, (conv) => ({ ...conv, localAnswers: { ...conv.localAnswers, [askId]: answers } }));
|
|
404
461
|
try {
|
|
405
462
|
if (askId && client.answerAsk) {
|
|
406
|
-
const { resolved } = await client.answerAsk(
|
|
407
|
-
if (resolved) return
|
|
463
|
+
const { resolved } = await client.answerAsk(key, askId, answers);
|
|
464
|
+
if (resolved) return key;
|
|
408
465
|
}
|
|
409
|
-
return await send(
|
|
466
|
+
return await send(key, formatAnswersText(questions, answers), overrides);
|
|
410
467
|
} catch (error) {
|
|
411
468
|
// 没发出去:卡片恢复成可回答。
|
|
412
|
-
patchConv(
|
|
469
|
+
patchConv(key, (conv) => {
|
|
413
470
|
const { [askId]: _drop, ...rest } = conv.localAnswers;
|
|
414
471
|
return { ...conv, localAnswers: rest };
|
|
415
472
|
});
|
|
@@ -417,31 +474,59 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
417
474
|
}
|
|
418
475
|
}
|
|
419
476
|
|
|
420
|
-
async function cancelTurn(
|
|
477
|
+
async function cancelTurn(key: string, turnId: string) {
|
|
421
478
|
try {
|
|
422
|
-
const turn = await client.cancelTurn(jobId, turnId);
|
|
423
|
-
|
|
479
|
+
const turn = await client.cancelTurn(parseConvKey(key).jobId, turnId);
|
|
480
|
+
// 撤回排队的消息会立刻收尾(cancelled):响应里已经结束、而我们还没收到的,先用上;其余以实时流为准。
|
|
481
|
+
patchConv(key, (conv) => {
|
|
482
|
+
const known = conv.turns.find((t) => t.turn_id === turn.turn_id);
|
|
483
|
+
if (known && (TERMINAL_TURN.has(known.status) || !TERMINAL_TURN.has(turn.status))) return conv;
|
|
484
|
+
return { ...conv, turns: upsertTurns(conv.turns, [turn]) };
|
|
485
|
+
});
|
|
424
486
|
} catch (error) {
|
|
425
487
|
const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
|
|
426
|
-
emit({ type: "error", jobId, error: err });
|
|
488
|
+
emit({ type: "error", jobId: key, error: err });
|
|
427
489
|
throw err;
|
|
428
490
|
}
|
|
429
491
|
}
|
|
430
492
|
|
|
431
|
-
|
|
493
|
+
/** 结束对话:指定了对话("job#task")的只关掉这一个;只有 job_id 的结束整个对话任务(和以前一样)。 */
|
|
494
|
+
async function close(key: string) {
|
|
495
|
+
const { jobId, taskId } = parseConvKey(key);
|
|
432
496
|
try {
|
|
433
|
-
|
|
497
|
+
if (taskId && taskId !== NEW_TASK) {
|
|
498
|
+
if (!client.closeConversationTask) throw new CenterError("当前后端不支持单独关闭对话", "UNSUPPORTED", 0);
|
|
499
|
+
await client.closeConversationTask(jobId, taskId);
|
|
500
|
+
} else {
|
|
501
|
+
await client.closeConversation(jobId);
|
|
502
|
+
}
|
|
434
503
|
await refreshJob(jobId, true);
|
|
435
504
|
} catch (error) {
|
|
436
505
|
const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
|
|
437
|
-
emit({ type: "error", jobId, error: err });
|
|
506
|
+
emit({ type: "error", jobId: key, error: err });
|
|
438
507
|
throw err;
|
|
439
508
|
}
|
|
440
509
|
}
|
|
441
510
|
|
|
511
|
+
// 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
|
|
512
|
+
const surfaces: BoardSurface[] = [];
|
|
513
|
+
function openInBoard(jobId: string, target: OpenTarget): boolean {
|
|
514
|
+
let handled = false;
|
|
515
|
+
for (let i = surfaces.length - 1; i >= 0 && !handled; i--) handled = surfaces[i](jobId, target);
|
|
516
|
+
emit({ type: "open", jobId, target, handled });
|
|
517
|
+
return handled;
|
|
518
|
+
}
|
|
519
|
+
|
|
442
520
|
return {
|
|
443
521
|
client,
|
|
444
522
|
getState: () => state,
|
|
523
|
+
/** 在看板里打开文件 / 浏览器 / 改动。返回有没有看板接住。 */
|
|
524
|
+
openInBoard,
|
|
525
|
+
/** 看板登记自己;返回注销函数。 */
|
|
526
|
+
registerBoard(surface: BoardSurface) {
|
|
527
|
+
surfaces.push(surface);
|
|
528
|
+
return () => { const i = surfaces.indexOf(surface); if (i >= 0) surfaces.splice(i, 1); };
|
|
529
|
+
},
|
|
445
530
|
subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
|
446
531
|
on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
|
|
447
532
|
select(jobId: string | null) {
|