@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.
@@ -0,0 +1,161 @@
1
+ // 工作区「下载整个文件夹」:逐级列目录、逐个读文件(经中枢),在浏览器里打成 ZIP 下载。
2
+ // 有上限:文件数、总大小(读进内存再打包);超了直接报出来,让用户进到更小的目录或单个下载,不会把页面撑爆。
3
+ // 弱网 / 节点忙:每个请求失败了按退避重试几次;某个文件一直读不到(或读的时候被任务删了)就跳过,
4
+ // 照样把其余的打包,失败的清单放进压缩包里的 FAILED_LIST_NAME,并告诉调用方。取消会中止正在传的请求。
5
+ import { CenterError, type BoardBackend } from "../core/client.js";
6
+ import { fmtSize } from "./fileKind.js";
7
+ import { saveBlob } from "./fileOps.js";
8
+ import { buildZip, type ZipEntry } from "./zip.js";
9
+
10
+ export const FOLDER_MAX_FILES = 2000;
11
+ export const FOLDER_MAX_BYTES = 300 * 1024 * 1024;
12
+ /** 同时读几个文件:中枢 / 节点不被一个人的下载占满,浏览器连接也留给别的请求。 */
13
+ const PARALLEL = 3;
14
+ /** 每个请求最多试几次,两次之间等多久(毫秒;带 ±30% 浮动)。 */
15
+ const ATTEMPTS = 3;
16
+ const BACKOFF_MS = [1_000, 3_000];
17
+ export const FAILED_LIST_NAME = "_下载失败的文件.txt";
18
+
19
+ export type FolderProgress = { phase: "listing" | "reading" | "zipping"; files: number; total: number; bytes: number; totalBytes?: number; failed?: number; retrying?: string };
20
+ export type FolderResult = { files: number; failed: Array<{ path: string; reason: string }> };
21
+ export type FolderZip = FolderResult & { blob: Blob; name: string };
22
+ export type ListOptions = { signal?: AbortSignal; backoff?: number[]; maxFiles?: number; maxBytes?: number };
23
+
24
+ const abortError = () => new DOMException("已取消", "AbortError");
25
+ const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
26
+
27
+ /** 值得再试的失败:网络(超时、断线)、限流、中枢 / 节点临时出错。404、403 之类再试也一样。 */
28
+ export function isTransient(error: unknown): boolean {
29
+ if (!(error instanceof CenterError)) return !isAbort(error);
30
+ return error.code === "NETWORK" || error.status === 429 || error.status === 408 || error.status >= 500 || error.code === "node_unreachable";
31
+ }
32
+
33
+ function sleep(ms: number, signal?: AbortSignal) {
34
+ return new Promise<void>((resolve, reject) => {
35
+ if (signal?.aborted) return reject(abortError());
36
+ const t = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms);
37
+ const stop = () => { clearTimeout(t); reject(abortError()); };
38
+ signal?.addEventListener("abort", stop, { once: true });
39
+ });
40
+ }
41
+
42
+ /** 失败了按退避再试(只试临时性的失败)。onRetry 告诉界面「正在重试」。 */
43
+ export async function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, onRetry?: (attempt: number, error: unknown) => void, backoff = BACKOFF_MS): Promise<T> {
44
+ for (let attempt = 1; ; attempt++) {
45
+ if (signal?.aborted) throw abortError();
46
+ try {
47
+ return await fn();
48
+ } catch (error) {
49
+ if (isAbort(error) || signal?.aborted) throw abortError();
50
+ if (attempt >= ATTEMPTS || !isTransient(error)) throw error;
51
+ onRetry?.(attempt, error);
52
+ const base = backoff[Math.min(attempt - 1, backoff.length - 1)] ?? 0;
53
+ await sleep(Math.round(base * (0.7 + Math.random() * 0.6)), signal);
54
+ }
55
+ }
56
+ }
57
+
58
+ /** 列出 dir 下所有文件(递归)。超过上限(缺省 FOLDER_MAX_FILES 个、FOLDER_MAX_BYTES)抛错。 */
59
+ export async function listFolder(backend: BoardBackend, jobId: string, dir: string, opts: ListOptions = {}): Promise<Array<{ path: string; size: number; mtime: number }>> {
60
+ const { signal, backoff, maxFiles = FOLDER_MAX_FILES, maxBytes = FOLDER_MAX_BYTES } = opts;
61
+ const out: Array<{ path: string; size: number; mtime: number }> = [];
62
+ let bytes = 0;
63
+ const queue = [dir || "."];
64
+ while (queue.length) {
65
+ if (signal?.aborted) throw abortError();
66
+ const cur = queue.shift()!;
67
+ let res;
68
+ try {
69
+ res = await withRetry(() => backend.stat(jobId, cur), signal, undefined, backoff);
70
+ } catch (error) {
71
+ // 子目录在列的时候被任务删了:跳过它;要下载的目录本身没了照常报错。
72
+ if (cur !== (dir || ".") && error instanceof CenterError && error.status === 404) continue;
73
+ throw error;
74
+ }
75
+ if (res.type !== "dir") continue;
76
+ if (res.truncated) throw new Error(`「${cur === "." ? "工作区" : cur}」里文件太多,节点只列出了一部分;请进到更小的目录再下载`);
77
+ for (const e of res.entries) {
78
+ if (e.type === "dir") { queue.push(e.path); continue; }
79
+ out.push({ path: e.path, size: e.size, mtime: e.mtime });
80
+ bytes += e.size;
81
+ if (out.length > maxFiles) throw new Error(`文件超过 ${maxFiles} 个,请进到更小的目录再下载`);
82
+ if (bytes > maxBytes) throw new Error(`总大小超过 ${fmtSize(maxBytes)},请进到更小的目录或单个下载`);
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ export type FolderOptions = { onProgress?: (p: FolderProgress) => void; signal?: AbortSignal; zipName?: string; backoff?: number[] };
89
+
90
+ /** 打包下载 dir("" = 整个工作区)到本地。返回打了几个文件、哪些没读到。 */
91
+ export async function downloadFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderResult> {
92
+ const { blob, name, files, failed } = await zipFolder(backend, jobId, dir, opts);
93
+ saveBlob(blob, name);
94
+ return { files, failed };
95
+ }
96
+
97
+ /** 把 dir("" = 整个工作区)打成 ZIP,返回 Blob(不下载;业务方可以自己上传到服务器)。 */
98
+ export async function zipFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderZip> {
99
+ const { onProgress, signal } = opts;
100
+ onProgress?.({ phase: "listing", files: 0, total: 0, bytes: 0 });
101
+ const files = await listFolder(backend, jobId, dir, { signal, backoff: opts.backoff });
102
+ if (!files.length) throw new Error("这个目录里没有文件");
103
+ const totalBytes = files.reduce((n, f) => n + f.size, 0);
104
+ const base = dir ? `${dir.replace(/\/+$/, "")}/` : "";
105
+ const entries: Array<ZipEntry | null> = new Array(files.length).fill(null);
106
+ const failed: FolderResult["failed"] = [];
107
+ // 每个文件已收到的字节(含正在传的),进度条按它走:一个大文件慢慢传时也看得到在动。
108
+ const got = new Array<number>(files.length).fill(0);
109
+ let done = 0;
110
+ let next = 0;
111
+ let retrying: string | undefined;
112
+ let lastReport = 0;
113
+ const report = (force = false) => {
114
+ const now = Date.now();
115
+ if (!force && now - lastReport < 200) return;
116
+ lastReport = now;
117
+ onProgress?.({ phase: "reading", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length, retrying });
118
+ };
119
+ const worker = async () => {
120
+ while (next < files.length) {
121
+ const i = next++;
122
+ if (signal?.aborted) throw abortError();
123
+ const f = files[i];
124
+ try {
125
+ const buffer = await withRetry(
126
+ () => { got[i] = 0; return backend.readBuffer(jobId, f.path, { signal, onBytes: (n) => { got[i] = n; report(); } }); },
127
+ signal,
128
+ () => { retrying = f.path; report(true); },
129
+ opts.backoff,
130
+ );
131
+ const data = new Uint8Array(buffer);
132
+ got[i] = data.length;
133
+ // 任务在写、文件比列的时候大了很多:总量还是要守住上限。
134
+ if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES * 1.1) throw new Error(`读到的总大小超过 ${fmtSize(FOLDER_MAX_BYTES)}(文件还在变大),请进到更小的目录或单个下载`);
135
+ // ZIP 里的路径相对下载的目录(文件夹名本身作为 ZIP 文件名)。
136
+ entries[i] = { path: f.path.startsWith(base) ? f.path.slice(base.length) : f.path, data, mtime: f.mtime };
137
+ } catch (error) {
138
+ if (isAbort(error) || signal?.aborted) throw abortError();
139
+ if (!(error instanceof CenterError)) throw error;
140
+ got[i] = 0;
141
+ failed.push({ path: f.path, reason: error.message });
142
+ }
143
+ if (retrying === f.path) retrying = undefined;
144
+ done++;
145
+ report(true);
146
+ }
147
+ };
148
+ // 列完马上报「读取 0 / N」:网络慢时第一个文件要好一会儿才有字节,不能一直停在「正在列出」。
149
+ report(true);
150
+ await Promise.all(Array.from({ length: Math.min(PARALLEL, files.length) }, worker));
151
+ const ok = entries.filter((e): e is ZipEntry => !!e);
152
+ if (!ok.length) throw new Error(`${failed.length} 个文件都没读到:${failed[0]?.reason ?? "未知原因"}`);
153
+ onProgress?.({ phase: "zipping", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length });
154
+ if (failed.length) {
155
+ const list = ["下面这些文件没有下载下来(其余文件都在压缩包里):", "", ...failed.map((f) => `${f.path}\t${f.reason}`), ""].join("\n");
156
+ ok.push({ path: FAILED_LIST_NAME, data: new TextEncoder().encode(list) as Uint8Array<ArrayBuffer> });
157
+ }
158
+ const blob = buildZip(ok);
159
+ const name = opts.zipName ?? `${(dir.split("/").filter(Boolean).pop() || "工作区")}.zip`;
160
+ return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed };
161
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * 给业务方的工作区接口:不显示看板也能拿到任务产出的文件。
3
+ *
4
+ * const ws = store.workspace("job_xxx"); // 不传 = 当前选中的对话;Web Component:el.workspace()
5
+ * await ws.listAll(); // 工作区里所有文件(递归)
6
+ * await ws.readText("reports/报告.md"); // 读成文字
7
+ * await ws.readFile("reports/图.png"); // 读成 Blob(可以自己上传)
8
+ * await ws.zip("reports"); // 打成 ZIP 的 Blob
9
+ * await ws.download("reports/报告.md"); // 下载到用户本地;ws.downloadFolder("reports") 打包下载
10
+ * ws.openFile(); // 看板上正在看的文件(没有看板 / 没打开文件时为 null)
11
+ *
12
+ * 数据都经中枢读执行节点(和看板一样),弱网时按网络错误(CenterError,code = "NETWORK")失败,可以重试。
13
+ */
14
+ import type { BoardBackend } from "../core/client.js";
15
+ import type { BoardInfo, FileEntry } from "../core/types.js";
16
+ import { directFileUrl, downloadFile, mimeOf } from "./fileOps.js";
17
+ import { fmtSize } from "./fileKind.js";
18
+ import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload.js";
19
+
20
+ /** readText 最多读这么大(更大的用 readFile)。 */
21
+ export const WORKSPACE_TEXT_MAX = 20 * 1024 * 1024;
22
+ /** readFile 最多读这么大进内存(更大的用 fileUrl / download,浏览器流式下载)。 */
23
+ export const WORKSPACE_FILE_MAX = 500 * 1024 * 1024;
24
+ /** listAll 最多列这么多个文件。 */
25
+ export const WORKSPACE_LIST_MAX = 10_000;
26
+
27
+ /** 看板现在的样子(看板组件报告给仓库;没有看板时没有)。 */
28
+ export interface BoardView {
29
+ /** 当前页签:files / changes / browser、"file:<路径>"(打开的文件)或自己加的页签 id。 */
30
+ tab: string;
31
+ /** 文件列表所在目录("" = 工作区根目录)。 */
32
+ dir: string;
33
+ /** 正在看的文件(当前页签是文件时),否则 null。 */
34
+ file: string | null;
35
+ /** 开着的文件页签。 */
36
+ files: string[];
37
+ }
38
+
39
+ export interface CliWorkspace {
40
+ /** 这个工作区属于哪个对话(对话键)。 */
41
+ readonly jobId: string;
42
+ /** 看板概况:派发到节点没有、节点是否在线、有没有浏览器。 */
43
+ info(): Promise<BoardInfo>;
44
+ /** 列一个目录(不递归)。 */
45
+ list(dir?: string): Promise<FileEntry[]>;
46
+ /** 列 dir 下所有文件(递归,只含文件)。 */
47
+ listAll(dir?: string, opts?: { signal?: AbortSignal }): Promise<FileEntry[]>;
48
+ /** 读成文字(GBK 导出的文件会自动转码)。 */
49
+ readText(path: string): Promise<string>;
50
+ /** 读成 Blob。onProgress 报已收到的字节数;超过 maxBytes(缺省 500 MB)报错,改用 fileUrl / download。 */
51
+ readFile(path: string, opts?: { signal?: AbortSignal; onProgress?: (bytes: number) => void; maxBytes?: number }): Promise<Blob>;
52
+ /** 浏览器能直接打开 / 下载的地址(没有同源代理时是 30 分钟有效的短期链接);接入方式不支持时为 null,改用 readFile。 */
53
+ fileUrl(path: string, opts?: { download?: boolean }): Promise<string | null>;
54
+ /** 下载一个文件到用户本地。 */
55
+ download(path: string): Promise<void>;
56
+ /** 把目录打包成 ZIP 下载到用户本地("" = 整个工作区)。 */
57
+ downloadFolder(dir?: string, opts?: FolderOptions): Promise<FolderResult>;
58
+ /** 把目录打成 ZIP,返回 Blob,不下载。 */
59
+ zip(dir?: string, opts?: FolderOptions): Promise<FolderZip>;
60
+ /** 看板上正在看的文件;没有看板或没打开文件时为 null。 */
61
+ openFile(): string | null;
62
+ /** 看板上开着的所有文件页签。 */
63
+ openFiles(): string[];
64
+ /** 在看板里打开这个文件(没有看板接时返回 false)。 */
65
+ open(path: string): boolean;
66
+ }
67
+
68
+ export function createWorkspace(deps: {
69
+ backend: BoardBackend | undefined;
70
+ key: string;
71
+ view: () => BoardView | undefined;
72
+ open: (path: string) => boolean;
73
+ }): CliWorkspace {
74
+ const { key } = deps;
75
+ const backend = () => {
76
+ if (!deps.backend) throw new Error("当前后端不支持读取工作区文件(需要任务中心的看板接口)");
77
+ return deps.backend;
78
+ };
79
+ const clean = (path: string) => path.replace(/^\.\//, "").replace(/\/+$/, "");
80
+ return {
81
+ jobId: key,
82
+ info: () => backend().info(key),
83
+ async list(dir = "") {
84
+ const res = await backend().stat(key, clean(dir) || ".");
85
+ if (res.type !== "dir") throw new Error(`「${dir}」不是目录`);
86
+ return res.entries;
87
+ },
88
+ async listAll(dir = "", opts = {}) {
89
+ const files = await listFolder(backend(), key, clean(dir), { signal: opts.signal, maxFiles: WORKSPACE_LIST_MAX, maxBytes: Infinity });
90
+ return files.map((f) => ({ name: f.path.split("/").pop() || f.path, path: f.path, type: "file" as const, size: f.size, mtime: f.mtime }));
91
+ },
92
+ async readText(path) {
93
+ const r = await backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
94
+ if (r.truncated) throw new Error(`文件较大(超过 ${WORKSPACE_TEXT_MAX / 1024 / 1024} MB),请用 readFile 读成 Blob`);
95
+ return r.text;
96
+ },
97
+ async readFile(path, opts = {}) {
98
+ const p = clean(path);
99
+ // 太大的读到一半就停:整个读进内存会把页面撑爆。
100
+ const ctl = new AbortController();
101
+ const stop = () => ctl.abort();
102
+ opts.signal?.addEventListener("abort", stop, { once: true });
103
+ let tooBig = false;
104
+ const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
105
+ try {
106
+ const buffer = await backend().readBuffer(key, p, {
107
+ signal: ctl.signal,
108
+ onBytes: (n) => { if (n > max) { tooBig = true; ctl.abort(); } else opts.onProgress?.(n); },
109
+ });
110
+ return new Blob([buffer], { type: mimeOf(p) });
111
+ } catch (error) {
112
+ if (tooBig) throw new Error(`文件超过 ${fmtSize(max)},请用 fileUrl / download(浏览器流式下载,不占内存)`);
113
+ throw error;
114
+ } finally {
115
+ opts.signal?.removeEventListener("abort", stop);
116
+ }
117
+ },
118
+ async fileUrl(path, opts = {}) {
119
+ const url = await directFileUrl(backend(), key, clean(path), opts.download);
120
+ return url && typeof location !== "undefined" ? new URL(url, location.href).href : url;
121
+ },
122
+ download: (path) => downloadFile(backend(), key, clean(path)),
123
+ downloadFolder: (dir = "", opts) => downloadFolder(backend(), key, clean(dir), opts),
124
+ zip: (dir = "", opts) => zipFolder(backend(), key, clean(dir), opts),
125
+ openFile: () => deps.view()?.file ?? null,
126
+ openFiles: () => [...(deps.view()?.files ?? [])],
127
+ open: (path) => deps.open(clean(path)),
128
+ };
129
+ }
@@ -0,0 +1,85 @@
1
+ // 在浏览器里把几个文件打成 ZIP(只存储、不压缩):工作区「下载整个文件夹」用。
2
+ // 中枢 / 节点没有打包接口,文件逐个经中枢读回来,在这里拼成一个 ZIP 交给浏览器下载。
3
+ // 不压缩:报告、图片、表格多半已经压缩过,存储模式快、内存只多一份目录表;文件名按 UTF-8 标记(中文名不乱码)。
4
+ // 不支持 ZIP64:调用方限制了总大小(远小于 4 GB)和文件数。
5
+
6
+ export type ZipEntry = { path: string; data: Uint8Array<ArrayBuffer>; mtime?: number };
7
+
8
+ const CRC_TABLE = (() => {
9
+ const t = new Uint32Array(256);
10
+ for (let n = 0; n < 256; n++) {
11
+ let c = n;
12
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
13
+ t[n] = c >>> 0;
14
+ }
15
+ return t;
16
+ })();
17
+
18
+ export function crc32(data: Uint8Array): number {
19
+ let c = 0xffffffff;
20
+ for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
21
+ return (c ^ 0xffffffff) >>> 0;
22
+ }
23
+
24
+ /** DOS 时间 / 日期(ZIP 头里用)。 */
25
+ function dosTime(ms: number | undefined): { time: number; date: number } {
26
+ const d = new Date(ms && ms > 0 ? ms : Date.now());
27
+ const year = Math.max(1980, d.getFullYear());
28
+ return {
29
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
30
+ date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
31
+ };
32
+ }
33
+
34
+ export function buildZip(entries: ZipEntry[]): Blob {
35
+ const enc = new TextEncoder();
36
+ const parts: BlobPart[] = [];
37
+ const central: Uint8Array<ArrayBuffer>[] = [];
38
+ let offset = 0;
39
+ for (const e of entries) {
40
+ const name = enc.encode(e.path.replace(/^\/+/, ""));
41
+ const crc = crc32(e.data);
42
+ const { time, date } = dosTime(e.mtime);
43
+ const local = new Uint8Array(30 + name.length);
44
+ const lv = new DataView(local.buffer);
45
+ lv.setUint32(0, 0x04034b50, true);
46
+ lv.setUint16(4, 20, true);
47
+ lv.setUint16(6, 0x0800, true); // 文件名是 UTF-8
48
+ lv.setUint16(8, 0, true); // 存储
49
+ lv.setUint16(10, time, true);
50
+ lv.setUint16(12, date, true);
51
+ lv.setUint32(14, crc, true);
52
+ lv.setUint32(18, e.data.length, true);
53
+ lv.setUint32(22, e.data.length, true);
54
+ lv.setUint16(26, name.length, true);
55
+ local.set(name, 30);
56
+ parts.push(local, e.data);
57
+
58
+ const head = new Uint8Array(46 + name.length);
59
+ const cv = new DataView(head.buffer);
60
+ cv.setUint32(0, 0x02014b50, true);
61
+ cv.setUint16(4, 20, true);
62
+ cv.setUint16(6, 20, true);
63
+ cv.setUint16(8, 0x0800, true);
64
+ cv.setUint16(10, 0, true);
65
+ cv.setUint16(12, time, true);
66
+ cv.setUint16(14, date, true);
67
+ cv.setUint32(16, crc, true);
68
+ cv.setUint32(20, e.data.length, true);
69
+ cv.setUint32(24, e.data.length, true);
70
+ cv.setUint16(28, name.length, true);
71
+ cv.setUint32(42, offset, true);
72
+ head.set(name, 46);
73
+ central.push(head);
74
+ offset += local.length + e.data.length;
75
+ }
76
+ const centralSize = central.reduce((n, c) => n + c.length, 0);
77
+ const end = new Uint8Array(22);
78
+ const ev = new DataView(end.buffer);
79
+ ev.setUint32(0, 0x06054b50, true);
80
+ ev.setUint16(8, entries.length, true);
81
+ ev.setUint16(10, entries.length, true);
82
+ ev.setUint32(12, centralSize, true);
83
+ ev.setUint32(16, offset, true);
84
+ return new Blob([...parts, ...central, end], { type: "application/zip" });
85
+ }
@@ -2,8 +2,10 @@
2
2
  // 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
3
3
  // 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
4
4
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
5
+ import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
5
6
  import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
6
7
  import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
8
+ import { boardContextBlock, serializeBlock } from "../core/blocks.js";
7
9
  import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
8
10
  import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
9
11
  import { Icon } from "../ui/icons.js";
@@ -66,7 +68,7 @@ export interface CliChatProps extends CliStoreOptions, ResultHandlers {
66
68
 
67
69
  export function CliChat(props: CliChatProps) {
68
70
  const ctx = useCliStoreContext();
69
- return withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx);
71
+ return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
70
72
  }
71
73
 
72
74
  // 不能再发消息的结束原因(调用方关闭 / 取消)。其余原因(过期、换线、节点丢失)再发会自动开新对话。
@@ -76,6 +78,9 @@ const REASON_TEXT: Record<string, string> = {
76
78
  node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
77
79
  };
78
80
 
81
+ /** 子任务结束了的状态:可以接着它聊。 */
82
+ const SETTLED_TASK = new Set(["succeeded", "failed", "cancelled"]);
83
+
79
84
  // 一次性任务(含续派)的子任务状态(center TaskStatus)。
80
85
  const BATCH_STATUS: Record<string, { dotCls: DotCls; statusText: string }> = {
81
86
  succeeded: { dotCls: "g", statusText: "子任务已完成" }, failed: { dotCls: "x", statusText: "子任务失败" }, cancelled: { dotCls: "x", statusText: "子任务已取消" },
@@ -173,7 +178,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
173
178
  // 例外是对话式子任务(续派时 mode=conversation):当对话用,随时能接着发(= 续派一条接着它),不显示子任务状态。
174
179
  const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
175
180
  const chat = !!taskId && isChatTask(conv);
176
- const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !chat);
181
+ // 普通子任务结束后(完成 / 失败 / 取消)也能接着聊:发出去 = 续派一条接着它的对话式子任务(同一个 Agent 会话、同一个工作区)。
182
+ // 还在跑的时候输入框照样显示、但不能发,提示结束后再接着聊(需要登录、验证码时在看板的浏览器里接管)。
183
+ const batchTaskStatus = batch ? conv?.batchTask?.status ?? job?.tasks?.find((t) => t.id === (taskId ?? conv?.task?.id))?.status ?? null : null;
184
+ const batchSettled = batch && !chat && !!batchTaskStatus && SETTLED_TASK.has(batchTaskStatus) && !!store.client.continueTask;
185
+ const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !chat && !batchSettled);
186
+ // 一次性任务的子任务没结束、不能发时,输入框也露出来(禁用 + 说明),别让人以为没有输入框。
187
+ const showComposer = !readOnly || (batch && !readOnlyProp && cfg.composer.show);
177
188
  // 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
178
189
  // 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
179
190
  const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
@@ -183,9 +194,10 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
183
194
  const agentText = labelOf(agent);
184
195
  const jobStatus = conv?.job?.status ?? job?.status ?? null;
185
196
  // 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
186
- const jobEnded = !chat && !!jobStatus && TERMINAL_JOB.has(jobStatus);
197
+ // 普通子任务结束后能接着聊的(batchSettled)同理:大任务结束不挡输入框。
198
+ const jobEnded = !chat && !batchSettled && !!jobStatus && TERMINAL_JOB.has(jobStatus);
187
199
  // 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
188
- const convClosed = !jobEnded && !!(conv?.task?.closed ?? info?.closed);
200
+ const convClosed = !jobEnded && !batchSettled && !!(conv?.task?.closed ?? info?.closed);
189
201
  const status = convClosed ? "succeeded" : jobStatus;
190
202
  const closedReason = convClosed ? "conversation_closed" : conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
191
203
  const ended = jobEnded || convClosed;
@@ -239,16 +251,38 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
239
251
  throw error;
240
252
  }
241
253
  }, []);
242
- const latest = useRef({ key, messages, conv });
243
- latest.current = { key, messages, conv };
254
+ // 操作这次运行(回答提问、填浏览器输入卡片、交还浏览器)落在哪个子任务上:一次性任务没指定子任务时是正在看的那个
255
+ // (几个子任务同时跑时,卡片属于它,不是中枢缺省的那个)。和能不能发消息无关。
256
+ const runKey = batch && !taskId && conv?.task?.id && jobId ? convKey(jobId, conv.task.id) : key;
257
+ const latest = useRef({ key, messages, conv, runKey });
258
+ latest.current = { key, messages, conv, runKey };
244
259
  // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
245
260
  const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
246
261
  sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
247
262
  const send = useCallback(async (text: string) => {
248
- const id = latest.current.key;
249
- const target = await store.send(id ?? null, text, sendOpts.current);
263
+ // 一次性任务:发给正在看的那个子任务(接着它聊)。
264
+ const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
265
+ // 看板里引用到这个对话的东西:合成一个「看板上下文」块跟着这条消息发出去;发失败就放回去。
266
+ // 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复)。
267
+ const refKey = latest.current.key;
268
+ const refs = refKey ? store.takeRefs(refKey) : [];
269
+ const viewing = refKey ? store.takeViewing(refKey) : null;
270
+ const lines = [...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewing.text] : []), ...refs.map((r) => r.text)];
271
+ const full = lines.length ? `${text}\n\n${serializeBlock(boardContextBlock(lines))}` : text;
272
+ let target: string;
273
+ try {
274
+ target = await store.send(id ?? null, full, sendOpts.current);
275
+ } catch (error) {
276
+ if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
277
+ throw error;
278
+ }
250
279
  if (target !== id) go(target);
251
280
  }, [store, go]);
281
+ const refs = useCliState((s) => (key ? s.refs[key] : undefined));
282
+ const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
283
+ const viewing = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
284
+ // 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
285
+ const contextItems = useCliState((s) => s.context[key ?? ""]);
252
286
  // AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
253
287
  // 打开对话时已有的交接行不算(先记下已有的再开始比较);没有看板时不弹窗打扰,行里有「查看浏览器」按钮。
254
288
  const handoffSeen = useRef<{ key: string | null; ids: Set<string> | null }>({ key: null, ids: null });
@@ -275,9 +309,11 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
275
309
  }, [popup]);
276
310
  const handlers = useMemo<MessageHandlers>(() => ({
277
311
  // 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
278
- onAnswer: readOnly ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
312
+ onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
279
313
  const id = latest.current.key;
280
314
  if (!id) return;
315
+ // 一次性任务:提问时回合开着等,答案直接交给节点;不能改发消息。
316
+ if (batch && !chat) { await report(store.answerAsk(latest.current.runKey ?? id, askId, questions, answers, { fallback: false }).then(() => undefined), "回答没有送到"); return; }
281
317
  const sent = askId
282
318
  ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
283
319
  : send(formatAnswersText(questions, answers));
@@ -285,14 +321,14 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
285
321
  },
286
322
  onCancelQueued: readOnly || batch ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
287
323
  // 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
288
- onInput: readOnly || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
289
- const id = latest.current.key;
324
+ onInput: readOnlyProp || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
325
+ const id = latest.current.runKey;
290
326
  if (!id) return "对话还没开始";
291
327
  try { return await store.client.submitInput!(id, inputId, values); } catch (error) { return error instanceof Error ? error.message : String(error); }
292
328
  },
293
329
  // 交还浏览器:后端自己的 handback(Operator 后端),没有就走看板接口(center 转发到节点)。
294
- onHandback: readOnly || !(store.client.handback || store.client.board) ? undefined : async () => {
295
- const id = latest.current.key;
330
+ onHandback: readOnlyProp || !(store.client.handback || store.client.board) ? undefined : async () => {
331
+ const id = latest.current.runKey;
296
332
  if (!id) return "对话还没开始";
297
333
  try {
298
334
  if (store.client.handback) return await store.client.handback(id);
@@ -311,7 +347,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
311
347
  const at = list.findIndex((m) => m.id === msgId);
312
348
  for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(list[j].content), "重试没有发出去").catch(() => undefined); return; }
313
349
  },
314
- }), [readOnly, batch, send, store, go, report, fileOpen]);
350
+ }), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
315
351
  const stop = useCallback(async () => {
316
352
  const { key: id, conv: c } = latest.current;
317
353
  const turn = c ? runningTurn(c) : null;
@@ -338,7 +374,14 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
338
374
  const [atBottom, setAtBottom] = useState(true);
339
375
  const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
340
376
  const queued = useMemo(() => messages.filter((m) => m.role === "queued"), [messages]);
341
- const items = useMemo(() => buildTranscriptItems(committed), [committed]);
377
+ // 节点转录比手里更早、还没取的条数(实时流只给最新一段,往上翻时分页取,和 Operator 一样)。
378
+ const older = conv?.older ?? 0;
379
+ const [loadingOlder, setLoadingOlder] = useState(false);
380
+ const olderRef = useRef(older); olderRef.current = older;
381
+ const keyRef2 = useRef(key); keyRef2.current = key;
382
+ const items = useMemo(() => buildTranscriptItems(committed, older > 0), [committed, older]);
383
+ // 展开了的「续等」折叠行(按折叠行的 key)。
384
+ const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
342
385
  const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
343
386
  const { startIdx, endIdx } = useMemo(() => {
344
387
  const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
@@ -373,10 +416,17 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
373
416
  }, []);
374
417
  const revealEarlier = useCallback(() => {
375
418
  const { startIdx: s, endIdx: e } = rangeRef.current;
376
- if (s <= 0) return;
419
+ if (s <= 0) {
420
+ // 手里的都显示了,节点上还有更早的:取一页接在前面,再往上展开(窗口按原来的那一行定位,不跳)。
421
+ const k = keyRef2.current;
422
+ if (!k || olderRef.current <= 0) return;
423
+ setLoadingOlder(true);
424
+ void store.loadOlder(k).then((ok) => { if (ok) requestAnimationFrame(() => revealRef.current.earlier()); }).finally(() => setLoadingOlder(false));
425
+ return;
426
+ }
377
427
  const ns = Math.max(0, s - RENDER_STEP);
378
428
  setRange(ns, Math.min(e, ns + RENDER_MAX - 1), measure());
379
- }, [measure, setRange]);
429
+ }, [measure, setRange, store]);
380
430
  const revealLater = useCallback(() => {
381
431
  const its = itemsRef.current;
382
432
  const { startIdx: s, endIdx: e } = rangeRef.current;
@@ -404,7 +454,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
404
454
 
405
455
  const topSentinelRef = useRef<HTMLDivElement>(null);
406
456
  const bottomSentinelRef = useRef<HTMLDivElement>(null);
407
- const hasAbove = hiddenAbove > 0;
457
+ const hasAbove = hiddenAbove > 0 || older > 0;
408
458
  const hasBelow = hiddenBelow > 0;
409
459
  useEffect(() => {
410
460
  const root = scrollRef.current;
@@ -483,7 +533,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
483
533
  fileUpload: cfg.composer.fileUpload, fileButtonText: cfg.composer.fileButtonText, queue: cfg.composer.queue,
484
534
  }), [cfg]);
485
535
  const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
486
- const disabledHint = readOnly ? "只读" : finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
536
+ const disabledHint = readOnly
537
+ ? batch && !readOnlyProp && cfg.composer.show
538
+ ? (batchTaskStatus && SETTLED_TASK.has(batchTaskStatus) ? "只读(当前中枢不支持接着子任务聊)"
539
+ : batchTaskStatus === "blocked" || batchTaskStatus === "queued" ? "子任务还没开始,开始并结束后可以接着它聊"
540
+ : "子任务运行中,结束后可以接着它继续聊;需要登录等操作请在右侧浏览器里接管")
541
+ : "只读"
542
+ : finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
487
543
 
488
544
  const fileUrlRef = useRef(fileUrl);
489
545
  fileUrlRef.current = fileUrl;
@@ -592,7 +648,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
592
648
  )}
593
649
  {hasAbove && (
594
650
  <div ref={topSentinelRef} className="load-older">
595
- <button className="btn btn-ghost btn-sm" onClick={revealEarlier}>加载更早的消息(还有 {hiddenAbove} 条)</button>
651
+ <button className="btn btn-ghost btn-sm" onClick={revealEarlier} disabled={loadingOlder}>{loadingOlder ? "正在加载更早的消息…" : `加载更早的消息(还有 ${hiddenAbove + older} 条)`}</button>
596
652
  </div>
597
653
  )}
598
654
  {shown.map((it) =>
@@ -600,6 +656,15 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
600
656
  cfg.transcript.sessionLabel ? <div key={it.key} data-tkey={it.key} className="session-label"><span className="ln" />第 {it.n} 次会话{it.current ? " · 当前" : ""}<span className="ln" /></div> : null
601
657
  ) : it.kind === "break" ? (
602
658
  <div key={it.key} data-tkey={it.key}><SessionBreak summary={it.summary} /></div>
659
+ ) : it.kind === "fold" ? (
660
+ <div key={it.key} data-tkey={it.key} data-msg="">
661
+ <button className="cli-wait-fold" onClick={() => setOpenFolds((o) => { const n = new Set(o); if (n.has(it.key)) n.delete(it.key); else n.add(it.key); return n; })} aria-expanded={openFolds.has(it.key)}>
662
+ <span className="cli-wait-ic">⏳</span>
663
+ <span>等你在浏览器里操作 · AI 已续等 {it.waits} 次</span>
664
+ <span className="cli-wait-more">{openFolds.has(it.key) ? "收起" : "展开"}</span>
665
+ </button>
666
+ {openFolds.has(it.key) && it.msgs.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho running={running} agent={agent} agentLabel={agentText} handlers={handlers} view={view} />)}
667
+ </div>
603
668
  ) : (
604
669
  <div key={it.key} data-tkey={it.key} data-msg="">
605
670
  <MessageView m={it.m} initial={it.initial} hideWho={it.hideWho} running={running} agent={agent} agentLabel={agentText} handlers={handlers} view={view} />
@@ -628,7 +693,35 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
628
693
  {!atBottom && <button className="msg-nav-btn" onClick={() => scrollToBottom()} title="跳到最新" aria-label="跳到最新">{Icon.toBottom()}</button>}
629
694
  </div>}
630
695
  </div>
631
- {!readOnly && (
696
+ {showComposer && !!contextItems?.length && (
697
+ <div className="cli-refs cli-ctxs" aria-label="补充背景,下一条消息会带上">
698
+ <span className="cli-refs-t">{Icon.flag()} 补充背景</span>
699
+ {contextItems.map((c) => (
700
+ <span key={c.id} className="cli-ref ctx" title={`${c.text.slice(0, 500)}${c.text.length > 500 ? "…" : ""}\n(下一条消息会带上)`}>
701
+ <span className="cli-ref-l">{c.title || c.text.replace(/\s+/g, " ").slice(0, 20)}</span>
702
+ </span>
703
+ ))}
704
+ </div>
705
+ )}
706
+ {showComposer && (!!refs?.length || !!viewing) && (
707
+ <div className="cli-refs" aria-label="引用自看板,下一条消息会带上">
708
+ <span className="cli-refs-t">{Icon.board()} 引用自看板</span>
709
+ {viewing && (
710
+ <span className="cli-ref auto" title={`${viewing.text}\n(看板上正在看,下一条消息缺省带上;点 × 这次不带)`}>
711
+ <span className="cli-ref-k">正在看</span>
712
+ <span className="cli-ref-l">{viewing.label}</span>
713
+ <button className="cli-ref-x" onClick={() => key && store.dismissViewing(key)} title="这次不带上" aria-label={`不带上 ${viewing.label}`}>{Icon.x()}</button>
714
+ </span>
715
+ )}
716
+ {(refs ?? []).map((r) => (
717
+ <span key={r.id} className={`cli-ref ${r.kind === "selection" ? "sel" : ""}`} title={r.text}>
718
+ <span className="cli-ref-l">{r.label}</span>
719
+ <button className="cli-ref-x" onClick={() => key && store.removeRef(key, r.id)} title="不带上" aria-label={`不带上 ${r.label}`}>{Icon.x()}</button>
720
+ </span>
721
+ ))}
722
+ </div>
723
+ )}
724
+ {showComposer && (
632
725
  <Composer
633
726
  key={key ?? "new"}
634
727
  draft={key ?? `new:${instanceId}`}