@spzxg/cli-ui 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -2
- package/dist/cli-ui-embed-hutDQhkT.js +37841 -0
- package/dist/cli-ui-index-C8TOk7M3.js +13516 -0
- package/dist/cli-ui.css +1 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +49 -4
- package/src/board/BrowserPane.tsx +418 -0
- package/src/board/ChangesPane.tsx +142 -0
- package/src/board/CliBoard.tsx +246 -0
- package/src/board/FileViewer.tsx +257 -0
- package/src/board/FilesPane.tsx +109 -0
- package/src/board/blocks.tsx +96 -0
- package/src/board/fileKind.ts +81 -0
- package/src/chat/CliChat.tsx +639 -0
- package/src/chat/Composer.tsx +194 -0
- package/src/chat/export.ts +34 -0
- package/src/core/blocks.ts +132 -0
- package/src/core/client.ts +441 -0
- package/src/core/config.ts +222 -0
- package/src/core/conversation.ts +439 -0
- package/src/core/provider.tsx +103 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +575 -0
- package/src/core/types.ts +385 -0
- package/src/elements.tsx +166 -0
- package/src/embed.ts +58 -0
- package/src/index.ts +40 -0
- package/src/msglist/CliMsgList.tsx +342 -0
- package/src/operator.css +1628 -0
- package/src/styles.css +174 -0
- package/src/ui/Markdown.tsx +55 -0
- package/src/ui/TextBlock.tsx +49 -0
- package/src/ui/Transcript.tsx +408 -0
- package/src/ui/fileLinks.ts +46 -0
- package/src/ui/format.ts +101 -0
- package/src/ui/icons.tsx +89 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +94 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组件共享的状态仓库(纯 TS,无 React)。积木之间靠它拼起来:
|
|
3
|
+
*
|
|
4
|
+
* · 选中哪个对话(cli-msglist 点一下,cli-chat 跟着切);
|
|
5
|
+
* · 列表里有哪些对话任务(宿主给 jobIds,或本地记住 + 从 center 发现);
|
|
6
|
+
* · 每个对话的实时状态:一个对话只开一条实时流,几个组件同时看也只连一次(引用计数)。
|
|
7
|
+
*
|
|
8
|
+
* 「一个对话」用对话键标识(见 conversation.ts convKey):一个对话任务里可以有几个对话,"job_x#task_y" 是其中一个,
|
|
9
|
+
* 只有 "job_x" 是它的第一个(和以前一样);"job_x#new" 是在这个对话任务里新开一个对话。jobs / jobIds 仍按对话任务。
|
|
10
|
+
*
|
|
11
|
+
* 单独用某个组件时它会自己建一个仓库;放进同一个 <CliCenterProvider>(或同一个 group 的 Web Component)就共享一个。
|
|
12
|
+
*/
|
|
13
|
+
import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client.js";
|
|
14
|
+
import { addTurnsIfAbsent, applyItems, applySnapshot, closedCallback, convEnded, convKey, emptyConv, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
|
|
15
|
+
import { formatAnswersText } from "../ui/format.js";
|
|
16
|
+
import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types.js";
|
|
17
|
+
|
|
18
|
+
export interface JobEntry {
|
|
19
|
+
job: CenterJob | null;
|
|
20
|
+
/** 第 1 轮的消息(列表卡片的描述)。 */
|
|
21
|
+
firstInput: string | null;
|
|
22
|
+
loading: boolean;
|
|
23
|
+
error: string | null;
|
|
24
|
+
fetchedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CliState {
|
|
28
|
+
/** 选中的对话(对话键)。 */
|
|
29
|
+
selected: string | null;
|
|
30
|
+
/** 列表里的对话任务(job_id)。 */
|
|
31
|
+
jobIds: string[];
|
|
32
|
+
/** 按 job_id。 */
|
|
33
|
+
jobs: Record<string, JobEntry>;
|
|
34
|
+
/** 按对话键。 */
|
|
35
|
+
convs: Record<string, ConvState>;
|
|
36
|
+
listLoading: boolean;
|
|
37
|
+
listError: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 与 center 回调同名同义的事件,方便宿主把前端事件和后端回调对上。 */
|
|
41
|
+
export type CliEvent =
|
|
42
|
+
/** jobId 是选中的对话键(只有 job_id,或 "job_id#task_id")。 */
|
|
43
|
+
| { type: "select"; jobId: string | null }
|
|
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;
|
|
64
|
+
|
|
65
|
+
export interface CliStoreOptions extends CenterClientOptions {
|
|
66
|
+
client?: CenterClient;
|
|
67
|
+
/** 在本机记住对话列表用的命名空间(不同业务 / 产品线请区分)。不给 = 不记,组件之间没有任何隐式共享。 */
|
|
68
|
+
namespace?: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* 新建对话时的参数(与 POST /api/jobs 对应):标题、业务 ID、工作区、回调、智能体、超时、浏览器模式。
|
|
71
|
+
* 带了 callback,center 每轮结束就向 callback.url 投递 conversation_turn,结束时投递 conversation_closed。
|
|
72
|
+
*/
|
|
73
|
+
newConversation?: NewConversationOptions;
|
|
74
|
+
/** 自己建对话(例如经业务后端建,以便把回调密钥留在服务端):返回 center 的 job_id。 */
|
|
75
|
+
createConversation?: (firstMessage: string) => Promise<string>;
|
|
76
|
+
/**
|
|
77
|
+
* 每条消息的 business_id(center 按它去重,回调里原样带回)。缺省 `cli-<uuid>`。
|
|
78
|
+
* kind = "create" 是第一条(新建对话),"message" 是之后的每条。
|
|
79
|
+
*/
|
|
80
|
+
businessIdFor?: (text: string, kind: "create" | "message") => string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 发一条消息时可以单独指定的新建参数(组件自己的 props),优先于仓库的。 */
|
|
84
|
+
export interface SendOptions {
|
|
85
|
+
/** 新建对话后是否改仓库的选中(缺省改)。 */
|
|
86
|
+
select?: boolean;
|
|
87
|
+
newConversation?: NewConversationOptions;
|
|
88
|
+
createConversation?: (firstMessage: string) => Promise<string>;
|
|
89
|
+
businessIdFor?: (text: string, kind: "create" | "message") => string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const RECONNECT_MS = [1_000, 2_000, 5_000, 10_000, 20_000];
|
|
93
|
+
const JOB_REFRESH_GAP_MS = 1_500;
|
|
94
|
+
|
|
95
|
+
export function createCliStore(options: CliStoreOptions = {}) {
|
|
96
|
+
const client = options.client ?? createCenterClient(options);
|
|
97
|
+
// 只有明确给了 namespace 才在本机记住对话列表:不给就不落任何共享状态,几个独立的组件互不影响。
|
|
98
|
+
const storageKey = options.namespace ? `cli-ui:${options.namespace}:jobs` : null;
|
|
99
|
+
|
|
100
|
+
let state: CliState = { selected: null, jobIds: loadIds(storageKey), jobs: {}, convs: {}, listLoading: false, listError: null };
|
|
101
|
+
const listeners = new Set<() => void>();
|
|
102
|
+
const eventListeners = new Set<(event: CliEvent) => void>();
|
|
103
|
+
const settledTurns = new Set<string>();
|
|
104
|
+
const watchedTurns = new Set<string>();
|
|
105
|
+
const closedJobs = new Set<string>();
|
|
106
|
+
const seenOpen = new Set<string>();
|
|
107
|
+
let hostIds = false;
|
|
108
|
+
|
|
109
|
+
const set = (next: Partial<CliState>) => {
|
|
110
|
+
state = { ...state, ...next };
|
|
111
|
+
for (const fn of listeners) fn();
|
|
112
|
+
};
|
|
113
|
+
const emit = (event: CliEvent) => { for (const fn of eventListeners) fn(event); };
|
|
114
|
+
const patchConv = (key: string, fn: (conv: ConvState) => ConvState) => {
|
|
115
|
+
const prev = state.convs[key] ?? emptyConv(key);
|
|
116
|
+
const next = fn(prev);
|
|
117
|
+
if (next !== prev) {
|
|
118
|
+
noticeTransitions(key, prev, next);
|
|
119
|
+
set({ convs: { ...state.convs, [key]: next } });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const patchJob = (jobId: string, patch: Partial<JobEntry>) => {
|
|
123
|
+
const prev = state.jobs[jobId] ?? { job: null, firstInput: null, loading: false, error: null, fetchedAt: 0 };
|
|
124
|
+
set({ jobs: { ...state.jobs, [jobId]: { ...prev, ...patch } } });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// 一轮从未结束变成结束 → conversation_turn;对话变成结束 → conversation_closed。
|
|
128
|
+
function noticeTransitions(key: string, prev: ConvState, next: ConvState) {
|
|
129
|
+
const { jobId } = next;
|
|
130
|
+
if (prev.turns !== next.turns) {
|
|
131
|
+
const before = new Map(prev.turns.map((turn) => [turn.turn_id, turn.status]));
|
|
132
|
+
// 有轮次开始执行 / 新排队:列表卡片的「运行中」「排队中」也要跟上,不等下一次轮询。
|
|
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)。
|
|
138
|
+
for (const turn of next.turns) {
|
|
139
|
+
if (!TERMINAL_TURN.has(turn.status)) break;
|
|
140
|
+
if (settledTurns.has(turn.turn_id)) continue;
|
|
141
|
+
settledTurns.add(turn.turn_id);
|
|
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);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
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)) {
|
|
153
|
+
const settled = next.turns.every((turn) => TERMINAL_TURN.has(turn.status));
|
|
154
|
+
if (settled || next.stream === "ended") {
|
|
155
|
+
closedJobs.add(key);
|
|
156
|
+
const payload = closedCallback(next);
|
|
157
|
+
emit({ type: "conversation_closed", jobId, key, reason: payload.closed_reason, continuedBy: next.continuedBy, payload });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 实时流里的对话状态同步给列表。
|
|
161
|
+
if (next.job && prev.job?.status !== next.job.status) {
|
|
162
|
+
const entry = state.jobs[jobId];
|
|
163
|
+
if (entry?.job) patchJob(jobId, { job: { ...entry.job, status: next.job.status } });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function persist(ids: string[]) {
|
|
168
|
+
if (!storageKey || hostIds) return;
|
|
169
|
+
try { localStorage.setItem(storageKey, JSON.stringify(ids.slice(0, 200))); } catch { /* 存不下就不记 */ }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── 列表 ─────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function setJobIds(ids: string[]) {
|
|
175
|
+
hostIds = true;
|
|
176
|
+
const unique = [...new Set(ids.filter(Boolean))];
|
|
177
|
+
if (unique.join() === state.jobIds.join()) return;
|
|
178
|
+
set({ jobIds: unique });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function addJob(jobId: string) {
|
|
182
|
+
if (state.jobIds[0] === jobId) return;
|
|
183
|
+
const ids = [jobId, ...state.jobIds.filter((id) => id !== jobId)];
|
|
184
|
+
set({ jobIds: ids });
|
|
185
|
+
persist(ids);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function removeJob(jobId: string) {
|
|
189
|
+
const ids = state.jobIds.filter((id) => id !== jobId);
|
|
190
|
+
set({ jobIds: ids, selected: state.selected === jobId ? null : state.selected });
|
|
191
|
+
persist(ids);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。oldId / newId 可以是对话键。 */
|
|
195
|
+
function replaceJob(oldId: string, newId: string) {
|
|
196
|
+
if (oldId === newId) return;
|
|
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];
|
|
201
|
+
set({ jobIds: [...new Set(ids)], selected: state.selected === oldId ? newId : state.selected });
|
|
202
|
+
persist(state.jobIds);
|
|
203
|
+
emit({ type: "job_change", jobId: to.jobId, previousJobId: from.jobId, taskId: to.taskId, key: newId, previousKey: oldId });
|
|
204
|
+
void refreshJob(to.jobId, true);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const refreshAgain = new Set<string>();
|
|
208
|
+
async function refreshJob(jobId: string, force = false) {
|
|
209
|
+
const entry = state.jobs[jobId];
|
|
210
|
+
// 正在拉:拉完再补一次,免得错过这期间的变化(例如轮次刚从排队变成执行中)。
|
|
211
|
+
if (entry?.loading) { refreshAgain.add(jobId); return; }
|
|
212
|
+
if (!force && entry && Date.now() - entry.fetchedAt < JOB_REFRESH_GAP_MS) return;
|
|
213
|
+
patchJob(jobId, { loading: true });
|
|
214
|
+
try {
|
|
215
|
+
const job = await client.getJob(jobId);
|
|
216
|
+
let firstInput = state.jobs[jobId]?.firstInput ?? null;
|
|
217
|
+
if (firstInput === null && job.mode === "conversation") {
|
|
218
|
+
// 卡片描述取第一个对话的第一条消息(按 seq 最早的那一轮)。
|
|
219
|
+
const first = await client.listTurns(jobId, { limit: 1 }).catch(() => null);
|
|
220
|
+
firstInput = first?.turns[0]?.input ?? "";
|
|
221
|
+
}
|
|
222
|
+
patchJob(jobId, { job, firstInput, loading: false, error: null, fetchedAt: Date.now() });
|
|
223
|
+
// 新数据里已包含实时用量,清零累计(这个对话任务里的每个对话)。
|
|
224
|
+
for (const conv of Object.values(state.convs)) if (conv.jobId === jobId) patchConv(conv.key, (c) => ({ ...c, liveUsage: { fresh: 0, cacheRead: 0 } }));
|
|
225
|
+
} catch (error) {
|
|
226
|
+
patchJob(jobId, { loading: false, error: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() });
|
|
227
|
+
// 本地记住的对话在 center 已经不存在(被删、换了环境):从列表里拿掉。宿主给的 jobIds 不动,由宿主决定。
|
|
228
|
+
if (!hostIds && error instanceof CenterError && error.status === 404 && (!state.selected || parseConvKey(state.selected).jobId !== jobId)) {
|
|
229
|
+
refreshAgain.delete(jobId);
|
|
230
|
+
removeJob(jobId);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (refreshAgain.delete(jobId)) void refreshJob(jobId, true);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 刷新列表。缺省只刷还没结束的对话(结束了的不会再变,轮询不必每次都拉);all = true 全部重拉(首次加载、手动刷新)。
|
|
239
|
+
*/
|
|
240
|
+
async function refreshAll(opts: { all?: boolean } = {}) {
|
|
241
|
+
set({ listLoading: true, listError: null });
|
|
242
|
+
try {
|
|
243
|
+
const ids = opts.all ? state.jobIds : state.jobIds.filter((id) => {
|
|
244
|
+
const status = state.jobs[id]?.job?.status;
|
|
245
|
+
return !status || !TERMINAL_JOB.has(status);
|
|
246
|
+
});
|
|
247
|
+
await runLimited(ids, 4, (id) => refreshJob(id, true));
|
|
248
|
+
set({ listLoading: false });
|
|
249
|
+
} catch (error) {
|
|
250
|
+
set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 从 center 发现本产品线的对话:center 没有「列出对话」接口,只能从全部子任务里按 job 去重,
|
|
256
|
+
* 再逐个看是不是对话任务。center 的 ID 是随机的,返回顺序不代表新旧,所以最多看 limit 个 job(缺省 200);
|
|
257
|
+
* 对话多的产品线请由宿主传 jobIds。只在宿主没给 jobIds 时调用。
|
|
258
|
+
*/
|
|
259
|
+
let discovering: Promise<void> | null = null;
|
|
260
|
+
function discover(limit = 200) {
|
|
261
|
+
// 同时多次调用(React StrictMode 下 effect 会跑两遍)只发现一次。
|
|
262
|
+
discovering ??= discoverOnce(limit).finally(() => { discovering = null; });
|
|
263
|
+
return discovering;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function discoverOnce(limit: number) {
|
|
267
|
+
set({ listLoading: true, listError: null });
|
|
268
|
+
try {
|
|
269
|
+
const { tasks } = await client.listTasks();
|
|
270
|
+
const candidates = [...new Set(tasks.map((task) => task.job_id))].filter((id) => !state.jobIds.includes(id)).slice(0, limit);
|
|
271
|
+
const found: string[] = [];
|
|
272
|
+
await runLimited(candidates, 4, async (id) => {
|
|
273
|
+
const job = await client.getJob(id).catch(() => null);
|
|
274
|
+
if (job?.mode === "conversation") {
|
|
275
|
+
found.push(id);
|
|
276
|
+
patchJob(id, { job, firstInput: null, loading: false, error: null, fetchedAt: 0 });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
const ids = [...new Set([...state.jobIds, ...found])];
|
|
280
|
+
set({ jobIds: ids, listLoading: false });
|
|
281
|
+
persist(ids);
|
|
282
|
+
await runLimited(found, 4, (id) => refreshJob(id, true));
|
|
283
|
+
} catch (error) {
|
|
284
|
+
set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── 实时流(引用计数 + 断线重连) ─────────────────────
|
|
289
|
+
|
|
290
|
+
const streams = new Map<string, { refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number }>();
|
|
291
|
+
|
|
292
|
+
function connect(key: string) {
|
|
293
|
+
const entry = streams.get(key);
|
|
294
|
+
if (!entry || entry.refs <= 0) return;
|
|
295
|
+
const { jobId, taskId } = parseConvKey(key);
|
|
296
|
+
patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
|
|
297
|
+
let ended = false;
|
|
298
|
+
entry.stop = client.stream(jobId, {
|
|
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)),
|
|
302
|
+
onEnd: (end) => {
|
|
303
|
+
ended = true;
|
|
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 }));
|
|
307
|
+
void refreshJob(jobId, true);
|
|
308
|
+
},
|
|
309
|
+
onClose: (error) => {
|
|
310
|
+
entry.stop = null;
|
|
311
|
+
const current = streams.get(key);
|
|
312
|
+
if (!current || current.refs <= 0) return;
|
|
313
|
+
// 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
|
|
314
|
+
if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
|
|
315
|
+
// 404 / 401:对话不存在或没权限,重连也没用。
|
|
316
|
+
if (error && (error.status === 404 || error.status === 401 || error.status === 403)) {
|
|
317
|
+
patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
|
|
318
|
+
emit({ type: "error", jobId: key, error });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const delay = RECONNECT_MS[Math.min(current.attempt, RECONNECT_MS.length - 1)];
|
|
322
|
+
current.attempt += 1;
|
|
323
|
+
patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
|
|
324
|
+
current.timer = setTimeout(() => { current.timer = null; connect(key); }, delay);
|
|
325
|
+
},
|
|
326
|
+
}, undefined, taskId);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
|
|
330
|
+
function reconnectNow() {
|
|
331
|
+
for (const [jobId, entry] of streams) {
|
|
332
|
+
if (!entry.timer) continue;
|
|
333
|
+
clearTimeout(entry.timer);
|
|
334
|
+
entry.timer = null;
|
|
335
|
+
entry.attempt = 0;
|
|
336
|
+
connect(jobId);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (typeof window !== "undefined") {
|
|
340
|
+
window.addEventListener("online", reconnectNow);
|
|
341
|
+
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reconnectNow(); });
|
|
342
|
+
}
|
|
343
|
+
|
|
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);
|
|
355
|
+
if (!entry) {
|
|
356
|
+
entry = { refs: 0, stop: null, timer: null, attempt: 0 };
|
|
357
|
+
streams.set(key, entry);
|
|
358
|
+
}
|
|
359
|
+
entry.refs += 1;
|
|
360
|
+
if (entry.refs === 1) {
|
|
361
|
+
void refreshJob(jobId);
|
|
362
|
+
connect(key);
|
|
363
|
+
}
|
|
364
|
+
return () => {
|
|
365
|
+
const current = streams.get(key);
|
|
366
|
+
if (!current) return;
|
|
367
|
+
current.refs -= 1;
|
|
368
|
+
if (current.refs > 0) return;
|
|
369
|
+
current.stop?.();
|
|
370
|
+
if (current.timer) clearTimeout(current.timer);
|
|
371
|
+
streams.delete(key);
|
|
372
|
+
patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── 发消息 / 新建 / 撤回 / 结束 ───────────────────────
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 发一条消息。key 是对话键:null 时新建对话任务(这条就是第一轮);"job#new" 时在这个对话任务里新开一个对话。
|
|
380
|
+
* 返回实际接收消息的对话键。带 business_id 发:网络抖动时重发一次也只会跑一轮。
|
|
381
|
+
*/
|
|
382
|
+
// 同一个对话的消息严格按发出的顺序送到 center(输入框、提问卡片、重试可能同时发):
|
|
383
|
+
// center 按到达顺序给轮次编号,并发发出去可能后发先到。每个对话一条队列,前一条失败不挡后面的。
|
|
384
|
+
const sendQueues = new Map<string, Promise<unknown>>();
|
|
385
|
+
function send(jobId: string | null, text: string, overrides: SendOptions = {}): Promise<string> {
|
|
386
|
+
const key = jobId ?? "\u0000new";
|
|
387
|
+
const prev = sendQueues.get(key) ?? Promise.resolve();
|
|
388
|
+
const run = prev.catch(() => undefined).then(() => sendNow(jobId, text, overrides));
|
|
389
|
+
const tail = run.catch(() => undefined);
|
|
390
|
+
sendQueues.set(key, tail);
|
|
391
|
+
void tail.then(() => { if (sendQueues.get(key) === tail) sendQueues.delete(key); });
|
|
392
|
+
return run;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function sendNow(key: string | null, text: string, overrides: SendOptions): Promise<string> {
|
|
396
|
+
// 发消息的组件自己的参数优先(同一个仓库里可以有几个对话组件,各带各的新建参数),没有再用仓库的。
|
|
397
|
+
const newConversation = overrides.newConversation ?? options.newConversation;
|
|
398
|
+
const createConversation = overrides.createConversation ?? options.createConversation;
|
|
399
|
+
const businessIdFor = overrides.businessIdFor ?? options.businessIdFor;
|
|
400
|
+
const ref = key ? parseConvKey(key) : null;
|
|
401
|
+
const businessId = businessIdFor?.(text, ref && ref.taskId !== NEW_TASK ? "message" : "create") || `cli-${uid()}`;
|
|
402
|
+
try {
|
|
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) {
|
|
416
|
+
let newId: string;
|
|
417
|
+
if (createConversation) {
|
|
418
|
+
newId = await createConversation(text);
|
|
419
|
+
} else {
|
|
420
|
+
// 新建只发一次:center 的 POST /api/jobs 没有幂等,建好了却没收到响应时重发会建出第二个对话。
|
|
421
|
+
const created = await client.createConversation(text, { ...newConversation, title: newConversation?.title || firstLine(text), businessId: newConversation?.businessId || businessId });
|
|
422
|
+
newId = created.id;
|
|
423
|
+
patchConv(newId, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, created.turn ? [created.turn] : []) }));
|
|
424
|
+
}
|
|
425
|
+
addJob(newId);
|
|
426
|
+
// 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
|
|
427
|
+
if (overrides.select !== false) set({ selected: newId });
|
|
428
|
+
emit({ type: "job_change", jobId: newId, previousJobId: null, taskId: null, key: newId, previousKey: null });
|
|
429
|
+
void refreshJob(newId, true);
|
|
430
|
+
return newId;
|
|
431
|
+
}
|
|
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);
|
|
437
|
+
// 先把这一轮放进状态:排队气泡立刻出现,不用等实时流的下一个块。
|
|
438
|
+
patchConv(target, (conv) => ({ ...conv, turns: addTurnsIfAbsent(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
|
|
439
|
+
if (jobTarget === ref.jobId) addJobIfMissing(ref.jobId);
|
|
440
|
+
return target;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
|
|
443
|
+
emit({ type: "error", jobId: key, error: err });
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function addJobIfMissing(jobId: string) {
|
|
449
|
+
if (!state.jobIds.includes(jobId)) addJob(jobId);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 回答 Agent 的提问(AskUserQuestion 卡片)。center 在 Agent 提问时已经结束了那一轮(asked=true),
|
|
454
|
+
* 所以回答作为下一条消息发出——与 Operator 在没有等待中的回合时(/answer 返回 resolved=false)的做法一致;
|
|
455
|
+
* 卡片先在本地标成已答(Operator 同样是先乐观更新)。
|
|
456
|
+
*/
|
|
457
|
+
async function answerAsk(key: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions = {}): Promise<string> {
|
|
458
|
+
// 与 Operator 的 answerQuestion 同一流程:先乐观标成已答 → 交给正在等的回合 → 没有在等的回合就当普通回复发出。
|
|
459
|
+
patchConv(key, (conv) => ({ ...conv, localAnswers: { ...conv.localAnswers, [askId]: answers } }));
|
|
460
|
+
try {
|
|
461
|
+
if (askId && client.answerAsk) {
|
|
462
|
+
const { resolved } = await client.answerAsk(key, askId, answers);
|
|
463
|
+
if (resolved) return key;
|
|
464
|
+
}
|
|
465
|
+
return await send(key, formatAnswersText(questions, answers), overrides);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
// 没发出去:卡片恢复成可回答。
|
|
468
|
+
patchConv(key, (conv) => {
|
|
469
|
+
const { [askId]: _drop, ...rest } = conv.localAnswers;
|
|
470
|
+
return { ...conv, localAnswers: rest };
|
|
471
|
+
});
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function cancelTurn(key: string, turnId: string) {
|
|
477
|
+
try {
|
|
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
|
+
});
|
|
485
|
+
} catch (error) {
|
|
486
|
+
const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
|
|
487
|
+
emit({ type: "error", jobId: key, error: err });
|
|
488
|
+
throw err;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** 结束对话:指定了对话("job#task")的只关掉这一个;只有 job_id 的结束整个对话任务(和以前一样)。 */
|
|
493
|
+
async function close(key: string) {
|
|
494
|
+
const { jobId, taskId } = parseConvKey(key);
|
|
495
|
+
try {
|
|
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
|
+
}
|
|
502
|
+
await refreshJob(jobId, true);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
|
|
505
|
+
emit({ type: "error", jobId: key, error: err });
|
|
506
|
+
throw err;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
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
|
+
|
|
519
|
+
return {
|
|
520
|
+
client,
|
|
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
|
+
},
|
|
529
|
+
subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
|
530
|
+
on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
|
|
531
|
+
select(jobId: string | null) {
|
|
532
|
+
if (state.selected === jobId) return;
|
|
533
|
+
set({ selected: jobId });
|
|
534
|
+
emit({ type: "select", jobId });
|
|
535
|
+
},
|
|
536
|
+
setJobIds, addJob, removeJob, replaceJob, refreshJob, refreshAll, discover,
|
|
537
|
+
open, send, answerAsk, cancelTurn, close,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export type CliStore = ReturnType<typeof createCliStore>;
|
|
542
|
+
|
|
543
|
+
function loadIds(key: string | null): string[] {
|
|
544
|
+
if (!key) return [];
|
|
545
|
+
try {
|
|
546
|
+
const value = JSON.parse(localStorage.getItem(key) || "[]");
|
|
547
|
+
return Array.isArray(value) ? [...new Set(value.filter((id): id is string => typeof id === "string"))] : [];
|
|
548
|
+
} catch {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function runLimited<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>) {
|
|
554
|
+
let index = 0;
|
|
555
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
556
|
+
while (index < items.length) await fn(items[index++]);
|
|
557
|
+
});
|
|
558
|
+
await Promise.all(workers);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** 只有网络层失败(没收到响应)才重发:带 business_id,center 会去重。 */
|
|
562
|
+
async function retryOnce<T>(fn: () => Promise<T>): Promise<T> {
|
|
563
|
+
try {
|
|
564
|
+
return await fn();
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error instanceof CenterError && error.code === "NETWORK") return fn();
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function uid() {
|
|
572
|
+
const cryptoObj = globalThis.crypto as Crypto | undefined;
|
|
573
|
+
if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
|
|
574
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
575
|
+
}
|