@spzxg/cli-ui 0.3.1 → 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.
@@ -1,9 +1,10 @@
1
1
  import { createSseParser } from "./sse.js";
2
2
  import { NEW_TASK, parseConvKey } from "./conversation.js";
3
3
  import type {
4
- BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo,
5
- BatchStreamSnapshot, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
4
+ BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo, Message,
5
+ BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
6
6
  } from "./types.js";
7
+ import { decodeText } from "./textDecode.js";
7
8
 
8
9
  /**
9
10
  * center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
@@ -33,10 +34,19 @@ export interface CenterClientOptions {
33
34
  * (同源代理注入令牌);否则浏览器直接请求带不上请求头,看板改为先 fetch 再用 blob 地址显示。
34
35
  */
35
36
  directFileLinks?: boolean;
37
+ /**
38
+ * 普通请求(不含实时流、文件下载)多久没有响应就放弃,缺省 30 秒。代理或网络挂住时组件不会一直「读取中」,
39
+ * 而是报「请求超时」并按各自的规则重试。打开任务浏览器另给 150 秒(节点要先启动浏览器)。
40
+ */
41
+ requestTimeoutMs?: number;
36
42
  }
37
43
 
38
44
  /** 实时流超过这么久没有任何数据(center 每 20 秒一次心跳)就当连接已断。 */
39
45
  export const STREAM_SILENCE_MS = 45_000;
46
+ /** 读文件:这么久没回响应头(节点卡住、代理挂住)就放弃。 */
47
+ export const FILE_HEADER_TIMEOUT_MS = 30_000;
48
+ /** 读文件:传输中这么久一个字节都没收到就当断了(大文件慢慢传不算,只要还在动)。 */
49
+ export const FILE_STALL_MS = 60_000;
40
50
 
41
51
  export class CenterError extends Error {
42
52
  constructor(message: string, readonly code: string, readonly status: number, readonly detail?: unknown) {
@@ -63,6 +73,8 @@ const ERROR_TEXT: Record<string, string> = {
63
73
  too_many_viewers: "同时观看这个浏览器的人太多,请稍后再试",
64
74
  viewer_not_found: "画面连接已断开,正在重连",
65
75
  viewer_not_ready: "画面连接还没就绪,正在重连",
76
+ link_expired: "文件链接已失效,请刷新",
77
+ task_not_found: "这个子任务不在对应的任务里",
66
78
  };
67
79
 
68
80
  /**
@@ -77,9 +89,19 @@ export interface ConversationBackend {
77
89
  /** taskId:只要对话任务里这一个对话的轮次。 */
78
90
  listTurns(jobId: string, query?: { afterSeq?: number; limit?: number; taskId?: string }): Promise<{ job_id: string; turns: CenterTurn[] }>;
79
91
  listTasks(): Promise<{ tasks: CenterTask[] }>;
92
+ /**
93
+ * 更早的节点转录(往上翻):remote 是流里给的节点会话,before 是手里最早那条消息的 id。
94
+ * 返回这一页(旧的在前)和再往前还剩几条。旧版中枢没有这个接口(实时流也就给的是全量,用不到)。
95
+ */
96
+ history?(jobId: string, remote: string, before: string, limit?: number): Promise<{ messages: Message[]; older: number }>;
80
97
  createConversation(firstMessage: string, opts?: NewConversationOptions & { businessId?: string }): Promise<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>;
81
98
  /** taskId:发给对话任务里的哪个对话,不给 = 第一个。 */
82
99
  sendMessage(jobId: string, text: string, opts?: { businessId?: string; timeoutSeconds?: number; taskId?: string }): Promise<SendResult>;
100
+ /**
101
+ * 一次性任务里的对话式子任务接着聊:续派一条 mode=conversation、continue_from 这个子任务的子任务(接在同一个 Agent 会话里)。
102
+ * 返回新子任务的 id。
103
+ */
104
+ continueTask?(jobId: string, taskId: string, text: string, opts?: { title?: string; businessId?: string }): Promise<{ task_id: string }>;
83
105
  /** 在对话任务里新开一个对话(同一节点、同一工作区),text 是它的第一条消息。 */
84
106
  openConversation?(jobId: string, text: string, opts?: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
85
107
  cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
@@ -89,11 +111,15 @@ export interface ConversationBackend {
89
111
  /** taskId:看对话任务里的哪个对话,不给 = 第一个。 */
90
112
  stream(jobId: string, handlers: StreamHandlers, intervalMs?: number, taskId?: string | null): () => void;
91
113
  /**
92
- * 把回答直接交给正停在提问上的回合(Operator 的 POST /api/tasks/:id/answer)。
93
- * resolved=false 表示没有在等的回合,调用方改用「下一条消息」发回答。center 不提供:它在 Agent 提问时已经结束那一轮。
114
+ * 把回答直接交给正停在提问上的回合(中枢 POST /api/jobs/:id/answer → 节点 /answer)。
115
+ * resolved=false 表示没有在等的回合:对话任务改用「下一条消息」发回答(中枢在 Agent 提问时已经结束那一轮);
116
+ * 一次性任务的子任务提问时回合开着等,答案直接交过去。
94
117
  */
95
118
  answerAsk?(jobId: string, askId: string, answers: string[][]): Promise<{ resolved: boolean }>;
96
- /** 提交浏览器输入卡片(Operator 的 POST /api/tasks/:id/input),返回错误文案或 null。center 不提供。 */
119
+ /**
120
+ * 提交浏览器输入卡片(验证码、账号、密码、选项;中枢 POST /api/jobs/:id/input → 节点 /input),返回错误文案或 null。
121
+ * 密码只交给节点上的浏览器脚本,不进模型、不进转录、中枢不落库。
122
+ */
97
123
  submitInput?(jobId: string, inputId: string, values: string[]): Promise<string | null>;
98
124
  /** 浏览器交接后交还给 AI(Operator 的 POST /api/tasks/:id/browser),返回错误文案或 null。center 不提供。 */
99
125
  handback?(jobId: string): Promise<string | null>;
@@ -113,9 +139,23 @@ export interface BoardBackend {
113
139
  stat(jobId: string, path?: string): Promise<DirListing | FileInfo>;
114
140
  /** 文件地址(给 <img> / <iframe> / 下载链接用)。相对路径用路径式地址,HTML 里的相对资源能跟着解析。 */
115
141
  fileUrl(jobId: string, path: string, opts?: { download?: boolean }): string;
116
- /** 读文本(最多 maxBytes,超过的截断)。 */
117
- readText(jobId: string, path: string, maxBytes?: number): Promise<{ text: string; truncated: boolean; size: number; contentType: string }>;
118
- readBuffer(jobId: string, path: string): Promise<ArrayBuffer>;
142
+ /**
143
+ * 浏览器能直接打开的文件地址前缀(…/files/raw,后面接相对路径或 ?path=):directLinks 时就是 fileUrl 的前缀;
144
+ * 否则向中枢换一个短期令牌放进路径(<img> / <iframe> / 下载都不需要请求头,大文件流式下载、不进浏览器内存),
145
+ * 快过期时自动换新的。中枢不支持时返回 null(看板退回先 fetch 再显示)。
146
+ */
147
+ fileLinkBase?(jobId: string): Promise<string | null>;
148
+ /**
149
+ * 读文本(最多 maxBytes,超过的截断)。只向中枢要这一段(Range),超大文件不整个传过来。
150
+ * tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
151
+ * 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
152
+ */
153
+ readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean }): Promise<TextChunk>;
154
+ /**
155
+ * 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
156
+ * 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
157
+ */
158
+ readBuffer(jobId: string, path: string, opts?: { signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<ArrayBuffer>;
119
159
  diff(jobId: string): Promise<DiffResult>;
120
160
  browserStatus(jobId: string): Promise<BrowserStatus>;
121
161
  browserAction(jobId: string, action: "open" | "close" | "takeover" | "handback"): Promise<BrowserStatus>;
@@ -124,6 +164,44 @@ export interface BoardBackend {
124
164
  /** 浏览器实时画面;onViewer 给出发输入要用的 viewer id。 */
125
165
  browserFrames(jobId: string, handlers: { onViewer: (viewer: string) => void; onMessage: (message: BrowserMessage) => void; onEnd?: (end: { reason: string; detail?: string }) => void; onClose?: (error: CenterError | null) => void }): () => void;
126
166
  browserInput(jobId: string, viewer: string, events: Array<Record<string, unknown>>): Promise<void>;
167
+ /**
168
+ * 浏览器的一条实时连接(状态 + 画面 + 弹窗 + 复制出的文字,见中枢 board-live.ts)。新版中枢才有:
169
+ * 旧版中枢回 404(路由不存在,error 为 "Not Found"),组件退回 browserEvents + browserFrames 两条。
170
+ */
171
+ browserLive?(jobId: string, handlers: BrowserLiveHandlers): () => void;
172
+ }
173
+
174
+ export interface TextChunk {
175
+ text: string;
176
+ /** 只读到了一部分。 */
177
+ truncated: boolean;
178
+ /** 文件总大小。 */
179
+ size: number;
180
+ contentType: string;
181
+ /** 读的是末尾那一段。 */
182
+ tail?: boolean;
183
+ encoding?: string;
184
+ }
185
+
186
+ export interface BrowserLiveHandlers {
187
+ /** 这条连接的 viewer id;ready = 输入能送到节点(画面连接已建好)。 */
188
+ onViewer: (viewer: string, ready: boolean) => void;
189
+ onStatus: (status: BrowserStatus) => void;
190
+ onMessage: (message: BrowserMessage) => void;
191
+ onFrames?: (state: BrowserFramesState) => void;
192
+ /** 不会再有数据(节点拒绝、中枢停机)。 */
193
+ onEnd?: (end: { reason: string; status?: number; detail?: string }) => void;
194
+ onClose?: (error: CenterError | null) => void;
195
+ }
196
+
197
+ /** 旧版中枢没有这个接口:Fastify 的「路由不存在」404(和「任务不存在」的 not_found 区分开)。 */
198
+ export const isMissingRoute = (error: CenterError | null | undefined) => !!error && error.status === 404 && error.code === "Not Found";
199
+
200
+ /** 由 fileLinkBase 给的前缀拼出文件地址。 */
201
+ export function fileUrlFromBase(linkBase: string, path: string, download = false) {
202
+ const clean = path.replace(/^\.\//, "");
203
+ if (clean.startsWith("/") || clean.startsWith("~") || /^[a-z]:[\\/]/i.test(clean)) return `${linkBase}?path=${encodeURIComponent(clean)}${download ? "&download=1" : ""}`;
204
+ return `${linkBase}/${clean.split("/").filter(Boolean).map(encodeURIComponent).join("/")}${download ? "?download=1" : ""}`;
127
205
  }
128
206
 
129
207
  export function createCenterClient(options: CenterClientOptions = {}): ConversationBackend {
@@ -137,18 +215,38 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
137
215
  return result;
138
216
  }
139
217
 
140
- async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
218
+ // 同一时刻对同一地址的 GET 只发一次,后来的共用结果:列表、对话、看板经常同时要同一份数据(同一个任务的详情、看板概况、目录),
219
+ // 浏览器对一个域名的连接有限,重复请求会把真正要紧的(点开文件、发消息)挤到后面排队。
220
+ const inflight = new Map<string, Promise<unknown>>();
221
+ function request<T>(path: string, init: RequestInit = {}, timeoutMs = options.requestTimeoutMs ?? 30_000): Promise<T> {
222
+ if ((init.method ?? "GET") !== "GET" || init.body) return send<T>(path, init, timeoutMs);
223
+ const shared = inflight.get(path);
224
+ if (shared) return shared as Promise<T>;
225
+ const p = send<T>(path, init, timeoutMs).finally(() => inflight.delete(path));
226
+ inflight.set(path, p);
227
+ return p;
228
+ }
229
+ async function send<T>(path: string, init: RequestInit, timeoutMs: number): Promise<T> {
141
230
  let response: Response;
231
+ const ctl = new AbortController();
232
+ let timedOut = false;
233
+ const timer = setTimeout(() => { timedOut = true; ctl.abort(); }, timeoutMs);
142
234
  try {
143
235
  response = await doFetch(`${base}${path}`, {
144
236
  ...init,
237
+ signal: ctl.signal,
145
238
  headers: headers(init.body ? { "Content-Type": "application/json" } : {}),
146
239
  });
147
240
  } catch (error) {
241
+ clearTimeout(timer);
242
+ if (timedOut) throw new CenterError(`任务中心 ${Math.round(timeoutMs / 1000)} 秒没有响应,请稍后重试`, "NETWORK", 0);
148
243
  throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
149
244
  }
150
- const body = (await response.json().catch(() => null)) as Record<string, unknown> | null;
245
+ // 响应体也算在时限里(代理把响应头发了、正文却挂住)。
246
+ const body = (await response.json().catch(() => null).finally(() => clearTimeout(timer))) as Record<string, unknown> | null;
151
247
  if (!response.ok) {
248
+ // 413:消息 / 请求体超过中枢上限(中枢回的是 Fastify 的英文说明)。
249
+ if (response.status === 413) throw new CenterError("内容太大,超过任务中心单次请求的上限,请删减或分几条发送", "payload_too_large", 413, body);
152
250
  const code = typeof body?.error === "string" ? body.error : `HTTP_${response.status}`;
153
251
  const detail = typeof body?.detail === "string" ? body.detail : undefined;
154
252
  throw new CenterError(ERROR_TEXT[code] ?? detail ?? `任务中心返回错误(HTTP ${response.status})`, code, response.status, body);
@@ -172,6 +270,26 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
172
270
  },
173
271
  /** 列表用:本产品线全部子任务(对话任务每个对话一条),用来发现对话。 */
174
272
  listTasks: () => request<{ tasks: CenterTask[] }>("/api/tasks"),
273
+ async answerAsk(jobId: string, askId: string, answers: string[][]) {
274
+ try {
275
+ return await request<{ resolved: boolean }>(boardPath(jobId, "answer"), { method: "POST", body: JSON.stringify({ ask_id: askId, answers }) });
276
+ } catch (error) {
277
+ // 旧版中枢没有这个接口:当作没有在等的回合(对话任务照旧走下一条消息)。
278
+ if (error instanceof CenterError && isMissingRoute(error)) return { resolved: false };
279
+ throw error;
280
+ }
281
+ },
282
+ async submitInput(jobId: string, inputId: string, values: string[]) {
283
+ try {
284
+ await request(boardPath(jobId, "input"), { method: "POST", body: JSON.stringify({ input_id: inputId, values }) });
285
+ return null;
286
+ } catch (error) {
287
+ if (error instanceof CenterError && isMissingRoute(error)) return "当前任务中心版本不支持在这里填写,请升级任务中心";
288
+ return error instanceof Error ? error.message : String(error);
289
+ }
290
+ },
291
+ history: (jobId: string, remote: string, before: string, limit = 200) =>
292
+ request<{ messages: Message[]; older: number }>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/history?remote=${id(remote)}&before=${id(before)}&limit=${limit}`),
175
293
  createConversation: (firstMessage: string, opts: NewConversationOptions & { businessId?: string } = {}) => {
176
294
  const title = (opts.title || firstLine(firstMessage)).slice(0, 120);
177
295
  return post<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>("/api/jobs", {
@@ -197,6 +315,13 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
197
315
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
198
316
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
199
317
  }),
318
+ continueTask: async (jobId: string, taskId: string, text: string, opts: { title?: string; businessId?: string } = {}) => {
319
+ const result = await post<{ task_ids: string[] }>(`/api/jobs`, {
320
+ job_id: jobId, mode: "conversation",
321
+ tasks: [{ title: opts.title || firstLine(text, 60), prompt: text, continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
322
+ });
323
+ return { task_id: result.task_ids[0] };
324
+ },
200
325
  /** 同一个 business_id 重发只开一个对话。 */
201
326
  openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
202
327
  post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
@@ -375,58 +500,154 @@ function createBoardClient(
375
500
  base: string,
376
501
  headers: (extra?: Record<string, string>) => Record<string, string>,
377
502
  doFetch: typeof fetch,
378
- request: <T>(path: string, init?: RequestInit) => Promise<T>,
503
+ request: <T>(path: string, init?: RequestInit, timeoutMs?: number) => Promise<T>,
379
504
  directLinks: boolean,
380
505
  ): BoardBackend {
381
- const fileFetch = async (jobId: string, path: string) => {
506
+ /**
507
+ * 取文件:响应头限时 FILE_HEADER_TIMEOUT_MS;body() 边读边看门(FILE_STALL_MS 没数据就断),
508
+ * limit 读够就停(大文件只要开头)。外面给的 signal 取消时一并中止。
509
+ */
510
+ const fileFetch = async (jobId: string, path: string, extra: Record<string, string> = {}, signal?: AbortSignal) => {
511
+ const name = path.split("/").pop() || path;
512
+ const aborted = () => new DOMException("已取消", "AbortError");
513
+ if (signal?.aborted) throw aborted();
514
+ const ctl = new AbortController();
515
+ const cancel = () => ctl.abort();
516
+ signal?.addEventListener("abort", cancel, { once: true });
517
+ let timedOut = false;
518
+ const timer = setTimeout(() => { timedOut = true; ctl.abort(); }, FILE_HEADER_TIMEOUT_MS);
519
+ const release = () => { clearTimeout(timer); signal?.removeEventListener("abort", cancel); };
382
520
  let response: Response;
383
521
  try {
384
- response = await doFetch(boardFileUrl(base, jobId, path), { headers: headers() });
522
+ // 和中止赛跑:宿主自定义的 fetch 不一定理会 signal,取消 / 超时也要马上生效。
523
+ const stopped = new Promise<never>((_r, reject) => {
524
+ if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
525
+ ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
526
+ });
527
+ response = await Promise.race([doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal }), stopped]);
385
528
  } catch (error) {
529
+ release();
530
+ if (signal?.aborted) throw aborted();
531
+ if (timedOut) throw new CenterError(`读取「${name}」${FILE_HEADER_TIMEOUT_MS / 1000} 秒没有响应(执行节点忙或网络慢),请稍后重试`, "NETWORK", 0);
386
532
  throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
387
533
  }
388
- if (!response.ok) {
534
+ clearTimeout(timer);
535
+ if (!response.ok && response.status !== 416) {
536
+ release();
389
537
  const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
390
- throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? body?.error ?? `读取失败(HTTP ${response.status})`, body?.error ?? `HTTP_${response.status}`, response.status);
538
+ const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : body?.error ?? `读取失败(HTTP ${response.status})`;
539
+ throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? fallback, body?.error ?? `HTTP_${response.status}`, response.status);
391
540
  }
392
- return response;
393
- };
394
- return {
395
- directLinks,
396
- info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
397
- stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
398
- fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
399
- async readText(jobId, path, maxBytes = 512 * 1024) {
400
- const response = await fileFetch(jobId, path);
401
- const contentType = response.headers.get("content-type") ?? "";
402
- const size = Number(response.headers.get("content-length") ?? 0);
403
- // 只读前 maxBytes:大文件不整个拉进浏览器。
541
+ const stallError = () => new CenterError(`读取「${name}」时 ${FILE_STALL_MS / 1000} 秒没有收到数据,传输已中断,请重试`, "NETWORK", 0);
542
+ /** 读响应体:limit 读够就停;onBytes 报进度;FILE_STALL_MS 没数据当断线。 */
543
+ const body = async (opts: { limit?: number; onBytes?: (bytes: number) => void } = {}): Promise<Uint8Array<ArrayBuffer>> => {
544
+ // 空文件要「前 N 字节」:节点回 416,就是空的。
545
+ if (response.status === 416) { release(); return new Uint8Array(0); }
404
546
  const reader = response.body?.getReader();
405
- if (!reader) return { text: await response.text(), truncated: false, size, contentType };
547
+ if (!reader) {
548
+ try { return new Uint8Array(await response.arrayBuffer()); } finally { release(); }
549
+ }
406
550
  const chunks: Uint8Array[] = [];
407
551
  let got = 0;
408
- let truncated = false;
409
- for (;;) {
410
- const { done, value } = await reader.read();
411
- if (done) break;
412
- chunks.push(value);
413
- got += value.length;
414
- if (got >= maxBytes) { truncated = true; await reader.cancel().catch(() => undefined); break; }
552
+ let stalled = false;
553
+ let stall: ReturnType<typeof setTimeout> | undefined;
554
+ const arm = () => {
555
+ clearTimeout(stall);
556
+ stall = setTimeout(() => { stalled = true; ctl.abort(); void reader.cancel().catch(() => undefined); }, FILE_STALL_MS);
557
+ };
558
+ try {
559
+ arm();
560
+ for (;;) {
561
+ const { done, value } = await reader.read();
562
+ if (stalled) throw stallError();
563
+ if (done) break;
564
+ arm();
565
+ chunks.push(value);
566
+ got += value.length;
567
+ opts.onBytes?.(got);
568
+ if (opts.limit !== undefined && got >= opts.limit) { await reader.cancel().catch(() => undefined); break; }
569
+ }
570
+ } catch (error) {
571
+ if (signal?.aborted) throw aborted();
572
+ if (stalled) throw stallError();
573
+ if (error instanceof CenterError) throw error;
574
+ throw new CenterError(`读取「${name}」中断:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
575
+ } finally {
576
+ clearTimeout(stall);
577
+ release();
415
578
  }
416
- const all = new Uint8Array(Math.min(got, maxBytes));
579
+ if (signal?.aborted) throw aborted();
580
+ const size = opts.limit !== undefined ? Math.min(got, opts.limit) : got;
581
+ const all = new Uint8Array(size);
417
582
  let offset = 0;
418
583
  for (const chunk of chunks) {
419
- const take = Math.min(chunk.length, all.length - offset);
584
+ const take = Math.min(chunk.length, size - offset);
420
585
  all.set(chunk.subarray(0, take), offset);
421
586
  offset += take;
422
- if (offset >= all.length) break;
587
+ if (offset >= size) break;
588
+ }
589
+ return all;
590
+ };
591
+ return { response, body };
592
+ };
593
+ // 文件链接令牌:按对话键缓存,离过期不到 5 分钟就换新的;同一时刻只换一次。
594
+ const links = new Map<string, { base: string; until: number }>();
595
+ const minting = new Map<string, Promise<string | null>>();
596
+ let linksUnsupported = false;
597
+ const LINK_REFRESH_MS = 5 * 60_000;
598
+ return {
599
+ directLinks,
600
+ info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
601
+ stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
602
+ fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
603
+ async fileLinkBase(jobId) {
604
+ const { job, task } = boardTarget(jobId);
605
+ if (directLinks) return `${base}/api/jobs/${job}${task ? `/tasks/${task}` : ""}/files/raw`;
606
+ if (linksUnsupported) return null;
607
+ const cached = links.get(jobId);
608
+ if (cached && cached.until - Date.now() > LINK_REFRESH_MS) return cached.base;
609
+ const running = minting.get(jobId);
610
+ if (running) return running;
611
+ const work = request<{ base?: unknown; expires_at?: string }>(boardPath(jobId, "files/token"), { method: "POST", body: "{}" }).then((r) => {
612
+ if (typeof r?.base !== "string") { linksUnsupported = true; return null; }
613
+ const linkBase = `${base}${r.base}`;
614
+ links.set(jobId, { base: linkBase, until: Date.parse(r.expires_at ?? "") || Date.now() + 30 * 60_000 });
615
+ return linkBase;
616
+ }, (error: unknown) => {
617
+ if (error instanceof CenterError && isMissingRoute(error)) { linksUnsupported = true; return null; }
618
+ throw error;
619
+ }).finally(() => minting.delete(jobId));
620
+ minting.set(jobId, work);
621
+ return work;
622
+ },
623
+ async readText(jobId, path, maxBytes = 512 * 1024, opts = {}) {
624
+ // 只要需要的那一段:开头 maxBytes,或者末尾 maxBytes(超大日志)。不支持 Range 的节点回整个文件(200),下面边读边截。
625
+ const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=0-${maxBytes - 1}` });
626
+ const contentType = response.headers.get("content-type") ?? "text/plain";
627
+ if (response.status === 416) { await body(); return { text: "", truncated: false, size: 0, contentType }; }
628
+ const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
629
+ const length = Number(response.headers.get("content-length") ?? 0);
630
+ if (response.status === 206) {
631
+ // 节点给的一段可能比要的长(不严格按 Range):也只留 maxBytes。
632
+ const bytes = await body({ limit: maxBytes });
633
+ const size = total ? Number(total) : bytes.length;
634
+ const start = Number(/bytes (\d+)-/.exec(response.headers.get("content-range") ?? "")?.[1] ?? 0);
635
+ const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
636
+ return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
423
637
  }
424
- return { text: new TextDecoder().decode(all), truncated, size: size || got, contentType };
638
+ // 不支持 Range(回 200 整个文件):只读前 maxBytes,大文件不整个拉进浏览器。
639
+ const all = await body({ limit: maxBytes });
640
+ const truncated = all.length >= maxBytes && (!length || length > maxBytes);
641
+ const { text, encoding } = decodeText(all);
642
+ return { text, truncated, size: length || all.length, contentType, encoding };
643
+ },
644
+ async readBuffer(jobId, path, opts = {}) {
645
+ const { body } = await fileFetch(jobId, path, {}, opts.signal);
646
+ return (await body({ onBytes: opts.onBytes })).buffer;
425
647
  },
426
- readBuffer: async (jobId, path) => (await fileFetch(jobId, path)).arrayBuffer(),
427
648
  diff: (jobId) => request<DiffResult>(boardPath(jobId, "diff")),
428
649
  browserStatus: (jobId) => request<BrowserStatus>(boardPath(jobId, "browser")),
429
- browserAction: (jobId, action) => request<BrowserStatus>(boardPath(jobId, "browser"), { method: "POST", body: JSON.stringify({ action }) }),
650
+ browserAction: (jobId, action) => request<BrowserStatus>(boardPath(jobId, "browser"), { method: "POST", body: JSON.stringify({ action }) }, 150_000),
430
651
  browserEvents: (jobId, handlers) => openSse(doFetch, `${base}${boardPath(jobId, "browser/events")}`, headers(), (event, data) => {
431
652
  if (event === "status") handlers.onStatus(data as BrowserStatus);
432
653
  }, handlers.onClose),
@@ -438,5 +659,13 @@ function createBoardClient(
438
659
  async browserInput(jobId, viewer, events) {
439
660
  await request(boardPath(jobId, "browser/input"), { method: "POST", body: JSON.stringify({ viewer, events }) });
440
661
  },
662
+ browserLive: (jobId, h) => openSse(doFetch, `${base}${boardPath(jobId, "browser/live")}`, headers(), (event, data) => {
663
+ const d = data as Record<string, unknown>;
664
+ if (event === "viewer") h.onViewer(String(d.viewer), d.ready === true);
665
+ else if (event === "status") h.onStatus(data as BrowserStatus);
666
+ else if (event === "message") h.onMessage(data as BrowserMessage);
667
+ else if (event === "frames") h.onFrames?.(data as BrowserFramesState);
668
+ else if (event === "end") h.onEnd?.(data as { reason: string; status?: number; detail?: string });
669
+ }, h.onClose),
441
670
  };
442
671
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(全部显示、默认文案)。
2
+ * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(默认文案;cli-chat 的顶部栏缺省隐藏,其余全部显示)。
3
3
  *
4
- * <CliChat config={{ header: { export: false, close: false }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
4
+ * <CliChat config={{ header: { title: true, status: true }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
5
5
  * <CliMsgList config={{ header: { refresh: false }, card: { turns: false } }} />
6
6
  *
7
7
  * 开关类的模块写 false 就整块隐藏,写对象就逐项开关;文案里可以用 {title}(对话标题)、{agent}(智能体名)。
@@ -10,7 +10,7 @@
10
10
  // ── cli-chat ─────────────────────────────────────────
11
11
 
12
12
  export interface CliChatConfig {
13
- /** 顶部栏。false = 整栏隐藏;对象 = 逐项开关。 */
13
+ /** 顶部栏。缺省隐藏;true = 每一项都显示;对象 = 只显示写了 true 的项。 */
14
14
  header?: boolean | {
15
15
  /** 面包屑:色块 + 名称 / 对话。 */
16
16
  crumb?: boolean;
@@ -66,6 +66,8 @@ export interface CliChatConfig {
66
66
  tools?: boolean;
67
67
  /** 「智能体 · 运行中 0:12」那一行。 */
68
68
  runningIndicator?: boolean;
69
+ /** 状态说明行:新对话的「尚未开始」、结束时的「对话已结束」「子任务已完成」等。缺省不显示。 */
70
+ status?: boolean;
69
71
  };
70
72
  /** 消息头上的称呼。 */
71
73
  labels?: { user?: string; agent?: string };
@@ -79,13 +81,14 @@ export interface ResolvedChatConfig {
79
81
  show: boolean; placeholder: string; placeholderNew: string; placeholderRunning: string; placeholderDisabled: string | null;
80
82
  hints: boolean; charCount: boolean; fileUpload: boolean; fileButtonText: string; stop: boolean; queue: boolean;
81
83
  };
82
- transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean };
84
+ transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
83
85
  labels: { user: string; agent: string };
84
86
  empty: { show: boolean; title: string; description: string };
85
87
  }
86
88
 
87
89
  export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
88
- header: { show: true, crumb: true, export: true, title: true, agent: true, usage: true, model: true, status: true, close: true },
90
+ // 顶部栏缺省整栏隐藏(模型、状态、用量这些对使用者是干扰),需要的项在 config.header 里打开。
91
+ header: { show: true, crumb: false, export: false, title: false, agent: false, usage: false, model: false, status: false, close: false },
89
92
  composer: {
90
93
  show: true,
91
94
  placeholder: "在“{title}”中回复 {agent}…(可拖入 TXT / Markdown / Word 文件)",
@@ -94,14 +97,14 @@ export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
94
97
  placeholderDisabled: null,
95
98
  hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
96
99
  },
97
- transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true },
100
+ transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true, status: false },
98
101
  labels: { user: "你", agent: "智能体" },
99
102
  empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 TXT、Markdown 或 Word 文件,内容会转成文字一起发送。" },
100
103
  };
101
104
 
102
105
  export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfig {
103
106
  const d = DEFAULT_CHAT_CONFIG;
104
- const header = toggle(config.header, d.header);
107
+ const header = config.header === true ? allOn(d.header) : toggle(config.header, d.header);
105
108
  const composer = toggle(config.composer, d.composer);
106
109
  const empty = toggle(config.empty, d.empty);
107
110
  // 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
@@ -138,22 +141,26 @@ export interface CliMsgListConfig {
138
141
  /** 搜索框:true 总是显示,false 不显示,"auto"(缺省)对话数 ≥ 6 时显示。 */
139
142
  search?: boolean | "auto";
140
143
  searchPlaceholder?: string;
144
+ /**
145
+ * 按状态分组(需要你处理 / 进行中 / 已结束 / 已取消),配合 groups 改组名。缺省不分组:一个列表,最近活动的在前。
146
+ */
147
+ grouped?: boolean;
141
148
  /** 卡片上显示哪些内容。 */
142
149
  card?: {
143
- /** 状态点和状态文字。 */
150
+ /** 状态点、状态文字,以及底部的「运行中 / 等你处理 / 已完成」等字样。缺省不显示(底部只有时间)。 */
144
151
  status?: boolean;
145
152
  /** 智能体标签。 */
146
153
  agent?: boolean;
147
154
  /** 第一条消息摘要。 */
148
155
  description?: boolean;
149
- /** 底部活动(运行中 · 3 分钟前……)。 */
156
+ /** 底部活动(3 分钟前;打开 status 时带上运行中、排队等状态)。 */
150
157
  activity?: boolean;
151
158
  /** 「N 轮对话」。 */
152
159
  turns?: boolean;
153
160
  /** 「+」按钮:在这个任务(对话任务)里新开一个对话,共用同一个工作区。 */
154
161
  newInJob?: boolean;
155
162
  };
156
- /** 分组标题;写 false 隐藏这一组。 */
163
+ /** 分组标题(grouped 时);写 false 隐藏这一组。 */
157
164
  groups?: { needs?: string | false; active?: string | false; done?: string | false; cancelled?: string | false };
158
165
  /** 没有对话时的提示。 */
159
166
  emptyTitle?: string;
@@ -164,6 +171,7 @@ export interface ResolvedMsgListConfig {
164
171
  header: { show: boolean; title: boolean; newButton: boolean; newButtonText: string; refresh: boolean; context: boolean };
165
172
  search: boolean | "auto";
166
173
  searchPlaceholder: string;
174
+ grouped: boolean;
167
175
  card: { status: boolean; agent: boolean; description: boolean; activity: boolean; turns: boolean; newInJob: boolean };
168
176
  groups: { needs: string | false; active: string | false; done: string | false; cancelled: string | false };
169
177
  emptyTitle: string;
@@ -174,7 +182,9 @@ export const DEFAULT_MSGLIST_CONFIG: ResolvedMsgListConfig = {
174
182
  header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: true, context: true },
175
183
  search: "auto",
176
184
  searchPlaceholder: "搜索对话…",
177
- card: { status: true, agent: true, description: true, activity: true, turns: true, newInJob: true },
185
+ grouped: false,
186
+ // 状态缺省不显示(未开始、进行中、已完成……对使用者是干扰),需要时打开 card.status / grouped。
187
+ card: { status: false, agent: true, description: true, activity: true, turns: true, newInJob: true },
178
188
  groups: { needs: "需要你处理", active: "进行中", done: "已结束", cancelled: "已取消" },
179
189
  emptyTitle: "还没有对话",
180
190
  emptyDescription: "点「对话」开始一个新的对话。",
@@ -186,6 +196,7 @@ export function resolveMsgListConfig(config: CliMsgListConfig = {}): ResolvedMsg
186
196
  header: toggle(config.header, d.header),
187
197
  search: config.search ?? d.search,
188
198
  searchPlaceholder: config.searchPlaceholder ?? d.searchPlaceholder,
199
+ grouped: config.grouped ?? d.grouped,
189
200
  card: { ...d.card, ...defined(config.card) },
190
201
  groups: { ...d.groups, ...defined(config.groups) },
191
202
  emptyTitle: config.emptyTitle ?? d.emptyTitle,
@@ -211,6 +222,12 @@ function defined<T extends object>(value: T | undefined): Partial<T> {
211
222
  /**
212
223
  * 开关型模块:false → 全关(show=false 且布尔项全 false,文案保留);true / 不给 → 缺省;对象 → 合并缺省。
213
224
  */
225
+ function allOn<T extends { show: boolean }>(defaults: T): T {
226
+ const on = { ...defaults } as Record<string, unknown>;
227
+ for (const key of Object.keys(on)) if (typeof on[key] === "boolean") on[key] = true;
228
+ return on as T;
229
+ }
230
+
214
231
  function toggle<T extends { show: boolean }>(value: boolean | object | undefined, defaults: T): T {
215
232
  if (value === false) {
216
233
  const off = { ...defaults, show: false } as Record<string, unknown>;