@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/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,36 @@ 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
|
-
if (next.job &&
|
|
149
|
+
// 对话结束(整个对话任务结束,或只关掉了这一个对话):只对本页看着还开着的对话发(打开时就已结束的不算);
|
|
150
|
+
// 而且要等所有轮次都收尾之后再发,保证 conversation_closed 排在这个对话最后一个 conversation_turn 之后(与 center 的 callback_seq 一致)。
|
|
151
|
+
if (next.job && !convEnded(next)) seenOpen.add(key);
|
|
152
|
+
if (next.job && convEnded(next) && seenOpen.has(key) && !closedJobs.has(key)) {
|
|
128
153
|
const settled = next.turns.every((turn) => TERMINAL_TURN.has(turn.status));
|
|
129
154
|
if (settled || next.stream === "ended") {
|
|
130
|
-
closedJobs.add(
|
|
131
|
-
|
|
155
|
+
closedJobs.add(key);
|
|
156
|
+
const payload = closedCallback(next);
|
|
157
|
+
emit({ type: "conversation_closed", jobId, key, reason: payload.closed_reason, continuedBy: next.continuedBy, payload });
|
|
132
158
|
}
|
|
133
159
|
}
|
|
134
160
|
// 实时流里的对话状态同步给列表。
|
|
@@ -165,14 +191,17 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
165
191
|
persist(ids);
|
|
166
192
|
}
|
|
167
193
|
|
|
168
|
-
/** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。 */
|
|
194
|
+
/** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。oldId / newId 可以是对话键。 */
|
|
169
195
|
function replaceJob(oldId: string, newId: string) {
|
|
170
196
|
if (oldId === newId) return;
|
|
171
|
-
const
|
|
197
|
+
const from = parseConvKey(oldId);
|
|
198
|
+
const to = parseConvKey(newId);
|
|
199
|
+
const ids = from.jobId === to.jobId ? state.jobIds
|
|
200
|
+
: state.jobIds.includes(from.jobId) ? state.jobIds.map((id) => (id === from.jobId ? to.jobId : id)) : [to.jobId, ...state.jobIds];
|
|
172
201
|
set({ jobIds: [...new Set(ids)], selected: state.selected === oldId ? newId : state.selected });
|
|
173
202
|
persist(state.jobIds);
|
|
174
|
-
emit({ type: "job_change", jobId:
|
|
175
|
-
void refreshJob(
|
|
203
|
+
emit({ type: "job_change", jobId: to.jobId, previousJobId: from.jobId, taskId: to.taskId, key: newId, previousKey: oldId });
|
|
204
|
+
void refreshJob(to.jobId, true);
|
|
176
205
|
}
|
|
177
206
|
|
|
178
207
|
const refreshAgain = new Set<string>();
|
|
@@ -186,16 +215,17 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
186
215
|
const job = await client.getJob(jobId);
|
|
187
216
|
let firstInput = state.jobs[jobId]?.firstInput ?? null;
|
|
188
217
|
if (firstInput === null && job.mode === "conversation") {
|
|
218
|
+
// 卡片描述取第一个对话的第一条消息(按 seq 最早的那一轮)。
|
|
189
219
|
const first = await client.listTurns(jobId, { limit: 1 }).catch(() => null);
|
|
190
220
|
firstInput = first?.turns[0]?.input ?? "";
|
|
191
221
|
}
|
|
192
222
|
patchJob(jobId, { job, firstInput, loading: false, error: null, fetchedAt: Date.now() });
|
|
193
|
-
//
|
|
194
|
-
|
|
223
|
+
// 新数据里已包含实时用量,清零累计(这个对话任务里的每个对话)。
|
|
224
|
+
for (const conv of Object.values(state.convs)) if (conv.jobId === jobId) patchConv(conv.key, (c) => ({ ...c, liveUsage: { fresh: 0, cacheRead: 0 } }));
|
|
195
225
|
} catch (error) {
|
|
196
226
|
patchJob(jobId, { loading: false, error: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() });
|
|
197
227
|
// 本地记住的对话在 center 已经不存在(被删、换了环境):从列表里拿掉。宿主给的 jobIds 不动,由宿主决定。
|
|
198
|
-
if (!hostIds && error instanceof CenterError && error.status === 404 && state.selected !== jobId) {
|
|
228
|
+
if (!hostIds && error instanceof CenterError && error.status === 404 && (!state.selected || parseConvKey(state.selected).jobId !== jobId)) {
|
|
199
229
|
refreshAgain.delete(jobId);
|
|
200
230
|
removeJob(jobId);
|
|
201
231
|
return;
|
|
@@ -259,38 +289,41 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
259
289
|
|
|
260
290
|
const streams = new Map<string, { refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number }>();
|
|
261
291
|
|
|
262
|
-
function connect(
|
|
263
|
-
const entry = streams.get(
|
|
292
|
+
function connect(key: string) {
|
|
293
|
+
const entry = streams.get(key);
|
|
264
294
|
if (!entry || entry.refs <= 0) return;
|
|
265
|
-
|
|
295
|
+
const { jobId, taskId } = parseConvKey(key);
|
|
296
|
+
patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
|
|
266
297
|
let ended = false;
|
|
267
298
|
entry.stop = client.stream(jobId, {
|
|
268
|
-
onOpen: () => { entry.attempt = 0; patchConv(
|
|
269
|
-
onSnapshot: (snapshot) => patchConv(
|
|
270
|
-
onChunk: (items) => patchConv(
|
|
299
|
+
onOpen: () => { entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
|
|
300
|
+
onSnapshot: (snapshot) => patchConv(key, (conv) => applySnapshot(conv, snapshot)),
|
|
301
|
+
onChunk: (items) => patchConv(key, (conv) => applyItems(conv, items)),
|
|
271
302
|
onEnd: (end) => {
|
|
272
303
|
ended = true;
|
|
273
|
-
|
|
304
|
+
// 接续的新对话:指定了对话的跟到接续它的那个对话(还没有就不跟,再发消息时 center 会开出来);只有 job_id 的照旧跟新 job。
|
|
305
|
+
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;
|
|
306
|
+
patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
|
|
274
307
|
void refreshJob(jobId, true);
|
|
275
308
|
},
|
|
276
309
|
onClose: (error) => {
|
|
277
310
|
entry.stop = null;
|
|
278
|
-
const current = streams.get(
|
|
311
|
+
const current = streams.get(key);
|
|
279
312
|
if (!current || current.refs <= 0) return;
|
|
280
313
|
// 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
|
|
281
|
-
if (ended && state.convs[
|
|
314
|
+
if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
|
|
282
315
|
// 404 / 401:对话不存在或没权限,重连也没用。
|
|
283
316
|
if (error && (error.status === 404 || error.status === 401 || error.status === 403)) {
|
|
284
|
-
patchConv(
|
|
285
|
-
emit({ type: "error", jobId, error });
|
|
317
|
+
patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
|
|
318
|
+
emit({ type: "error", jobId: key, error });
|
|
286
319
|
return;
|
|
287
320
|
}
|
|
288
321
|
const delay = RECONNECT_MS[Math.min(current.attempt, RECONNECT_MS.length - 1)];
|
|
289
322
|
current.attempt += 1;
|
|
290
|
-
patchConv(
|
|
291
|
-
current.timer = setTimeout(() => { current.timer = null; connect(
|
|
323
|
+
patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
|
|
324
|
+
current.timer = setTimeout(() => { current.timer = null; connect(key); }, delay);
|
|
292
325
|
},
|
|
293
|
-
});
|
|
326
|
+
}, undefined, taskId);
|
|
294
327
|
}
|
|
295
328
|
|
|
296
329
|
// 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
|
|
@@ -308,35 +341,43 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
308
341
|
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reconnectNow(); });
|
|
309
342
|
}
|
|
310
343
|
|
|
311
|
-
/**
|
|
312
|
-
|
|
313
|
-
|
|
344
|
+
/**
|
|
345
|
+
* 开始看一个对话(对话键;返回停止函数)。同一对话多处同时看只连一次。
|
|
346
|
+
* "job#new"(还没开出来的对话)没有实时流,只拉对话任务的信息。
|
|
347
|
+
*/
|
|
348
|
+
function open(key: string): () => void {
|
|
349
|
+
const { jobId, taskId } = parseConvKey(key);
|
|
350
|
+
if (taskId === NEW_TASK) {
|
|
351
|
+
void refreshJob(jobId);
|
|
352
|
+
return () => undefined;
|
|
353
|
+
}
|
|
354
|
+
let entry = streams.get(key);
|
|
314
355
|
if (!entry) {
|
|
315
356
|
entry = { refs: 0, stop: null, timer: null, attempt: 0 };
|
|
316
|
-
streams.set(
|
|
357
|
+
streams.set(key, entry);
|
|
317
358
|
}
|
|
318
359
|
entry.refs += 1;
|
|
319
360
|
if (entry.refs === 1) {
|
|
320
361
|
void refreshJob(jobId);
|
|
321
|
-
connect(
|
|
362
|
+
connect(key);
|
|
322
363
|
}
|
|
323
364
|
return () => {
|
|
324
|
-
const current = streams.get(
|
|
365
|
+
const current = streams.get(key);
|
|
325
366
|
if (!current) return;
|
|
326
367
|
current.refs -= 1;
|
|
327
368
|
if (current.refs > 0) return;
|
|
328
369
|
current.stop?.();
|
|
329
370
|
if (current.timer) clearTimeout(current.timer);
|
|
330
|
-
streams.delete(
|
|
331
|
-
patchConv(
|
|
371
|
+
streams.delete(key);
|
|
372
|
+
patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
|
|
332
373
|
};
|
|
333
374
|
}
|
|
334
375
|
|
|
335
376
|
// ── 发消息 / 新建 / 撤回 / 结束 ───────────────────────
|
|
336
377
|
|
|
337
378
|
/**
|
|
338
|
-
* 发一条消息。
|
|
339
|
-
*
|
|
379
|
+
* 发一条消息。key 是对话键:null 时新建对话任务(这条就是第一轮);"job#new" 时在这个对话任务里新开一个对话。
|
|
380
|
+
* 返回实际接收消息的对话键。带 business_id 发:网络抖动时重发一次也只会跑一轮。
|
|
340
381
|
*/
|
|
341
382
|
// 同一个对话的消息严格按发出的顺序送到 center(输入框、提问卡片、重试可能同时发):
|
|
342
383
|
// center 按到达顺序给轮次编号,并发发出去可能后发先到。每个对话一条队列,前一条失败不挡后面的。
|
|
@@ -351,14 +392,27 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
351
392
|
return run;
|
|
352
393
|
}
|
|
353
394
|
|
|
354
|
-
async function sendNow(
|
|
395
|
+
async function sendNow(key: string | null, text: string, overrides: SendOptions): Promise<string> {
|
|
355
396
|
// 发消息的组件自己的参数优先(同一个仓库里可以有几个对话组件,各带各的新建参数),没有再用仓库的。
|
|
356
397
|
const newConversation = overrides.newConversation ?? options.newConversation;
|
|
357
398
|
const createConversation = overrides.createConversation ?? options.createConversation;
|
|
358
399
|
const businessIdFor = overrides.businessIdFor ?? options.businessIdFor;
|
|
359
|
-
const
|
|
400
|
+
const ref = key ? parseConvKey(key) : null;
|
|
401
|
+
const businessId = businessIdFor?.(text, ref && ref.taskId !== NEW_TASK ? "message" : "create") || `cli-${uid()}`;
|
|
360
402
|
try {
|
|
361
|
-
if (
|
|
403
|
+
if (ref?.taskId === NEW_TASK) {
|
|
404
|
+
// 在对话任务里新开一个对话:标题取第一行,智能体 / 浏览器模式沿用对话任务的第一个对话(center 决定)。
|
|
405
|
+
if (!client.openConversation) throw new CenterError("当前后端不支持在对话任务里新开对话", "UNSUPPORTED", 0);
|
|
406
|
+
const result = await retryOnce(() => client.openConversation!(ref.jobId, text, { title: firstLine(text, 60), businessId }));
|
|
407
|
+
const target = convKey(result.job_id, result.task_id ?? result.turn.task_id);
|
|
408
|
+
patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
|
|
409
|
+
addJobIfMissing(result.job_id);
|
|
410
|
+
if (overrides.select !== false && state.selected === key) set({ selected: target });
|
|
411
|
+
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 });
|
|
412
|
+
void refreshJob(result.job_id, true);
|
|
413
|
+
return target;
|
|
414
|
+
}
|
|
415
|
+
if (!key || !ref) {
|
|
362
416
|
let newId: string;
|
|
363
417
|
if (createConversation) {
|
|
364
418
|
newId = await createConversation(text);
|
|
@@ -366,25 +420,27 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
366
420
|
// 新建只发一次:center 的 POST /api/jobs 没有幂等,建好了却没收到响应时重发会建出第二个对话。
|
|
367
421
|
const created = await client.createConversation(text, { ...newConversation, title: newConversation?.title || firstLine(text), businessId: newConversation?.businessId || businessId });
|
|
368
422
|
newId = created.id;
|
|
369
|
-
patchConv(newId, (conv) => ({ ...conv, turns:
|
|
423
|
+
patchConv(newId, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, created.turn ? [created.turn] : []) }));
|
|
370
424
|
}
|
|
371
425
|
addJob(newId);
|
|
372
426
|
// 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
|
|
373
427
|
if (overrides.select !== false) set({ selected: newId });
|
|
374
|
-
emit({ type: "job_change", jobId: newId, previousJobId: null });
|
|
428
|
+
emit({ type: "job_change", jobId: newId, previousJobId: null, taskId: null, key: newId, previousKey: null });
|
|
375
429
|
void refreshJob(newId, true);
|
|
376
430
|
return newId;
|
|
377
431
|
}
|
|
378
|
-
const result = await retryOnce(() => client.sendMessage(jobId, text, { businessId }));
|
|
379
|
-
const
|
|
380
|
-
|
|
432
|
+
const result = await retryOnce(() => client.sendMessage(ref.jobId, text, { businessId, ...(ref.taskId ? { taskId: ref.taskId } : {}) }));
|
|
433
|
+
const jobTarget = result.job_id || ref.jobId;
|
|
434
|
+
// 指定了对话的:接续后跟到新对话任务里对应的那个对话;只有 job_id 的照旧只换 job。
|
|
435
|
+
const target = ref.taskId ? convKey(jobTarget, result.task_id ?? result.turn.task_id ?? ref.taskId) : jobTarget;
|
|
436
|
+
if (target !== key) replaceJob(key, target);
|
|
381
437
|
// 先把这一轮放进状态:排队气泡立刻出现,不用等实时流的下一个块。
|
|
382
|
-
patchConv(target, (conv) => ({ ...conv, turns:
|
|
383
|
-
if (
|
|
438
|
+
patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
|
|
439
|
+
if (jobTarget === ref.jobId) addJobIfMissing(ref.jobId);
|
|
384
440
|
return target;
|
|
385
441
|
} catch (error) {
|
|
386
442
|
const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
|
|
387
|
-
emit({ type: "error", jobId, error: err });
|
|
443
|
+
emit({ type: "error", jobId: key, error: err });
|
|
388
444
|
throw err;
|
|
389
445
|
}
|
|
390
446
|
}
|
|
@@ -398,18 +454,18 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
398
454
|
* 所以回答作为下一条消息发出——与 Operator 在没有等待中的回合时(/answer 返回 resolved=false)的做法一致;
|
|
399
455
|
* 卡片先在本地标成已答(Operator 同样是先乐观更新)。
|
|
400
456
|
*/
|
|
401
|
-
async function answerAsk(
|
|
457
|
+
async function answerAsk(key: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions = {}): Promise<string> {
|
|
402
458
|
// 与 Operator 的 answerQuestion 同一流程:先乐观标成已答 → 交给正在等的回合 → 没有在等的回合就当普通回复发出。
|
|
403
|
-
patchConv(
|
|
459
|
+
patchConv(key, (conv) => ({ ...conv, localAnswers: { ...conv.localAnswers, [askId]: answers } }));
|
|
404
460
|
try {
|
|
405
461
|
if (askId && client.answerAsk) {
|
|
406
|
-
const { resolved } = await client.answerAsk(
|
|
407
|
-
if (resolved) return
|
|
462
|
+
const { resolved } = await client.answerAsk(key, askId, answers);
|
|
463
|
+
if (resolved) return key;
|
|
408
464
|
}
|
|
409
|
-
return await send(
|
|
465
|
+
return await send(key, formatAnswersText(questions, answers), overrides);
|
|
410
466
|
} catch (error) {
|
|
411
467
|
// 没发出去:卡片恢复成可回答。
|
|
412
|
-
patchConv(
|
|
468
|
+
patchConv(key, (conv) => {
|
|
413
469
|
const { [askId]: _drop, ...rest } = conv.localAnswers;
|
|
414
470
|
return { ...conv, localAnswers: rest };
|
|
415
471
|
});
|
|
@@ -417,31 +473,59 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
417
473
|
}
|
|
418
474
|
}
|
|
419
475
|
|
|
420
|
-
async function cancelTurn(
|
|
476
|
+
async function cancelTurn(key: string, turnId: string) {
|
|
421
477
|
try {
|
|
422
|
-
const turn = await client.cancelTurn(jobId, turnId);
|
|
423
|
-
|
|
478
|
+
const turn = await client.cancelTurn(parseConvKey(key).jobId, turnId);
|
|
479
|
+
// 撤回排队的消息会立刻收尾(cancelled):响应里已经结束、而我们还没收到的,先用上;其余以实时流为准。
|
|
480
|
+
patchConv(key, (conv) => {
|
|
481
|
+
const known = conv.turns.find((t) => t.turn_id === turn.turn_id);
|
|
482
|
+
if (known && (TERMINAL_TURN.has(known.status) || !TERMINAL_TURN.has(turn.status))) return conv;
|
|
483
|
+
return { ...conv, turns: upsertTurns(conv.turns, [turn]) };
|
|
484
|
+
});
|
|
424
485
|
} catch (error) {
|
|
425
486
|
const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
|
|
426
|
-
emit({ type: "error", jobId, error: err });
|
|
487
|
+
emit({ type: "error", jobId: key, error: err });
|
|
427
488
|
throw err;
|
|
428
489
|
}
|
|
429
490
|
}
|
|
430
491
|
|
|
431
|
-
|
|
492
|
+
/** 结束对话:指定了对话("job#task")的只关掉这一个;只有 job_id 的结束整个对话任务(和以前一样)。 */
|
|
493
|
+
async function close(key: string) {
|
|
494
|
+
const { jobId, taskId } = parseConvKey(key);
|
|
432
495
|
try {
|
|
433
|
-
|
|
496
|
+
if (taskId && taskId !== NEW_TASK) {
|
|
497
|
+
if (!client.closeConversationTask) throw new CenterError("当前后端不支持单独关闭对话", "UNSUPPORTED", 0);
|
|
498
|
+
await client.closeConversationTask(jobId, taskId);
|
|
499
|
+
} else {
|
|
500
|
+
await client.closeConversation(jobId);
|
|
501
|
+
}
|
|
434
502
|
await refreshJob(jobId, true);
|
|
435
503
|
} catch (error) {
|
|
436
504
|
const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
|
|
437
|
-
emit({ type: "error", jobId, error: err });
|
|
505
|
+
emit({ type: "error", jobId: key, error: err });
|
|
438
506
|
throw err;
|
|
439
507
|
}
|
|
440
508
|
}
|
|
441
509
|
|
|
510
|
+
// 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
|
|
511
|
+
const surfaces: BoardSurface[] = [];
|
|
512
|
+
function openInBoard(jobId: string, target: OpenTarget): boolean {
|
|
513
|
+
let handled = false;
|
|
514
|
+
for (let i = surfaces.length - 1; i >= 0 && !handled; i--) handled = surfaces[i](jobId, target);
|
|
515
|
+
emit({ type: "open", jobId, target, handled });
|
|
516
|
+
return handled;
|
|
517
|
+
}
|
|
518
|
+
|
|
442
519
|
return {
|
|
443
520
|
client,
|
|
444
521
|
getState: () => state,
|
|
522
|
+
/** 在看板里打开文件 / 浏览器 / 改动。返回有没有看板接住。 */
|
|
523
|
+
openInBoard,
|
|
524
|
+
/** 看板登记自己;返回注销函数。 */
|
|
525
|
+
registerBoard(surface: BoardSurface) {
|
|
526
|
+
surfaces.push(surface);
|
|
527
|
+
return () => { const i = surfaces.indexOf(surface); if (i >= 0) surfaces.splice(i, 1); };
|
|
528
|
+
},
|
|
445
529
|
subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
|
446
530
|
on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
|
|
447
531
|
select(jobId: string | null) {
|
package/src/core/types.ts
CHANGED
|
@@ -46,6 +46,7 @@ export interface CenterTurn {
|
|
|
46
46
|
export interface CenterTask {
|
|
47
47
|
id: string;
|
|
48
48
|
job_id: string;
|
|
49
|
+
seq?: number;
|
|
49
50
|
title: string;
|
|
50
51
|
status: string;
|
|
51
52
|
agent: string | null;
|
|
@@ -57,9 +58,30 @@ export interface CenterTask {
|
|
|
57
58
|
cache_creation_tokens: number;
|
|
58
59
|
browser?: boolean;
|
|
59
60
|
last_error: string | null;
|
|
61
|
+
/** 接续出来的对话:指回旧对话任务里对应的那个对话。 */
|
|
62
|
+
continue_from_task_id?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 对话任务里的一个对话(一个子任务、一个 Agent 会话)。conversation.conversations 的一项。 */
|
|
66
|
+
export interface ConversationInfo {
|
|
67
|
+
task_id: string;
|
|
68
|
+
seq: number;
|
|
69
|
+
title: string;
|
|
70
|
+
status: string;
|
|
71
|
+
/** 关掉了(单独关闭这一个,或整个对话任务结束)。 */
|
|
72
|
+
closed: boolean;
|
|
73
|
+
turns: number;
|
|
74
|
+
queued_turns: number;
|
|
75
|
+
running_turn: boolean;
|
|
76
|
+
last_turn: CenterTurn | null;
|
|
77
|
+
remote_task_id: string | null;
|
|
78
|
+
continue_from_task_id: string | null;
|
|
79
|
+
created_at: string | null;
|
|
60
80
|
}
|
|
61
81
|
|
|
62
82
|
export interface ConversationSummary {
|
|
83
|
+
/** 里面的每个对话(按开的先后)。旧版 center 没有这个字段:当作只有一个对话。 */
|
|
84
|
+
conversations?: ConversationInfo[];
|
|
63
85
|
turns: number;
|
|
64
86
|
queued_turns: number;
|
|
65
87
|
running_turn: boolean;
|
|
@@ -81,9 +103,11 @@ export interface CenterJob {
|
|
|
81
103
|
tasks: CenterTask[];
|
|
82
104
|
}
|
|
83
105
|
|
|
84
|
-
/** POST /api/jobs/:id/messages */
|
|
106
|
+
/** POST /api/jobs/:id/messages、POST /api/jobs/:id/conversations */
|
|
85
107
|
export interface SendResult {
|
|
86
108
|
job_id: string;
|
|
109
|
+
/** 实际接收这条消息的对话(对话任务过期接续后是新对话任务里对应的那个)。 */
|
|
110
|
+
task_id?: string;
|
|
87
111
|
status: JobStatus;
|
|
88
112
|
duplicate: boolean;
|
|
89
113
|
/** true = 原对话已结束,这条消息开了一个新对话(job_id 是新的)。 */
|
|
@@ -202,16 +226,27 @@ export type StreamItem =
|
|
|
202
226
|
| { type: "transcript"; messages: Message[] | null; pending: PendingMessage[] | null; running: boolean | null }
|
|
203
227
|
| { type: "node"; event: NodeEvent }
|
|
204
228
|
| { type: "turn"; turn: CenterTurn }
|
|
205
|
-
| { type: "job"; job: { id: string; status: JobStatus; closed_reason: string | null; node_id: string | null } }
|
|
229
|
+
| { type: "job"; job: { id: string; status: JobStatus; closed_reason: string | null; node_id: string | null } }
|
|
230
|
+
/** 正在看的这个对话的状态变化(关闭了:closed=true)。 */
|
|
231
|
+
| { type: "task"; task: StreamTask };
|
|
232
|
+
|
|
233
|
+
/** 实时流看的那个对话(GET /api/jobs/:id/stream?task=)。 */
|
|
234
|
+
export interface StreamTask { id: string; seq: number; title: string; status: string; closed: boolean; remote_task_id: string | null; continue_from_task_id: string | null }
|
|
206
235
|
|
|
207
236
|
export interface StreamSnapshot {
|
|
208
237
|
job: { id: string; status: JobStatus; closed_reason: string | null; node_id: string | null; mode: string; title: string; conversation: ConversationSummary };
|
|
238
|
+
/** 旧版 center 没有。 */
|
|
239
|
+
task?: StreamTask;
|
|
209
240
|
turns: CenterTurn[];
|
|
210
241
|
}
|
|
211
242
|
|
|
212
243
|
export interface StreamEnd {
|
|
244
|
+
/** 对话任务的结束原因;只是这个对话被关闭时为 conversation_closed。 */
|
|
213
245
|
reason: string;
|
|
246
|
+
task_id?: string;
|
|
214
247
|
continued_by_job_id?: string | null;
|
|
248
|
+
/** 接续这个对话的新对话(新对话任务里的子任务);还没有为 null。 */
|
|
249
|
+
continued_by_task_id?: string | null;
|
|
215
250
|
}
|
|
216
251
|
|
|
217
252
|
// ── center 回调体(与中枢 conversation.ts turnCallbackPayload / job-callback.ts 的字段一一对应) ──
|
|
@@ -264,6 +299,8 @@ export interface ConversationClosedCallback {
|
|
|
264
299
|
/** 投递顺序号:最后一轮的 seq + 1,永远排在这个对话所有 conversation_turn 之后。 */
|
|
265
300
|
callback_seq: number;
|
|
266
301
|
job_id: string;
|
|
302
|
+
/** 只关掉了对话任务里的这一个对话时才有(前端事件;center 只在整个对话任务结束时回调)。 */
|
|
303
|
+
task_id?: string;
|
|
267
304
|
/** succeeded(关闭 / 过期 / 换线)/ cancelled / failed。 */
|
|
268
305
|
status: JobStatus;
|
|
269
306
|
/** closed / cancelled / expired / node_reassigned / node_lost。 */
|
|
@@ -276,3 +313,73 @@ export interface ConversationClosedCallback {
|
|
|
276
313
|
}
|
|
277
314
|
|
|
278
315
|
export type ConversationCallback = ConversationTurnCallback | ConversationClosedCallback;
|
|
316
|
+
|
|
317
|
+
/** 一轮结束时 onTurnEnd 收到的载荷(字段与 center 的 conversation_turn 回调体一致)。 */
|
|
318
|
+
export type TurnEndPayload = ConversationTurnCallback;
|
|
319
|
+
/** 对话结束时 onConversationEnd 收到的载荷(字段与 center 的 conversation_closed 回调体一致)。 */
|
|
320
|
+
export type ConversationEndPayload = ConversationClosedCallback;
|
|
321
|
+
|
|
322
|
+
// ── 右侧看板(cli-board):center 的 /api/jobs/:id/board、files、diff、browser ──
|
|
323
|
+
|
|
324
|
+
export interface BoardInfo {
|
|
325
|
+
job_id: string;
|
|
326
|
+
/** false = 还没派发到执行节点(文件和浏览器都还没有)。 */
|
|
327
|
+
dispatched: boolean;
|
|
328
|
+
detail?: string;
|
|
329
|
+
task_id?: string;
|
|
330
|
+
remote_task_id?: string;
|
|
331
|
+
node?: { id: string; health: string };
|
|
332
|
+
files: boolean;
|
|
333
|
+
/** 节点开了浏览器功能。 */
|
|
334
|
+
browser: boolean;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface FileEntry {
|
|
338
|
+
name: string;
|
|
339
|
+
/** 相对工作目录的路径,用 / 分隔。 */
|
|
340
|
+
path: string;
|
|
341
|
+
type: "dir" | "file";
|
|
342
|
+
size: number;
|
|
343
|
+
mtime: number;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean }
|
|
347
|
+
export interface FileInfo { type: "file"; path: string; name: string; size: number; mtime: number; contentType: string; inline: boolean }
|
|
348
|
+
|
|
349
|
+
export interface DiffFile { path: string; status: string; additions: number; deletions: number; binary: boolean; patch: string; truncated?: boolean }
|
|
350
|
+
/** Operator 的 /api/tasks/:id/diff(经 center 原样转发)。 */
|
|
351
|
+
export interface DiffResult {
|
|
352
|
+
isolated?: boolean;
|
|
353
|
+
reason?: string;
|
|
354
|
+
branch?: string;
|
|
355
|
+
baseLabel?: string;
|
|
356
|
+
files?: DiffFile[];
|
|
357
|
+
isDirty?: boolean;
|
|
358
|
+
ahead?: number;
|
|
359
|
+
alreadyMerged?: boolean;
|
|
360
|
+
error?: string;
|
|
361
|
+
/** 有的节点只给一整段 unified diff。 */
|
|
362
|
+
diff?: string;
|
|
363
|
+
patch?: string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Operator 的任务浏览器状态(/api/tasks/:id/browser)。 */
|
|
367
|
+
export interface BrowserStatus {
|
|
368
|
+
feature: boolean;
|
|
369
|
+
state: "none" | "opening" | "running" | "closing";
|
|
370
|
+
origin: "ai" | "manual" | "check" | null;
|
|
371
|
+
control: "ai" | "human";
|
|
372
|
+
handoff: { instruction: string; by: "ai" | "user" | "browser" | "kept" } | null;
|
|
373
|
+
capability: "config" | "manual" | "ai" | null;
|
|
374
|
+
viewers: number;
|
|
375
|
+
spaceName: string;
|
|
376
|
+
instance: { running: boolean; spaces: number };
|
|
377
|
+
shownLocally: boolean;
|
|
378
|
+
error: string | null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 浏览器画面流里的一条消息(节点原样转发)。 */
|
|
382
|
+
export type BrowserMessage =
|
|
383
|
+
| { type: "frame"; data: string; metadata?: { deviceWidth: number; deviceHeight: number } }
|
|
384
|
+
| { type: "page"; url?: string; title?: string }
|
|
385
|
+
| { type: "error"; message?: string };
|