@spzxg/cli-ui 0.6.6 → 0.6.8
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/CHANGELOG.md +40 -1
- package/README.md +90 -21
- package/dist/cli-ui-embed-Be9_d9jB.js +49691 -0
- package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-CzNfiynu.js} +2 -2
- package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-BL0mSekN.js} +2 -2
- package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-DjCIAfse.js} +1 -1
- package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-CscI4xk0.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +259 -132
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -76
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +10 -8
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +14 -39
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +38 -6
- package/src/chat/CliChat.tsx +121 -51
- package/src/chat/Composer.tsx +54 -22
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +143 -46
- package/src/chat/draft.ts +12 -4
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +1 -1
- package/src/core/blocks.ts +2 -1
- package/src/core/client.ts +65 -61
- package/src/core/config.ts +6 -2
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/store.ts +81 -20
- package/src/core/types.ts +3 -1
- package/src/index.ts +4 -0
- package/src/msglist/CliMsgList.tsx +9 -2
- package/src/operator/node/Chat.tsx +11 -9
- package/src/operator/node/Composer.tsx +103 -75
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +31 -28
- package/src/operator/node/Transcript.tsx +21 -9
- package/src/operator/node/boardBackend.ts +0 -19
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/useTaskStream.ts +3 -1
- package/src/operator.base.css +150 -43
- package/src/operator.css +150 -43
- package/src/styles.css +253 -39
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +25 -12
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +20 -10
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
package/src/board/textLoad.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// 预览里文本文件(代码、日志、Markdown、CSV)的读取。
|
|
2
2
|
//
|
|
3
3
|
// 能读多大:一次最多 max 字节(预览里是 32 MB),更大的只看开头或末尾。
|
|
4
|
-
//
|
|
5
|
-
// 这一小段和手里的末尾一模一样就说明文件只是往后追加了,只取新增的部分接上;对不上(文件被改写、变短了)才整个重读。
|
|
6
|
-
// 只对 UTF-8 文件这样做(GBK 的字节边界不好对齐,整个重读)。
|
|
4
|
+
// 新协议以不可变修订和内容块进行同步;旧后端不能通过尾部重叠推断整份文件未变化。
|
|
7
5
|
|
|
8
6
|
import type { BoardBackend } from "../core/client";
|
|
9
7
|
import { decodeText } from "../core/textDecode";
|
|
8
|
+
import { syncFile } from "./fileSync";
|
|
10
9
|
|
|
11
10
|
/** 往前多取这么多字节,用来确认文件只是追加。 */
|
|
12
11
|
export const OVERLAP = 256;
|
|
13
12
|
/** 追加的段落多了合成一段(每段是 <pre> 里的一个文字节点)。 */
|
|
14
|
-
|
|
13
|
+
|
|
15
14
|
|
|
16
15
|
export interface LoadedText {
|
|
17
16
|
text: string;
|
|
@@ -24,6 +23,7 @@ export interface LoadedText {
|
|
|
24
23
|
/** 读的是末尾。 */
|
|
25
24
|
tail: boolean;
|
|
26
25
|
encoding?: string;
|
|
26
|
+
revision?: string;
|
|
27
27
|
/** 读到的内容在文件里的字节范围 [start, end)。 */
|
|
28
28
|
start: number;
|
|
29
29
|
end: number;
|
|
@@ -44,11 +44,16 @@ export function incompleteUtf8Tail(bytes: Uint8Array) {
|
|
|
44
44
|
return 0;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
|
|
48
48
|
|
|
49
49
|
/** 第一次读:开头(或末尾)最多 max 字节。 */
|
|
50
|
-
export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void; signal?: AbortSignal }): Promise<LoadedText> {
|
|
50
|
+
export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void; signal?: AbortSignal; preview?: boolean; fresh?: boolean }): Promise<LoadedText> {
|
|
51
51
|
if (opts.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
|
|
52
|
+
if (backend.fileSync) {
|
|
53
|
+
const result = await syncFile(backend.fileSync, jobId, path, { signal: opts.signal, preview: opts.preview, fresh: opts.fresh, range: { maxBytes: opts.max, ...(opts.tail ? { tail: true } : {}) }, onProgress: progress => opts.onBytes?.(progress.loadedBytes) });
|
|
54
|
+
const text = result.text ?? decodeText(result.bytes, { fromMiddle: result.start > 0 }).text;
|
|
55
|
+
return { text, parts: [text], truncated: result.truncated, size: result.size, tail: result.start > 0, encoding: result.encoding, revision: result.revision, start: result.start, end: result.start + result.bytes.length, tailBytes: null };
|
|
56
|
+
}
|
|
52
57
|
if (!backend.readBytes) {
|
|
53
58
|
const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
|
|
54
59
|
return { text: r.text, parts: [r.text], truncated: r.truncated, size: r.size, tail: !!r.tail, encoding: r.encoding, start: 0, end: 0, tailBytes: null };
|
|
@@ -64,38 +69,8 @@ export async function loadText(backend: BoardBackend, jobId: string, path: strin
|
|
|
64
69
|
};
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
* 只看开头且文件比 max 大时,开头没变就不用重读(追加的在后面,本来就不显示)。
|
|
70
|
-
*/
|
|
71
|
-
export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number, options: { signal?: AbortSignal } = {}): Promise<LoadedText | null> {
|
|
72
|
+
/** Every refresh verifies a revision. A matching tail cannot prove the rest stayed unchanged. */
|
|
73
|
+
export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number, options: { signal?: AbortSignal; preview?: boolean; fresh?: boolean } = {}): Promise<LoadedText | null> {
|
|
72
74
|
if (options.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
|
|
73
|
-
|
|
74
|
-
const overlap = prev.tailBytes.length;
|
|
75
|
-
const from = prev.end - overlap;
|
|
76
|
-
const headOnly = !prev.tail && prev.truncated;
|
|
77
|
-
// 末尾模式攒多了(超过 1.5 倍):整个重读末尾,免得越攒越大。
|
|
78
|
-
if (prev.tail && prev.end - prev.start > max * 1.5) return null;
|
|
79
|
-
const room = headOnly ? 0 : prev.tail ? max : Math.max(0, max - (prev.end - prev.start));
|
|
80
|
-
const chunk = await backend.readBytes(jobId, path, { start: from, maxBytes: overlap + room, signal: options.signal });
|
|
81
|
-
if (chunk.size < prev.end || chunk.start !== from || chunk.bytes.length < overlap) return null;
|
|
82
|
-
if (!sameBytes(chunk.bytes.subarray(0, overlap), prev.tailBytes)) return null;
|
|
83
|
-
const readEnd = from + chunk.bytes.length;
|
|
84
|
-
if (headOnly) return { ...prev, size: chunk.size };
|
|
85
|
-
const added = chunk.bytes.subarray(overlap);
|
|
86
|
-
const k = incompleteUtf8Tail(added);
|
|
87
|
-
const delta = added.subarray(0, added.length - k);
|
|
88
|
-
let addedText: string;
|
|
89
|
-
try { addedText = new TextDecoder("utf-8", { fatal: true }).decode(delta); } catch { return null; }
|
|
90
|
-
const end = prev.end + delta.length;
|
|
91
|
-
const tailBytes = new Uint8Array(Math.min(OVERLAP, overlap + delta.length));
|
|
92
|
-
const joined = new Uint8Array(overlap + delta.length);
|
|
93
|
-
joined.set(prev.tailBytes, 0);
|
|
94
|
-
joined.set(delta, overlap);
|
|
95
|
-
tailBytes.set(joined.subarray(joined.length - tailBytes.length));
|
|
96
|
-
const parts = addedText ? (prev.parts.length >= MAX_PARTS ? [prev.text, addedText] : [...prev.parts, addedText]) : prev.parts;
|
|
97
|
-
return {
|
|
98
|
-
...prev, text: addedText ? prev.text + addedText : prev.text, parts, size: chunk.size, end, tailBytes,
|
|
99
|
-
truncated: prev.tail || chunk.size > readEnd,
|
|
100
|
-
};
|
|
75
|
+
return backend.fileSync ? loadText(backend, jobId, path, { max, tail: prev.tail, signal: options.signal, preview: options.preview, fresh: options.fresh }) : null;
|
|
101
76
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { clientId } from "../core/id";
|
|
3
|
+
import type { DocumentReceipt, UploadDocument, UploadPhase } from "../core/documentUpload";
|
|
4
|
+
import { FILE_UPLOAD_ACCEPT } from "../core/documentUpload";
|
|
5
|
+
import { UploadProgress } from "../ui/UploadProgress";
|
|
6
|
+
|
|
7
|
+
type PendingUpload = { file: File; phase: UploadPhase | "ready" | "error"; bytes: number; error?: string };
|
|
8
|
+
|
|
9
|
+
/** Uploading a file only changes the current workspace. It never submits a message. */
|
|
10
|
+
export function useBoardUpload(scope: string | null, upload: UploadDocument | undefined, onUploaded: (receipt: DocumentReceipt) => void) {
|
|
11
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
12
|
+
const active = useRef<AbortController | null>(null);
|
|
13
|
+
const committedScope = useRef(scope);
|
|
14
|
+
const [pending, setPending] = useState<PendingUpload | null>(null);
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
committedScope.current = scope;
|
|
17
|
+
setPending(null);
|
|
18
|
+
return () => { active.current?.abort(); active.current = null; };
|
|
19
|
+
}, [scope, upload]);
|
|
20
|
+
|
|
21
|
+
const start = (file: File) => {
|
|
22
|
+
if (!scope || !upload || active.current) return;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
active.current = controller;
|
|
25
|
+
const current = () => active.current === controller && committedScope.current === scope && !controller.signal.aborted;
|
|
26
|
+
setPending({ file, phase: "queued", bytes: 0 });
|
|
27
|
+
void Promise.resolve().then(() => upload(file, {
|
|
28
|
+
draftId: clientId(), signal: controller.signal,
|
|
29
|
+
onPhase: phase => { if (current()) setPending(previous => previous ? { ...previous, phase } : previous); },
|
|
30
|
+
onBytes: bytes => { if (current()) setPending(previous => previous ? { ...previous, bytes } : previous); },
|
|
31
|
+
})).then(receipt => {
|
|
32
|
+
if (!current()) return;
|
|
33
|
+
if (!receipt.path) throw new Error("文件尚未进入当前工作区,请稍后重试");
|
|
34
|
+
setPending({ file, phase: "ready", bytes: file.size });
|
|
35
|
+
onUploaded(receipt);
|
|
36
|
+
}).catch(error => {
|
|
37
|
+
if (current()) setPending(previous => previous ? { ...previous, phase: "error", error: error instanceof Error ? error.message : "上传失败,请重试" } : previous);
|
|
38
|
+
}).finally(() => { if (active.current === controller) active.current = null; });
|
|
39
|
+
};
|
|
40
|
+
const dismiss = () => { active.current?.abort(); active.current = null; setPending(null); };
|
|
41
|
+
const busy = !!pending && pending.phase !== "ready" && pending.phase !== "error";
|
|
42
|
+
return {
|
|
43
|
+
busy,
|
|
44
|
+
choose: () => { if (!busy) inputRef.current?.click(); },
|
|
45
|
+
input: <input ref={inputRef} type="file" hidden accept={FILE_UPLOAD_ACCEPT} aria-label="上传文件到工作区"
|
|
46
|
+
onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; if (file) start(file); }} />,
|
|
47
|
+
progress: pending && <div className="cli-board-upload"><UploadProgress name={pending.file.name} status={pending.phase}
|
|
48
|
+
bytes={pending.bytes} total={pending.file.size} error={pending.error} onCancel={dismiss} onRemove={dismiss}
|
|
49
|
+
onRetry={() => start(pending.file)} /></div>,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
const useLayout = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
4
|
+
|
|
5
|
+
/** Observe this board's ancestor chain, including host CSS that hides a pane. */
|
|
6
|
+
export function useBoardVisibility(ref: RefObject<HTMLElement | null>, enabled: boolean) {
|
|
7
|
+
const [visible, setVisible] = useState(true);
|
|
8
|
+
useLayout(() => {
|
|
9
|
+
if (!enabled || !ref.current) return;
|
|
10
|
+
const ancestors: HTMLElement[] = [];
|
|
11
|
+
for (let node: HTMLElement | null = ref.current; node; node = node.parentElement) ancestors.push(node);
|
|
12
|
+
let pending = false, alive = true;
|
|
13
|
+
const read = () => {
|
|
14
|
+
pending = false;
|
|
15
|
+
if (!alive) return;
|
|
16
|
+
setVisible(ancestors.every(node => {
|
|
17
|
+
const style = getComputedStyle(node);
|
|
18
|
+
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && style.contentVisibility !== "hidden";
|
|
19
|
+
}));
|
|
20
|
+
};
|
|
21
|
+
const schedule = () => {
|
|
22
|
+
if (pending) return;
|
|
23
|
+
pending = true;
|
|
24
|
+
queueMicrotask(read);
|
|
25
|
+
};
|
|
26
|
+
const mutations = new MutationObserver(schedule);
|
|
27
|
+
for (const node of ancestors) mutations.observe(node, { attributes: true, attributeFilter: ["class", "style", "hidden"] });
|
|
28
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
|
|
29
|
+
resize?.observe(ref.current);
|
|
30
|
+
window.addEventListener("resize", schedule);
|
|
31
|
+
read();
|
|
32
|
+
return () => { alive = false; mutations.disconnect(); resize?.disconnect(); window.removeEventListener("resize", schedule); };
|
|
33
|
+
}, [ref, enabled]);
|
|
34
|
+
return visible;
|
|
35
|
+
}
|
package/src/board/workspace.ts
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 数据都经中枢读执行节点(和看板一样),弱网时按网络错误(CenterError,code = "NETWORK")失败,可以重试。
|
|
13
13
|
*/
|
|
14
|
-
import { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./fileWatch";
|
|
14
|
+
import { retryWorkspaceFiles, subscribeWorkspaceFiles, type WorkspaceWatchOptions, type WorkspaceRefreshOptions } from "./fileWatch";
|
|
15
15
|
import type { BoardBackend } from "../core/client";
|
|
16
|
-
import type { BoardInfo, FileEntry, WorkspaceChangeEvent } from "../core/types";
|
|
16
|
+
import type { BoardInfo, FileEntry, DirListing, WorkspaceChangeEvent } from "../core/types";
|
|
17
17
|
import { directFileUrl, downloadFile, mimeOf } from "./fileOps";
|
|
18
18
|
import { fmtSize } from "./fileKind";
|
|
19
|
+
import { syncFile, FILE_SYNC_MAX } from "./fileSync";
|
|
20
|
+
import { isOriginalDocument } from "../core/documentUpload";
|
|
21
|
+
import { loadText } from "./textLoad";
|
|
19
22
|
import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload";
|
|
20
23
|
|
|
21
24
|
/** readText 最多读这么大(更大的用 readFile)。 */
|
|
@@ -55,9 +58,13 @@ export interface CliWorkspace {
|
|
|
55
58
|
stat(path: string): ReturnType<BoardBackend["stat"]>;
|
|
56
59
|
/** 订阅文件变更。初始快照只建立基线;返回取消函数。 */
|
|
57
60
|
watch(listener: (event: WorkspaceChangeEvent) => void, options?: WorkspaceWatchOptions): () => void;
|
|
61
|
+
/** 请求一次增量检查。轮末回调用无参;用户明确重试可传 manual:true(仍遵守服务端冷却)。不创建连接或周期轮询。 */
|
|
62
|
+
refresh(options?: WorkspaceRefreshOptions): void;
|
|
58
63
|
/** 包括当前文件、打开页签、最近浏览和显示状态的背景快照。 */
|
|
59
64
|
context(): BoardView | null;
|
|
60
|
-
/**
|
|
65
|
+
/** 按需读取单层目录的一页;nextCursor 非空时还有后续页。 */
|
|
66
|
+
listPage(dir?: string, opts?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
|
|
67
|
+
/** 显式列出一个目录(不递归,最多10000项);界面首屏推荐 listPage。 */
|
|
61
68
|
list(dir?: string): Promise<FileEntry[]>;
|
|
62
69
|
/** 列 dir 下所有文件(递归,只含文件)。 */
|
|
63
70
|
listAll(dir?: string, opts?: { signal?: AbortSignal }): Promise<FileEntry[]>;
|
|
@@ -100,11 +107,27 @@ export function createWorkspace(deps: {
|
|
|
100
107
|
info: () => backend().info(key),
|
|
101
108
|
stat: path => backend().stat(key, clean(path) || "."),
|
|
102
109
|
watch: (listener, options) => deps.watch ? deps.watch(listener,options) : subscribeWorkspaceFiles(backend(), key, listener, options),
|
|
110
|
+
refresh: options => options ? retryWorkspaceFiles(backend(), key, options) : retryWorkspaceFiles(backend(), key),
|
|
103
111
|
context: () => deps.context?.() ?? deps.view() ?? null,
|
|
112
|
+
async listPage(dir = "", opts = {}) {
|
|
113
|
+
const api = backend();
|
|
114
|
+
const result = api.listFiles ? await api.listFiles(key, clean(dir) || ".", opts) : await api.stat(key, clean(dir) || ".");
|
|
115
|
+
if (result.type !== "dir") throw new Error(`「${dir}」不是目录`);
|
|
116
|
+
return result;
|
|
117
|
+
},
|
|
104
118
|
async list(dir = "") {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
const api = backend(), entries: FileEntry[] = [], seen = new Set<string>();
|
|
120
|
+
let cursor: string | undefined;
|
|
121
|
+
do {
|
|
122
|
+
const result = api.listFiles ? await api.listFiles(key, clean(dir) || ".", { cursor, limit: 200 }) : await api.stat(key, clean(dir) || ".");
|
|
123
|
+
if (result.type !== "dir") throw new Error(`「${dir}」不是目录`);
|
|
124
|
+
entries.push(...result.entries);
|
|
125
|
+
if (entries.length > WORKSPACE_LIST_MAX) throw new Error("目录超过10000项,请用 listPage 按需读取");
|
|
126
|
+
cursor = result.nextCursor;
|
|
127
|
+
if ((result.truncated && (!cursor || !api.listFiles)) || (cursor && seen.has(cursor))) throw new Error("目录列表不完整,请使用支持分页的服务端");
|
|
128
|
+
if (cursor) seen.add(cursor);
|
|
129
|
+
} while (cursor);
|
|
130
|
+
return entries;
|
|
108
131
|
},
|
|
109
132
|
async listAll(dir = "", opts = {}) {
|
|
110
133
|
const files = await listFolder(backend(), key, clean(dir), { signal: opts.signal, maxFiles: WORKSPACE_LIST_MAX, maxBytes: Infinity });
|
|
@@ -115,6 +138,10 @@ export function createWorkspace(deps: {
|
|
|
115
138
|
if (signal?.aborted) throw new DOMException("已取消", "AbortError");
|
|
116
139
|
let rejectAbort: (() => void) | undefined;
|
|
117
140
|
try {
|
|
141
|
+
if (backend().fileSync && isOriginalDocument(path)) {
|
|
142
|
+
const result = await loadText(backend(), key, clean(path), { max: FILE_SYNC_MAX, signal });
|
|
143
|
+
return result.text;
|
|
144
|
+
}
|
|
118
145
|
const reading = signal ? backend().readText(key, clean(path), WORKSPACE_TEXT_MAX, { signal }) : backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
|
|
119
146
|
const r = signal ? await Promise.race([reading, new Promise<never>((_resolve, reject) => {
|
|
120
147
|
rejectAbort = () => reject(new DOMException("已取消", "AbortError"));
|
|
@@ -129,6 +156,11 @@ export function createWorkspace(deps: {
|
|
|
129
156
|
const p = clean(path);
|
|
130
157
|
const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
|
|
131
158
|
if(!Number.isFinite(max)||max<0)throw new RangeError("maxBytes 必须是非负有限字节数");
|
|
159
|
+
if (backend().fileSync && isOriginalDocument(p)) {
|
|
160
|
+
const result = await syncFile(backend().fileSync!, key, p, { signal: opts.signal, decode: false, range: { maxBytes: Math.min(max, FILE_SYNC_MAX) }, onProgress: progress => opts.onProgress?.(progress.loadedBytes) });
|
|
161
|
+
if (result.truncated) throw new Error(`文件超过 ${fmtSize(max)},请用 fileUrl / download`);
|
|
162
|
+
return new Blob([result.bytes], { type: mimeOf(p) });
|
|
163
|
+
}
|
|
132
164
|
// 太大的读到一半就停:整个读进内存会把页面撑爆。
|
|
133
165
|
const ctl = new AbortController();
|
|
134
166
|
const stop = () => ctl.abort();
|
package/src/chat/CliChat.tsx
CHANGED
|
@@ -20,9 +20,22 @@ import { exportMarkdown } from "./export";
|
|
|
20
20
|
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
|
|
21
21
|
import { FileViewer } from "../board/FileViewer";
|
|
22
22
|
import { BrowserPane } from "../board/BrowserPane";
|
|
23
|
+
import { CliBoard } from "../board/CliBoard";
|
|
24
|
+
import { WorkspaceTabs } from "../ui/WorkspaceTabs";
|
|
25
|
+
import { WorkspaceDrawer, useWorkspaceDrawer } from "../ui/WorkspaceDrawer";
|
|
26
|
+
import { useChatScrollbar } from "../ui/useChatScrollbar";
|
|
27
|
+
import { QueuedMessages } from "../ui/QueuedMessages";
|
|
28
|
+
import { ReferenceCard } from "../ui/ReferenceCard";
|
|
29
|
+
import type { UploadDocument } from "../core/documentUpload";
|
|
30
|
+
import { GroupChat, type GroupChatAdapter } from "./GroupChat";
|
|
31
|
+
import { RunControls } from "./RunControls";
|
|
32
|
+
import { useExecutionConfig } from "./useExecutionConfig";
|
|
33
|
+
import { CenterError } from "../core/client";
|
|
23
34
|
|
|
24
35
|
/** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
|
|
25
36
|
export interface CliChatProps extends CliStoreOptions, ResultHandlers {
|
|
37
|
+
/** 宿主提供的多发言人群聊;发送统一交给 group.onSend,不连接普通单任务转录。 */
|
|
38
|
+
group?: GroupChatAdapter;
|
|
26
39
|
/** Integration slots share this transcript/composer; they do not create another chat implementation. */
|
|
27
40
|
slots?: { beforeMessages?: ReactNode; afterMessages?: ReactNode; composerToolbar?: ReactNode; beforeComposer?: ReactNode };
|
|
28
41
|
/** Saving execution settings blocks send until persistence completes. */
|
|
@@ -77,6 +90,7 @@ export interface CliChatProps extends CliStoreOptions, ResultHandlers {
|
|
|
77
90
|
|
|
78
91
|
export function CliChat(props: CliChatProps) {
|
|
79
92
|
const ctx = useCliStoreContext();
|
|
93
|
+
if (props.group) return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <GroupChat key={props.group.scopeKey} group={props.group} theme={props.theme} className={props.className} style={props.style} readOnly={props.readOnly} />, !!ctx)}</CliErrorBoundary>;
|
|
80
94
|
return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
81
95
|
}
|
|
82
96
|
|
|
@@ -143,6 +157,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
143
157
|
// 内部用对话键(key):只有 job_id = 对话任务的第一个对话;"job#task" = 里面指定的那个对话(见 core/conversation.ts)。
|
|
144
158
|
// 新对话的草稿按组件实例存(对话的草稿按对话键存,看同一个对话的组件共用一份是对的)。
|
|
145
159
|
const instanceId = useId();
|
|
160
|
+
const inlineOwner = `chat-board-${instanceId}`;
|
|
146
161
|
const controlled = jobIdProp !== undefined;
|
|
147
162
|
// jobId 也可以直接给对话键(例如 cli-msglist 的 onSelect 给的 "job#task"),taskId 再给就以 taskId 为准。
|
|
148
163
|
const keyProp = jobIdProp === undefined ? undefined : jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null;
|
|
@@ -164,6 +179,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
164
179
|
}, [controlled, store]);
|
|
165
180
|
const conv = useCliState((s) => (key && !isNew ? s.convs[key] : undefined)) ?? (key && !isNew ? emptyConv(key) : null);
|
|
166
181
|
const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
|
|
182
|
+
const externalBoardVisible = useCliState(s => !!(key && s.externalBoards[key]));
|
|
183
|
+
const listVisible = useCliState(s => s.listVisible);
|
|
184
|
+
const externalBoardOptions = useCliState(s => key ? s.externalBoardOptions[key] : undefined);
|
|
185
|
+
const [tabsHost, setTabsHost] = useState<HTMLDivElement | null>(null);
|
|
186
|
+
const [workspace, setWorkspace] = useState<{ key: string | null; active: boolean }>({ key: null, active: false });
|
|
187
|
+
const showChat = useCallback(() => setWorkspace({ key, active: false }), [key]);
|
|
188
|
+
const showWorkspace = useCallback(() => setWorkspace({ key, active: true }), [key]);
|
|
167
189
|
|
|
168
190
|
// 看这个对话:开实时流(同一对话多处看只连一次)。
|
|
169
191
|
useEffect(() => (key ? store.open(key) : undefined), [store, key]);
|
|
@@ -210,7 +232,17 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
210
232
|
const task = (shownTaskId ? job?.tasks?.find((t) => t.id === shownTaskId) : undefined) ?? (shownTaskId || batch ? undefined : job?.tasks?.[0]);
|
|
211
233
|
const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
|
|
212
234
|
const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
|
|
213
|
-
const
|
|
235
|
+
const draftScope = store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`;
|
|
236
|
+
const initialOptions = newConversation ?? store.getNewConversationOptions();
|
|
237
|
+
const execution = useExecutionConfig({ client: store.client, scope: draftScope,
|
|
238
|
+
target: key && !isNew ? key : null, running: running || batchPending,
|
|
239
|
+
enabled: showComposer && (cfg.composer.agentSelector || cfg.composer.executionMode),
|
|
240
|
+
initial: { ...(initialOptions?.agent ? { agent: initialOptions.agent } : {}), ...initialOptions?.executionConfig },
|
|
241
|
+
});
|
|
242
|
+
const agentText = labelOf(execution.config?.agent ?? agent);
|
|
243
|
+
const executionAgent = execution.config?.agents.find(item => item.id === execution.config?.agent);
|
|
244
|
+
const executionReadOnly = !!key && !isNew && !store.client.setExecutionConfig;
|
|
245
|
+
const settingsBusy = !!configurationBusy || execution.blocked;
|
|
214
246
|
const jobStatus = conv?.job?.status ?? job?.status ?? null;
|
|
215
247
|
// 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
|
|
216
248
|
// 支持续派的普通子任务同理:大任务结束不挡输入框。
|
|
@@ -287,11 +319,11 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
287
319
|
// 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
|
|
288
320
|
const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
|
|
289
321
|
useLayoutEffect(() => { sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled }; });
|
|
290
|
-
const
|
|
291
|
-
const
|
|
292
|
-
useLayoutEffect(()=>{
|
|
293
|
-
useEffect(()=>setSkipPassive(false),[key]);
|
|
322
|
+
const prepareExecution = useRef(execution.prepareSend);
|
|
323
|
+
const reloadExecution = useRef(execution.reload);
|
|
324
|
+
useLayoutEffect(() => { prepareExecution.current = execution.prepareSend; reloadExecution.current = execution.reload; });
|
|
294
325
|
const send = useCallback(async (text: string) => {
|
|
326
|
+
const executionOptions = prepareExecution.current();
|
|
295
327
|
// 一次性任务:发给正在看的那个子任务(接着它聊)。
|
|
296
328
|
const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
|
|
297
329
|
// 看板里引用到这个对话的东西:合成一个「看板上下文」块跟着这条消息发出去;发失败就放回去。
|
|
@@ -299,7 +331,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
299
331
|
const refKey = latest.current.key;
|
|
300
332
|
const refs = refKey ? store.takeRefs(refKey) : [];
|
|
301
333
|
const passiveView=refKey?store.getState().boardContext[refKey]:null;
|
|
302
|
-
const passive=passiveContext
|
|
334
|
+
const passive=passiveContext&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
|
|
303
335
|
const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
|
|
304
336
|
const blocks = [
|
|
305
337
|
...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
|
|
@@ -309,13 +341,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
309
341
|
const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
|
|
310
342
|
let target: string;
|
|
311
343
|
try {
|
|
312
|
-
if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("
|
|
313
|
-
target = await store.send(id ?? null, full, sendOpts.current);
|
|
344
|
+
if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简消息内容后重试。");
|
|
345
|
+
target = await store.send(id ?? null, full, { ...sendOpts.current, ...executionOptions });
|
|
314
346
|
} catch (error) {
|
|
315
347
|
if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
|
|
348
|
+
if (error instanceof CenterError && error.code === "execution_config_conflict" && latest.current.key === refKey) await reloadExecution.current();
|
|
316
349
|
throw error;
|
|
317
350
|
}
|
|
318
|
-
if(latest.current.key===refKey)setSkipPassive(false);
|
|
319
351
|
if (target !== id && latest.current.key === refKey) go(target);
|
|
320
352
|
}, [store, go, passiveContext]);
|
|
321
353
|
const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
|
|
@@ -326,6 +358,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
326
358
|
// 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
|
|
327
359
|
const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
328
360
|
const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
|
|
361
|
+
const chatRef = useRef<HTMLDivElement>(null);
|
|
329
362
|
// 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
|
|
330
363
|
const contextItems = useCliState((s) => s.context[key ?? ""]);
|
|
331
364
|
// AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
|
|
@@ -340,8 +373,8 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
340
373
|
if (!seen.ids) { seen.ids = new Set(handoffs.map((m) => m.id)); return; }
|
|
341
374
|
const fresh = handoffs.filter((m) => !seen.ids!.has(m.id));
|
|
342
375
|
for (const m of fresh) seen.ids.add(m.id);
|
|
343
|
-
if (fresh.length) store.openInBoard(key, { kind: "browser" });
|
|
344
|
-
}, [key, conv, fileOpen, store]);
|
|
376
|
+
if (fresh.length) store.openInBoard(key, { kind: "browser" }, inlineOwner);
|
|
377
|
+
}, [key, conv, fileOpen, store, inlineOwner]);
|
|
345
378
|
|
|
346
379
|
// 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
|
|
347
380
|
const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
|
|
@@ -353,7 +386,12 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
353
386
|
return () => window.removeEventListener("keydown", onKey);
|
|
354
387
|
}, [popup]);
|
|
355
388
|
const handlers = useMemo<MessageHandlers>(() => ({
|
|
356
|
-
onQuote: readOnly ? undefined : (m,selected) => {
|
|
389
|
+
onQuote: readOnly ? undefined : (m,selected) => {
|
|
390
|
+
const id = latest.current.key;
|
|
391
|
+
if (!id) return;
|
|
392
|
+
store.addRef(id,{id:`message:${m.id}`,kind:"message",label:m.role==='user'?'引用你的消息':'引用智能体消息',text:selected||(m.role==='user'?withoutBoardContext(m.content):m.content)});
|
|
393
|
+
chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
|
|
394
|
+
},
|
|
357
395
|
// 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
|
|
358
396
|
onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
359
397
|
const id = latest.current.key;
|
|
@@ -385,7 +423,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
385
423
|
onShowBrowser: !store.client.board || fileOpen === "link" ? undefined : () => {
|
|
386
424
|
const id = latest.current.key;
|
|
387
425
|
if (!id) return;
|
|
388
|
-
if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" })) return;
|
|
426
|
+
if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" }, inlineOwner)) return;
|
|
389
427
|
if (fileOpen !== "board") setPopup({ kind: "browser" });
|
|
390
428
|
},
|
|
391
429
|
onRetry: readOnly ? undefined : (msgId: string) => {
|
|
@@ -393,7 +431,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
393
431
|
const at = list.findIndex((m) => m.id === msgId);
|
|
394
432
|
for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(withoutBoardContext(list[j].content)), "重试没有发出去").catch(() => undefined); return; }
|
|
395
433
|
},
|
|
396
|
-
}), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
|
|
434
|
+
}), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen, inlineOwner]);
|
|
397
435
|
const stop = useCallback(async () => {
|
|
398
436
|
const { key: id, conv: c } = latest.current;
|
|
399
437
|
const turn = c ? runningTurn(c) : null;
|
|
@@ -428,6 +466,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
428
466
|
|
|
429
467
|
// ── 分窗渲染(移植自 Operator 的 SessionView):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
|
|
430
468
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
469
|
+
useChatScrollbar(scrollRef, key);
|
|
431
470
|
const pinned = useRef(true);
|
|
432
471
|
const [atBottom, setAtBottom] = useState(true);
|
|
433
472
|
const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
|
|
@@ -569,7 +608,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
569
608
|
if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
|
|
570
609
|
}
|
|
571
610
|
}, [win]);
|
|
572
|
-
useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx]);
|
|
611
|
+
useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx, win]);
|
|
573
612
|
useEffect(() => {
|
|
574
613
|
const el = scrollRef.current;
|
|
575
614
|
if (!el || typeof ResizeObserver === "undefined") return;
|
|
@@ -601,6 +640,18 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
601
640
|
const fileUrlRef = useRef(fileUrl);
|
|
602
641
|
fileUrlRef.current = fileUrl;
|
|
603
642
|
const board = store.client.board;
|
|
643
|
+
const mobileWorkspace = useWorkspaceDrawer(chatRef);
|
|
644
|
+
const workspaceAvailable = h.show && !!key && !isNew && !!board && !externalBoardVisible;
|
|
645
|
+
const workspaceTabs = workspaceAvailable && !mobileWorkspace;
|
|
646
|
+
const workspaceActive = workspaceAvailable && workspace.key === key && workspace.active;
|
|
647
|
+
const showCurrentTitle = h.title || externalBoardVisible || (mobileWorkspace && workspaceAvailable);
|
|
648
|
+
const quoteToChat = useCallback(() => {
|
|
649
|
+
const sourceKey = key;
|
|
650
|
+
showChat();
|
|
651
|
+
requestAnimationFrame(() => {
|
|
652
|
+
if (keyRef.current === sourceKey) chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
|
|
653
|
+
});
|
|
654
|
+
}, [key, showChat]);
|
|
604
655
|
// 文件地址:宿主给的 fileUrl 优先;没给且后端有看板接口、地址能直接打开时,用 center 的文件接口。
|
|
605
656
|
const resolveFile = useMemo(() => {
|
|
606
657
|
if (fileUrl) return (path: string, kind: "link" | "image") => (jobId ? fileUrlRef.current?.(path, jobId, kind, taskId) ?? null : null);
|
|
@@ -611,27 +662,44 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
611
662
|
const openFile = useMemo(() => {
|
|
612
663
|
if (!key || fileOpen === "link") return null;
|
|
613
664
|
return (path: string) => {
|
|
614
|
-
if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path })) return true;
|
|
665
|
+
if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path }, inlineOwner)) return true;
|
|
615
666
|
if (fileOpen === "board" || !board) return false;
|
|
616
667
|
setPopup({ kind: "file", path });
|
|
617
668
|
return true;
|
|
618
669
|
};
|
|
619
|
-
}, [key, fileOpen, store, board]);
|
|
670
|
+
}, [key, fileOpen, store, board, inlineOwner]);
|
|
671
|
+
const uploadDocument = useMemo<UploadDocument | undefined>(() => {
|
|
672
|
+
const staging = store.client.uploadDocument;
|
|
673
|
+
if (!key || isNew || !board?.uploadFile) return staging;
|
|
674
|
+
return async (file, options) => {
|
|
675
|
+
const target = await board.info(key);
|
|
676
|
+
if (options.signal?.aborted) throw new DOMException("上传已取消", "AbortError");
|
|
677
|
+
if (!target.dispatched) {
|
|
678
|
+
if (staging) return staging(file, options);
|
|
679
|
+
throw new Error("当前会话工作区尚未就绪");
|
|
680
|
+
}
|
|
681
|
+
const receipt = await board.uploadFile!(key, file, options);
|
|
682
|
+
if (!receipt.path) throw new Error("文件尚未进入当前工作区,请重试");
|
|
683
|
+
if (!options.signal?.aborted && latest.current.key === key) openFile?.(receipt.path);
|
|
684
|
+
return receipt;
|
|
685
|
+
};
|
|
686
|
+
}, [store, key, isNew, board, openFile]);
|
|
620
687
|
|
|
621
688
|
return (
|
|
622
689
|
<FileLinkContext.Provider value={resolveFile}>
|
|
623
690
|
<FileOpenContext.Provider value={openFile}>
|
|
624
|
-
<div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
691
|
+
<div ref={chatRef} className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
625
692
|
<div className="session">
|
|
626
|
-
{h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
|
|
627
|
-
{(h.crumb ||
|
|
693
|
+
{h.show && (workspaceAvailable || externalBoardVisible || h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
|
|
694
|
+
{workspaceTabs ? <WorkspaceTabs title={convTitle} subtitle={h.crumb ? title : undefined} chatActive={!workspaceActive} onChat={showChat} onTabsHost={setTabsHost} /> : (h.crumb || showCurrentTitle) && <div className="sh-main">
|
|
628
695
|
{h.crumb && <div className="crumb">
|
|
629
696
|
<span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{title[0]}</span>
|
|
630
697
|
{title} <span className="sep">/</span> 对话
|
|
631
698
|
</div>}
|
|
632
|
-
{
|
|
699
|
+
{showCurrentTitle && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
|
|
633
700
|
</div>}
|
|
634
701
|
<div className="sh-tools">
|
|
702
|
+
{mobileWorkspace && workspaceAvailable && <button type="button" className="btn btn-ghost btn-sm workspace-open" aria-label="打开工作区" aria-haspopup="dialog" aria-expanded={workspaceActive} title="打开工作区" onClick={showWorkspace}>{Icon.board()}</button>}
|
|
635
703
|
{h.agent && agent && <AgentBadge label={agentText} />}
|
|
636
704
|
{h.usage && (fresh > 0 || cacheRead > 0) && (
|
|
637
705
|
<span className="usage-chip" title={`新增 ${fresh.toLocaleString()} tokens${cacheRead ? `\n缓存读取 ${cacheRead.toLocaleString()}(每轮重复读取的上下文)` : ""}`}>
|
|
@@ -661,6 +729,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
661
729
|
</div>
|
|
662
730
|
</div>}
|
|
663
731
|
|
|
732
|
+
<div className="workspace-chat-content" data-chat-layout={listVisible || externalBoardVisible ? "wide" : "centered"} hidden={workspaceActive && !mobileWorkspace} style={workspaceActive && !mobileWorkspace ? { display: "none" } : undefined}>
|
|
664
733
|
{closing !== "idle" && key && (
|
|
665
734
|
<div className="clear-confirm">
|
|
666
735
|
<span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
|
|
@@ -737,7 +806,6 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
737
806
|
</div>
|
|
738
807
|
)}
|
|
739
808
|
{cfg.transcript.runningIndicator && running && !awaiting && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
740
|
-
{!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
|
|
741
809
|
{ended && cfg.transcript.status && (
|
|
742
810
|
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
|
743
811
|
)}
|
|
@@ -763,42 +831,36 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
763
831
|
))}
|
|
764
832
|
</div>
|
|
765
833
|
)}
|
|
766
|
-
{showComposer && (!!shownRefs?.length || !!viewing) && (
|
|
767
|
-
<div className="cli-refs" aria-label="引用自看板,下一条消息会带上">
|
|
768
|
-
<span className="cli-refs-t">{Icon.board()} 引用自看板</span>
|
|
769
|
-
{viewing && (
|
|
770
|
-
<span className="cli-ref auto" title={`${viewing.text}\n(看板上正在看,下一条消息缺省带上;点 × 这次不带)`}>
|
|
771
|
-
<span className="cli-ref-k">正在看</span>
|
|
772
|
-
<span className="cli-ref-l">{viewing.label}</span>
|
|
773
|
-
<button className="cli-ref-x" onClick={() => key && store.dismissViewing(key)} title="这次不带上" aria-label={`不带上 ${viewing.label}`}>{Icon.x()}</button>
|
|
774
|
-
</span>
|
|
775
|
-
)}
|
|
776
|
-
{(shownRefs ?? []).map((r) => (
|
|
777
|
-
<span key={r.id} className={`cli-ref ${r.kind === "selection" ? "sel" : ""}`} title={r.text}>
|
|
778
|
-
<span className="cli-ref-l">{r.label}</span>
|
|
779
|
-
<button className="cli-ref-x" onClick={() => key && store.removeRef(key, r.id)} title="不带上" aria-label={`不带上 ${r.label}`}>{Icon.x()}</button>
|
|
780
|
-
</span>
|
|
781
|
-
))}
|
|
782
|
-
</div>
|
|
783
|
-
)}
|
|
784
|
-
{showComposer && passiveText && passiveView && <div className="cli-board-background" role="note" aria-label="看板浏览背景">
|
|
785
|
-
<details><summary>看板背景 · {(passiveView.current?.path??passiveView.file)?.split('/').pop()??'未选中文件'} · 打开 {(passiveView.opened??passiveView.files).length} · 最近浏览 {passiveView.history?.length??0}</summary>
|
|
786
|
-
<div>{passiveView.visible===false?'看板已收起,将附带上次选中的文件及浏览记录。':'将附带当前文件、打开页签和最近浏览记录,仅发送路径等信息。'}</div>
|
|
787
|
-
<ul>{(passiveView.history??[]).map((file,index)=><li key={`${file.sourceId}:${file.path}:${index}`}>{file.path}{file.sourceId&&file.sourceId!==key?'(来源任务)':''}</li>)}</ul>
|
|
788
|
-
</details>
|
|
789
|
-
<label><input type="checkbox" checked={!skipPassive} onChange={event=>setSkipPassive(!event.target.checked)} />下条消息带入</label>
|
|
790
|
-
</div>}
|
|
791
834
|
{slots?.beforeComposer}
|
|
835
|
+
{showComposer && execution.error && <div className="control-error" role="status">{execution.error} {store.client.getExecutionConfig && <button type="button" className="btn btn-ghost btn-sm" disabled={execution.loading || execution.busy} onClick={() => void execution.reload()}>重新读取</button>}</div>}
|
|
836
|
+
{!showComposer && <QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} attachmentUrl={handlers.attachmentUrl} />}
|
|
792
837
|
{showComposer && (
|
|
793
838
|
<Composer
|
|
839
|
+
uploadDocument={uploadDocument}
|
|
794
840
|
key={key ?? "new"}
|
|
795
|
-
draft={
|
|
841
|
+
draft={draftScope}
|
|
842
|
+
context={(!!shownRefs?.length || !!viewing) && <div className="cli-refs composer-references" aria-label="下一条消息的引用">
|
|
843
|
+
{viewing && <ReferenceCard key={`${key}:viewing:${viewing.id}`} className="reference-viewing" title={`正在看${viewing.label}`} text={viewing.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${viewing.label}`} onRemove={() => { if (key) store.dismissViewing(key); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />}
|
|
844
|
+
{(shownRefs ?? []).map(ref => <ReferenceCard key={`${key}:${ref.id}`} title={ref.kind === "message" && ref.label === "引用你的消息" ? "你" : ref.kind === "message" && ref.label === "引用智能体消息" ? "智能体" : ref.label} text={ref.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${ref.label}`} onRemove={() => { if (key) store.removeRef(key, ref.id); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />)}
|
|
845
|
+
</div>}
|
|
796
846
|
placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
|
|
797
|
-
disabled={composerDisabled ||
|
|
847
|
+
disabled={composerDisabled || settingsBusy}
|
|
798
848
|
disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
|
|
799
849
|
running={running || batchPending}
|
|
800
|
-
toolbar={
|
|
801
|
-
|
|
850
|
+
toolbar={<>
|
|
851
|
+
<RunControls task={{ id: draftScope, agent: execution.config?.agent ?? agent ?? initialOptions?.agent ?? undefined,
|
|
852
|
+
model: execution.config?.model, reasoning: execution.config?.reasoning, permission_mode: execution.config?.permission_mode }}
|
|
853
|
+
agents={execution.config?.agents} caps={executionAgent?.capabilities} defaults={executionAgent?.defaults}
|
|
854
|
+
showAgent={cfg.composer.agentSelector} showPermission={cfg.composer.executionMode}
|
|
855
|
+
unavailableReason={executionReadOnly ? "当前接入仅支持读取执行设置。" : execution.error ?? (execution.loading ? "正在读取执行设置…" : undefined)}
|
|
856
|
+
disabled={composerDisabled || running || batchPending || !!configurationBusy || execution.loading || executionReadOnly}
|
|
857
|
+
onSetAgent={agent => execution.save({ agent, model: null, reasoning: null, permission_mode: null })}
|
|
858
|
+
onSetModel={model => execution.save({ model })} onSetReasoning={reasoning => execution.save({ reasoning })}
|
|
859
|
+
onSetPermission={permission_mode => execution.save({ permission_mode })} />
|
|
860
|
+
{slots?.composerToolbar}
|
|
861
|
+
</>}
|
|
862
|
+
queued={<QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} attachmentUrl={handlers.attachmentUrl} />}
|
|
863
|
+
onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } const sentFrom = key; await send(text); if (!running && !batchPending && latest.current.key === sentFrom) scrollToBottom("auto"); }}
|
|
802
864
|
onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
|
|
803
865
|
taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
|
|
804
866
|
paused: controlTask.status === "paused",
|
|
@@ -810,6 +872,14 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
810
872
|
options={composerOptions}
|
|
811
873
|
/>
|
|
812
874
|
)}
|
|
875
|
+
</div>
|
|
876
|
+
<WorkspaceDrawer enabled={mobileWorkspace} open={workspaceActive} onClose={showChat}>
|
|
877
|
+
{workspaceAvailable && <CliBoard jobId={key} inlineOwner={inlineOwner} quoteToChat={!readOnly} autoQuoteOpenFile={passiveContext}
|
|
878
|
+
readOnly={readOnly || externalBoardOptions?.readOnly} availableTabs={externalBoardOptions?.availableTabs}
|
|
879
|
+
acceptOpen={fileOpen !== "modal" && fileOpen !== "link"} theme={theme}
|
|
880
|
+
visible={workspaceActive} onExpand={showWorkspace} onQuoteToChat={quoteToChat}
|
|
881
|
+
inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
|
|
882
|
+
</WorkspaceDrawer>
|
|
813
883
|
</div>
|
|
814
884
|
{popup && key && board && (
|
|
815
885
|
<div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
|