@spzxg/cli-ui 0.6.6 → 0.6.7
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 +33 -1
- package/README.md +79 -20
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-Sl7ypvSe.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 +86 -46
- package/src/chat/Composer.tsx +52 -20
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +132 -42
- package/src/chat/draft.ts +5 -2
- package/src/core/blocks.ts +2 -1
- package/src/core/client.ts +44 -57
- package/src/core/documentUpload.ts +196 -0
- package/src/core/store.ts +71 -14
- package/src/core/types.ts +1 -1
- package/src/index.ts +3 -0
- package/src/msglist/CliMsgList.tsx +9 -2
- package/src/operator/node/Chat.tsx +7 -8
- package/src/operator/node/Composer.tsx +99 -72
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +25 -25
- 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 +141 -38
- package/src/operator.css +141 -38
- package/src/styles.css +248 -34
- 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/core/client.ts
CHANGED
|
@@ -5,6 +5,9 @@ import type {
|
|
|
5
5
|
BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot, WorkspaceFileBatch,
|
|
6
6
|
} from "./types";
|
|
7
7
|
import { decodeText } from "./textDecode";
|
|
8
|
+
import { createDocumentUploader, extractDocumentReferences, documentRequest, isTaskUploadPath, type UploadDocument, type DocumentUploadOptions, type DocumentReceipt } from "./documentUpload";
|
|
9
|
+
import { syncFile, type FileSyncTransport } from "../board/fileSync";
|
|
10
|
+
import { createFileSyncTransport } from "../board/fileSyncTransport";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
|
|
@@ -92,6 +95,8 @@ const ERROR_TEXT: Record<string, string> = {
|
|
|
92
95
|
* 带 ? 的是可选能力:后端不提供,界面就按 Operator 在「没有这个能力」时的样子显示(例如提问的回答改走下一条消息)。
|
|
93
96
|
*/
|
|
94
97
|
export interface ConversationBackend {
|
|
98
|
+
/** Upload original HTML/Markdown without embedding document bytes in a message. */
|
|
99
|
+
uploadDocument?: UploadDocument;
|
|
95
100
|
baseUrl: string;
|
|
96
101
|
getJob(jobId: string): Promise<CenterJob>;
|
|
97
102
|
/** taskId:只要对话任务里这一个对话的轮次。 */
|
|
@@ -143,6 +148,15 @@ export interface ConversationBackend {
|
|
|
143
148
|
* 方法里的 jobId 也可以是对话键("job_x#task_y"):看对话任务里那个对话的文件、改动和浏览器。
|
|
144
149
|
*/
|
|
145
150
|
export interface BoardBackend {
|
|
151
|
+
/** Save an original file to this conversation without sending a message or starting the agent. */
|
|
152
|
+
uploadFile?(jobId: string, file: File, options: DocumentUploadOptions): Promise<DocumentReceipt>;
|
|
153
|
+
/** Authorized input directory of this task; browsing it never starts an agent turn. */
|
|
154
|
+
uploadDirectory?(jobId: string, signal?: AbortSignal): Promise<string | null>;
|
|
155
|
+
/** Immutable revisions and content-addressed blocks; refreshes reuse validated local blocks. */
|
|
156
|
+
fileSync?: FileSyncTransport;
|
|
157
|
+
/** One directory page only. No recursive scan or file body prefetch. */
|
|
158
|
+
listFiles?(jobId: string, dir: string, options?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
|
|
159
|
+
pollFiles?(jobId: string, scope: { paths: string[]; dirs: string[]; cursor?: string }, signal?: AbortSignal): Promise<WorkspaceFileBatch & { cursor?: string; directories: Array<{ path: string; revision: string | null }> }>;
|
|
146
160
|
/** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
|
|
147
161
|
directLinks: boolean;
|
|
148
162
|
info(jobId: string): Promise<BoardInfo>;
|
|
@@ -286,6 +300,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
286
300
|
|
|
287
301
|
return {
|
|
288
302
|
baseUrl: base,
|
|
303
|
+
uploadDocument: createDocumentUploader(doFetch, `${base}/api/file-sync`, headers),
|
|
289
304
|
getJob: (jobId: string) => request<CenterJob>(`/api/jobs/${id(jobId)}`),
|
|
290
305
|
listTurns: (jobId: string, query: { afterSeq?: number; limit?: number; taskId?: string } = {}) => {
|
|
291
306
|
const params = new URLSearchParams();
|
|
@@ -326,7 +341,8 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
326
341
|
...(opts.callback ? { callback: opts.callback } : {}),
|
|
327
342
|
tasks: [{
|
|
328
343
|
title,
|
|
329
|
-
prompt: firstMessage,
|
|
344
|
+
prompt: extractDocumentReferences(firstMessage).text,
|
|
345
|
+
...(extractDocumentReferences(firstMessage).attachments ? { attachments: extractDocumentReferences(firstMessage).attachments } : {}),
|
|
330
346
|
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
331
347
|
...(opts.agent ? { agent: opts.agent } : {}),
|
|
332
348
|
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
@@ -339,7 +355,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
339
355
|
/** 带 business_id 时 center 按它去重,网络超时可以放心重发同一条。 */
|
|
340
356
|
sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number; taskId?: string } = {}) =>
|
|
341
357
|
post<SendResult>(`/api/jobs/${id(jobId)}/messages`, {
|
|
342
|
-
text,
|
|
358
|
+
...extractDocumentReferences(text),
|
|
343
359
|
...(opts.taskId ? { task_id: opts.taskId } : {}),
|
|
344
360
|
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
345
361
|
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
@@ -347,7 +363,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
347
363
|
continueTask: async (jobId: string, taskId: string, text: string, opts: { title?: string; businessId?: string } = {}) => {
|
|
348
364
|
const result = await post<{ task_ids: string[] }>(`/api/jobs`, {
|
|
349
365
|
job_id: jobId, mode: "conversation",
|
|
350
|
-
tasks: [{ title: opts.title || firstLine(text, 60), prompt: text, continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
|
|
366
|
+
tasks: [{ title: opts.title || firstLine(text, 60), prompt: extractDocumentReferences(text).text, ...(extractDocumentReferences(text).attachments ? { attachments: extractDocumentReferences(text).attachments } : {}), continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
|
|
351
367
|
});
|
|
352
368
|
return { task_id: result.task_ids[0] };
|
|
353
369
|
},
|
|
@@ -356,7 +372,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
356
372
|
/** 同一个 business_id 重发只开一个对话。 */
|
|
357
373
|
openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
|
|
358
374
|
post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
|
|
359
|
-
text,
|
|
375
|
+
...extractDocumentReferences(text),
|
|
360
376
|
...(opts.title ? { title: opts.title } : {}),
|
|
361
377
|
...(opts.agent ? { agent: opts.agent } : {}),
|
|
362
378
|
...(opts.browser !== undefined ? { browser: opts.browser } : {}),
|
|
@@ -534,31 +550,6 @@ function boardPath(key: string, rest: string, query = "") {
|
|
|
534
550
|
return `/api/jobs/${job}/${rest}${params ? `?${params}` : ""}`;
|
|
535
551
|
}
|
|
536
552
|
|
|
537
|
-
/** Reject a malformed manifest as a whole: silently dropping entries could invent deletions. */
|
|
538
|
-
function isWorkspaceFileBatch(value: unknown): value is WorkspaceFileBatch {
|
|
539
|
-
if (!value || typeof value !== "object") return false;
|
|
540
|
-
const batch = value as Record<string, unknown>;
|
|
541
|
-
if (typeof batch.complete !== "boolean" || (batch.revision !== undefined && typeof batch.revision !== "string")) return false;
|
|
542
|
-
const file = (value: unknown) => {
|
|
543
|
-
if (!value || typeof value !== "object") return false;
|
|
544
|
-
const entry = value as Record<string, unknown>;
|
|
545
|
-
return typeof entry.name === "string" && typeof entry.path === "string" && !!entry.path
|
|
546
|
-
&& (entry.type === "file" || entry.type === "dir")
|
|
547
|
-
&& typeof entry.size === "number" && Number.isFinite(entry.size) && entry.size >= 0
|
|
548
|
-
&& typeof entry.mtime === "number" && Number.isFinite(entry.mtime)
|
|
549
|
-
&& (entry.changeToken === undefined || typeof entry.changeToken === "string");
|
|
550
|
-
};
|
|
551
|
-
if (batch.type === "snapshot") return Array.isArray(batch.files) && batch.files.every(file);
|
|
552
|
-
if (batch.type === "resync") return batch.complete === false && typeof batch.reason === "string";
|
|
553
|
-
return batch.type === "changes" && Array.isArray(batch.changes) && batch.changes.every(value => {
|
|
554
|
-
if (!value || typeof value !== "object") return false;
|
|
555
|
-
const change = value as Record<string, unknown>;
|
|
556
|
-
return ["created", "modified", "deleted"].includes(String(change.kind)) && typeof change.path === "string" && !!change.path
|
|
557
|
-
&& (change.file === undefined || (file(change.file) && (change.file as { path: string }).path === change.path))
|
|
558
|
-
&& (change.previous === undefined || (file(change.previous) && (change.previous as { path: string }).path === change.path));
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
|
|
562
553
|
function createBoardClient(
|
|
563
554
|
base: string,
|
|
564
555
|
headers: (extra?: Record<string, string>) => Record<string, string>,
|
|
@@ -698,37 +689,28 @@ function createBoardClient(
|
|
|
698
689
|
const minting = new Map<string, Promise<string | null>>();
|
|
699
690
|
let linksUnsupported = false;
|
|
700
691
|
const LINK_REFRESH_MS = 5 * 60_000;
|
|
692
|
+
const uploads = new Map<string, UploadDocument>();
|
|
693
|
+
const fileSync = createFileSyncTransport(doFetch, (jobId, operation) => `${base}${boardPath(jobId, `file-sync/${operation}`)}`, headers);
|
|
701
694
|
return {
|
|
702
695
|
directLinks,
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const fetchStream: typeof fetch = async (input, init) => {
|
|
712
|
-
const response = await doFetch(input, init);
|
|
713
|
-
if (response.ok && !/^text\/event-stream(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) {
|
|
714
|
-
await response.body?.cancel().catch(() => undefined);
|
|
715
|
-
throw new CenterError("文件事件接口未返回事件流,请检查服务代理配置", "INVALID_RESPONSE", 502);
|
|
716
|
-
}
|
|
717
|
-
return response;
|
|
718
|
-
};
|
|
719
|
-
stop = openSse(fetchStream, `${base}${boardPath(jobId, "files/events")}`, headers(), (event, data) => {
|
|
720
|
-
if (!active) return;
|
|
721
|
-
if (event === "files" || event === "message") {
|
|
722
|
-
if (!isWorkspaceFileBatch(data)) { close(new CenterError("文件事件数据不完整,正在重新同步", "INVALID_RESPONSE", 502)); return; }
|
|
723
|
-
handlers.onEvent(data);
|
|
724
|
-
} else if (event === "end") {
|
|
725
|
-
const end = data && typeof data === "object" ? data as Record<string, unknown> : {};
|
|
726
|
-
close(new CenterError(typeof end.detail === "string" ? end.detail : "文件监听连接中断,正在重连", typeof end.reason === "string" ? end.reason : "NETWORK", typeof end.status === "number" ? end.status : 0));
|
|
727
|
-
}
|
|
728
|
-
}, close);
|
|
729
|
-
return () => { active = false; stop(); };
|
|
696
|
+
uploadFile(jobId, file, options) {
|
|
697
|
+
let upload = uploads.get(jobId);
|
|
698
|
+
if (!upload) {
|
|
699
|
+
const { job, task } = boardTarget(jobId);
|
|
700
|
+
upload = createDocumentUploader(doFetch, `${base}/api/jobs/${job}${task ? `/tasks/${task}` : ""}/file-sync`, headers);
|
|
701
|
+
uploads.set(jobId, upload);
|
|
702
|
+
}
|
|
703
|
+
return upload(file, options);
|
|
730
704
|
},
|
|
731
|
-
|
|
705
|
+
async uploadDirectory(jobId, signal) {
|
|
706
|
+
const capabilities = await documentRequest<{ uploadsPath?: string }>(doFetch, `${base}${boardPath(jobId, "file-sync/capabilities")}`, { signal }, headers);
|
|
707
|
+
return typeof capabilities.uploadsPath === "string" ? capabilities.uploadsPath : null;
|
|
708
|
+
},
|
|
709
|
+
fileSync,
|
|
710
|
+
listFiles: (jobId, dir, options = {}) => documentRequest<DirListing>(doFetch, `${base}${boardPath(jobId, "file-sync/list", new URLSearchParams({ dir: dir || ".", limit: String(options.limit ?? 200), ...(options.cursor ? { cursor: options.cursor } : {}) }).toString())}`, { signal: options.signal }, headers),
|
|
711
|
+
info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
|
|
712
|
+
pollFiles: (jobId, scope, signal) => documentRequest(doFetch, `${base}${boardPath(jobId, "file-sync/changes")}`, { method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify(scope) }, headers),
|
|
713
|
+
stat: (jobId, path = ".") => documentRequest<DirListing | FileInfo>(doFetch, `${base}${boardPath(jobId, "file-sync/stat", `path=${encodeURIComponent(path)}`)}`, {}, headers),
|
|
732
714
|
fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
|
|
733
715
|
async fileLinkBase(jobId) {
|
|
734
716
|
const { job, task } = boardTarget(jobId);
|
|
@@ -761,6 +743,11 @@ function createBoardClient(
|
|
|
761
743
|
return { bytes, start, size };
|
|
762
744
|
},
|
|
763
745
|
async readBuffer(jobId, path, opts = {}) {
|
|
746
|
+
if (isTaskUploadPath(path)) {
|
|
747
|
+
const file = await syncFile(fileSync, jobId, path, { signal: opts.signal, decode: false,
|
|
748
|
+
onProgress: progress => opts.onBytes?.(progress.loadedBytes) });
|
|
749
|
+
return file.bytes.buffer as ArrayBuffer;
|
|
750
|
+
}
|
|
764
751
|
const { body } = await fileFetch(jobId, path, {}, opts.signal);
|
|
765
752
|
return (await body({ onBytes: opts.onBytes })).buffer;
|
|
766
753
|
},
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createFileWorkQueue } from "../board/fileSyncQueue";
|
|
2
|
+
/** Original attachments. The message contains an authorized reference only. */
|
|
3
|
+
export const DOCUMENT_MAX_BYTES = 100 * 1024 * 1024;
|
|
4
|
+
export const UPLOAD_OTHER_MAX_BYTES = 10 * 1024 * 1024;
|
|
5
|
+
export const DOCUMENT_CHUNK_BYTES = 1024 * 1024;
|
|
6
|
+
export const isOriginalDocument = (name: string) => /\.(html?|md|markdown)$/i.test(name);
|
|
7
|
+
export const isTaskUploadPath = (path: string) => /\/(?:uploads|attachments)\/[^/]+\/[^/]+$/.test(path.replace(/\\/g, "/"));
|
|
8
|
+
const UPLOAD_TYPES: Record<string, string> = {
|
|
9
|
+
html: "text/html", htm: "text/html", md: "text/markdown", markdown: "text/markdown",
|
|
10
|
+
txt: "text/plain", log: "text/plain", yaml: "text/yaml", yml: "text/yaml", csv: "text/csv", tsv: "text/tab-separated-values", json: "application/json",
|
|
11
|
+
js: "text/plain", jsx: "text/plain", mjs: "text/plain", cjs: "text/plain", ts: "text/plain", tsx: "text/plain", mts: "text/plain", cts: "text/plain", py: "text/plain", sh: "text/plain", css: "text/plain", mjsx: "text/plain", cjsx: "text/plain", mtsx: "text/plain", ctsx: "text/plain",
|
|
12
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
13
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
14
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsm: "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
15
|
+
};
|
|
16
|
+
export const FILE_UPLOAD_ACCEPT = Object.keys(UPLOAD_TYPES).map(extension => `.${extension}`).join(",");
|
|
17
|
+
export const uploadFileType = (name: string) => {
|
|
18
|
+
const dot = name.lastIndexOf(".");
|
|
19
|
+
const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : undefined;
|
|
20
|
+
return extension && Object.hasOwn(UPLOAD_TYPES, extension) ? UPLOAD_TYPES[extension] : undefined;
|
|
21
|
+
};
|
|
22
|
+
export type DocumentReceipt = { uploadId: string; draftId: string; name: string; size: number; sha256: string; path?: string; url?: string };
|
|
23
|
+
export type UploadPhase = "queued" | "checking" | "uploading" | "verifying";
|
|
24
|
+
export type DocumentUploadOptions = { draftId: string; signal?: AbortSignal; onBytes?: (bytes: number) => void; onPhase?: (phase: UploadPhase) => void };
|
|
25
|
+
export type UploadDocument = (file: File, options: DocumentUploadOptions) => Promise<DocumentReceipt>;
|
|
26
|
+
const aborted = () => new DOMException("上传已取消", "AbortError");
|
|
27
|
+
function pause(ms: number, signal?: AbortSignal) {
|
|
28
|
+
return new Promise<void>((resolve, reject) => {
|
|
29
|
+
if (signal?.aborted) return reject(aborted());
|
|
30
|
+
const stop = () => { clearTimeout(timer); reject(aborted()); };
|
|
31
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms);
|
|
32
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Blob cloning is cheap; hashing a 100 MiB file does not copy it through the UI thread. */
|
|
37
|
+
export async function hashDocument(file: Blob, signal?: AbortSignal): Promise<string> {
|
|
38
|
+
if (signal?.aborted) throw aborted();
|
|
39
|
+
if (typeof Worker !== "undefined" && typeof URL.createObjectURL === "function") {
|
|
40
|
+
const source = `onmessage=async e=>{try{const bytes=await e.data.arrayBuffer();const hash=await crypto.subtle.digest('SHA-256',bytes);postMessage({hash:Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join('')});}catch(e){postMessage({error:String(e)})}}`;
|
|
41
|
+
const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
|
42
|
+
let worker: Worker | undefined;
|
|
43
|
+
try {
|
|
44
|
+
worker = new Worker(url);
|
|
45
|
+
return await new Promise<string>((resolve, reject) => {
|
|
46
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", stop); worker!.onmessage = null; worker!.onerror = null; };
|
|
47
|
+
const stop = () => { cleanup(); reject(aborted()); };
|
|
48
|
+
const timer = setTimeout(() => { cleanup(); reject(Object.assign(new Error("文件校验超时,请重试"), { status: 408, code: "hash_timeout" })); }, 120_000);
|
|
49
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
50
|
+
worker!.onmessage = e => { cleanup(); e.data?.hash ? resolve(e.data.hash) : reject(new Error(e.data?.error || "文件校验失败,请重试")); };
|
|
51
|
+
worker!.onerror = () => { cleanup(); reject(new Error("文件校验失败,请重试")); };
|
|
52
|
+
if (signal?.aborted) { stop(); return; }
|
|
53
|
+
try { worker!.postMessage(file); } catch (error) { cleanup(); reject(error); }
|
|
54
|
+
});
|
|
55
|
+
} finally { worker?.terminate(); URL.revokeObjectURL(url); }
|
|
56
|
+
}
|
|
57
|
+
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
|
58
|
+
if (signal?.aborted) throw aborted();
|
|
59
|
+
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, "0")).join("");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** All calls are bounded short requests, including body reads. */
|
|
63
|
+
export async function documentRequest<T>(fetcher: typeof fetch, url: string, init: RequestInit = {}, headers: () => Record<string, string> = () => ({})): Promise<T> {
|
|
64
|
+
const ctl = new AbortController();
|
|
65
|
+
const signal = init.signal;
|
|
66
|
+
const cancel = () => ctl.abort();
|
|
67
|
+
if (signal?.aborted) throw aborted();
|
|
68
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
69
|
+
const timer = setTimeout(cancel, 30_000);
|
|
70
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
71
|
+
let rejectAbort: (() => void) | undefined;
|
|
72
|
+
try {
|
|
73
|
+
const cancelled = new Promise<never>((_resolve, reject) => { rejectAbort = () => reject(aborted()); ctl.signal.addEventListener("abort", rejectAbort, { once: true }); });
|
|
74
|
+
return await Promise.race([cancelled, (async () => {
|
|
75
|
+
const response = await fetcher(url, { ...init, cache: "no-store", signal: ctl.signal, headers: { ...headers(), ...init.headers } });
|
|
76
|
+
if (ctl.signal.aborted) { void response.body?.cancel().catch(() => undefined); throw aborted(); }
|
|
77
|
+
reader = response.body?.getReader();
|
|
78
|
+
let raw = "", bytes = 0;
|
|
79
|
+
if (reader) {
|
|
80
|
+
const decoder = new TextDecoder();
|
|
81
|
+
for (;;) {
|
|
82
|
+
const part = await reader.read(); if (part.done) break;
|
|
83
|
+
bytes += part.value.byteLength;
|
|
84
|
+
if (bytes > 2 * 1024 * 1024) throw Object.assign(new Error("文件响应超过协议上限"), { status: 502, code: "response_too_large" });
|
|
85
|
+
raw += decoder.decode(part.value, { stream: true });
|
|
86
|
+
}
|
|
87
|
+
raw += decoder.decode();
|
|
88
|
+
} else raw = await response.text();
|
|
89
|
+
let value: Record<string, unknown>;
|
|
90
|
+
try { value = raw ? JSON.parse(raw) : {}; } catch { throw Object.assign(new Error("文件接口返回的数据无效"), { status: 502, code: "invalid_response" }); }
|
|
91
|
+
const retryHeader = response.headers.get("retry-after");
|
|
92
|
+
const retryHint = retryHeader ? (/^\d+(\.\d+)?$/.test(retryHeader) ? Number(retryHeader) * 1000 : Date.parse(retryHeader) - Date.now()) : undefined;
|
|
93
|
+
const retryAfterMs = retryHint !== undefined && Number.isFinite(retryHint) ? Math.max(0, retryHint) : undefined;
|
|
94
|
+
if (response.status === 202) throw Object.assign(new Error("文件正在校验"), { status: 202, retryAfterMs: retryAfterMs ?? 1000 });
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const missing = value.error === "file_not_found" ? "文件不存在(可能已被删除或改名)" : "服务端尚未支持原件上传或增量文件同步,请升级节点和中枢";
|
|
97
|
+
const common: Record<string, string> = { file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
|
|
98
|
+
const error = new Error(common[String(value.error)] ?? String(value.detail || (response.status === 404 ? missing : value.error) || `文件请求失败(HTTP ${response.status})`));
|
|
99
|
+
Object.assign(error, { status: response.status, code: value.error, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
return value as T;
|
|
103
|
+
})()]);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (signal?.aborted) throw aborted();
|
|
106
|
+
if (ctl.signal.aborted) throw Object.assign(new Error("文件请求超时,请重试"), { status: 0, code: "timeout" });
|
|
107
|
+
throw error;
|
|
108
|
+
} finally { clearTimeout(timer); signal?.removeEventListener("abort", cancel); if (rejectAbort) ctl.signal.removeEventListener("abort", rejectAbort); ctl.abort(); void reader?.cancel().catch(() => undefined); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const uploadQueues = new WeakMap<typeof fetch, ReturnType<typeof createFileWorkQueue>>();
|
|
112
|
+
async function retryUploadRequest<T>(work: () => Promise<T>, signal: AbortSignal | undefined, busyOnly = false, deadline = Date.now() + 180_000): Promise<T> {
|
|
113
|
+
for (let attempt = 0; ; attempt++) {
|
|
114
|
+
if (signal?.aborted) throw aborted();
|
|
115
|
+
try { return await work(); } catch (error) {
|
|
116
|
+
const failure = error as { status?: number; code?: string; name?: string; retryAfterMs?: number };
|
|
117
|
+
const status = failure.status ?? 0;
|
|
118
|
+
const retryable = busyOnly ? status === 429 : !status || [408, 429, 500, 502, 503, 504].includes(status);
|
|
119
|
+
if (signal?.aborted || failure.name === "AbortError" || !retryable || attempt >= 3 || ["invalid_response", "response_too_large", "integrity", "client_queue_full"].includes(failure.code ?? "")) throw error;
|
|
120
|
+
const base = 500 * 2 ** attempt, hint = Number(failure.retryAfterMs ?? 0);
|
|
121
|
+
const delay = Math.max(base, Number.isFinite(hint) ? hint : 0) + Math.floor(Math.random() * base / 4);
|
|
122
|
+
if (delay > deadline - Date.now()) throw error;
|
|
123
|
+
await pause(delay, signal);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function createDocumentUploader(fetcher: typeof fetch, base: string, headers: () => Record<string, string> = () => ({})): UploadDocument {
|
|
128
|
+
// Share hashing/upload slots across tasks using this client, with cancellable bounded waiters.
|
|
129
|
+
let queue = uploadQueues.get(fetcher);
|
|
130
|
+
if (!queue) { queue = createFileWorkQueue(1, 128); uploadQueues.set(fetcher, queue); }
|
|
131
|
+
return (file, options) => {
|
|
132
|
+
options.onPhase?.("queued");
|
|
133
|
+
const upload = async (): Promise<DocumentReceipt> => {
|
|
134
|
+
const { signal, onBytes, onPhase, draftId } = options;
|
|
135
|
+
if (signal?.aborted) throw aborted();
|
|
136
|
+
const type = uploadFileType(file.name);
|
|
137
|
+
if (!type) throw new Error("暂不支持此文件类型,请选择文档、图片或文本文件");
|
|
138
|
+
const maxBytes = isOriginalDocument(file.name) ? DOCUMENT_MAX_BYTES : UPLOAD_OTHER_MAX_BYTES;
|
|
139
|
+
if (file.size > maxBytes) throw new Error(`“${file.name}”超过 ${isOriginalDocument(file.name) ? "100" : "10"} MiB,请缩小后重试。`);
|
|
140
|
+
if (new TextEncoder().encode(file.name).length > 240) throw new Error("文件名超过 240 个 UTF-8 字节,请缩短文件名后重试。");
|
|
141
|
+
onPhase?.("checking");
|
|
142
|
+
const sha256 = await hashDocument(file, signal);
|
|
143
|
+
const request = <T>(path: string, init: RequestInit) => documentRequest<T>(fetcher, `${base}${path}`, { ...init, signal }, headers);
|
|
144
|
+
const session = await retryUploadRequest(() => request<{ uploadId: string; chunkBytes: number }>("/uploads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId, name: file.name, size: file.size, type, sha256 }) }), signal, true);
|
|
145
|
+
if (!session.uploadId || session.chunkBytes !== DOCUMENT_CHUNK_BYTES) throw new Error("上传协议不匹配,请升级服务端");
|
|
146
|
+
const root = `/uploads/${encodeURIComponent(session.uploadId)}`;
|
|
147
|
+
try {
|
|
148
|
+
onPhase?.("uploading");
|
|
149
|
+
onBytes?.(0);
|
|
150
|
+
for (let start = 0, index = 0; start < file.size; start += session.chunkBytes, index++) {
|
|
151
|
+
const body = file.slice(start, Math.min(file.size, start + session.chunkBytes));
|
|
152
|
+
// Only these numbered chunks are safely idempotent after ambiguous network failures.
|
|
153
|
+
await retryUploadRequest(() => request(`${root}/chunks/${index}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body }), signal);
|
|
154
|
+
onBytes?.(Math.min(file.size, start + session.chunkBytes));
|
|
155
|
+
}
|
|
156
|
+
onPhase?.("verifying");
|
|
157
|
+
const deadline = Date.now() + 180_000;
|
|
158
|
+
for (;;) {
|
|
159
|
+
try {
|
|
160
|
+
const result = await retryUploadRequest(() => request<DocumentReceipt>(`${root}/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId }) }), signal, false, deadline);
|
|
161
|
+
if (result.sha256 !== sha256) throw new Error("上传文件校验不一致,请重试");
|
|
162
|
+
return { ...result, draftId, name: file.name, size: file.size };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const pending = error as { status?: number; retryAfterMs?: number };
|
|
165
|
+
if (pending.status !== 202 || Date.now() >= deadline) throw error;
|
|
166
|
+
const delay = Math.max(100, pending.retryAfterMs ?? 1000);
|
|
167
|
+
if (delay > deadline - Date.now()) throw error;
|
|
168
|
+
await pause(delay, signal);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
void documentRequest(fetcher, `${base}${root}`, { method: "DELETE" }, headers).catch(() => undefined);
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
return queue.run(upload, options.signal);
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function documentMarker(receipt: DocumentReceipt) {
|
|
181
|
+
if (receipt.path) {
|
|
182
|
+
if (/[\r\n\]]/.test(receipt.path)) throw new Error("上传文件路径无效,请重试");
|
|
183
|
+
return `[Attached ${uploadFileType(receipt.name)?.startsWith("image/") ? "image" : "file"}: ${receipt.path}]`;
|
|
184
|
+
}
|
|
185
|
+
const name = receipt.name.replace(/[\r\n\[\]]/g, " ");
|
|
186
|
+
return `[文件原件:${name}](cli-upload://${encodeURIComponent(receipt.draftId)}/${encodeURIComponent(receipt.uploadId)})`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function extractDocumentReferences(text: string) {
|
|
190
|
+
const attachments: Array<{ uploadId: string; draftId: string }> = [];
|
|
191
|
+
const clean = text.replace(/\[文件原件:([^\]\r\n]*)\]\(cli-upload:\/\/([^/\s)]+)\/([^\s)]+)\)/g, (_match, name, draft, upload) => {
|
|
192
|
+
try { attachments.push({ draftId: decodeURIComponent(draft), uploadId: decodeURIComponent(upload) }); } catch { throw new Error("文件引用无效,请移除后重新上传"); }
|
|
193
|
+
return `[文件原件:${name}]`;
|
|
194
|
+
});
|
|
195
|
+
return { text: clean, ...(attachments.length ? { attachments } : {}) };
|
|
196
|
+
}
|
package/src/core/store.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { formatAnswersText } from "../ui/format";
|
|
|
19
19
|
import { backgroundBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "./blocks";
|
|
20
20
|
import { cloneBoardView, reportBoardOwner, activateBoardOwner, selectedBoardOwner, mergeBoardHistory, rememberBoardFile } from "../board/context";
|
|
21
21
|
import { cloneWorkspaceChange, subscribeWorkspaceFiles, retryWorkspaceFiles, type WorkspaceWatchOptions } from "../board/fileWatch";
|
|
22
|
+
import { retainFileScope } from "../board/fileScope";
|
|
22
23
|
import type { WorkspaceChangeEvent, WorkspaceWatchStatus } from "./types";
|
|
23
24
|
import { createWorkspace, type BoardView, type CliWorkspace } from "../board/workspace";
|
|
24
25
|
import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types";
|
|
@@ -52,6 +53,12 @@ export interface CliState {
|
|
|
52
53
|
viewing: Record<string, { ref: BoardRef; off: boolean }>;
|
|
53
54
|
/** 看板现在的样子(按对话键):正在看的文件、开着的文件页签、当前页签。没有看板时没有。 */
|
|
54
55
|
boards: Record<string, BoardView>;
|
|
56
|
+
/** 当前对话是否有展开且可见的外置看板;Chat 内嵌看板不计入。 */
|
|
57
|
+
externalBoards: Record<string, boolean>;
|
|
58
|
+
/** 同一工作区中是否有展开且可见的对话列表。 */
|
|
59
|
+
listVisible: boolean;
|
|
60
|
+
/** 外置看板的模块和只读限制,内嵌视图沿用同一约束。 */
|
|
61
|
+
externalBoardOptions: Record<string, { availableTabs: ("files" | "changes" | "browser")[]; readOnly: boolean }>;
|
|
55
62
|
/** 最近看板背景,收起/卸载后保留,供下一条消息被动参考。 */
|
|
56
63
|
boardContext: Record<string, BoardView>;
|
|
57
64
|
/** 业务方补充的项目背景(按对话键;"" = 还没建的新对话),随下一条消息发出(见 appendContext)。 */
|
|
@@ -222,7 +229,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
222
229
|
// 列表只放宿主给的 jobIds 和本页里新建 / 接续出来的对话:不从 center 发现,也不从本地存储恢复——
|
|
223
230
|
// 不传就不加载,免得把这个产品线令牌下别的业务、别的用户的对话列出来。
|
|
224
231
|
|
|
225
|
-
let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, boardContext: {}, context: {} };
|
|
232
|
+
let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, externalBoards: {}, listVisible: false, externalBoardOptions: {}, boardContext: {}, context: {} };
|
|
226
233
|
const listeners = new Set<() => void>();
|
|
227
234
|
const eventListeners = new Set<(event: CliEvent) => void>();
|
|
228
235
|
const settledTurns = new Set<string>();
|
|
@@ -392,10 +399,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
392
399
|
previous?.();
|
|
393
400
|
if (streams.get(key) !== entry || entry.refs <= 0) return;
|
|
394
401
|
const { jobId, taskId } = parseConvKey(key);
|
|
395
|
-
let ended = false, retired = false;
|
|
402
|
+
let ended = false, retired = false, snapshotReceived = false;
|
|
396
403
|
let cleanup: (() => void) | null = null;
|
|
397
404
|
const owns = () => streams.get(key) === entry && entry.refs > 0 && entry.stop === retire;
|
|
398
405
|
const active = () => !retired && owns();
|
|
406
|
+
const refreshFiles = () => { if (active() && client.board) retryWorkspaceFiles(client.board, key); };
|
|
399
407
|
const retire = () => {
|
|
400
408
|
if (retired) return;
|
|
401
409
|
retired = true;
|
|
@@ -435,13 +443,37 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
435
443
|
try {
|
|
436
444
|
cleanup = client.stream(jobId, {
|
|
437
445
|
onOpen: () => { if (!active()) return; entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
|
|
438
|
-
onSnapshot: (snapshot) => {
|
|
439
|
-
|
|
440
|
-
|
|
446
|
+
onSnapshot: (snapshot) => {
|
|
447
|
+
if (!active()) return;
|
|
448
|
+
patchConv(key, (conv) => applySnapshot(conv, snapshot));
|
|
449
|
+
// Each connection catches up once, including rounds completed while disconnected.
|
|
450
|
+
if (!snapshotReceived) { snapshotReceived = true; refreshFiles(); }
|
|
451
|
+
},
|
|
452
|
+
onChunk: (items) => {
|
|
441
453
|
if (!active()) return;
|
|
454
|
+
let refresh = false;
|
|
455
|
+
patchConv(key, (conv) => {
|
|
456
|
+
let next = conv;
|
|
457
|
+
for (const item of items) {
|
|
458
|
+
const previous = next;
|
|
459
|
+
next = applyItems(next, [item]);
|
|
460
|
+
// applyItems also rejects events from another batch task's node session.
|
|
461
|
+
if (next === previous) continue;
|
|
462
|
+
// Observe every event: end + next user in one chunk can leave running=true.
|
|
463
|
+
if (item.type === "node" && item.event.type === "turn_end" || item.type === "transcript") refresh = true;
|
|
464
|
+
if (item.type === "turn" && item.turn.status !== "merged" && TERMINAL_TURN.has(item.turn.status)
|
|
465
|
+
&& !TERMINAL_TURN.has(previous.turns.find((turn) => turn.turn_id === item.turn.turn_id)?.status ?? "")) refresh = true;
|
|
466
|
+
}
|
|
467
|
+
return next;
|
|
468
|
+
});
|
|
469
|
+
if (refresh) refreshFiles();
|
|
470
|
+
},
|
|
471
|
+
onEnd: (end) => {
|
|
472
|
+
if (!active() || ended) return;
|
|
442
473
|
ended = true;
|
|
443
474
|
const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
|
|
444
475
|
patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
|
|
476
|
+
refreshFiles();
|
|
445
477
|
void refreshJob(jobId, true);
|
|
446
478
|
},
|
|
447
479
|
onClose: close,
|
|
@@ -727,7 +759,28 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
727
759
|
}
|
|
728
760
|
|
|
729
761
|
// 同一个仓库里登记的看板(cli-board)。后登记的优先(例如弹出的看板盖在常驻的上面)。
|
|
730
|
-
const surfaces: BoardSurface[] = [];
|
|
762
|
+
const surfaces: { surface: BoardSurface; owner?: string; inline?: boolean; priority?: () => number }[] = [];
|
|
763
|
+
const visibleLists = new Set<string>();
|
|
764
|
+
function reportListPresence(owner: string, visible: boolean) {
|
|
765
|
+
if (visible) visibleLists.add(owner); else visibleLists.delete(owner);
|
|
766
|
+
const listVisible = visibleLists.size > 0;
|
|
767
|
+
if (state.listVisible !== listVisible) set({ listVisible });
|
|
768
|
+
}
|
|
769
|
+
const externalBoards = new Map<string, Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>>();
|
|
770
|
+
function reportBoardPresence(key: string, owner: string, visible: boolean | null, options: { availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean } = {}) {
|
|
771
|
+
const owners = externalBoards.get(key) ?? new Map<string, { visible: boolean; availableTabs?: readonly ("files" | "changes" | "browser")[]; readOnly?: boolean }>();
|
|
772
|
+
if (visible === null) owners.delete(owner); else owners.set(owner, { visible, ...options });
|
|
773
|
+
if (owners.size) externalBoards.set(key, owners); else externalBoards.delete(key);
|
|
774
|
+
const values = [...owners.values()];
|
|
775
|
+
const shown = values.some(value => value.visible);
|
|
776
|
+
const limits = owners.size ? { availableTabs: (["files", "changes", "browser"] as const).filter(tab => values.every(value => !value.availableTabs || value.availableTabs.includes(tab))), readOnly: values.some(value => value.readOnly) } : undefined;
|
|
777
|
+
if (!!state.externalBoards[key] === shown && JSON.stringify(state.externalBoardOptions[key]) === JSON.stringify(limits)) return;
|
|
778
|
+
const next = { ...state.externalBoards };
|
|
779
|
+
if (shown) next[key] = true; else delete next[key];
|
|
780
|
+
const configurations = { ...state.externalBoardOptions };
|
|
781
|
+
if (limits) configurations[key] = limits; else delete configurations[key];
|
|
782
|
+
set({ externalBoards: next, externalBoardOptions: configurations });
|
|
783
|
+
}
|
|
731
784
|
// ── 看板 → 对话的引用 ─────────────────────────────
|
|
732
785
|
|
|
733
786
|
function setRefs(key: string, refs: BoardRef[]) {
|
|
@@ -821,12 +874,13 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
821
874
|
const fileSubscriptions = new Map<string,{listeners:Set<{change?:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']}>;stop:()=>void;status?:WorkspaceWatchStatus}>();
|
|
822
875
|
function watchWorkspace(key:string,listener?:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}) {
|
|
823
876
|
if(!client.board) throw new Error("当前后端不支持工作区文件订阅");
|
|
877
|
+
const releaseScope=options.paths?.length||options.dirs?.length?retainFileScope(client.board,key,{paths:[...options.paths??[]],dirs:[...options.dirs??[]]}):()=>{};
|
|
824
878
|
const item={change:listener,status:options.onStatus};let subscription=fileSubscriptions.get(key);
|
|
825
879
|
if(!subscription){
|
|
826
880
|
subscription={listeners:new Set([item]),stop:()=>{}};fileSubscriptions.set(key,subscription);const owned=subscription;
|
|
827
|
-
owned.stop=subscribeWorkspaceFiles(client.board,key,event=>{emit(event);for(const next of [...owned.listeners])notifyCallback(next.change,cloneWorkspaceChange(event));},{
|
|
881
|
+
owned.stop=subscribeWorkspaceFiles(client.board,key,event=>{emit(event);for(const next of [...owned.listeners])notifyCallback(next.change,cloneWorkspaceChange(event));},{intervalMs:options.intervalMs,onStatus:status=>{owned.status=status;emit(status);for(const next of [...owned.listeners])notifyCallback(next.status,{...status});}});
|
|
828
882
|
}else{subscription.listeners.add(item);if(subscription.status)notifyCallback(item.status,{...subscription.status});retryWorkspaceFiles(client.board,key);}
|
|
829
|
-
const owned=subscription;let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(item);if(!owned.listeners.size){if(fileSubscriptions.get(key)===owned)fileSubscriptions.delete(key);owned.stop();}};
|
|
883
|
+
const owned=subscription;let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(item);if(!owned.listeners.size){if(fileSubscriptions.get(key)===owned)fileSubscriptions.delete(key);owned.stop();}releaseScope();};
|
|
830
884
|
}
|
|
831
885
|
|
|
832
886
|
// ── 补充背景:业务方随时追加,随下一条消息发给智能体 ────────
|
|
@@ -890,9 +944,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
890
944
|
if (ref && cur && cur.ref.id === ref.id && cur.off) setViewingState(key, { ...cur, off: false });
|
|
891
945
|
}
|
|
892
946
|
|
|
893
|
-
function openInBoard(jobId: string, target: OpenTarget): boolean {
|
|
947
|
+
function openInBoard(jobId: string, target: OpenTarget, preferredInlineOwner?: string): boolean {
|
|
894
948
|
let handled = false;
|
|
895
|
-
|
|
949
|
+
const available = [...surfaces].reverse().filter(item => !preferredInlineOwner || !item.inline || item.owner === preferredInlineOwner)
|
|
950
|
+
.sort((a, b) => (b.priority?.() ?? 2) - (a.priority?.() ?? 2));
|
|
951
|
+
for (const { surface } of available) {
|
|
896
952
|
try { handled = surface(jobId, { ...target }); }
|
|
897
953
|
catch (error) { console.error("[@spzxg/cli-ui] board surface callback failed", error); }
|
|
898
954
|
if (handled) break;
|
|
@@ -908,9 +964,10 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
908
964
|
/** 在看板里打开文件 / 浏览器 / 改动。返回有没有看板接住。 */
|
|
909
965
|
openInBoard,
|
|
910
966
|
/** 看板登记自己;返回注销函数。 */
|
|
911
|
-
registerBoard(surface: BoardSurface) {
|
|
912
|
-
|
|
913
|
-
|
|
967
|
+
registerBoard(surface: BoardSurface, options: { owner?: string; inline?: boolean; priority?: () => number } = {}) {
|
|
968
|
+
const entry = { surface, ...options };
|
|
969
|
+
surfaces.push(entry);
|
|
970
|
+
return () => { const i = surfaces.indexOf(entry); if (i >= 0) surfaces.splice(i, 1); };
|
|
914
971
|
},
|
|
915
972
|
subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
|
916
973
|
on(fn: (event: CliEvent) => void) { eventListeners.add(fn); return () => { eventListeners.delete(fn); }; },
|
|
@@ -923,7 +980,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
923
980
|
addRef, removeRef, takeRefs, restoreRefs,
|
|
924
981
|
setViewing, dismissViewing, takeViewing, restoreViewing,
|
|
925
982
|
/** 看板报告自己的样子(看板组件用)。 */
|
|
926
|
-
reportBoard, activateBoard,
|
|
983
|
+
reportBoard, activateBoard, reportBoardPresence, reportListPresence,
|
|
927
984
|
/** 业务方追加项目背景:随下一条消息发给智能体。 */
|
|
928
985
|
appendContext, removeContext, pendingContext,
|
|
929
986
|
/** 工作区文件接口(不显示看板也能用):列文件、读文件、下载、打包。不传 = 当前选中的对话。 */
|
package/src/core/types.ts
CHANGED
|
@@ -400,7 +400,7 @@ export interface FileEntry {
|
|
|
400
400
|
changeToken?: string;
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
-
export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number }
|
|
403
|
+
export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number; nextCursor?: string; revision?: string }
|
|
404
404
|
export interface FileInfo { type: "file"; path: string; name: string; size: number; mtime: number; contentType: string; inline: boolean }
|
|
405
405
|
|
|
406
406
|
export interface DiffFile {
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { CliMsgList, CliChat, CliBoard, type CliMsgListProps, type CliChatProps, type CliBoardProps } from "./components";
|
|
2
|
+
export type { GroupChatAdapter, GroupChatMessage, GroupChatReference } from "./chat/GroupChat";
|
|
2
3
|
/**
|
|
3
4
|
* @spzxg/cli-ui —— 任务中心(cli-task-center)对话任务的前端积木,数据全部来自 center 服务。
|
|
4
5
|
*
|
|
@@ -50,3 +51,5 @@ export { formatBoardContext, appendBoardContext, BOARD_HISTORY_MAX } from "./boa
|
|
|
50
51
|
export { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./board/fileWatch";
|
|
51
52
|
export type { WorkspaceHandlers } from "./core/provider";
|
|
52
53
|
export type { BoardFile } from "./board/workspace";
|
|
54
|
+
|
|
55
|
+
export { syncFile, clearFileSyncNamespace, FILE_SYNC_MAX, type FileSyncTransport, type FileSyncManifest, type FileSyncProgress } from "./board/fileSync";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cli-msglist:消息对话列表。界面取自 Operator 的任务栏(TasksColumn 的列表视图,不做看板),
|
|
2
2
|
// 一张卡片是 center 里的一个对话任务(mode=conversation)。对话任务里开了几个对话时,卡片下面按对话展开,
|
|
3
3
|
// 点哪个对话选哪个(选中值是对话键 "job#task");卡片上的「+」在这个对话任务里新开一个对话(同一个工作区)。
|
|
4
|
-
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
4
|
+
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
5
5
|
import { CliErrorBoundary } from "../ui/ErrorBoundary";
|
|
6
6
|
import type { CliEvent, JobEntry } from "../core/store";
|
|
7
7
|
import type { CliStoreOptions, CliStore } from "../core/store";
|
|
@@ -12,6 +12,7 @@ import { Icon } from "../ui/icons";
|
|
|
12
12
|
import { agentLabel, relTime, toMs } from "../ui/format";
|
|
13
13
|
import { AgentBadge, ErrNote, Skel, StatusDot, type DotCls } from "../ui/shared";
|
|
14
14
|
import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig } from "../core/config";
|
|
15
|
+
import { useBoardVisibility } from "../board/useBoardVisibility";
|
|
15
16
|
|
|
16
17
|
/** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
|
|
17
18
|
export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
|
|
@@ -284,6 +285,12 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
|
|
|
284
285
|
const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
285
286
|
const h = cfg.header;
|
|
286
287
|
const store = useCliStore();
|
|
288
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
289
|
+
const owner = useId();
|
|
290
|
+
const visible = useBoardVisibility(rootRef, true);
|
|
291
|
+
const expanded = visible && !className?.split(/\s+/).includes("collapsed");
|
|
292
|
+
useLayoutEffect(() => { store.reportListPresence(owner, expanded); }, [store, owner, expanded]);
|
|
293
|
+
useLayoutEffect(() => () => { store.reportListPresence(owner, false); }, [store, owner]);
|
|
287
294
|
const ids = useCliState((s) => s.jobIds);
|
|
288
295
|
const jobs = useCliState((s) => s.jobs);
|
|
289
296
|
const shared = useCliState((s) => s.selected);
|
|
@@ -343,7 +350,7 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
|
|
|
343
350
|
const showBanner = showRow || showContext;
|
|
344
351
|
|
|
345
352
|
return (
|
|
346
|
-
<div className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
|
|
353
|
+
<div ref={rootRef} className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
|
|
347
354
|
<div className="col col-tasks">
|
|
348
355
|
{showBanner && <div className="proj-banner">
|
|
349
356
|
{showRow && <div className="pb-row">
|