@spzxg/cli-ui 0.4.0 → 0.6.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 +78 -10
- package/dist/cli-ui-embed-CXdmrwo1.js +40896 -0
- package/dist/cli-ui-index-BZppwn-v.js +13528 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +2 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +244 -63
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +114 -21
- package/src/core/blocks.ts +21 -2
- package/src/core/client.ts +258 -41
- package/src/core/conversation.ts +17 -7
- package/src/core/store.ts +340 -71
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +21 -6
- package/src/elements.tsx +106 -11
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +50 -22
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-D7xtcvGG.js +0 -37967
- package/dist/cli-ui-index-O0krpZBg.js +0 -13516
package/src/core/store.ts
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* 单独用某个组件时它会自己建一个仓库;放进同一个 <CliCenterProvider>(或同一个 group 的 Web Component)就共享一个。
|
|
12
12
|
*/
|
|
13
13
|
import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client.js";
|
|
14
|
-
import { addTurnsIfAbsent, applyItems, applySnapshot, closedCallback, convEnded, convKey, emptyConv, isChatTask, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
|
|
14
|
+
import { addTurnsIfAbsent, applyItems, applyOlderPage, applySnapshot, closedCallback, convEnded, convKey, emptyConv, isChatTask, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
|
|
15
15
|
import { formatAnswersText } from "../ui/format.js";
|
|
16
|
+
import { backgroundBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "./blocks.js";
|
|
17
|
+
import { createWorkspace, type BoardView, type CliWorkspace } from "../board/workspace.js";
|
|
16
18
|
import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types.js";
|
|
17
19
|
|
|
18
20
|
export interface JobEntry {
|
|
@@ -35,6 +37,44 @@ export interface CliState {
|
|
|
35
37
|
convs: Record<string, ConvState>;
|
|
36
38
|
listLoading: boolean;
|
|
37
39
|
listError: string | null;
|
|
40
|
+
/** 看板里引用到对话的东西(按对话键),下一条消息发出时一起带上(见 BoardRef)。 */
|
|
41
|
+
refs: Record<string, BoardRef[]>;
|
|
42
|
+
/**
|
|
43
|
+
* 看板上正在看的东西(按对话键,现在是打开着的文件):缺省跟着下一条消息带上一次,对话输入框上方显示成虚线卡片;
|
|
44
|
+
* off = 这一个已经带过 / 用户点了不带。换了看的东西重新算。
|
|
45
|
+
*/
|
|
46
|
+
viewing: Record<string, { ref: BoardRef; off: boolean }>;
|
|
47
|
+
/** 看板现在的样子(按对话键):正在看的文件、开着的文件页签、当前页签。没有看板时没有。 */
|
|
48
|
+
boards: Record<string, BoardView>;
|
|
49
|
+
/** 业务方补充的项目背景(按对话键;"" = 还没建的新对话),随下一条消息发出(见 appendContext)。 */
|
|
50
|
+
context: Record<string, ContextItem[]>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 一条补充背景。 */
|
|
54
|
+
export interface ContextItem {
|
|
55
|
+
/** 同一个 id 再追加会替换。 */
|
|
56
|
+
id: string;
|
|
57
|
+
/** 标题(输入框上方的小卡片、发给智能体的块里都显示)。 */
|
|
58
|
+
title?: string;
|
|
59
|
+
text: string;
|
|
60
|
+
}
|
|
61
|
+
/** 一条补充背景最长多少字(和创建任务时 workspace.context 的上限一致)。 */
|
|
62
|
+
export const CONTEXT_ITEM_MAX = 20_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 从看板引用到对话的一样东西:一个文件、浏览器当前页面、一处改动,或宿主自己加的页签里的内容(终端输出……)。
|
|
66
|
+
* 对话组件把它们显示在输入框上方;下一条消息发出时合成一个「看板上下文」块带给智能体
|
|
67
|
+
* (转录里显示成一张可展开的卡片,不占对话篇幅),发完清空。
|
|
68
|
+
*/
|
|
69
|
+
export interface BoardRef {
|
|
70
|
+
/** 同一个 id 再引用一次会替换(例如浏览器换了页面)。 */
|
|
71
|
+
id: string;
|
|
72
|
+
/** file / browser / changes,或宿主自定义的种类(terminal …)。 */
|
|
73
|
+
kind: string;
|
|
74
|
+
/** 输入框上方小卡片里显示的名字。 */
|
|
75
|
+
label: string;
|
|
76
|
+
/** 带给智能体的那一行(说清是什么、在哪)。 */
|
|
77
|
+
text: string;
|
|
38
78
|
}
|
|
39
79
|
|
|
40
80
|
/** 与 center 回调同名同义的事件,方便宿主把前端事件和后端回调对上。 */
|
|
@@ -55,16 +95,27 @@ export type CliEvent =
|
|
|
55
95
|
* 有人要在看板里打开东西(对话里点了本地文件链接、「查看浏览器」,或宿主调了 store.openInBoard)。
|
|
56
96
|
* handled = 有看板接住了;没有时对话组件自己弹窗打开。宿主可以据此展开右侧面板。
|
|
57
97
|
*/
|
|
58
|
-
| { type: "open"; jobId: string; target: OpenTarget; handled: boolean }
|
|
98
|
+
| { type: "open"; jobId: string; target: OpenTarget; handled: boolean }
|
|
99
|
+
/** 看板里引用 / 取消引用了东西(refs 是这个对话现在的全部引用),宿主可以据此做自己的提示。 */
|
|
100
|
+
| { type: "board_ref"; key: string; refs: BoardRef[] }
|
|
101
|
+
/** 看板上切了页签、打开 / 关掉了文件(view 为 null = 看板关了)。 */
|
|
102
|
+
| { type: "board_change"; key: string; view: BoardView | null }
|
|
103
|
+
/**
|
|
104
|
+
* 补充背景变了:追加 / 去掉(pending 是还没发出的),或随消息发出了(sent 是这次发出的)。
|
|
105
|
+
* key 是对话键;新对话还没建时是 ""。
|
|
106
|
+
*/
|
|
107
|
+
| { type: "context"; key: string; pending: ContextItem[]; sent?: ContextItem[] };
|
|
59
108
|
|
|
60
109
|
/** 看板要打开的东西。 */
|
|
61
|
-
export type OpenTarget = { kind: "file"; path: string } | { kind: "browser" } | { kind: "changes" } | { kind: "files"; path?: string }
|
|
110
|
+
export type OpenTarget = { kind: "file"; path: string } | { kind: "browser" } | { kind: "changes" } | { kind: "files"; path?: string }
|
|
111
|
+
/** 宿主用 extraTabs 加的页签。 */
|
|
112
|
+
| { kind: "tab"; id: string };
|
|
62
113
|
/** 看板登记的接收函数:接得住(显示的是这个对话)就返回 true。 */
|
|
63
114
|
export type BoardSurface = (jobId: string, target: OpenTarget) => boolean;
|
|
64
115
|
|
|
65
116
|
export interface CliStoreOptions extends CenterClientOptions {
|
|
66
117
|
client?: CenterClient;
|
|
67
|
-
/**
|
|
118
|
+
/** 命名空间(不同业务 / 产品线请区分;Web Component 没写 group 时用它区分仓库)。列表不会按它在本机记住任何对话。 */
|
|
68
119
|
namespace?: string | null;
|
|
69
120
|
/**
|
|
70
121
|
* 新建对话时的参数(与 POST /api/jobs 对应):标题、业务 ID、工作区、回调、智能体、超时、浏览器模式。
|
|
@@ -90,14 +141,65 @@ export interface SendOptions {
|
|
|
90
141
|
}
|
|
91
142
|
|
|
92
143
|
const RECONNECT_MS = [1_000, 2_000, 5_000, 10_000, 20_000];
|
|
144
|
+
/**
|
|
145
|
+
* 退避时间上下浮动 ±30%:中枢重启时所有页面同时断开,不错开的话会在同一秒一起重连、一起拉快照,
|
|
146
|
+
* 人多时把刚起来的中枢再压垮一次。
|
|
147
|
+
*/
|
|
148
|
+
export const jitter = (ms: number) => Math.round(ms * (0.7 + Math.random() * 0.6));
|
|
149
|
+
/**
|
|
150
|
+
* 标签页在后台超过这么久:先断开实时流(中枢那头每条流都在轮询数据库、连着执行节点),切回来时重连并拉一次快照补齐。
|
|
151
|
+
* 很多人开着页面不看时,中枢和节点不用一直为他们推数据。
|
|
152
|
+
*/
|
|
153
|
+
export const PARK_HIDDEN_MS = 5 * 60_000;
|
|
154
|
+
/** 401 / 403:多半是宿主的登录会话过期了。宿主续上会话后要能自己恢复,所以慢慢重试,不永久放弃。 */
|
|
155
|
+
const AUTH_RETRY_MS = 30_000;
|
|
156
|
+
|
|
157
|
+
// 全局只挂一份「网络恢复 / 切回页面」监听,转给当前有实时流的仓库。
|
|
158
|
+
const reconnectors = new Set<() => void>();
|
|
159
|
+
const parkers = new Set<() => void>();
|
|
160
|
+
let listening = false;
|
|
161
|
+
let parkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
162
|
+
const fireReconnect = () => { for (const fn of [...reconnectors]) fn(); };
|
|
163
|
+
const onVisible = () => {
|
|
164
|
+
if (document.visibilityState === "visible") {
|
|
165
|
+
if (parkTimer) { clearTimeout(parkTimer); parkTimer = null; }
|
|
166
|
+
fireReconnect();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
parkTimer ??= setTimeout(() => { parkTimer = null; if (document.visibilityState === "hidden") for (const fn of [...parkers]) fn(); }, PARK_HIDDEN_MS);
|
|
170
|
+
};
|
|
171
|
+
function watchReconnect(fn: () => void, park: () => void) {
|
|
172
|
+
reconnectors.add(fn);
|
|
173
|
+
parkers.add(park);
|
|
174
|
+
if (listening || typeof window === "undefined") return;
|
|
175
|
+
listening = true;
|
|
176
|
+
window.addEventListener("online", fireReconnect);
|
|
177
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
178
|
+
}
|
|
179
|
+
function unwatchReconnect(fn: () => void, park: () => void) {
|
|
180
|
+
reconnectors.delete(fn);
|
|
181
|
+
parkers.delete(park);
|
|
182
|
+
if (!listening || reconnectors.size) return;
|
|
183
|
+
listening = false;
|
|
184
|
+
window.removeEventListener("online", fireReconnect);
|
|
185
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
186
|
+
}
|
|
187
|
+
/** 测试用:当前登记着的仓库数。 */
|
|
188
|
+
export const liveReconnectors = () => reconnectors.size;
|
|
93
189
|
const JOB_REFRESH_GAP_MS = 1_500;
|
|
190
|
+
/**
|
|
191
|
+
* 列表在后台刷新 / 发现对话时同时最多发几个请求。HTTP/1.1 下浏览器对一个域名只有 6 个连接,
|
|
192
|
+
* 对话实时流、看板的浏览器画面又各占着长连接;列表少占一些,点开文件、发消息这类用户在等的请求才不用排队。
|
|
193
|
+
*/
|
|
194
|
+
const LIST_CONCURRENCY = 2;
|
|
94
195
|
|
|
95
196
|
export function createCliStore(options: CliStoreOptions = {}) {
|
|
96
197
|
const client = options.client ?? createCenterClient(options);
|
|
97
198
|
// 只有明确给了 namespace 才在本机记住对话列表:不给就不落任何共享状态,几个独立的组件互不影响。
|
|
98
|
-
|
|
199
|
+
// 列表只放宿主给的 jobIds 和本页里新建 / 接续出来的对话:不从 center 发现,也不从本地存储恢复——
|
|
200
|
+
// 不传就不加载,免得把这个产品线令牌下别的业务、别的用户的对话列出来。
|
|
99
201
|
|
|
100
|
-
let state: CliState = { selected: null, jobIds:
|
|
202
|
+
let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, context: {} };
|
|
101
203
|
const listeners = new Set<() => void>();
|
|
102
204
|
const eventListeners = new Set<(event: CliEvent) => void>();
|
|
103
205
|
const settledTurns = new Set<string>();
|
|
@@ -158,6 +260,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
158
260
|
emit({ type: "conversation_closed", jobId, key, reason: payload.closed_reason, continuedBy: next.continuedBy, payload });
|
|
159
261
|
}
|
|
160
262
|
}
|
|
263
|
+
// 一次性任务里看着的子任务换了状态(开跑、结束):列表里那一行马上跟上,不等列表的定时刷新。
|
|
264
|
+
if (next.batchTask && (prev.batchTask?.id !== next.batchTask.id || prev.batchTask?.status !== next.batchTask.status)) void refreshJob(jobId);
|
|
161
265
|
// 实时流里的对话状态同步给列表。
|
|
162
266
|
if (next.job && prev.job?.status !== next.job.status) {
|
|
163
267
|
const entry = state.jobs[jobId];
|
|
@@ -165,11 +269,6 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
165
269
|
}
|
|
166
270
|
}
|
|
167
271
|
|
|
168
|
-
function persist(ids: string[]) {
|
|
169
|
-
if (!storageKey || hostIds) return;
|
|
170
|
-
try { localStorage.setItem(storageKey, JSON.stringify(ids.slice(0, 200))); } catch { /* 存不下就不记 */ }
|
|
171
|
-
}
|
|
172
|
-
|
|
173
272
|
// ── 列表 ─────────────────────────────────────────────
|
|
174
273
|
|
|
175
274
|
function setJobIds(ids: string[]) {
|
|
@@ -183,13 +282,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
183
282
|
if (state.jobIds[0] === jobId) return;
|
|
184
283
|
const ids = [jobId, ...state.jobIds.filter((id) => id !== jobId)];
|
|
185
284
|
set({ jobIds: ids });
|
|
186
|
-
persist(ids);
|
|
187
285
|
}
|
|
188
286
|
|
|
189
287
|
function removeJob(jobId: string) {
|
|
190
288
|
const ids = state.jobIds.filter((id) => id !== jobId);
|
|
191
289
|
set({ jobIds: ids, selected: state.selected === jobId ? null : state.selected });
|
|
192
|
-
persist(ids);
|
|
193
290
|
}
|
|
194
291
|
|
|
195
292
|
/** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。oldId / newId 可以是对话键。 */
|
|
@@ -200,7 +297,6 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
200
297
|
const ids = from.jobId === to.jobId ? state.jobIds
|
|
201
298
|
: state.jobIds.includes(from.jobId) ? state.jobIds.map((id) => (id === from.jobId ? to.jobId : id)) : [to.jobId, ...state.jobIds];
|
|
202
299
|
set({ jobIds: [...new Set(ids)], selected: state.selected === oldId ? newId : state.selected });
|
|
203
|
-
persist(state.jobIds);
|
|
204
300
|
emit({ type: "job_change", jobId: to.jobId, previousJobId: from.jobId, taskId: to.taskId, key: newId, previousKey: oldId });
|
|
205
301
|
void refreshJob(to.jobId, true);
|
|
206
302
|
}
|
|
@@ -245,50 +341,16 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
245
341
|
const status = state.jobs[id]?.job?.status;
|
|
246
342
|
return !status || !TERMINAL_JOB.has(status);
|
|
247
343
|
});
|
|
248
|
-
await runLimited(ids,
|
|
344
|
+
await runLimited(ids, LIST_CONCURRENCY, (id) => refreshJob(id, true));
|
|
249
345
|
set({ listLoading: false });
|
|
250
346
|
} catch (error) {
|
|
251
347
|
set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
|
|
252
348
|
}
|
|
253
349
|
}
|
|
254
350
|
|
|
255
|
-
/**
|
|
256
|
-
* 从 center 发现本产品线的对话:center 没有「列出对话」接口,只能从全部子任务里按 job 去重,
|
|
257
|
-
* 再逐个看是不是对话任务。center 的 ID 是随机的,返回顺序不代表新旧,所以最多看 limit 个 job(缺省 200);
|
|
258
|
-
* 对话多的产品线请由宿主传 jobIds。只在宿主没给 jobIds 时调用。
|
|
259
|
-
*/
|
|
260
|
-
let discovering: Promise<void> | null = null;
|
|
261
|
-
function discover(limit = 200) {
|
|
262
|
-
// 同时多次调用(React StrictMode 下 effect 会跑两遍)只发现一次。
|
|
263
|
-
discovering ??= discoverOnce(limit).finally(() => { discovering = null; });
|
|
264
|
-
return discovering;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async function discoverOnce(limit: number) {
|
|
268
|
-
set({ listLoading: true, listError: null });
|
|
269
|
-
try {
|
|
270
|
-
const { tasks } = await client.listTasks();
|
|
271
|
-
const candidates = [...new Set(tasks.map((task) => task.job_id))].filter((id) => !state.jobIds.includes(id)).slice(0, limit);
|
|
272
|
-
const found: string[] = [];
|
|
273
|
-
await runLimited(candidates, 4, async (id) => {
|
|
274
|
-
const job = await client.getJob(id).catch(() => null);
|
|
275
|
-
if (job?.mode === "conversation") {
|
|
276
|
-
found.push(id);
|
|
277
|
-
patchJob(id, { job, firstInput: null, loading: false, error: null, fetchedAt: 0 });
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
const ids = [...new Set([...state.jobIds, ...found])];
|
|
281
|
-
set({ jobIds: ids, listLoading: false });
|
|
282
|
-
persist(ids);
|
|
283
|
-
await runLimited(found, 4, (id) => refreshJob(id, true));
|
|
284
|
-
} catch (error) {
|
|
285
|
-
set({ listLoading: false, listError: error instanceof Error ? error.message : String(error) });
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
351
|
// ── 实时流(引用计数 + 断线重连) ─────────────────────
|
|
290
352
|
|
|
291
|
-
const streams = new Map<string, { refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number }>();
|
|
353
|
+
const streams = new Map<string, { refs: number; stop: (() => void) | null; timer: ReturnType<typeof setTimeout> | null; attempt: number; parked?: boolean }>();
|
|
292
354
|
|
|
293
355
|
function connect(key: string) {
|
|
294
356
|
const entry = streams.get(key);
|
|
@@ -313,23 +375,33 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
313
375
|
if (!current || current.refs <= 0) return;
|
|
314
376
|
// 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
|
|
315
377
|
if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
|
|
316
|
-
// 404
|
|
317
|
-
if (error &&
|
|
378
|
+
// 404:对话不存在,重连也没用。
|
|
379
|
+
if (error && error.status === 404) {
|
|
318
380
|
patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
|
|
319
381
|
emit({ type: "error", jobId: key, error });
|
|
320
382
|
return;
|
|
321
383
|
}
|
|
384
|
+
// 401 / 403:多半是宿主的登录会话过期。报出来(宿主可以据此续会话),之后每 30 秒再试,会话续上了自己恢复。
|
|
385
|
+
if (error && (error.status === 401 || error.status === 403)) {
|
|
386
|
+
patchConv(key, (conv) => ({ ...conv, stream: "error", streamError: error.message }));
|
|
387
|
+
if (current.attempt === 0) emit({ type: "error", jobId: key, error });
|
|
388
|
+
current.attempt += 1;
|
|
389
|
+
current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(AUTH_RETRY_MS));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
322
392
|
const delay = RECONNECT_MS[Math.min(current.attempt, RECONNECT_MS.length - 1)];
|
|
323
393
|
current.attempt += 1;
|
|
324
394
|
patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
|
|
325
|
-
current.timer = setTimeout(() => { current.timer = null; connect(key); }, delay);
|
|
395
|
+
current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(delay));
|
|
326
396
|
},
|
|
327
397
|
}, undefined, taskId);
|
|
328
398
|
}
|
|
329
399
|
|
|
330
400
|
// 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
|
|
401
|
+
// 只在有实时流时登记到全局(见 watchReconnect):组件卸载、流都关了就撤掉,宿主单页应用里反复挂载组件不会越积越多。
|
|
331
402
|
function reconnectNow() {
|
|
332
403
|
for (const [jobId, entry] of streams) {
|
|
404
|
+
if (entry.parked && entry.refs > 0) { entry.parked = false; entry.attempt = 0; connect(jobId); continue; }
|
|
333
405
|
if (!entry.timer) continue;
|
|
334
406
|
clearTimeout(entry.timer);
|
|
335
407
|
entry.timer = null;
|
|
@@ -337,10 +409,18 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
337
409
|
connect(jobId);
|
|
338
410
|
}
|
|
339
411
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
412
|
+
// 页面在后台太久:停掉实时流(主动停止不算断线,不会触发重连),切回来由 reconnectNow 接上。
|
|
413
|
+
function parkAll() {
|
|
414
|
+
for (const entry of streams.values()) {
|
|
415
|
+
// 只停连着的和正在等重连的;对话已结束、不再连的不动。
|
|
416
|
+
if (entry.refs <= 0 || (!entry.stop && !entry.timer)) continue;
|
|
417
|
+
if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
|
|
418
|
+
entry.stop?.();
|
|
419
|
+
entry.stop = null;
|
|
420
|
+
entry.parked = true;
|
|
421
|
+
}
|
|
343
422
|
}
|
|
423
|
+
const syncLive = () => { if (streams.size) watchReconnect(reconnectNow, parkAll); else unwatchReconnect(reconnectNow, parkAll); };
|
|
344
424
|
|
|
345
425
|
/**
|
|
346
426
|
* 开始看一个对话(对话键;返回停止函数)。同一对话多处同时看只连一次。
|
|
@@ -356,6 +436,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
356
436
|
if (!entry) {
|
|
357
437
|
entry = { refs: 0, stop: null, timer: null, attempt: 0 };
|
|
358
438
|
streams.set(key, entry);
|
|
439
|
+
syncLive();
|
|
359
440
|
}
|
|
360
441
|
entry.refs += 1;
|
|
361
442
|
if (entry.refs === 1) {
|
|
@@ -370,6 +451,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
370
451
|
current.stop?.();
|
|
371
452
|
if (current.timer) clearTimeout(current.timer);
|
|
372
453
|
streams.delete(key);
|
|
454
|
+
syncLive();
|
|
373
455
|
patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
|
|
374
456
|
};
|
|
375
457
|
}
|
|
@@ -393,7 +475,34 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
393
475
|
return run;
|
|
394
476
|
}
|
|
395
477
|
|
|
396
|
-
async function sendNow(key: string | null,
|
|
478
|
+
async function sendNow(key: string | null, typed: string, overrides: SendOptions): Promise<string> {
|
|
479
|
+
// 业务方补充的背景:随这条消息带上(发出前取走,失败放回去)。超过单条上限就不发,背景留着。
|
|
480
|
+
const ck = ctxKey(key);
|
|
481
|
+
const ctx = state.context[ck] ?? [];
|
|
482
|
+
let text = typed;
|
|
483
|
+
if (ctx.length) {
|
|
484
|
+
text = `${typed}\n\n${serializeBlock(backgroundBlock(ctx))}`;
|
|
485
|
+
if (charCount(text) > MAX_MESSAGE_CHARS) {
|
|
486
|
+
const err = new CenterError(`补充背景加上这条消息超过单条上限(${MAX_MESSAGE_CHARS.toLocaleString()} 字),请精简补充背景`, "too_long", 0);
|
|
487
|
+
emit({ type: "error", jobId: key, error: err });
|
|
488
|
+
throw err;
|
|
489
|
+
}
|
|
490
|
+
setContext(ck, []);
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
const target = await sendWithText(key, text, overrides);
|
|
494
|
+
if (ctx.length) emit({ type: "context", key: target, pending: state.context[ck] ?? [], sent: ctx });
|
|
495
|
+
return target;
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (ctx.length) {
|
|
498
|
+
const have = new Set((state.context[ck] ?? []).map((c) => c.id));
|
|
499
|
+
setContext(ck, [...ctx.filter((c) => !have.has(c.id)), ...(state.context[ck] ?? [])]);
|
|
500
|
+
}
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function sendWithText(key: string | null, text: string, overrides: SendOptions): Promise<string> {
|
|
397
506
|
// 发消息的组件自己的参数优先(同一个仓库里可以有几个对话组件,各带各的新建参数),没有再用仓库的。
|
|
398
507
|
const newConversation = overrides.newConversation ?? options.newConversation;
|
|
399
508
|
const createConversation = overrides.createConversation ?? options.createConversation;
|
|
@@ -431,8 +540,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
431
540
|
return newId;
|
|
432
541
|
}
|
|
433
542
|
const shown = state.convs[key];
|
|
434
|
-
if (ref.taskId && isChatTask(shown)) {
|
|
435
|
-
//
|
|
543
|
+
if (ref.taskId && (isChatTask(shown) || shown?.batch || state.convs[ref.jobId]?.batch)) {
|
|
544
|
+
// 一次性任务里的子任务(对话式的,或已经结束的普通子任务):续派一条接着它的对话(中枢接到这个对话最新的那条后面),对话键不变。
|
|
436
545
|
// 续派没有按 business_id 去重,不重发。
|
|
437
546
|
if (!client.continueTask) throw new CenterError("当前后端不支持接着对话式子任务聊", "UNSUPPORTED", 0);
|
|
438
547
|
const { task_id } = await client.continueTask(ref.jobId, ref.taskId, text, { businessId });
|
|
@@ -471,7 +580,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
471
580
|
* 所以回答作为下一条消息发出——与 Operator 在没有等待中的回合时(/answer 返回 resolved=false)的做法一致;
|
|
472
581
|
* 卡片先在本地标成已答(Operator 同样是先乐观更新)。
|
|
473
582
|
*/
|
|
474
|
-
|
|
583
|
+
/** fallback = false:只交给正在等的回合,不改发消息(一次性任务不能发消息);没有在等的回合时报错。 */
|
|
584
|
+
async function answerAsk(key: string, askId: string, questions: AskQuestion[], answers: AskAnswers, overrides: SendOptions & { fallback?: boolean } = {}): Promise<string> {
|
|
475
585
|
// 与 Operator 的 answerQuestion 同一流程:先乐观标成已答 → 交给正在等的回合 → 没有在等的回合就当普通回复发出。
|
|
476
586
|
patchConv(key, (conv) => ({ ...conv, localAnswers: { ...conv.localAnswers, [askId]: answers } }));
|
|
477
587
|
try {
|
|
@@ -479,6 +589,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
479
589
|
const { resolved } = await client.answerAsk(key, askId, answers);
|
|
480
590
|
if (resolved) return key;
|
|
481
591
|
}
|
|
592
|
+
if (overrides.fallback === false) throw new CenterError("这个提问已经没有在等回答了(回合已经结束)", "ASK_EXPIRED", 0);
|
|
482
593
|
return await send(key, formatAnswersText(questions, answers), overrides);
|
|
483
594
|
} catch (error) {
|
|
484
595
|
// 没发出去:卡片恢复成可回答。
|
|
@@ -524,8 +635,164 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
524
635
|
}
|
|
525
636
|
}
|
|
526
637
|
|
|
638
|
+
/**
|
|
639
|
+
* 往上翻:取这个对话更早的一页节点消息,接在最前面。实时流只给最新一段(older 是更早还有几条)。
|
|
640
|
+
* 同一个对话同一时刻只取一页。返回取到没有。
|
|
641
|
+
*/
|
|
642
|
+
const loadingOlder = new Set<string>();
|
|
643
|
+
async function loadOlder(key: string): Promise<boolean> {
|
|
644
|
+
const conv = state.convs[key];
|
|
645
|
+
// 节点会话:一次性任务是正在看的那个对话,对话任务是流里这个对话的远端任务。
|
|
646
|
+
const remote = conv ? conv.remoteTaskId ?? conv.task?.remote_task_id ?? null : null;
|
|
647
|
+
const first = conv?.msgs[0];
|
|
648
|
+
if (!conv || conv.older <= 0 || !remote || !first || !client.history || loadingOlder.has(key)) return false;
|
|
649
|
+
loadingOlder.add(key);
|
|
650
|
+
try {
|
|
651
|
+
const page = await client.history(key, remote, first.id, 200);
|
|
652
|
+
// 取的过程中换了节点会话(重建、续派切过去):这一页作废。
|
|
653
|
+
patchConv(key, (c) => ((c.remoteTaskId ?? c.task?.remote_task_id ?? null) === remote && c.msgs[0]?.id === first.id ? applyOlderPage(c, page.messages ?? [], page.older) : c));
|
|
654
|
+
return true;
|
|
655
|
+
} catch (error) {
|
|
656
|
+
emit({ type: "error", jobId: key, error: error instanceof CenterError ? error : new CenterError(String(error), "UNKNOWN", 0) });
|
|
657
|
+
return false;
|
|
658
|
+
} finally {
|
|
659
|
+
loadingOlder.delete(key);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
527
663
|
// 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
|
|
528
664
|
const surfaces: BoardSurface[] = [];
|
|
665
|
+
// ── 看板 → 对话的引用 ─────────────────────────────
|
|
666
|
+
|
|
667
|
+
function setRefs(key: string, refs: BoardRef[]) {
|
|
668
|
+
const next = { ...state.refs };
|
|
669
|
+
if (refs.length) next[key] = refs; else delete next[key];
|
|
670
|
+
set({ refs: next });
|
|
671
|
+
emit({ type: "board_ref", key, refs });
|
|
672
|
+
}
|
|
673
|
+
/** 把看板里的东西引用到这个对话的下一条消息(同 id 替换)。 */
|
|
674
|
+
function addRef(key: string, ref: BoardRef) {
|
|
675
|
+
const list = (state.refs[key] ?? []).filter((r) => r.id !== ref.id);
|
|
676
|
+
// 引用是「指向看板里的什么」,不是把内容整段塞进消息:每条截到 REF_TEXT_MAX,最多留 REF_MAX 条,消息不会因此超长。
|
|
677
|
+
const text = ref.text.length > REF_TEXT_MAX ? `${ref.text.slice(0, REF_TEXT_MAX)}…(已截断,共 ${ref.text.length} 字)` : ref.text;
|
|
678
|
+
setRefs(key, [...list, { ...ref, text }].slice(-REF_MAX));
|
|
679
|
+
}
|
|
680
|
+
function removeRef(key: string, id: string) {
|
|
681
|
+
const list = state.refs[key] ?? [];
|
|
682
|
+
if (list.some((r) => r.id === id)) setRefs(key, list.filter((r) => r.id !== id));
|
|
683
|
+
}
|
|
684
|
+
/** 取走这个对话的引用(发消息时用;发失败时用 restoreRefs 放回去)。 */
|
|
685
|
+
function takeRefs(key: string): BoardRef[] {
|
|
686
|
+
const list = state.refs[key] ?? [];
|
|
687
|
+
if (list.length) setRefs(key, []);
|
|
688
|
+
return list;
|
|
689
|
+
}
|
|
690
|
+
function restoreRefs(key: string, refs: BoardRef[]) {
|
|
691
|
+
if (!refs.length) return;
|
|
692
|
+
const have = new Set((state.refs[key] ?? []).map((r) => r.id));
|
|
693
|
+
setRefs(key, [...refs.filter((r) => !have.has(r.id)), ...(state.refs[key] ?? [])]);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ── 看板上正在看的东西(缺省带进下一条消息一次)────────
|
|
697
|
+
function setViewingState(key: string, value: { ref: BoardRef; off: boolean } | null) {
|
|
698
|
+
const next = { ...state.viewing };
|
|
699
|
+
if (value) next[key] = value; else delete next[key];
|
|
700
|
+
set({ viewing: next });
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* 看板报告这个对话正在看什么(null = 没在看)。同一个东西重复报告不变。
|
|
704
|
+
* owner:哪个看板报的。同一个对话开着两个看板(常驻的 + 弹出的)时,一个撤掉只撤自己报的,不清掉另一个的。
|
|
705
|
+
*/
|
|
706
|
+
// 每个看板各自报告(按 owner 记),生效的是最近报告、还开着的那个;一个看板撤掉只撤它自己的。
|
|
707
|
+
const viewSources = new Map<string, Map<string, BoardRef>>();
|
|
708
|
+
const boardSources = new Map<string, Map<string, BoardView>>();
|
|
709
|
+
/** 记下 owner 的报告,返回现在生效的那个(最近报告的)。 */
|
|
710
|
+
function latestReport<T>(sources: Map<string, Map<string, T>>, key: string, owner: string, value: T | null): T | null {
|
|
711
|
+
const m = sources.get(key) ?? new Map<string, T>();
|
|
712
|
+
m.delete(owner);
|
|
713
|
+
if (value) m.set(owner, value);
|
|
714
|
+
if (m.size) sources.set(key, m); else sources.delete(key);
|
|
715
|
+
return m.size ? [...m.values()][m.size - 1] : null;
|
|
716
|
+
}
|
|
717
|
+
function setViewing(key: string, ref: BoardRef | null, owner = "default") {
|
|
718
|
+
const top = latestReport(viewSources, key, owner, ref);
|
|
719
|
+
const cur = state.viewing[key];
|
|
720
|
+
if (!top) { if (cur) setViewingState(key, null); return; }
|
|
721
|
+
if (cur && cur.ref.id === top.id && cur.ref.text === top.text) return;
|
|
722
|
+
setViewingState(key, { ref: top, off: cur?.ref.id === top.id ? cur.off : false });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ── 看板现在的样子(给业务方:当前打开的文件等)────────
|
|
726
|
+
function reportBoard(key: string, view: BoardView | null, owner = "default") {
|
|
727
|
+
const top = latestReport(boardSources, key, owner, view ? { ...view, files: [...view.files] } : null);
|
|
728
|
+
const cur = state.boards[key];
|
|
729
|
+
if (!top && !cur) return;
|
|
730
|
+
if (top && cur && cur.tab === top.tab && cur.dir === top.dir && cur.file === top.file && cur.files.join("\n") === top.files.join("\n")) return;
|
|
731
|
+
const next = { ...state.boards };
|
|
732
|
+
if (top) next[key] = top; else delete next[key];
|
|
733
|
+
set({ boards: next });
|
|
734
|
+
emit({ type: "board_change", key, view: top ? { ...top, files: [...top.files] } : null });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── 补充背景:业务方随时追加,随下一条消息发给智能体 ────────
|
|
738
|
+
const ctxKey = (key: string | null) => key ?? "";
|
|
739
|
+
function setContext(key: string, items: ContextItem[], sent?: ContextItem[]) {
|
|
740
|
+
const next = { ...state.context };
|
|
741
|
+
if (items.length) next[key] = items; else delete next[key];
|
|
742
|
+
set({ context: next });
|
|
743
|
+
emit({ type: "context", key, pending: items, ...(sent ? { sent } : {}) });
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* 给这个对话追加一段项目背景(null = 还没建的新对话,随第一条消息带上)。
|
|
747
|
+
* 下一条消息发出时一起带给智能体(对话里显示成一张「补充背景」卡片),发出后智能体在这个对话里一直记得。
|
|
748
|
+
* 同一个 id 再追加会替换。返回这条的 id。
|
|
749
|
+
*/
|
|
750
|
+
function appendContext(key: string | null, text: string, opts: { title?: string; id?: string } = {}): string {
|
|
751
|
+
const body = String(text ?? "").trim();
|
|
752
|
+
if (!body) throw new Error("补充背景不能为空");
|
|
753
|
+
if (charCount(body) > CONTEXT_ITEM_MAX) throw new Error(`一条补充背景最长 ${CONTEXT_ITEM_MAX.toLocaleString()} 字,请拆成几条或精简`);
|
|
754
|
+
const k = ctxKey(key);
|
|
755
|
+
const id = opts.id ?? `ctx-${uid()}`;
|
|
756
|
+
const list = (state.context[k] ?? []).filter((c) => c.id !== id);
|
|
757
|
+
setContext(k, [...list, { id, text: body, ...(opts.title ? { title: opts.title.trim().slice(0, 60) } : {}) }]);
|
|
758
|
+
return id;
|
|
759
|
+
}
|
|
760
|
+
function removeContext(key: string | null, id?: string) {
|
|
761
|
+
const k = ctxKey(key);
|
|
762
|
+
const list = state.context[k] ?? [];
|
|
763
|
+
const next = id ? list.filter((c) => c.id !== id) : [];
|
|
764
|
+
if (next.length !== list.length) setContext(k, next);
|
|
765
|
+
}
|
|
766
|
+
/** 还没发出的补充背景。 */
|
|
767
|
+
const pendingContext = (key: string | null): ContextItem[] => [...(state.context[ctxKey(key)] ?? [])];
|
|
768
|
+
|
|
769
|
+
function workspace(key?: string | null): CliWorkspace {
|
|
770
|
+
const k = key ?? state.selected;
|
|
771
|
+
if (!k) throw new Error("没有指定对话:请传 jobId,或先选中一个对话");
|
|
772
|
+
return createWorkspace({
|
|
773
|
+
backend: client.board,
|
|
774
|
+
key: k,
|
|
775
|
+
view: () => state.boards[k],
|
|
776
|
+
open: (path) => openInBoard(k, { kind: "file", path }),
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
/** 这次不带上正在看的东西。 */
|
|
780
|
+
function dismissViewing(key: string) {
|
|
781
|
+
const cur = state.viewing[key];
|
|
782
|
+
if (cur && !cur.off) setViewingState(key, { ...cur, off: true });
|
|
783
|
+
}
|
|
784
|
+
/** 发消息时取走(取走后同一个东西不再重复带;看了别的才再带)。 */
|
|
785
|
+
function takeViewing(key: string): BoardRef | null {
|
|
786
|
+
const cur = state.viewing[key];
|
|
787
|
+
if (!cur || cur.off) return null;
|
|
788
|
+
setViewingState(key, { ...cur, off: true });
|
|
789
|
+
return cur.ref;
|
|
790
|
+
}
|
|
791
|
+
function restoreViewing(key: string, ref: BoardRef | null) {
|
|
792
|
+
const cur = state.viewing[key];
|
|
793
|
+
if (ref && cur && cur.ref.id === ref.id && cur.off) setViewingState(key, { ...cur, off: false });
|
|
794
|
+
}
|
|
795
|
+
|
|
529
796
|
function openInBoard(jobId: string, target: OpenTarget): boolean {
|
|
530
797
|
let handled = false;
|
|
531
798
|
for (let i = surfaces.length - 1; i >= 0 && !handled; i--) handled = surfaces[i](jobId, target);
|
|
@@ -550,22 +817,21 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
550
817
|
set({ selected: jobId });
|
|
551
818
|
emit({ type: "select", jobId });
|
|
552
819
|
},
|
|
553
|
-
setJobIds, addJob, removeJob, replaceJob, refreshJob, refreshAll,
|
|
554
|
-
|
|
820
|
+
setJobIds, addJob, removeJob, replaceJob, refreshJob, refreshAll,
|
|
821
|
+
addRef, removeRef, takeRefs, restoreRefs,
|
|
822
|
+
setViewing, dismissViewing, takeViewing, restoreViewing,
|
|
823
|
+
/** 看板报告自己的样子(看板组件用)。 */
|
|
824
|
+
reportBoard,
|
|
825
|
+
/** 业务方追加项目背景:随下一条消息发给智能体。 */
|
|
826
|
+
appendContext, removeContext, pendingContext,
|
|
827
|
+
/** 工作区文件接口(不显示看板也能用):列文件、读文件、下载、打包。不传 = 当前选中的对话。 */
|
|
828
|
+
workspace,
|
|
829
|
+
open, send, answerAsk, cancelTurn, close, loadOlder,
|
|
555
830
|
};
|
|
556
831
|
}
|
|
557
832
|
|
|
558
833
|
export type CliStore = ReturnType<typeof createCliStore>;
|
|
559
834
|
|
|
560
|
-
function loadIds(key: string | null): string[] {
|
|
561
|
-
if (!key) return [];
|
|
562
|
-
try {
|
|
563
|
-
const value = JSON.parse(localStorage.getItem(key) || "[]");
|
|
564
|
-
return Array.isArray(value) ? [...new Set(value.filter((id): id is string => typeof id === "string"))] : [];
|
|
565
|
-
} catch {
|
|
566
|
-
return [];
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
835
|
|
|
570
836
|
async function runLimited<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>) {
|
|
571
837
|
let index = 0;
|
|
@@ -575,6 +841,9 @@ async function runLimited<T>(items: T[], limit: number, fn: (item: T) => Promise
|
|
|
575
841
|
await Promise.all(workers);
|
|
576
842
|
}
|
|
577
843
|
|
|
844
|
+
const REF_MAX = 20;
|
|
845
|
+
const REF_TEXT_MAX = 4_000;
|
|
846
|
+
|
|
578
847
|
/** 只有网络层失败(没收到响应)才重发:带 business_id,center 会去重。 */
|
|
579
848
|
async function retryOnce<T>(fn: () => Promise<T>): Promise<T> {
|
|
580
849
|
try {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作区文本文件的解码:智能体写的多是 UTF-8,但业务导出的 CSV / TXT 常常是 GBK(GB18030),
|
|
3
|
+
* 按 UTF-8 硬解会满屏乱码。顺序:BOM(UTF-8 / UTF-16)→ 严格 UTF-8 → GB18030。
|
|
4
|
+
*
|
|
5
|
+
* 只读了文件的一段(开头 / 末尾)时:开头那段末尾可能切在一个字中间——按流式解码,把不完整的尾巴留下不报错;
|
|
6
|
+
* 末尾那段开头可能切在一个 UTF-8 字中间——跳过开头最多 3 个续字节。
|
|
7
|
+
*/
|
|
8
|
+
export function decodeText(bytes: Uint8Array, opts: { fromMiddle?: boolean } = {}): { text: string; encoding: string } {
|
|
9
|
+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return { text: new TextDecoder("utf-8").decode(bytes.subarray(3), { stream: true }), encoding: "utf-8" };
|
|
10
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) return { text: new TextDecoder("utf-16le").decode(bytes.subarray(2), { stream: true }), encoding: "utf-16le" };
|
|
11
|
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) return { text: new TextDecoder("utf-16be").decode(bytes.subarray(2), { stream: true }), encoding: "utf-16be" };
|
|
12
|
+
let start = 0;
|
|
13
|
+
if (opts.fromMiddle) while (start < 3 && start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
|
|
14
|
+
const body = bytes.subarray(start);
|
|
15
|
+
try {
|
|
16
|
+
return { text: new TextDecoder("utf-8", { fatal: true }).decode(body, { stream: true }), encoding: "utf-8" };
|
|
17
|
+
} catch {
|
|
18
|
+
try {
|
|
19
|
+
return { text: new TextDecoder("gb18030").decode(bytes, { stream: true }), encoding: "gb18030" };
|
|
20
|
+
} catch {
|
|
21
|
+
return { text: new TextDecoder("utf-8").decode(body, { stream: true }), encoding: "utf-8" };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|