@spzxg/cli-ui 0.0.0-stage → 0.1.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.
@@ -0,0 +1,89 @@
1
+ import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
2
+ import { createCliStore, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store.js";
3
+ import type { ConversationCallback } from "./types.js";
4
+
5
+ /**
6
+ * 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库:
7
+ * 列表点哪个对话,对话组件就显示哪个;同一个对话只连一条实时流。
8
+ *
9
+ * <CliCenterProvider baseUrl="/api/center">
10
+ * <CliMsgList />
11
+ * <CliChat />
12
+ * </CliCenterProvider>
13
+ *
14
+ * 组件单独使用时不需要它:组件发现外面没有 Provider,会用自己的 props 建一个。
15
+ */
16
+ const StoreContext = createContext<CliStore | null>(null);
17
+
18
+ export interface CliCenterProviderProps extends CliStoreOptions {
19
+ /** 外部建好的仓库(例如 Web Component 按 group 共享的);给了就不再自己建。 */
20
+ store?: CliStore;
21
+ /** 监听仓库事件:选中、一轮结束、对话结束、job 变化、错误。 */
22
+ onEvent?: (event: CliEvent) => void;
23
+ /**
24
+ * 前端回调:每轮结束 / 对话结束时调用,载荷与 center 投递给 callback.url 的请求体同名同义
25
+ * (conversation_turn / conversation_closed)。用来刷新业务界面;落库以后端回调为准。
26
+ */
27
+ onCallback?: (payload: ConversationCallback) => void;
28
+ children: ReactNode;
29
+ }
30
+
31
+ export function CliCenterProvider({ store: external, onEvent, onCallback, children, ...options }: CliCenterProviderProps) {
32
+ // 连接参数只在建仓库时用一次:中途换 baseUrl / token 请换 key 重建。
33
+ const optionsRef = useRef(options);
34
+ const store = useMemo(() => external ?? createCliStore(optionsRef.current), [external]);
35
+ const onEventRef = useRef(onEvent);
36
+ onEventRef.current = onEvent;
37
+ const onCallbackRef = useRef(onCallback);
38
+ onCallbackRef.current = onCallback;
39
+ useEffect(() => store.on((event) => {
40
+ onEventRef.current?.(event);
41
+ if (event.type === "conversation_turn" || event.type === "conversation_closed") onCallbackRef.current?.(event.payload);
42
+ }), [store]);
43
+ return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
44
+ }
45
+
46
+ export function useCliStoreContext(): CliStore | null {
47
+ return useContext(StoreContext);
48
+ }
49
+
50
+ export function useCliStore(): CliStore {
51
+ const store = useContext(StoreContext);
52
+ if (!store) throw new Error("useCliStore 必须在 <CliCenterProvider> 内使用");
53
+ return store;
54
+ }
55
+
56
+ /** 订阅仓库状态的一部分。选择器返回的值按引用比较,状态是不可变的,所以返回子对象是安全的。 */
57
+ export function useCliState<T>(selector: (state: CliState) => T): T {
58
+ const store = useCliStore();
59
+ return useSyncExternalStore(store.subscribe, () => selector(store.getState()), () => selector(store.getState()));
60
+ }
61
+
62
+ /** 组件外面没有 Provider 时,用组件自己的 props 包一层。 */
63
+ export function withOwnProvider<P extends object>(props: P & CliStoreOptions & { onEvent?: (event: CliEvent) => void; onCallback?: (payload: ConversationCallback) => void; store?: CliStore }, inner: ReactNode, hasContext: boolean) {
64
+ if (hasContext) return inner;
65
+ const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onCallback, store } = props;
66
+ // 连接参数变了(换了中枢地址、令牌、产品线、命名空间)就换一个新仓库重连,不沿用旧连接。
67
+ const connection = [baseUrl ?? "", token ?? "", line ?? "", namespace ?? ""].join("\u0000");
68
+ return (
69
+ <CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onCallback, store }}>
70
+ {inner}
71
+ </CliCenterProvider>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * 组件自己的 onEvent / onCallback:组件放在外层 Provider 里时由这里订阅(自己建 Provider 时交给那个 Provider,免得调两次)。
77
+ */
78
+ export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefined, onCallback: ((payload: ConversationCallback) => void) | undefined, enabled: boolean) {
79
+ const store = useContext(StoreContext);
80
+ const ref = useRef({ onEvent, onCallback });
81
+ ref.current = { onEvent, onCallback };
82
+ useEffect(() => {
83
+ if (!enabled || !store) return;
84
+ return store.on((event) => {
85
+ ref.current.onEvent?.(event);
86
+ if (event.type === "conversation_turn" || event.type === "conversation_closed") ref.current.onCallback?.(event.payload);
87
+ });
88
+ }, [store, enabled]);
89
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 极简 SSE 解析器。用 fetch 读流而不是 EventSource:EventSource 不能带 Authorization 头,
3
+ * 而 center 的接口按产品线令牌鉴权(直连或经代理都可能需要带头)。
4
+ *
5
+ * 按 https://html.spec.whatwg.org/multipage/server-sent-events.html 的行规则:
6
+ * `event:` 设事件名,`data:` 累加数据,空行派发;以 `:` 开头的是注释(心跳 ping)。
7
+ */
8
+ export interface SseMessage { event: string; data: string }
9
+
10
+ export function createSseParser(onMessage: (message: SseMessage) => void) {
11
+ let buffer = "";
12
+ let event = "";
13
+ let data: string[] = [];
14
+
15
+ const dispatch = () => {
16
+ if (data.length) onMessage({ event: event || "message", data: data.join("\n") });
17
+ event = "";
18
+ data = [];
19
+ };
20
+
21
+ const line = (raw: string) => {
22
+ if (raw === "") return dispatch();
23
+ if (raw.startsWith(":")) return;
24
+ const colon = raw.indexOf(":");
25
+ const field = colon === -1 ? raw : raw.slice(0, colon);
26
+ let value = colon === -1 ? "" : raw.slice(colon + 1);
27
+ if (value.startsWith(" ")) value = value.slice(1);
28
+ if (field === "event") event = value;
29
+ else if (field === "data") data.push(value);
30
+ };
31
+
32
+ return {
33
+ push(chunk: string) {
34
+ buffer += chunk;
35
+ let index: number;
36
+ // 行尾可能是 \n、\r\n 或 \r。
37
+ while ((index = buffer.search(/\r\n|\r|\n/)) !== -1) {
38
+ const sep = buffer.startsWith("\r\n", index) ? 2 : 1;
39
+ // 只剩一个 \r 时等下一块:它可能是 \r\n 的前半。
40
+ if (buffer[index] === "\r" && sep === 1 && index === buffer.length - 1) break;
41
+ line(buffer.slice(0, index));
42
+ buffer = buffer.slice(index + sep);
43
+ }
44
+ },
45
+ end() {
46
+ if (buffer) line(buffer);
47
+ buffer = "";
48
+ dispatch();
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,491 @@
1
+ /**
2
+ * 组件共享的状态仓库(纯 TS,无 React)。积木之间靠它拼起来:
3
+ *
4
+ * · 选中哪个对话(cli-msglist 点一下,cli-chat 跟着切);
5
+ * · 列表里有哪些对话(宿主给 jobIds,或本地记住 + 从 center 发现);
6
+ * · 每个对话的实时状态:一个对话只开一条实时流,几个组件同时看也只连一次(引用计数)。
7
+ *
8
+ * 单独用某个组件时它会自己建一个仓库;放进同一个 <CliCenterProvider>(或同一个 group 的 Web Component)就共享一个。
9
+ */
10
+ 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";
12
+ import { formatAnswersText } from "../ui/format.js";
13
+ import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types.js";
14
+
15
+ export interface JobEntry {
16
+ job: CenterJob | null;
17
+ /** 第 1 轮的消息(列表卡片的描述)。 */
18
+ firstInput: string | null;
19
+ loading: boolean;
20
+ error: string | null;
21
+ fetchedAt: number;
22
+ }
23
+
24
+ export interface CliState {
25
+ selected: string | null;
26
+ jobIds: string[];
27
+ jobs: Record<string, JobEntry>;
28
+ convs: Record<string, ConvState>;
29
+ listLoading: boolean;
30
+ listError: string | null;
31
+ }
32
+
33
+ /** 与 center 回调同名同义的事件,方便宿主把前端事件和后端回调对上。 */
34
+ export type CliEvent =
35
+ | { type: "select"; jobId: string | null }
36
+ /** 一轮结束。payload 与 center 回调 conversation_turn 的请求体同名同义。 */
37
+ | { type: "conversation_turn"; jobId: string; turn: CenterTurn; payload: ConversationTurnCallback }
38
+ /** 对话结束。payload 与 center 回调 conversation_closed 的请求体同名同义。 */
39
+ | { type: "conversation_closed"; jobId: string; reason: string | null; continuedBy: string | null; payload: ConversationClosedCallback }
40
+ /** 新建了对话,或旧对话结束后消息开了新对话(job_id 变了)。 */
41
+ | { type: "job_change"; jobId: string; previousJobId: string | null }
42
+ | { type: "error"; jobId: string | null; error: CenterError };
43
+
44
+ export interface CliStoreOptions extends CenterClientOptions {
45
+ client?: CenterClient;
46
+ /** 在本机记住对话列表用的命名空间(不同业务 / 产品线请区分)。不给 = 不记,组件之间没有任何隐式共享。 */
47
+ namespace?: string | null;
48
+ /**
49
+ * 新建对话时的参数(与 POST /api/jobs 对应):标题、业务 ID、工作区、回调、智能体、超时、浏览器模式。
50
+ * 带了 callback,center 每轮结束就向 callback.url 投递 conversation_turn,结束时投递 conversation_closed。
51
+ */
52
+ newConversation?: NewConversationOptions;
53
+ /** 自己建对话(例如经业务后端建,以便把回调密钥留在服务端):返回 center 的 job_id。 */
54
+ createConversation?: (firstMessage: string) => Promise<string>;
55
+ /**
56
+ * 每条消息的 business_id(center 按它去重,回调里原样带回)。缺省 `cli-<uuid>`。
57
+ * kind = "create" 是第一条(新建对话),"message" 是之后的每条。
58
+ */
59
+ businessIdFor?: (text: string, kind: "create" | "message") => string;
60
+ }
61
+
62
+ /** 发一条消息时可以单独指定的新建参数(组件自己的 props),优先于仓库的。 */
63
+ export interface SendOptions {
64
+ /** 新建对话后是否改仓库的选中(缺省改)。 */
65
+ select?: boolean;
66
+ newConversation?: NewConversationOptions;
67
+ createConversation?: (firstMessage: string) => Promise<string>;
68
+ businessIdFor?: (text: string, kind: "create" | "message") => string;
69
+ }
70
+
71
+ const RECONNECT_MS = [1_000, 2_000, 5_000, 10_000, 20_000];
72
+ const JOB_REFRESH_GAP_MS = 1_500;
73
+
74
+ export function createCliStore(options: CliStoreOptions = {}) {
75
+ const client = options.client ?? createCenterClient(options);
76
+ // 只有明确给了 namespace 才在本机记住对话列表:不给就不落任何共享状态,几个独立的组件互不影响。
77
+ const storageKey = options.namespace ? `cli-ui:${options.namespace}:jobs` : null;
78
+
79
+ let state: CliState = { selected: null, jobIds: loadIds(storageKey), jobs: {}, convs: {}, listLoading: false, listError: null };
80
+ const listeners = new Set<() => void>();
81
+ const eventListeners = new Set<(event: CliEvent) => void>();
82
+ const settledTurns = new Set<string>();
83
+ const closedJobs = new Set<string>();
84
+ const seenOpen = new Set<string>();
85
+ let hostIds = false;
86
+
87
+ const set = (next: Partial<CliState>) => {
88
+ state = { ...state, ...next };
89
+ for (const fn of listeners) fn();
90
+ };
91
+ const emit = (event: CliEvent) => { for (const fn of eventListeners) fn(event); };
92
+ const patchConv = (jobId: string, fn: (conv: ConvState) => ConvState) => {
93
+ const prev = state.convs[jobId] ?? emptyConv(jobId);
94
+ const next = fn(prev);
95
+ if (next !== prev) {
96
+ noticeTransitions(jobId, prev, next);
97
+ set({ convs: { ...state.convs, [jobId]: next } });
98
+ }
99
+ };
100
+ const patchJob = (jobId: string, patch: Partial<JobEntry>) => {
101
+ const prev = state.jobs[jobId] ?? { job: null, firstInput: null, loading: false, error: null, fetchedAt: 0 };
102
+ set({ jobs: { ...state.jobs, [jobId]: { ...prev, ...patch } } });
103
+ };
104
+
105
+ // 一轮从未结束变成结束 → conversation_turn;对话变成结束 → conversation_closed。
106
+ function noticeTransitions(jobId: string, prev: ConvState, next: ConvState) {
107
+ if (prev.turns !== next.turns) {
108
+ const before = new Map(prev.turns.map((turn) => [turn.turn_id, turn.status]));
109
+ // 有轮次开始执行 / 新排队:列表卡片的「运行中」「排队中」也要跟上,不等下一次轮询。
110
+ if (next.turns.some((turn) => before.get(turn.turn_id) !== turn.status && !TERMINAL_TURN.has(turn.status))) void refreshJob(jobId);
111
+ for (const turn of next.turns) {
112
+ if (!TERMINAL_TURN.has(turn.status) || settledTurns.has(turn.turn_id)) continue;
113
+ settledTurns.add(turn.turn_id);
114
+ const was = before.get(turn.turn_id);
115
+ // 打开页面时已经结束的历史轮次不算「刚结束」。
116
+ // 被合并进别的轮次的消息不单独回调(center 也不单独投递),结果在那一轮的 messages 里。
117
+ if (was !== undefined && !TERMINAL_TURN.has(was) && turn.status !== "merged") {
118
+ const continuedFrom = state.jobs[jobId]?.job?.conversation?.continued_from_job_id ?? null;
119
+ emit({ type: "conversation_turn", jobId, turn, payload: turnCallback(next, turn, continuedFrom) });
120
+ void refreshJob(jobId, true);
121
+ }
122
+ }
123
+ }
124
+ // 对话结束:只对本页看着还开着的对话发(打开时就已结束的不算);而且要等所有轮次都收尾之后再发,
125
+ // 保证 conversation_closed 排在这个对话最后一个 conversation_turn 之后(与 center 的 callback_seq 一致)。
126
+ if (next.job && !TERMINAL_JOB.has(next.job.status)) seenOpen.add(jobId);
127
+ if (next.job && TERMINAL_JOB.has(next.job.status) && seenOpen.has(jobId) && !closedJobs.has(jobId)) {
128
+ const settled = next.turns.every((turn) => TERMINAL_TURN.has(turn.status));
129
+ if (settled || next.stream === "ended") {
130
+ closedJobs.add(jobId);
131
+ emit({ type: "conversation_closed", jobId, reason: next.job.closed_reason, continuedBy: next.continuedBy, payload: closedCallback(next) });
132
+ }
133
+ }
134
+ // 实时流里的对话状态同步给列表。
135
+ if (next.job && prev.job?.status !== next.job.status) {
136
+ const entry = state.jobs[jobId];
137
+ if (entry?.job) patchJob(jobId, { job: { ...entry.job, status: next.job.status } });
138
+ }
139
+ }
140
+
141
+ function persist(ids: string[]) {
142
+ if (!storageKey || hostIds) return;
143
+ try { localStorage.setItem(storageKey, JSON.stringify(ids.slice(0, 200))); } catch { /* 存不下就不记 */ }
144
+ }
145
+
146
+ // ── 列表 ─────────────────────────────────────────────
147
+
148
+ function setJobIds(ids: string[]) {
149
+ hostIds = true;
150
+ const unique = [...new Set(ids.filter(Boolean))];
151
+ if (unique.join() === state.jobIds.join()) return;
152
+ set({ jobIds: unique });
153
+ }
154
+
155
+ function addJob(jobId: string) {
156
+ if (state.jobIds[0] === jobId) return;
157
+ const ids = [jobId, ...state.jobIds.filter((id) => id !== jobId)];
158
+ set({ jobIds: ids });
159
+ persist(ids);
160
+ }
161
+
162
+ function removeJob(jobId: string) {
163
+ const ids = state.jobIds.filter((id) => id !== jobId);
164
+ set({ jobIds: ids, selected: state.selected === jobId ? null : state.selected });
165
+ persist(ids);
166
+ }
167
+
168
+ /** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。 */
169
+ function replaceJob(oldId: string, newId: string) {
170
+ if (oldId === newId) return;
171
+ const ids = state.jobIds.includes(oldId) ? state.jobIds.map((id) => (id === oldId ? newId : id)) : [newId, ...state.jobIds];
172
+ set({ jobIds: [...new Set(ids)], selected: state.selected === oldId ? newId : state.selected });
173
+ persist(state.jobIds);
174
+ emit({ type: "job_change", jobId: newId, previousJobId: oldId });
175
+ void refreshJob(newId, true);
176
+ }
177
+
178
+ const refreshAgain = new Set<string>();
179
+ async function refreshJob(jobId: string, force = false) {
180
+ const entry = state.jobs[jobId];
181
+ // 正在拉:拉完再补一次,免得错过这期间的变化(例如轮次刚从排队变成执行中)。
182
+ if (entry?.loading) { refreshAgain.add(jobId); return; }
183
+ if (!force && entry && Date.now() - entry.fetchedAt < JOB_REFRESH_GAP_MS) return;
184
+ patchJob(jobId, { loading: true });
185
+ try {
186
+ const job = await client.getJob(jobId);
187
+ let firstInput = state.jobs[jobId]?.firstInput ?? null;
188
+ if (firstInput === null && job.mode === "conversation") {
189
+ const first = await client.listTurns(jobId, { limit: 1 }).catch(() => null);
190
+ firstInput = first?.turns[0]?.input ?? "";
191
+ }
192
+ patchJob(jobId, { job, firstInput, loading: false, error: null, fetchedAt: Date.now() });
193
+ // 新数据里已包含实时用量,清零累计。
194
+ if (state.convs[jobId]) patchConv(jobId, (conv) => ({ ...conv, liveUsage: { fresh: 0, cacheRead: 0 } }));
195
+ } catch (error) {
196
+ patchJob(jobId, { loading: false, error: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() });
197
+ // 本地记住的对话在 center 已经不存在(被删、换了环境):从列表里拿掉。宿主给的 jobIds 不动,由宿主决定。
198
+ if (!hostIds && error instanceof CenterError && error.status === 404 && state.selected !== jobId) {
199
+ refreshAgain.delete(jobId);
200
+ removeJob(jobId);
201
+ return;
202
+ }
203
+ }
204
+ if (refreshAgain.delete(jobId)) void refreshJob(jobId, true);
205
+ }
206
+
207
+ /**
208
+ * 刷新列表。缺省只刷还没结束的对话(结束了的不会再变,轮询不必每次都拉);all = true 全部重拉(首次加载、手动刷新)。
209
+ */
210
+ async function refreshAll(opts: { all?: boolean } = {}) {
211
+ set({ listLoading: true, listError: null });
212
+ try {
213
+ const ids = opts.all ? state.jobIds : state.jobIds.filter((id) => {
214
+ const status = state.jobs[id]?.job?.status;
215
+ return !status || !TERMINAL_JOB.has(status);
216
+ });
217
+ await runLimited(ids, 4, (id) => refreshJob(id, true));
218
+ set({ listLoading: false });
219
+ } catch (error) {
220
+ set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
221
+ }
222
+ }
223
+
224
+ /**
225
+ * 从 center 发现本产品线的对话:center 没有「列出对话」接口,只能从全部子任务里按 job 去重,
226
+ * 再逐个看是不是对话任务。center 的 ID 是随机的,返回顺序不代表新旧,所以最多看 limit 个 job(缺省 200);
227
+ * 对话多的产品线请由宿主传 jobIds。只在宿主没给 jobIds 时调用。
228
+ */
229
+ let discovering: Promise<void> | null = null;
230
+ function discover(limit = 200) {
231
+ // 同时多次调用(React StrictMode 下 effect 会跑两遍)只发现一次。
232
+ discovering ??= discoverOnce(limit).finally(() => { discovering = null; });
233
+ return discovering;
234
+ }
235
+
236
+ async function discoverOnce(limit: number) {
237
+ set({ listLoading: true, listError: null });
238
+ try {
239
+ const { tasks } = await client.listTasks();
240
+ const candidates = [...new Set(tasks.map((task) => task.job_id))].filter((id) => !state.jobIds.includes(id)).slice(0, limit);
241
+ const found: string[] = [];
242
+ await runLimited(candidates, 4, async (id) => {
243
+ const job = await client.getJob(id).catch(() => null);
244
+ if (job?.mode === "conversation") {
245
+ found.push(id);
246
+ patchJob(id, { job, firstInput: null, loading: false, error: null, fetchedAt: 0 });
247
+ }
248
+ });
249
+ const ids = [...new Set([...state.jobIds, ...found])];
250
+ set({ jobIds: ids, listLoading: false });
251
+ persist(ids);
252
+ await runLimited(found, 4, (id) => refreshJob(id, true));
253
+ } catch (error) {
254
+ set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
255
+ }
256
+ }
257
+
258
+ // ── 实时流(引用计数 + 断线重连) ─────────────────────
259
+
260
+ const streams = new Map<string, { refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number }>();
261
+
262
+ function connect(jobId: string) {
263
+ const entry = streams.get(jobId);
264
+ if (!entry || entry.refs <= 0) return;
265
+ patchConv(jobId, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
266
+ let ended = false;
267
+ entry.stop = client.stream(jobId, {
268
+ onOpen: () => { entry.attempt = 0; patchConv(jobId, (conv) => ({ ...conv, stream: "live", streamError: null })); },
269
+ onSnapshot: (snapshot) => patchConv(jobId, (conv) => applySnapshot(conv, snapshot)),
270
+ onChunk: (items) => patchConv(jobId, (conv) => applyItems(conv, items)),
271
+ onEnd: (end) => {
272
+ ended = true;
273
+ patchConv(jobId, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy: end.continued_by_job_id ?? null, nodeRunning: false }));
274
+ void refreshJob(jobId, true);
275
+ },
276
+ onClose: (error) => {
277
+ entry.stop = null;
278
+ const current = streams.get(jobId);
279
+ if (!current || current.refs <= 0) return;
280
+ // 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
281
+ if (ended && state.convs[jobId]?.endReason !== "server_shutdown") return;
282
+ // 404 / 401:对话不存在或没权限,重连也没用。
283
+ if (error && (error.status === 404 || error.status === 401 || error.status === 403)) {
284
+ patchConv(jobId, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
285
+ emit({ type: "error", jobId, error });
286
+ return;
287
+ }
288
+ const delay = RECONNECT_MS[Math.min(current.attempt, RECONNECT_MS.length - 1)];
289
+ current.attempt += 1;
290
+ patchConv(jobId, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
291
+ current.timer = setTimeout(() => { current.timer = null; connect(jobId); }, delay);
292
+ },
293
+ });
294
+ }
295
+
296
+ // 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
297
+ function reconnectNow() {
298
+ for (const [jobId, entry] of streams) {
299
+ if (!entry.timer) continue;
300
+ clearTimeout(entry.timer);
301
+ entry.timer = null;
302
+ entry.attempt = 0;
303
+ connect(jobId);
304
+ }
305
+ }
306
+ if (typeof window !== "undefined") {
307
+ window.addEventListener("online", reconnectNow);
308
+ document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reconnectNow(); });
309
+ }
310
+
311
+ /** 开始看一个对话(返回停止函数)。同一对话多处同时看只连一次。 */
312
+ function open(jobId: string): () => void {
313
+ let entry = streams.get(jobId);
314
+ if (!entry) {
315
+ entry = { refs: 0, stop: null, timer: null, attempt: 0 };
316
+ streams.set(jobId, entry);
317
+ }
318
+ entry.refs += 1;
319
+ if (entry.refs === 1) {
320
+ void refreshJob(jobId);
321
+ connect(jobId);
322
+ }
323
+ return () => {
324
+ const current = streams.get(jobId);
325
+ if (!current) return;
326
+ current.refs -= 1;
327
+ if (current.refs > 0) return;
328
+ current.stop?.();
329
+ if (current.timer) clearTimeout(current.timer);
330
+ streams.delete(jobId);
331
+ patchConv(jobId, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
332
+ };
333
+ }
334
+
335
+ // ── 发消息 / 新建 / 撤回 / 结束 ───────────────────────
336
+
337
+ /**
338
+ * 发一条消息。jobId 为 null 时新建对话(这条就是第一轮)。返回实际接收消息的对话 id。
339
+ * 带 business_id 发:网络抖动时重发一次也只会跑一轮。
340
+ */
341
+ // 同一个对话的消息严格按发出的顺序送到 center(输入框、提问卡片、重试可能同时发):
342
+ // center 按到达顺序给轮次编号,并发发出去可能后发先到。每个对话一条队列,前一条失败不挡后面的。
343
+ const sendQueues = new Map<string, Promise<unknown>>();
344
+ function send(jobId: string | null, text: string, overrides: SendOptions = {}): Promise<string> {
345
+ const key = jobId ?? "\u0000new";
346
+ const prev = sendQueues.get(key) ?? Promise.resolve();
347
+ const run = prev.catch(() => undefined).then(() => sendNow(jobId, text, overrides));
348
+ const tail = run.catch(() => undefined);
349
+ sendQueues.set(key, tail);
350
+ void tail.then(() => { if (sendQueues.get(key) === tail) sendQueues.delete(key); });
351
+ return run;
352
+ }
353
+
354
+ async function sendNow(jobId: string | null, text: string, overrides: SendOptions): Promise<string> {
355
+ // 发消息的组件自己的参数优先(同一个仓库里可以有几个对话组件,各带各的新建参数),没有再用仓库的。
356
+ const newConversation = overrides.newConversation ?? options.newConversation;
357
+ const createConversation = overrides.createConversation ?? options.createConversation;
358
+ const businessIdFor = overrides.businessIdFor ?? options.businessIdFor;
359
+ const businessId = businessIdFor?.(text, jobId ? "message" : "create") || `cli-${uid()}`;
360
+ try {
361
+ if (!jobId) {
362
+ let newId: string;
363
+ if (createConversation) {
364
+ newId = await createConversation(text);
365
+ } else {
366
+ // 新建只发一次:center 的 POST /api/jobs 没有幂等,建好了却没收到响应时重发会建出第二个对话。
367
+ const created = await client.createConversation(text, { ...newConversation, title: newConversation?.title || firstLine(text), businessId: newConversation?.businessId || businessId });
368
+ newId = created.id;
369
+ patchConv(newId, (conv) => ({ ...conv, turns: upsertTurns(conv.turns, created.turn ? [created.turn] : []) }));
370
+ }
371
+ addJob(newId);
372
+ // 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
373
+ if (overrides.select !== false) set({ selected: newId });
374
+ emit({ type: "job_change", jobId: newId, previousJobId: null });
375
+ void refreshJob(newId, true);
376
+ return newId;
377
+ }
378
+ const result = await retryOnce(() => client.sendMessage(jobId, text, { businessId }));
379
+ const target = result.job_id || jobId;
380
+ if (result.new_conversation && target !== jobId) replaceJob(jobId, target);
381
+ // 先把这一轮放进状态:排队气泡立刻出现,不用等实时流的下一个块。
382
+ patchConv(target, (conv) => ({ ...conv, turns: upsertTurns(conv.turns, [{ ...result.turn, queue_position: result.queue_position }]) }));
383
+ if (target === jobId) addJobIfMissing(jobId);
384
+ return target;
385
+ } catch (error) {
386
+ const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
387
+ emit({ type: "error", jobId, error: err });
388
+ throw err;
389
+ }
390
+ }
391
+
392
+ function addJobIfMissing(jobId: string) {
393
+ if (!state.jobIds.includes(jobId)) addJob(jobId);
394
+ }
395
+
396
+ /**
397
+ * 回答 Agent 的提问(AskUserQuestion 卡片)。center 在 Agent 提问时已经结束了那一轮(asked=true),
398
+ * 所以回答作为下一条消息发出——与 Operator 在没有等待中的回合时(/answer 返回 resolved=false)的做法一致;
399
+ * 卡片先在本地标成已答(Operator 同样是先乐观更新)。
400
+ */
401
+ async function answerAsk(jobId: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions = {}): Promise<string> {
402
+ // 与 Operator 的 answerQuestion 同一流程:先乐观标成已答 → 交给正在等的回合 → 没有在等的回合就当普通回复发出。
403
+ patchConv(jobId, (conv) => ({ ...conv, localAnswers: { ...conv.localAnswers, [askId]: answers } }));
404
+ try {
405
+ if (askId && client.answerAsk) {
406
+ const { resolved } = await client.answerAsk(jobId, askId, answers);
407
+ if (resolved) return jobId;
408
+ }
409
+ return await send(jobId, formatAnswersText(questions, answers), overrides);
410
+ } catch (error) {
411
+ // 没发出去:卡片恢复成可回答。
412
+ patchConv(jobId, (conv) => {
413
+ const { [askId]: _drop, ...rest } = conv.localAnswers;
414
+ return { ...conv, localAnswers: rest };
415
+ });
416
+ throw error;
417
+ }
418
+ }
419
+
420
+ async function cancelTurn(jobId: string, turnId: string) {
421
+ try {
422
+ const turn = await client.cancelTurn(jobId, turnId);
423
+ patchConv(jobId, (conv) => ({ ...conv, turns: upsertTurns(conv.turns, [turn]) }));
424
+ } catch (error) {
425
+ const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
426
+ emit({ type: "error", jobId, error: err });
427
+ throw err;
428
+ }
429
+ }
430
+
431
+ async function close(jobId: string) {
432
+ try {
433
+ await client.closeConversation(jobId);
434
+ await refreshJob(jobId, true);
435
+ } catch (error) {
436
+ const err = error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0);
437
+ emit({ type: "error", jobId, error: err });
438
+ throw err;
439
+ }
440
+ }
441
+
442
+ return {
443
+ client,
444
+ getState: () => state,
445
+ subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
446
+ on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
447
+ select(jobId: string | null) {
448
+ if (state.selected === jobId) return;
449
+ set({ selected: jobId });
450
+ emit({ type: "select", jobId });
451
+ },
452
+ setJobIds, addJob, removeJob, replaceJob, refreshJob, refreshAll, discover,
453
+ open, send, answerAsk, cancelTurn, close,
454
+ };
455
+ }
456
+
457
+ export type CliStore = ReturnType<typeof createCliStore>;
458
+
459
+ function loadIds(key: string | null): string[] {
460
+ if (!key) return [];
461
+ try {
462
+ const value = JSON.parse(localStorage.getItem(key) || "[]");
463
+ return Array.isArray(value) ? [...new Set(value.filter((id): id is string => typeof id === "string"))] : [];
464
+ } catch {
465
+ return [];
466
+ }
467
+ }
468
+
469
+ async function runLimited<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>) {
470
+ let index = 0;
471
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
472
+ while (index < items.length) await fn(items[index++]);
473
+ });
474
+ await Promise.all(workers);
475
+ }
476
+
477
+ /** 只有网络层失败(没收到响应)才重发:带 business_id,center 会去重。 */
478
+ async function retryOnce<T>(fn: () => Promise<T>): Promise<T> {
479
+ try {
480
+ return await fn();
481
+ } catch (error) {
482
+ if (error instanceof CenterError && error.code === "NETWORK") return fn();
483
+ throw error;
484
+ }
485
+ }
486
+
487
+ function uid() {
488
+ const cryptoObj = globalThis.crypto as Crypto | undefined;
489
+ if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
490
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
491
+ }