@spzxg/cli-ui 0.6.5 → 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 +55 -0
- package/README.md +109 -30
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-zs7oEtPj.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 +11 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +276 -136
- 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 -70
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +12 -10
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +18 -41
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +53 -11
- package/src/chat/CliChat.tsx +105 -55
- package/src/chat/Composer.tsx +55 -21
- 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 +17 -5
- package/src/core/client.ts +88 -19
- package/src/core/conversation.ts +1 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +134 -36
- package/src/core/types.ts +31 -10
- package/src/index.ts +3 -0
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +18 -5
- 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 +28 -11
- package/src/operator/node/boardBackend.ts +6 -23
- 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/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +8 -5
- package/src/operator.base.css +148 -37
- package/src/operator.css +148 -37
- package/src/styles.css +274 -32
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +32 -14
- 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 +27 -12
- 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-BmLN2Jjg.js +0 -43290
package/src/board/fileOps.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// 看板、文件预览和给业务方的工作区接口(workspace.ts)共用。
|
|
3
3
|
import { fileUrlFromBase, type BoardBackend } from "../core/client";
|
|
4
4
|
import { extOf, fmtSize } from "./fileKind";
|
|
5
|
+
import { syncFile } from "./fileSync";
|
|
6
|
+
import { isOriginalDocument, isTaskUploadPath } from "../core/documentUpload";
|
|
5
7
|
|
|
6
8
|
/** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的不在浏览器里拼下载。 */
|
|
7
9
|
const BLOB_DOWNLOAD_MAX = 300 * 1024 * 1024;
|
|
@@ -9,7 +11,7 @@ const BLOB_DOWNLOAD_MAX = 300 * 1024 * 1024;
|
|
|
9
11
|
export const MIME: Record<string, string> = {
|
|
10
12
|
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
11
13
|
bmp: "image/bmp", ico: "image/x-icon", avif: "image/avif", pdf: "application/pdf", html: "text/html", htm: "text/html",
|
|
12
|
-
md: "text/markdown", txt: "text/plain", csv: "text/csv", json: "application/json", zip: "application/zip",
|
|
14
|
+
md: "text/markdown", markdown: "text/markdown", txt: "text/plain", csv: "text/csv", json: "application/json", zip: "application/zip",
|
|
13
15
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
14
16
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
15
17
|
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
@@ -70,7 +72,12 @@ function clickDownload(href: string, name: string) {
|
|
|
70
72
|
* 只有中枢太旧时才 fetch 成 blob 再存(太大的拒绝,免得撑爆浏览器内存)。
|
|
71
73
|
*/
|
|
72
74
|
export async function downloadFile(backend: BoardBackend, jobId: string, path: string, size?: number) {
|
|
73
|
-
|
|
75
|
+
let name = path.split("/").pop() || "file";
|
|
76
|
+
if (backend.fileSync && (isOriginalDocument(path) || isTaskUploadPath(path))) {
|
|
77
|
+
const info = await backend.stat(jobId, path); if (info.type === "file" && info.name) name = info.name;
|
|
78
|
+
const result = await syncFile(backend.fileSync, jobId, path, { decode: false });
|
|
79
|
+
saveBlob(new Blob([result.bytes], { type: mimeOf(path) }), name); return;
|
|
80
|
+
}
|
|
74
81
|
const url = await directFileUrl(backend, jobId, path, true);
|
|
75
82
|
if (url) { clickDownload(url, name); return; }
|
|
76
83
|
if (size !== undefined && size > BLOB_DOWNLOAD_MAX) throw new Error(`文件较大(${fmtSize(size)}),当前接入方式不能在浏览器里下载,请升级任务中心`);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BoardBackend } from "../core/client";
|
|
2
|
+
|
|
3
|
+
type Scope = { paths: string[]; dirs: string[]; onDenied?: () => void };
|
|
4
|
+
const scopes = new WeakMap<BoardBackend, Map<string, Map<object, Scope>>>();
|
|
5
|
+
const listeners = new WeakMap<BoardBackend, Map<string, Set<() => void>>>();
|
|
6
|
+
/** A preview/list owns its subscription. Unmounted/closed directories are not polled. */
|
|
7
|
+
export function retainFileScope(backend: BoardBackend, jobId: string, scope: Scope): () => void {
|
|
8
|
+
let jobs = scopes.get(backend); if (!jobs) { jobs = new Map(); scopes.set(backend, jobs); }
|
|
9
|
+
let owners = jobs.get(jobId); if (!owners) { owners = new Map(); jobs.set(jobId, owners); }
|
|
10
|
+
const owner = {}; owners.set(owner, scope);
|
|
11
|
+
const notify = () => listeners.get(backend)?.get(jobId)?.forEach(fn => fn());
|
|
12
|
+
notify();
|
|
13
|
+
let active = true;
|
|
14
|
+
return () => { if (!active) return; active = false; owners!.delete(owner); if (!owners!.size && jobs!.get(jobId) === owners) jobs!.delete(jobId); notify(); };
|
|
15
|
+
}
|
|
16
|
+
export function readFileScope(backend: BoardBackend, jobId: string): Scope {
|
|
17
|
+
const active = [...(scopes.get(backend)?.get(jobId)?.values() ?? [])];
|
|
18
|
+
return { paths: [...new Set(active.flatMap(scope => scope.paths))].sort(), dirs: [...new Set(active.flatMap(scope => scope.dirs))].sort() };
|
|
19
|
+
}
|
|
20
|
+
export function denyFileScope(backend: BoardBackend, jobId: string) {
|
|
21
|
+
for (const scope of scopes.get(backend)?.get(jobId)?.values() ?? []) scope.onDenied?.();
|
|
22
|
+
}
|
|
23
|
+
export function onFileScope(backend: BoardBackend, jobId: string, listener: () => void) {
|
|
24
|
+
let jobs = listeners.get(backend); if (!jobs) { jobs = new Map(); listeners.set(backend, jobs); }
|
|
25
|
+
let set = jobs.get(jobId); if (!set) { set = new Set(); jobs.set(jobId, set); }
|
|
26
|
+
set.add(listener);
|
|
27
|
+
let active = true;
|
|
28
|
+
return () => { if (!active) return; active = false; set!.delete(listener); if (!set!.size && jobs!.get(jobId) === set) jobs!.delete(jobId); };
|
|
29
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { notifyCallback } from "../core/callbacks";
|
|
2
|
+
import { FileSyncCache, fileSyncScopeKey, type FileSyncScope } from "./fileSyncCache";
|
|
3
|
+
import { FileSyncProcessor } from "./fileSyncProcessor";
|
|
4
|
+
import { createFileWorkQueue } from "./fileSyncQueue";
|
|
5
|
+
|
|
6
|
+
export const FILE_SYNC_MAX = 100 * 1024 * 1024;
|
|
7
|
+
export interface FileSyncManifest { protocol: 1; namespace: string; revision: string; size: number; hash: string; chunks: { id: string; size: number }[] }
|
|
8
|
+
export interface FileSyncTransport {
|
|
9
|
+
/** null means 304 after the transport checked the authenticated namespace. */
|
|
10
|
+
manifest(jobId: string, path: string, options?: { signal?: AbortSignal; revision?: string }): Promise<FileSyncManifest | null>;
|
|
11
|
+
chunks(jobId: string, input: { path: string; revision: string; ids: string[] }, options?: { signal?: AbortSignal }): Promise<Uint8Array<ArrayBuffer>>;
|
|
12
|
+
}
|
|
13
|
+
export class FileSyncError extends Error {
|
|
14
|
+
constructor(message: string, readonly status: number, readonly code: string, readonly retryAfterMs?: number) { super(message); this.name = "FileSyncError"; }
|
|
15
|
+
}
|
|
16
|
+
export interface FileSyncProgress { phase: "manifest" | "chunks" | "verify"; loadedBytes: number; totalBytes: number; downloadedBytes: number; reusedBytes: number }
|
|
17
|
+
export interface FileSyncOptions { signal?: AbortSignal; onProgress?: (progress: FileSyncProgress) => void; decode?: boolean; range?: { start?: number; maxBytes: number; tail?: boolean }; pendingTimeoutMs?: number; /** Require a manifest request started after this explicit refresh. */ fresh?: boolean; /** Remember only successfully viewed ranges, never downloads. */ preview?: boolean }
|
|
18
|
+
export interface FilePreviewRefresh { changed: string[]; error?: Error }
|
|
19
|
+
export interface SyncedFile {
|
|
20
|
+
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
21
|
+
revision: string; namespace: string; hash: string; size: number; start: number; end: number; truncated: boolean;
|
|
22
|
+
text?: string; encoding?: string; downloadedBytes: number; reusedBytes: number; cacheMode: "persistent" | "session";
|
|
23
|
+
}
|
|
24
|
+
const hex = /^[a-f0-9]{64}$/;
|
|
25
|
+
export function validateFileSyncManifest(value: unknown): FileSyncManifest {
|
|
26
|
+
const m = value as FileSyncManifest;
|
|
27
|
+
if (!m || m.protocol !== 1 || typeof m.namespace !== "string" || !m.namespace || m.namespace.length > 1024 || typeof m.revision !== "string" || typeof m.hash !== "string" || !hex.test(m.revision) || !hex.test(m.hash)
|
|
28
|
+
|| !Number.isSafeInteger(m.size) || m.size < 0 || m.size > FILE_SYNC_MAX || !Array.isArray(m.chunks) || m.chunks.length > 10000) throw new FileSyncError("文件修订清单无效或超过100MiB", 502, "invalid_manifest");
|
|
29
|
+
let total = 0; const sizes = new Map<string, number>();
|
|
30
|
+
for (const chunk of m.chunks) {
|
|
31
|
+
if (!chunk || typeof chunk.id !== "string" || !hex.test(chunk.id) || !Number.isSafeInteger(chunk.size) || chunk.size <= 0 || chunk.size > 256 * 1024 || (sizes.has(chunk.id) && sizes.get(chunk.id) !== chunk.size)) throw new FileSyncError("文件分块清单无效", 502, "invalid_manifest");
|
|
32
|
+
total += chunk.size; sizes.set(chunk.id, chunk.size);
|
|
33
|
+
}
|
|
34
|
+
if (total !== m.size) throw new FileSyncError("文件分块长度与清单不一致", 502, "invalid_manifest");
|
|
35
|
+
return { protocol: 1, namespace: m.namespace, revision: m.revision, size: m.size, hash: m.hash, chunks: m.chunks.map(chunk => ({ id: chunk.id, size: chunk.size })) };
|
|
36
|
+
}
|
|
37
|
+
const aborted = () => new DOMException("文件同步已取消", "AbortError");
|
|
38
|
+
const check = (signal: AbortSignal) => { if (signal.aborted) throw aborted(); };
|
|
39
|
+
function untilCancelled<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
40
|
+
if (!signal) return promise;
|
|
41
|
+
if (signal.aborted) return Promise.reject(aborted());
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const cancel = () => { signal.removeEventListener("abort", cancel); reject(aborted()); };
|
|
44
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
45
|
+
promise.then(value => { signal.removeEventListener("abort", cancel); resolve(value); }, error => { signal.removeEventListener("abort", cancel); reject(error); });
|
|
46
|
+
if (signal.aborted) cancel();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const statusOf = (error: unknown) => error && typeof error === "object" && "status" in error ? Number(error.status) : 0;
|
|
50
|
+
async function retryPending<T>(work: () => Promise<T>, signal: AbortSignal, timeoutMs = 180_000): Promise<T> {
|
|
51
|
+
const deadline = Date.now() + (Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Math.min(timeoutMs, 180_000) : 180_000);
|
|
52
|
+
let pending = 0, failures = 0;
|
|
53
|
+
for (;;) {
|
|
54
|
+
check(signal);
|
|
55
|
+
try { return await work(); } catch (error) {
|
|
56
|
+
check(signal);
|
|
57
|
+
const status = statusOf(error), code = (error as FileSyncError)?.code;
|
|
58
|
+
const permanent = error instanceof Error && error.name === "AbortError"
|
|
59
|
+
|| ["client_queue_full", "client_queue_timeout", "integrity", "invalid_manifest", "invalid_response", "response_too_large", "unsupported", "file_sync_unsupported"].includes(code);
|
|
60
|
+
const retryable = !status || status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
61
|
+
if (permanent || (status !== 202 && (!retryable || ++failures > 3)) || Date.now() >= deadline) throw error;
|
|
62
|
+
const backoff = Math.min(8000, 500 * 2 ** (status === 202 ? pending++ : failures - 1));
|
|
63
|
+
const hint = Number((error as FileSyncError)?.retryAfterMs ?? (status === 202 ? 1000 : 0));
|
|
64
|
+
// Retry-After is a lower bound. Jitter spreads a burst without retrying before the server is ready.
|
|
65
|
+
const delay = Math.max(Number.isFinite(hint) && hint > 0 ? hint : 0, backoff) + Math.random() * Math.min(1000, backoff * .25);
|
|
66
|
+
if (delay > deadline - Date.now()) throw error;
|
|
67
|
+
await new Promise<void>((resolve, reject) => {
|
|
68
|
+
const cancel = () => { clearTimeout(timer); reject(aborted()); };
|
|
69
|
+
const timer = setTimeout(() => { signal.removeEventListener("abort", cancel); resolve(); }, delay);
|
|
70
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
71
|
+
if (signal.aborted) cancel();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
type Shared<T> = { controller: AbortController; promise: Promise<T>; consumers: Set<object>; progress: Map<object, (value: FileSyncProgress) => void> };
|
|
77
|
+
function lease<T>(map: Map<string, Shared<T>>, key: string, work: (signal: AbortSignal, notify: (value: FileSyncProgress) => void) => Promise<T>, signal?: AbortSignal, progress?: (value: FileSyncProgress) => void): Promise<T> {
|
|
78
|
+
if (signal?.aborted) return Promise.reject(aborted());
|
|
79
|
+
let entry = map.get(key);
|
|
80
|
+
if (!entry) {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const created = { controller, consumers: new Set<object>(), progress: new Map<object, (value: FileSyncProgress) => void>(), promise: null! as Promise<T> };
|
|
83
|
+
created.promise = Promise.resolve().then(() => work(controller.signal, value => { for (const callback of [...created.progress.values()]) notifyCallback(callback, value); })).finally(() => { if (map.get(key) === created) map.delete(key); });
|
|
84
|
+
void created.promise.catch(() => undefined); map.set(key, created); entry = created;
|
|
85
|
+
}
|
|
86
|
+
const owned = entry, consumer = {}; owned.consumers.add(consumer); if (progress) owned.progress.set(consumer, progress);
|
|
87
|
+
return new Promise<T>((resolve, reject) => {
|
|
88
|
+
let active = true;
|
|
89
|
+
const release = () => { active = false; signal?.removeEventListener("abort", cancel); owned.consumers.delete(consumer); owned.progress.delete(consumer); };
|
|
90
|
+
const cancel = () => { if (!active) return; release(); reject(aborted()); if (!owned.consumers.size) { owned.controller.abort(); if (map.get(key) === owned) map.delete(key); } };
|
|
91
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
92
|
+
owned.promise.then(value => { if (active) { release(); resolve(value); } }, error => { if (active) { release(); reject(error); } });
|
|
93
|
+
if (signal?.aborted) cancel();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Per-transport coordinator: every caller authorizes a manifest before any cached bytes are read. */
|
|
98
|
+
export function createFileSynchronizer(transport: FileSyncTransport, cache = new FileSyncCache()) {
|
|
99
|
+
const last = new Map<string, FileSyncManifest>(), manifests = new Map<string, Shared<FileSyncManifest>>(), files = new Map<string, Shared<SyncedFile>>(), chunks = new Map<string, Shared<Uint8Array<ArrayBuffer>>>();
|
|
100
|
+
type Preview = { namespace: string; revision: string; range?: FileSyncOptions["range"]; stale?: boolean };
|
|
101
|
+
const previews = new Map<string, Preview>(), previewReads = new Map<string, object>(), previewListeners = new Map<string, Set<() => void>>();
|
|
102
|
+
const manifestQueue = createFileWorkQueue(3), chunkQueue = createFileWorkQueue(3);
|
|
103
|
+
const assemblyQueue = createFileWorkQueue(2), refreshQueue = createFileWorkQueue(1);
|
|
104
|
+
const retireNamespace = (namespace: string) => {
|
|
105
|
+
for (const entries of [files, chunks]) for (const [key, entry] of entries) if (JSON.parse(JSON.parse(key)[0])[0] === namespace) { entry.controller.abort(); entries.delete(key); }
|
|
106
|
+
};
|
|
107
|
+
const retireFile = (scope: FileSyncScope) => {
|
|
108
|
+
const identity = fileSyncScopeKey(scope);
|
|
109
|
+
for (const entries of [files, chunks]) for (const [key, entry] of entries) if (JSON.parse(key)[0] === identity) { entry.controller.abort(); entries.delete(key); }
|
|
110
|
+
};
|
|
111
|
+
type QueuedChunk = { identity: string; jobId: string; path: string; revision: string; id: string; size: number; signal: AbortSignal; timeout?: number; resolve: (bytes: Uint8Array<ArrayBuffer>) => void; reject: (error: unknown) => void };
|
|
112
|
+
let queued: QueuedChunk[] = [], flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
+
const flush = () => {
|
|
114
|
+
flushTimer = undefined; const pending = queued; queued = [];
|
|
115
|
+
while (pending.length) {
|
|
116
|
+
const first = pending.shift()!;
|
|
117
|
+
if (first.signal.aborted) { first.reject(aborted()); continue; }
|
|
118
|
+
const batch = [first]; let size = first.size;
|
|
119
|
+
for (let i = 0; i < pending.length;) {
|
|
120
|
+
const item = pending[i];
|
|
121
|
+
if (item.identity === first.identity && item.revision === first.revision && size + item.size <= 1024 * 1024) { pending.splice(i, 1); if (item.signal.aborted) item.reject(aborted()); else { batch.push(item); size += item.size; } } else i++;
|
|
122
|
+
}
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const cancel = () => { if (batch.every(item => item.signal.aborted)) controller.abort(); };
|
|
125
|
+
for (const item of batch) item.signal.addEventListener("abort", cancel);
|
|
126
|
+
void retryPending(() => chunkQueue.run(() => transport.chunks(first.jobId, { path: first.path, revision: first.revision, ids: batch.map(item => item.id) }, { signal: controller.signal }), controller.signal), controller.signal, first.timeout).then(bytes => {
|
|
127
|
+
if (bytes.length !== size) throw new FileSyncError("文件块批次长度错误", 502, "integrity");
|
|
128
|
+
let offset = 0; for (const item of batch) { const data = bytes.slice(offset, offset + item.size); offset += item.size; if (item.signal.aborted) item.reject(aborted()); else item.resolve(data); }
|
|
129
|
+
}).catch(error => { for (const item of batch) item.reject(error); }).finally(() => { for (const item of batch) item.signal.removeEventListener("abort", cancel); });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const fetchChunk = (item: Omit<QueuedChunk, "resolve" | "reject">) => new Promise<Uint8Array<ArrayBuffer>>((resolve, reject) => {
|
|
133
|
+
queued.push({ ...item, resolve, reject }); if (flushTimer === undefined) flushTimer = setTimeout(flush, 12);
|
|
134
|
+
});
|
|
135
|
+
const manifestFor = async (jobId: string, path: string, signal?: AbortSignal, timeout?: number, fresh = false) => {
|
|
136
|
+
const resource = JSON.stringify([jobId, path]);
|
|
137
|
+
// A round-end check must start after that event. Sharing an older in-flight
|
|
138
|
+
// check can miss the final edit indefinitely when there is no polling.
|
|
139
|
+
const earlier = fresh ? manifests.get(resource) : undefined;
|
|
140
|
+
if (earlier) await untilCancelled(earlier.promise.catch(() => undefined), signal);
|
|
141
|
+
return lease(manifests, resource, async inner => {
|
|
142
|
+
const previous = last.get(resource);
|
|
143
|
+
try {
|
|
144
|
+
const response = await retryPending(() => manifestQueue.run(() => transport.manifest(jobId, path, { signal: inner, revision: previous?.revision }), inner), inner, timeout); check(inner);
|
|
145
|
+
const manifest = response ? validateFileSyncManifest(response) : previous;
|
|
146
|
+
if (!manifest) throw new FileSyncError("没有可复用的授权修订清单", 502, "invalid_manifest");
|
|
147
|
+
last.delete(resource); last.set(resource, manifest); while (last.size > 40) last.delete(last.keys().next().value!);
|
|
148
|
+
return manifest;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const status = statusOf(error);
|
|
151
|
+
if ([401, 403, 404].includes(status)) {
|
|
152
|
+
last.delete(resource);
|
|
153
|
+
if (previous) {
|
|
154
|
+
if (status === 404) { const scope = { namespace: previous.namespace, taskId: jobId, path }; retireFile(scope); await cache.purgeFile(scope); }
|
|
155
|
+
else { retireNamespace(previous.namespace); await cache.purgeNamespace(previous.namespace); }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}, signal);
|
|
161
|
+
};
|
|
162
|
+
const read = async (jobId: string, path: string, options: FileSyncOptions = {}, initialManifest?: FileSyncManifest): Promise<SyncedFile> => {
|
|
163
|
+
if (!path || path.length > 4096 || path.includes("\0")) throw new FileSyncError("文件路径无效", 400, "invalid_path");
|
|
164
|
+
if (options.range && (!Number.isSafeInteger(options.range.maxBytes) || options.range.maxBytes < 0 || !Number.isSafeInteger(options.range.start ?? 0) || (options.range.start ?? 0) < 0)) throw new FileSyncError("文件读取范围无效", 400, "invalid_range");
|
|
165
|
+
for (let attempt = 0; ; attempt++) {
|
|
166
|
+
notifyCallback(options.onProgress, { phase: "manifest", loadedBytes: 0, totalBytes: 0, downloadedBytes: 0, reusedBytes: 0 });
|
|
167
|
+
const manifest = attempt === 0 && initialManifest ? initialManifest : await manifestFor(jobId, path, options.signal, options.pendingTimeoutMs, options.fresh || attempt > 0);
|
|
168
|
+
const start = options.range?.tail ? Math.max(0, manifest.size - options.range.maxBytes) : Math.min(manifest.size, options.range?.start ?? 0);
|
|
169
|
+
const end = options.range ? Math.min(manifest.size, start + options.range.maxBytes) : manifest.size;
|
|
170
|
+
const scope: FileSyncScope = { namespace: manifest.namespace, taskId: jobId, path };
|
|
171
|
+
if (options.signal?.aborted) throw aborted();
|
|
172
|
+
await untilCancelled(cache.authorize(scope, manifest.revision), options.signal);
|
|
173
|
+
if (options.signal?.aborted) throw aborted();
|
|
174
|
+
const identity = fileSyncScopeKey(scope), key = JSON.stringify([identity, manifest.revision, start, end, options.decode !== false]);
|
|
175
|
+
try {
|
|
176
|
+
return await lease(files, key, (outerSignal, progress) => assemblyQueue.run(async () => {
|
|
177
|
+
const controller = new AbortController(), cancel = () => controller.abort();
|
|
178
|
+
outerSignal.addEventListener("abort", cancel, { once: true }); if (outerSignal.aborted) cancel();
|
|
179
|
+
const signal = controller.signal;
|
|
180
|
+
const processor = new FileSyncProcessor(signal);
|
|
181
|
+
let downloadedBytes = 0, reusedBytes = 0, loadedBytes = 0;
|
|
182
|
+
const report = (phase: FileSyncProgress["phase"]) => progress({ phase, loadedBytes, totalBytes: end - start, downloadedBytes, reusedBytes });
|
|
183
|
+
let offset = 0;
|
|
184
|
+
const selected = manifest.chunks.map(chunk => { const value = { ...chunk, offset }; offset += chunk.size; return value; }).filter(chunk => chunk.offset < end && chunk.offset + chunk.size > start);
|
|
185
|
+
const release = cache.retain(scope, new Set(selected.map(chunk => chunk.id)));
|
|
186
|
+
const coverStart = selected[0]?.offset ?? start, coverSize = selected.reduce((sum, chunk) => sum + chunk.size, 0);
|
|
187
|
+
const network = async (chunk: { id: string; size: number }) => {
|
|
188
|
+
const bytes = await lease(chunks, JSON.stringify([identity, manifest.revision, chunk.id]), inner => fetchChunk({ identity, jobId, path, revision: manifest.revision, id: chunk.id, size: chunk.size, signal: inner, timeout: options.pendingTimeoutMs }), signal);
|
|
189
|
+
check(signal); if (bytes.byteLength !== chunk.size) throw new FileSyncError("文件块长度错误", 502, "integrity");
|
|
190
|
+
const verified = await processor.verify(bytes.slice(), chunk.id); check(signal); downloadedBytes += verified.byteLength;
|
|
191
|
+
await cache.putBlock(scope, chunk.id, verified); check(signal); return verified;
|
|
192
|
+
};
|
|
193
|
+
const read = async (chunk: { id: string; size: number }) => {
|
|
194
|
+
const cached = await cache.getBlock(scope, chunk.id); check(signal);
|
|
195
|
+
if (cached?.byteLength === chunk.size) return { bytes: cached, cached: true };
|
|
196
|
+
if (cached) await cache.removeBlock(scope, chunk.id);
|
|
197
|
+
return { bytes: await network(chunk), cached: false };
|
|
198
|
+
};
|
|
199
|
+
const prefetched = new Map<number, ReturnType<typeof read>>();
|
|
200
|
+
const prefetch = (index: number) => { let bytes = 0; for (let i = index; i < Math.min(selected.length, index + 48); i++) { bytes += selected[i].size; if (bytes > 3 * 1024 * 1024) break; if (!prefetched.has(i)) { const promise = read(selected[i]); void promise.catch(() => undefined); prefetched.set(i, promise); } } };
|
|
201
|
+
try {
|
|
202
|
+
await processor.start(coverSize); prefetch(0);
|
|
203
|
+
for (let index = 0; index < selected.length; index++) {
|
|
204
|
+
check(signal); const chunk = selected[index]; const data = await prefetched.get(index)!; prefetched.delete(index);
|
|
205
|
+
try { await processor.append(data.bytes, chunk.id); if (data.cached) reusedBytes += chunk.size; }
|
|
206
|
+
catch (error) {
|
|
207
|
+
check(signal); if (!data.cached) throw error;
|
|
208
|
+
// Several prefetched positions may hold the same old corrupt
|
|
209
|
+
// block. Reuse a repair already committed by an earlier position.
|
|
210
|
+
const repaired = await cache.getBlock(scope, chunk.id); check(signal);
|
|
211
|
+
let reused = false;
|
|
212
|
+
if (repaired?.byteLength === chunk.size) try { await processor.append(repaired, chunk.id); reused = true; reusedBytes += chunk.size; } catch { check(signal); }
|
|
213
|
+
if (!reused) { await cache.removeBlock(scope, chunk.id); await processor.append(await network(chunk), chunk.id); }
|
|
214
|
+
}
|
|
215
|
+
loadedBytes = Math.min(end - start, chunk.offset + chunk.size - start); report("chunks"); prefetch(index + 1);
|
|
216
|
+
}
|
|
217
|
+
report("verify");
|
|
218
|
+
const complete = start === 0 && end === manifest.size;
|
|
219
|
+
const assembled = await processor.finish(complete ? manifest.hash : null, options.decode !== false, { start: start - coverStart, length: end - start, fromMiddle: start > 0 });
|
|
220
|
+
check(signal); const latest = last.get(JSON.stringify([jobId, path]));
|
|
221
|
+
if (complete && latest?.namespace === manifest.namespace && latest.revision === manifest.revision) await cache.putManifest(scope, manifest); check(signal);
|
|
222
|
+
return { ...assembled, namespace: manifest.namespace, revision: manifest.revision, hash: manifest.hash, size: manifest.size, start, end, truncated: !complete, downloadedBytes, reusedBytes, cacheMode: cache.mode };
|
|
223
|
+
} finally { controller.abort(); outerSignal.removeEventListener("abort", cancel); processor.dispose(); release(); }
|
|
224
|
+
}, outerSignal), options.signal, options.onProgress);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (statusOf(error) !== 410 || attempt >= 2 || options.signal?.aborted) throw error;
|
|
227
|
+
last.delete(JSON.stringify([jobId, path]));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const sync = async (jobId: string, path: string, options: FileSyncOptions = {}): Promise<SyncedFile> => {
|
|
232
|
+
const resource = JSON.stringify([jobId, path]), token = {};
|
|
233
|
+
if (options.preview) previewReads.set(resource, token);
|
|
234
|
+
try {
|
|
235
|
+
const result = await read(jobId, path, options);
|
|
236
|
+
if (options.preview && previewReads.get(resource) === token && !options.signal?.aborted) {
|
|
237
|
+
const previous = previews.get(resource);
|
|
238
|
+
const range = options.range ? { start: options.range.start ?? 0, maxBytes: options.range.maxBytes, tail: !!options.range.tail } : undefined;
|
|
239
|
+
// A preview can finish behind a metadata baseline or a newer background refresh.
|
|
240
|
+
// Normal revision-only updates must not create a watch -> preview -> watch cycle.
|
|
241
|
+
const latest = last.get(resource), behindLatest = latest && (latest.namespace !== result.namespace || latest.revision !== result.revision);
|
|
242
|
+
const alreadyRefreshed = behindLatest && previous?.namespace === latest.namespace && previous.revision === latest.revision && JSON.stringify(previous.range) === JSON.stringify(range);
|
|
243
|
+
if (alreadyRefreshed) {
|
|
244
|
+
// Keep the latest verified revision, but request one corrective event
|
|
245
|
+
// because this caller is about to display an older snapshot.
|
|
246
|
+
previews.set(resource, { ...previous, stale: true });
|
|
247
|
+
} else {
|
|
248
|
+
previews.delete(resource); previews.set(resource, { namespace: result.namespace, revision: result.revision, range });
|
|
249
|
+
while (previews.size > 40) previews.delete(previews.keys().next().value!);
|
|
250
|
+
}
|
|
251
|
+
if (!previous || behindLatest || previous.namespace !== result.namespace || JSON.stringify(previous.range) !== JSON.stringify(range)) {
|
|
252
|
+
for (const callback of [...previewListeners.get(jobId) ?? []]) notifyCallback(callback, undefined);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
} finally { if (previewReads.get(resource) === token) previewReads.delete(resource); }
|
|
257
|
+
};
|
|
258
|
+
const refreshPreviews = (jobId: string, paths: Iterable<string>, signal?: AbortSignal): Promise<FilePreviewRefresh> => {
|
|
259
|
+
const requested = [...new Set(paths)];
|
|
260
|
+
// Serialize background assembly across tasks as well: a chunk request limit alone does not
|
|
261
|
+
// prevent several 100MiB workers allocating full buffers at the same time.
|
|
262
|
+
const work = async (): Promise<FilePreviewRefresh> => {
|
|
263
|
+
const changed = new Set<string>(); let error: Error | undefined, hasRecoverableError = false, retryAfterMs = 0;
|
|
264
|
+
for (const path of requested) {
|
|
265
|
+
if (signal?.aborted) throw aborted();
|
|
266
|
+
const resource = JSON.stringify([jobId, path]), previous = previews.get(resource);
|
|
267
|
+
if (!previous) continue; // Restored/unread tabs are still demand-loaded on activation.
|
|
268
|
+
try {
|
|
269
|
+
const manifest = await manifestFor(jobId, path, signal, undefined, true);
|
|
270
|
+
if (manifest.namespace === previous.namespace && manifest.revision === previous.revision && !previous.stale) continue;
|
|
271
|
+
changed.add(path);
|
|
272
|
+
const result = await read(jobId, path, { signal, decode: false, range: previous.range }, manifest);
|
|
273
|
+
const latest = last.get(resource), current = previews.get(resource);
|
|
274
|
+
if (current?.namespace === previous.namespace && current.revision === previous.revision && JSON.stringify(current.range) === JSON.stringify(previous.range)
|
|
275
|
+
&& latest?.namespace === result.namespace && latest.revision === result.revision) {
|
|
276
|
+
previews.set(resource, { ...current, namespace: result.namespace, revision: result.revision, stale: false });
|
|
277
|
+
}
|
|
278
|
+
} catch (failure) {
|
|
279
|
+
const status = statusOf(failure);
|
|
280
|
+
if (signal?.aborted || (failure instanceof Error && failure.name === "AbortError") || status === 401 || status === 403) throw failure;
|
|
281
|
+
if (status === 404) {
|
|
282
|
+
changed.add(path);
|
|
283
|
+
if (previews.get(resource) === previous) {
|
|
284
|
+
previews.delete(resource);
|
|
285
|
+
// Deletion may arrive from a chunk request after manifest authorization too.
|
|
286
|
+
const scope = { namespace: last.get(resource)?.namespace ?? previous.namespace, taskId: jobId, path };
|
|
287
|
+
last.delete(resource); retireFile(scope); await cache.purgeFile(scope);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
const candidate = failure instanceof Error ? failure : new Error(String(failure));
|
|
291
|
+
const code = (failure as FileSyncError)?.code;
|
|
292
|
+
// Match event-driven workspace recovery: a permanent failure of one
|
|
293
|
+
// file must not hide a later file's recoverable failure.
|
|
294
|
+
const recoverable = !["invalid_manifest", "invalid_response", "integrity", "response_too_large", "client_queue_timeout"].includes(code)
|
|
295
|
+
&& (!status || [408, 429, 500, 502, 503, 504].includes(status));
|
|
296
|
+
if (!error || (recoverable && !hasRecoverableError)) error = candidate;
|
|
297
|
+
if (recoverable) {
|
|
298
|
+
hasRecoverableError = true;
|
|
299
|
+
const hint = (failure as FileSyncError)?.retryAfterMs;
|
|
300
|
+
if (typeof hint === "number" && Number.isFinite(hint) && hint >= 0) retryAfterMs = Math.max(retryAfterMs, hint);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Preserve the original errors: consumers may share them with other reads.
|
|
306
|
+
const combined = error && hasRecoverableError && retryAfterMs > 0 ? Object.assign(new Error(error.message), error, { name: error.name, retryAfterMs }) : error;
|
|
307
|
+
return { changed: [...changed], ...(combined ? { error: combined } : {}) };
|
|
308
|
+
};
|
|
309
|
+
return refreshQueue.run(work, signal);
|
|
310
|
+
};
|
|
311
|
+
const onPreviewScope = (jobId: string, callback: () => void) => {
|
|
312
|
+
let listeners = previewListeners.get(jobId); if (!listeners) { listeners = new Set(); previewListeners.set(jobId, listeners); }
|
|
313
|
+
listeners.add(callback);
|
|
314
|
+
return () => { listeners.delete(callback); if (!listeners.size) previewListeners.delete(jobId); };
|
|
315
|
+
};
|
|
316
|
+
return { sync, refreshPreviews, onPreviewScope, cache, clearNamespace: (namespace: string) => {
|
|
317
|
+
for (const entry of manifests.values()) entry.controller.abort(); manifests.clear();
|
|
318
|
+
retireNamespace(namespace);
|
|
319
|
+
for (const [key, manifest] of last) if (manifest.namespace === namespace) last.delete(key);
|
|
320
|
+
for (const [key, preview] of previews) if (preview.namespace === namespace) previews.delete(key);
|
|
321
|
+
return cache.purgeNamespace(namespace);
|
|
322
|
+
} };
|
|
323
|
+
}
|
|
324
|
+
const synchronizers = new WeakMap<FileSyncTransport, ReturnType<typeof createFileSynchronizer>>();
|
|
325
|
+
function synchronizerFor(transport: FileSyncTransport) {
|
|
326
|
+
let sync = synchronizers.get(transport); if (!sync) { sync = createFileSynchronizer(transport); synchronizers.set(transport, sync); }
|
|
327
|
+
return sync;
|
|
328
|
+
}
|
|
329
|
+
export function syncFile(transport: FileSyncTransport, jobId: string, path: string, options: FileSyncOptions = {}) {
|
|
330
|
+
return synchronizerFor(transport).sync(jobId, path, options);
|
|
331
|
+
}
|
|
332
|
+
/** Refresh only ranges that were actually previewed, one file at a time. */
|
|
333
|
+
export function refreshFilePreviews(transport: FileSyncTransport, jobId: string, paths: Iterable<string>, signal?: AbortSignal) {
|
|
334
|
+
return synchronizerFor(transport).refreshPreviews(jobId, paths, signal);
|
|
335
|
+
}
|
|
336
|
+
export function onFilePreviewScope(transport: FileSyncTransport, jobId: string, callback: () => void) {
|
|
337
|
+
return synchronizerFor(transport).onPreviewScope(jobId, callback);
|
|
338
|
+
}
|
|
339
|
+
/** Hosts can call this on an explicit account logout; a viewer unmount keeps reusable cache. */
|
|
340
|
+
export function clearFileSyncNamespace(transport: FileSyncTransport, namespace: string) {
|
|
341
|
+
const synchronizer = synchronizers.get(transport);
|
|
342
|
+
return synchronizer ? synchronizer.clearNamespace(namespace) : new FileSyncCache().purgeNamespace(namespace);
|
|
343
|
+
}
|