@spzxg/cli-ui 0.4.0 → 0.6.1
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 +84 -10
- package/dist/cli-ui-embed-Dk2QGHxn.js +41146 -0
- package/dist/cli-ui-index-BFpRZXn4.js +11209 -0
- package/dist/cli-ui-jszip.min-B9tqMA-e.js +2324 -0
- package/dist/cli-ui-jszip.min-DIavJzUG.js +26 -0
- package/dist/cli-ui-xlsx-BKHhqcoa.js +93 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -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 +387 -73
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileKind.ts +31 -0
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/frameQuote.ts +50 -0
- package/src/board/textLoad.ts +99 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +122 -23
- package/src/chat/Composer.tsx +4 -4
- package/src/core/blocks.ts +69 -11
- package/src/core/client.ts +285 -41
- package/src/core/config.ts +18 -5
- 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/core/xlsx.ts +142 -0
- 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/Transcript.tsx +10 -5
- 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
|
@@ -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
|
+
}
|
package/src/board/zip.ts
ADDED
|
@@ -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
|
+
}
|
package/src/chat/CliChat.tsx
CHANGED
|
@@ -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, viewingBlock } 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";
|
|
@@ -12,7 +14,7 @@ import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.j
|
|
|
12
14
|
import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
|
|
13
15
|
import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
|
|
14
16
|
import { Composer, type ComposerOptions } from "./Composer.js";
|
|
15
|
-
import { fillTemplate, resolveChatConfig, type CliChatConfig } from "../core/config.js";
|
|
17
|
+
import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config.js";
|
|
16
18
|
import { exportMarkdown } from "./export.js";
|
|
17
19
|
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
|
|
18
20
|
import { FileViewer } from "../board/FileViewer.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
|
-
|
|
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
|
-
|
|
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,44 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
239
251
|
throw error;
|
|
240
252
|
}
|
|
241
253
|
}, []);
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
249
|
-
const
|
|
263
|
+
// 一次性任务:发给正在看的那个子任务(接着它聊)。
|
|
264
|
+
const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
|
|
265
|
+
// 看板里引用到这个对话的东西:合成一个「看板上下文」块跟着这条消息发出去;发失败就放回去。
|
|
266
|
+
// 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复),单独一块「看板正在看」:转录里按 config.refs.viewing 显示或不显示。
|
|
267
|
+
const refKey = latest.current.key;
|
|
268
|
+
const refs = refKey ? store.takeRefs(refKey) : [];
|
|
269
|
+
const viewing = refKey ? store.takeViewing(refKey) : null;
|
|
270
|
+
const blocks = [
|
|
271
|
+
...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewingBlock(viewing.text)] : []),
|
|
272
|
+
...(refs.length ? [boardContextBlock(refs.map((r) => r.text))] : []),
|
|
273
|
+
];
|
|
274
|
+
const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
|
|
275
|
+
let target: string;
|
|
276
|
+
try {
|
|
277
|
+
target = await store.send(id ?? null, full, sendOpts.current);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
250
282
|
if (target !== id) go(target);
|
|
251
283
|
}, [store, go]);
|
|
284
|
+
const refs = useCliState((s) => (key ? s.refs[key] : undefined));
|
|
285
|
+
const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
|
|
286
|
+
const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
|
|
287
|
+
// 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
|
|
288
|
+
const viewing = viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
289
|
+
const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
|
|
290
|
+
// 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
|
|
291
|
+
const contextItems = useCliState((s) => s.context[key ?? ""]);
|
|
252
292
|
// AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
|
|
253
293
|
// 打开对话时已有的交接行不算(先记下已有的再开始比较);没有看板时不弹窗打扰,行里有「查看浏览器」按钮。
|
|
254
294
|
const handoffSeen = useRef<{ key: string | null; ids: Set<string> | null }>({ key: null, ids: null });
|
|
@@ -275,9 +315,11 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
275
315
|
}, [popup]);
|
|
276
316
|
const handlers = useMemo<MessageHandlers>(() => ({
|
|
277
317
|
// 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
|
|
278
|
-
onAnswer: readOnly ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
318
|
+
onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
279
319
|
const id = latest.current.key;
|
|
280
320
|
if (!id) return;
|
|
321
|
+
// 一次性任务:提问时回合开着等,答案直接交给节点;不能改发消息。
|
|
322
|
+
if (batch && !chat) { await report(store.answerAsk(latest.current.runKey ?? id, askId, questions, answers, { fallback: false }).then(() => undefined), "回答没有送到"); return; }
|
|
281
323
|
const sent = askId
|
|
282
324
|
? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
|
|
283
325
|
: send(formatAnswersText(questions, answers));
|
|
@@ -285,14 +327,14 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
285
327
|
},
|
|
286
328
|
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
329
|
// 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
|
|
288
|
-
onInput:
|
|
289
|
-
const id = latest.current.
|
|
330
|
+
onInput: readOnlyProp || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
|
|
331
|
+
const id = latest.current.runKey;
|
|
290
332
|
if (!id) return "对话还没开始";
|
|
291
333
|
try { return await store.client.submitInput!(id, inputId, values); } catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
292
334
|
},
|
|
293
335
|
// 交还浏览器:后端自己的 handback(Operator 后端),没有就走看板接口(center 转发到节点)。
|
|
294
|
-
onHandback:
|
|
295
|
-
const id = latest.current.
|
|
336
|
+
onHandback: readOnlyProp || !(store.client.handback || store.client.board) ? undefined : async () => {
|
|
337
|
+
const id = latest.current.runKey;
|
|
296
338
|
if (!id) return "对话还没开始";
|
|
297
339
|
try {
|
|
298
340
|
if (store.client.handback) return await store.client.handback(id);
|
|
@@ -311,7 +353,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
311
353
|
const at = list.findIndex((m) => m.id === msgId);
|
|
312
354
|
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
355
|
},
|
|
314
|
-
}), [readOnly, batch, send, store, go, report, fileOpen]);
|
|
356
|
+
}), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
|
|
315
357
|
const stop = useCallback(async () => {
|
|
316
358
|
const { key: id, conv: c } = latest.current;
|
|
317
359
|
const turn = c ? runningTurn(c) : null;
|
|
@@ -338,7 +380,14 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
338
380
|
const [atBottom, setAtBottom] = useState(true);
|
|
339
381
|
const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
|
|
340
382
|
const queued = useMemo(() => messages.filter((m) => m.role === "queued"), [messages]);
|
|
341
|
-
|
|
383
|
+
// 节点转录比手里更早、还没取的条数(实时流只给最新一段,往上翻时分页取,和 Operator 一样)。
|
|
384
|
+
const older = conv?.older ?? 0;
|
|
385
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
386
|
+
const olderRef = useRef(older); olderRef.current = older;
|
|
387
|
+
const keyRef2 = useRef(key); keyRef2.current = key;
|
|
388
|
+
const items = useMemo(() => buildTranscriptItems(committed, older > 0), [committed, older]);
|
|
389
|
+
// 展开了的「续等」折叠行(按折叠行的 key)。
|
|
390
|
+
const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
|
|
342
391
|
const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
343
392
|
const { startIdx, endIdx } = useMemo(() => {
|
|
344
393
|
const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
|
|
@@ -373,10 +422,17 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
373
422
|
}, []);
|
|
374
423
|
const revealEarlier = useCallback(() => {
|
|
375
424
|
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
376
|
-
if (s <= 0)
|
|
425
|
+
if (s <= 0) {
|
|
426
|
+
// 手里的都显示了,节点上还有更早的:取一页接在前面,再往上展开(窗口按原来的那一行定位,不跳)。
|
|
427
|
+
const k = keyRef2.current;
|
|
428
|
+
if (!k || olderRef.current <= 0) return;
|
|
429
|
+
setLoadingOlder(true);
|
|
430
|
+
void store.loadOlder(k).then((ok) => { if (ok) requestAnimationFrame(() => revealRef.current.earlier()); }).finally(() => setLoadingOlder(false));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
377
433
|
const ns = Math.max(0, s - RENDER_STEP);
|
|
378
434
|
setRange(ns, Math.min(e, ns + RENDER_MAX - 1), measure());
|
|
379
|
-
}, [measure, setRange]);
|
|
435
|
+
}, [measure, setRange, store]);
|
|
380
436
|
const revealLater = useCallback(() => {
|
|
381
437
|
const its = itemsRef.current;
|
|
382
438
|
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
@@ -404,7 +460,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
404
460
|
|
|
405
461
|
const topSentinelRef = useRef<HTMLDivElement>(null);
|
|
406
462
|
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
|
407
|
-
const hasAbove = hiddenAbove > 0;
|
|
463
|
+
const hasAbove = hiddenAbove > 0 || older > 0;
|
|
408
464
|
const hasBelow = hiddenBelow > 0;
|
|
409
465
|
useEffect(() => {
|
|
410
466
|
const root = scrollRef.current;
|
|
@@ -477,13 +533,19 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
477
533
|
|
|
478
534
|
const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
|
|
479
535
|
const h = cfg.header;
|
|
480
|
-
const view = useMemo(() => ({ timestamps: cfg.transcript.timestamps, initialBadge: cfg.transcript.initialBadge, userLabel: cfg.labels.user, agentName: cfg.labels.agent }), [cfg]);
|
|
536
|
+
const view = useMemo(() => ({ timestamps: cfg.transcript.timestamps, initialBadge: cfg.transcript.initialBadge, userLabel: cfg.labels.user, agentName: cfg.labels.agent, viewingBlock: refShown(cfg, "viewing") }), [cfg]);
|
|
481
537
|
const composerOptions = useMemo<ComposerOptions>(() => ({
|
|
482
538
|
placeholderRunning: cfg.composer.placeholderRunning, hints: cfg.composer.hints, charCount: cfg.composer.charCount,
|
|
483
539
|
fileUpload: cfg.composer.fileUpload, fileButtonText: cfg.composer.fileButtonText, queue: cfg.composer.queue,
|
|
484
540
|
}), [cfg]);
|
|
485
541
|
const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
|
|
486
|
-
const disabledHint = readOnly
|
|
542
|
+
const disabledHint = readOnly
|
|
543
|
+
? batch && !readOnlyProp && cfg.composer.show
|
|
544
|
+
? (batchTaskStatus && SETTLED_TASK.has(batchTaskStatus) ? "只读(当前中枢不支持接着子任务聊)"
|
|
545
|
+
: batchTaskStatus === "blocked" || batchTaskStatus === "queued" ? "子任务还没开始,开始并结束后可以接着它聊"
|
|
546
|
+
: "子任务运行中,结束后可以接着它继续聊;需要登录等操作请在右侧浏览器里接管")
|
|
547
|
+
: "只读"
|
|
548
|
+
: finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
|
|
487
549
|
|
|
488
550
|
const fileUrlRef = useRef(fileUrl);
|
|
489
551
|
fileUrlRef.current = fileUrl;
|
|
@@ -592,7 +654,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
592
654
|
)}
|
|
593
655
|
{hasAbove && (
|
|
594
656
|
<div ref={topSentinelRef} className="load-older">
|
|
595
|
-
<button className="btn btn-ghost btn-sm" onClick={revealEarlier}
|
|
657
|
+
<button className="btn btn-ghost btn-sm" onClick={revealEarlier} disabled={loadingOlder}>{loadingOlder ? "正在加载更早的消息…" : `加载更早的消息(还有 ${hiddenAbove + older} 条)`}</button>
|
|
596
658
|
</div>
|
|
597
659
|
)}
|
|
598
660
|
{shown.map((it) =>
|
|
@@ -600,6 +662,15 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
600
662
|
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
663
|
) : it.kind === "break" ? (
|
|
602
664
|
<div key={it.key} data-tkey={it.key}><SessionBreak summary={it.summary} /></div>
|
|
665
|
+
) : it.kind === "fold" ? (
|
|
666
|
+
<div key={it.key} data-tkey={it.key} data-msg="">
|
|
667
|
+
<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)}>
|
|
668
|
+
<span className="cli-wait-ic">⏳</span>
|
|
669
|
+
<span>等你在浏览器里操作 · AI 已续等 {it.waits} 次</span>
|
|
670
|
+
<span className="cli-wait-more">{openFolds.has(it.key) ? "收起" : "展开"}</span>
|
|
671
|
+
</button>
|
|
672
|
+
{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} />)}
|
|
673
|
+
</div>
|
|
603
674
|
) : (
|
|
604
675
|
<div key={it.key} data-tkey={it.key} data-msg="">
|
|
605
676
|
<MessageView m={it.m} initial={it.initial} hideWho={it.hideWho} running={running} agent={agent} agentLabel={agentText} handlers={handlers} view={view} />
|
|
@@ -628,7 +699,35 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
628
699
|
{!atBottom && <button className="msg-nav-btn" onClick={() => scrollToBottom()} title="跳到最新" aria-label="跳到最新">{Icon.toBottom()}</button>}
|
|
629
700
|
</div>}
|
|
630
701
|
</div>
|
|
631
|
-
{
|
|
702
|
+
{showComposer && !!contextItems?.length && (
|
|
703
|
+
<div className="cli-refs cli-ctxs" aria-label="补充背景,下一条消息会带上">
|
|
704
|
+
<span className="cli-refs-t">{Icon.flag()} 补充背景</span>
|
|
705
|
+
{contextItems.map((c) => (
|
|
706
|
+
<span key={c.id} className="cli-ref ctx" title={`${c.text.slice(0, 500)}${c.text.length > 500 ? "…" : ""}\n(下一条消息会带上)`}>
|
|
707
|
+
<span className="cli-ref-l">{c.title || c.text.replace(/\s+/g, " ").slice(0, 20)}</span>
|
|
708
|
+
</span>
|
|
709
|
+
))}
|
|
710
|
+
</div>
|
|
711
|
+
)}
|
|
712
|
+
{showComposer && (!!shownRefs?.length || !!viewing) && (
|
|
713
|
+
<div className="cli-refs" aria-label="引用自看板,下一条消息会带上">
|
|
714
|
+
<span className="cli-refs-t">{Icon.board()} 引用自看板</span>
|
|
715
|
+
{viewing && (
|
|
716
|
+
<span className="cli-ref auto" title={`${viewing.text}\n(看板上正在看,下一条消息缺省带上;点 × 这次不带)`}>
|
|
717
|
+
<span className="cli-ref-k">正在看</span>
|
|
718
|
+
<span className="cli-ref-l">{viewing.label}</span>
|
|
719
|
+
<button className="cli-ref-x" onClick={() => key && store.dismissViewing(key)} title="这次不带上" aria-label={`不带上 ${viewing.label}`}>{Icon.x()}</button>
|
|
720
|
+
</span>
|
|
721
|
+
)}
|
|
722
|
+
{(shownRefs ?? []).map((r) => (
|
|
723
|
+
<span key={r.id} className={`cli-ref ${r.kind === "selection" ? "sel" : ""}`} title={r.text}>
|
|
724
|
+
<span className="cli-ref-l">{r.label}</span>
|
|
725
|
+
<button className="cli-ref-x" onClick={() => key && store.removeRef(key, r.id)} title="不带上" aria-label={`不带上 ${r.label}`}>{Icon.x()}</button>
|
|
726
|
+
</span>
|
|
727
|
+
))}
|
|
728
|
+
</div>
|
|
729
|
+
)}
|
|
730
|
+
{showComposer && (
|
|
632
731
|
<Composer
|
|
633
732
|
key={key ?? "new"}
|
|
634
733
|
draft={key ?? `new:${instanceId}`}
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// 输入框。结构与 Operator 的 Composer.tsx 一致(comp-box / comp-area / send-group / comp-foot),
|
|
2
|
-
//
|
|
2
|
+
// 但只发文本:不收图片和其他附件。文件只收文档(Word、Excel、Markdown、TXT、HTML、CSV),在前端解析成纯文本,
|
|
3
3
|
// 和长粘贴一样变成可折叠的文本块,发送时按统一格式拼进正文(见 core/blocks.ts)。
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import {
|
|
6
|
-
ACCEPT_ATTR, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
|
|
6
|
+
ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
|
|
7
7
|
} from "../core/blocks.js";
|
|
8
8
|
import { Icon } from "../ui/icons.js";
|
|
9
9
|
import { TextBlockCard } from "../ui/TextBlock.js";
|
|
@@ -154,7 +154,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
154
154
|
if (files.length) {
|
|
155
155
|
e.preventDefault();
|
|
156
156
|
if (!options.fileUpload) { setError("这里只能发送文字"); return; }
|
|
157
|
-
if (files.some((f) => f.type.startsWith("image/"))) setError(
|
|
157
|
+
if (files.some((f) => f.type.startsWith("image/"))) setError(`不支持发送图片,只能发送文字,或上传文档:${ACCEPTED_LABEL}`);
|
|
158
158
|
addFiles(files.filter((f) => !f.type.startsWith("image/")));
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
@@ -184,7 +184,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
184
184
|
<span className="spacer" />
|
|
185
185
|
{options.fileUpload && <input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />}
|
|
186
186
|
{options.fileUpload && !disabled && (
|
|
187
|
-
<button className="hint" style={{ cursor: "pointer" }} title=
|
|
187
|
+
<button className="hint" style={{ cursor: "pointer" }} title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`} onMouseDown={(e) => { e.preventDefault(); fileRef.current?.click(); }}>{Icon.clip()} {options.fileButtonText}</button>
|
|
188
188
|
)}
|
|
189
189
|
</div>}
|
|
190
190
|
</div>
|