@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
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { FileSyncManifest } from "./fileSync";
|
|
2
|
+
|
|
3
|
+
export interface FileSyncScope { namespace: string; taskId: string; path: string }
|
|
4
|
+
export const fileSyncScopeKey = (scope: FileSyncScope) => JSON.stringify([scope.namespace, scope.taskId, scope.path]);
|
|
5
|
+
type CachedFile = { key: string; namespace: string; bytes: number; touched: number; manifest?: FileSyncManifest; authorizedRevision?: string };
|
|
6
|
+
type CachedBlock = { key: string; scope: string; id: string; bytes: ArrayBuffer; size: number; touched: number };
|
|
7
|
+
export interface FileSyncCacheOptions { indexedDB?: IDBFactory | null; name?: string; maxBytes?: number }
|
|
8
|
+
const CACHE_TIMEOUT_MS = 5000;
|
|
9
|
+
const unavailable = new WeakSet<IDBDatabase>(), expired = new WeakSet<IDBTransaction>();
|
|
10
|
+
const cacheTimeout = () => new Error("本地文件缓存响应超时");
|
|
11
|
+
function abandon(tx: IDBTransaction | null) {
|
|
12
|
+
if (!tx) return;
|
|
13
|
+
expired.add(tx); unavailable.add(tx.db);
|
|
14
|
+
try { tx.abort(); } catch { /* A completed transaction cannot be aborted. */ }
|
|
15
|
+
tx.db.close();
|
|
16
|
+
}
|
|
17
|
+
const request = <T>(value: IDBRequest<T>) => new Promise<T>((resolve, reject) => {
|
|
18
|
+
const finish = (error?: unknown) => { clearTimeout(timer); value.onsuccess = value.onerror = null; if (error) reject(error); else resolve(value.result); };
|
|
19
|
+
const timer = setTimeout(() => { abandon(value.transaction); finish(cacheTimeout()); }, CACHE_TIMEOUT_MS);
|
|
20
|
+
value.onsuccess = () => finish(value.transaction && expired.has(value.transaction) ? cacheTimeout() : undefined);
|
|
21
|
+
value.onerror = () => finish(value.error ?? new Error("文件缓存读取失败"));
|
|
22
|
+
});
|
|
23
|
+
const completed = (tx: IDBTransaction) => {
|
|
24
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
25
|
+
const finish = (error?: unknown) => { clearTimeout(timer); tx.oncomplete = tx.onabort = tx.onerror = null; if (error) reject(error); else resolve(); };
|
|
26
|
+
const timer = setTimeout(() => { abandon(tx); finish(cacheTimeout()); }, CACHE_TIMEOUT_MS);
|
|
27
|
+
tx.oncomplete = () => finish(); tx.onabort = tx.onerror = () => finish(tx.error ?? new Error("文件缓存事务失败"));
|
|
28
|
+
});
|
|
29
|
+
void done.catch(() => undefined); return done;
|
|
30
|
+
};
|
|
31
|
+
function visitCursor(cursor: IDBRequest<IDBCursor | null>, visit: (item: IDBCursor) => boolean | void) {
|
|
32
|
+
return new Promise<void>((resolve, reject) => {
|
|
33
|
+
const finish = (error?: unknown) => { clearTimeout(timer); cursor.onsuccess = cursor.onerror = null; if (error) reject(error); else resolve(); };
|
|
34
|
+
const timer = setTimeout(() => { abandon(cursor.transaction); finish(cacheTimeout()); }, CACHE_TIMEOUT_MS);
|
|
35
|
+
cursor.onerror = () => finish(cursor.error ?? new Error("文件缓存索引读取失败"));
|
|
36
|
+
cursor.onsuccess = () => {
|
|
37
|
+
try {
|
|
38
|
+
if (cursor.transaction && expired.has(cursor.transaction)) throw cacheTimeout();
|
|
39
|
+
const item = cursor.result;
|
|
40
|
+
if (!item || visit(item) === false) finish(); else item.continue();
|
|
41
|
+
} catch (error) { finish(error); }
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const blockKey = (scope: string, id: string) => JSON.stringify([scope, id]);
|
|
46
|
+
const isBuffer = (value: unknown): value is ArrayBuffer => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
|
|
47
|
+
|
|
48
|
+
/** Only authenticated namespaces reach this cache. A cache miss never substitutes another scope. */
|
|
49
|
+
export class FileSyncCache {
|
|
50
|
+
private database: Promise<IDBDatabase | null>;
|
|
51
|
+
private writes: Promise<unknown> = Promise.resolve();
|
|
52
|
+
private memory = new Map<string, CachedBlock>();
|
|
53
|
+
private files = new Map<string, CachedFile>();
|
|
54
|
+
private leases = new Map<string, number>();
|
|
55
|
+
private protectedBlocks = new Map<string, Map<string, number>>();
|
|
56
|
+
private touches = new Map<string, number>();
|
|
57
|
+
private budget: number;
|
|
58
|
+
private fallback = false;
|
|
59
|
+
private estimate: { at: number; available: number } | null = null;
|
|
60
|
+
constructor(options: FileSyncCacheOptions = {}) {
|
|
61
|
+
this.budget = Math.max(0, options.maxBytes ?? 256 * 1024 * 1024);
|
|
62
|
+
const factory = options.indexedDB === undefined ? globalThis.indexedDB : options.indexedDB;
|
|
63
|
+
this.database = !factory ? Promise.resolve(null) : new Promise(resolve => {
|
|
64
|
+
let abandoned = false;
|
|
65
|
+
let opened: IDBOpenDBRequest;
|
|
66
|
+
try { opened = factory.open(options.name ?? "cli-ui-file-sync-v1", 2); } catch { resolve(null); return; }
|
|
67
|
+
const timer = setTimeout(() => { abandoned = true; try { opened.transaction?.abort(); } catch { /* The open can complete later. */ } resolve(null); }, CACHE_TIMEOUT_MS);
|
|
68
|
+
opened.onupgradeneeded = event => {
|
|
69
|
+
if (abandoned) { opened.transaction?.abort(); return; }
|
|
70
|
+
const db = opened.result;
|
|
71
|
+
if (!db.objectStoreNames.contains("files")) db.createObjectStore("files", { keyPath: "key" });
|
|
72
|
+
const blocks = db.objectStoreNames.contains("blocks") ? opened.transaction!.objectStore("blocks") : db.createObjectStore("blocks", { keyPath: "key" });
|
|
73
|
+
if (!blocks.indexNames.contains("scope")) blocks.createIndex("scope", "scope");
|
|
74
|
+
if (!blocks.indexNames.contains("lru")) blocks.createIndex("lru", ["touched", "size"]);
|
|
75
|
+
if (event.oldVersion === 1) { const cursor = blocks.openCursor(); cursor.onsuccess = () => { if (abandoned) return; const item = cursor.result; if (item) { item.update({ ...item.value, size: item.value.bytes.byteLength, touched: item.value.touched ?? 0 }); item.continue(); } }; }
|
|
76
|
+
};
|
|
77
|
+
opened.onerror = opened.onblocked = () => { abandoned = true; clearTimeout(timer); resolve(null); };
|
|
78
|
+
opened.onsuccess = () => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
const db = opened.result;
|
|
81
|
+
if (abandoned) { db.close(); return; }
|
|
82
|
+
db.onversionchange = () => { db.close(); unavailable.add(db); this.fallback = true; };
|
|
83
|
+
db.onclose = () => { unavailable.add(db); this.fallback = true; };
|
|
84
|
+
resolve(db);
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
get mode(): "persistent" | "session" { return this.fallback ? "session" : this.persistent ? "persistent" : "session"; }
|
|
89
|
+
private persistent = false;
|
|
90
|
+
private async db() {
|
|
91
|
+
const db = await this.database;
|
|
92
|
+
if (db && unavailable.has(db)) { this.fallback = true; return null; }
|
|
93
|
+
this.persistent = !!db;
|
|
94
|
+
return db;
|
|
95
|
+
}
|
|
96
|
+
retain(scope: FileSyncScope, ids: ReadonlySet<string> = new Set()) {
|
|
97
|
+
const key = fileSyncScopeKey(scope); this.leases.set(key, (this.leases.get(key) ?? 0) + 1);
|
|
98
|
+
const protectedIds = this.protectedBlocks.get(key) ?? new Map<string, number>(); for (const id of ids) protectedIds.set(id, (protectedIds.get(id) ?? 0) + 1); this.protectedBlocks.set(key, protectedIds);
|
|
99
|
+
let active = true;
|
|
100
|
+
return () => { if (!active) return; active = false; const count = (this.leases.get(key) ?? 1) - 1; if (count) this.leases.set(key, count); else this.leases.delete(key); for (const id of ids) { const uses = (protectedIds.get(id) ?? 1) - 1; if (uses) protectedIds.set(id, uses); else protectedIds.delete(id); } if (!protectedIds.size) this.protectedBlocks.delete(key); };
|
|
101
|
+
}
|
|
102
|
+
private serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
103
|
+
const next = this.writes.then(work, work); this.writes = next.catch(() => undefined); return next;
|
|
104
|
+
}
|
|
105
|
+
private touch(key: string) {
|
|
106
|
+
const now = Date.now(); if (this.touches.has(key) && now - this.touches.get(key)! < 5000) return;
|
|
107
|
+
this.touches.set(key, now); const file = this.files.get(key); if (file) file.touched = now;
|
|
108
|
+
void this.serialize(async () => {
|
|
109
|
+
const db = await this.db(); if (!db || this.fallback) return;
|
|
110
|
+
try { const tx = db.transaction("files", "readwrite"), done = completed(tx); const files = tx.objectStore("files"), file = await request(files.get(key)) as CachedFile | undefined; if (file) files.put({ ...file, touched: now }); await done; } catch { /* LRU hints are not required for reading verified bytes. */ }
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async getBlock(scope: FileSyncScope, id: string): Promise<Uint8Array<ArrayBuffer> | null> {
|
|
114
|
+
const scopeKey = fileSyncScopeKey(scope), key = blockKey(scopeKey, id), hot = this.memory.get(key);
|
|
115
|
+
if (hot) { this.touch(scopeKey); return new Uint8Array(hot.bytes.slice(0)); }
|
|
116
|
+
const db = await this.db(); if (!db) return null;
|
|
117
|
+
try { const tx = db.transaction("blocks", "readonly"); const item = await request(tx.objectStore("blocks").get(key)) as CachedBlock | undefined; if (item && (!isBuffer(item.bytes) || item.scope !== scopeKey || item.id !== id)) { await this.removeBlock(scope, id); return null; } if (item) this.touch(scopeKey); return item ? new Uint8Array(item.bytes) : null; }
|
|
118
|
+
catch { this.fallback = true; return null; }
|
|
119
|
+
}
|
|
120
|
+
private async deleteDiskFile(tx: IDBTransaction, key: string) {
|
|
121
|
+
const blocks = tx.objectStore("blocks"), index = blocks.index("scope");
|
|
122
|
+
await visitCursor(index.openKeyCursor(IDBKeyRange.only(key)), item => { blocks.delete(item.primaryKey); });
|
|
123
|
+
tx.objectStore("files").delete(key);
|
|
124
|
+
}
|
|
125
|
+
private memoryPut(scope: FileSyncScope, id: string, bytes: Uint8Array<ArrayBuffer>) {
|
|
126
|
+
const key = fileSyncScopeKey(scope), entryKey = blockKey(key, id);
|
|
127
|
+
let used = [...this.memory.values()].reduce((n, block) => n + block.bytes.byteLength, 0);
|
|
128
|
+
const replaced = this.memory.get(entryKey)?.bytes.byteLength ?? 0; used -= replaced;
|
|
129
|
+
for (const file of [...this.files.values()].sort((a, b) => a.touched - b.touched)) {
|
|
130
|
+
if (used + bytes.byteLength <= this.budget) break;
|
|
131
|
+
if (this.leases.has(file.key)) continue;
|
|
132
|
+
for (const [oldKey, block] of this.memory) if (block.scope === file.key) { used -= block.bytes.byteLength; this.memory.delete(oldKey); }
|
|
133
|
+
this.files.delete(file.key);
|
|
134
|
+
}
|
|
135
|
+
for (const [oldKey, block] of [...this.memory].sort((a, b) => a[1].touched - b[1].touched)) {
|
|
136
|
+
if (used + bytes.byteLength <= this.budget) break;
|
|
137
|
+
if (oldKey === entryKey || this.protectedBlocks.get(block.scope)?.has(block.id)) continue;
|
|
138
|
+
used -= block.bytes.byteLength; this.memory.delete(oldKey); const file = this.files.get(block.scope); if (file) file.bytes -= block.bytes.byteLength;
|
|
139
|
+
}
|
|
140
|
+
if (used + bytes.byteLength > this.budget) return false;
|
|
141
|
+
this.memory.set(entryKey, { key: entryKey, scope: key, id, bytes: bytes.slice().buffer, size: bytes.byteLength, touched: Date.now() });
|
|
142
|
+
const previous = this.files.get(key); this.files.set(key, { ...previous, key, namespace: scope.namespace, bytes: (previous?.bytes ?? 0) + bytes.byteLength - replaced, touched: Date.now() });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
putBlock(scope: FileSyncScope, id: string, bytes: Uint8Array<ArrayBuffer>): Promise<boolean> {
|
|
146
|
+
return this.serialize(async () => {
|
|
147
|
+
const db = await this.db();
|
|
148
|
+
if (!db || this.fallback) { this.fallback = true; return this.memoryPut(scope, id, bytes); }
|
|
149
|
+
const key = fileSyncScopeKey(scope), entryKey = blockKey(key, id);
|
|
150
|
+
let transaction: IDBTransaction | undefined;
|
|
151
|
+
try {
|
|
152
|
+
if (!this.estimate || Date.now() - this.estimate.at > 5000) {
|
|
153
|
+
const values = await request(db.transaction("files", "readonly").objectStore("files").getAll()) as CachedFile[];
|
|
154
|
+
if (values.some(file => !Number.isSafeInteger(file.bytes) || file.bytes < 0)) throw new Error("文件缓存计账已损坏");
|
|
155
|
+
const owned = values.reduce((sum, file) => sum + file.bytes, 0);
|
|
156
|
+
let available = this.budget;
|
|
157
|
+
let estimateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
try {
|
|
159
|
+
const estimate = await Promise.race([navigator.storage?.estimate(), new Promise<undefined>(resolve => { estimateTimer = setTimeout(() => resolve(undefined), 1000); })]);
|
|
160
|
+
if (estimate?.quota !== undefined && estimate.usage !== undefined) available = Math.min(available, Math.max(0, estimate.quota - estimate.usage + owned));
|
|
161
|
+
} catch { /* Best-effort estimates cannot disable viewing. */ }
|
|
162
|
+
finally { if (estimateTimer !== undefined) clearTimeout(estimateTimer); }
|
|
163
|
+
this.estimate = { at: Date.now(), available };
|
|
164
|
+
}
|
|
165
|
+
const available = this.estimate.available;
|
|
166
|
+
// Read accounting and mutate it under the same IndexedDB write lock,
|
|
167
|
+
// including writers in other tabs or cache instances.
|
|
168
|
+
const update = transaction = db.transaction(["blocks", "files"], "readwrite"), committed = completed(update);
|
|
169
|
+
const existing = await request(update.objectStore("blocks").get(entryKey)) as CachedBlock | undefined;
|
|
170
|
+
const all = await request(update.objectStore("files").getAll()) as CachedFile[];
|
|
171
|
+
if (all.some(file => !Number.isSafeInteger(file.bytes) || file.bytes < 0)) throw new Error("文件缓存计账已损坏");
|
|
172
|
+
let used = all.reduce((sum, file) => sum + file.bytes, 0);
|
|
173
|
+
const added = bytes.byteLength - (isBuffer(existing?.bytes) ? existing!.bytes.byteLength : 0);
|
|
174
|
+
for (const file of all.sort((a, b) => a.touched - b.touched)) {
|
|
175
|
+
if (used + added <= available) break;
|
|
176
|
+
if (this.leases.has(file.key) || file.key === key) continue;
|
|
177
|
+
await this.deleteDiskFile(update, file.key); used -= file.bytes;
|
|
178
|
+
}
|
|
179
|
+
if (used + added > available) await visitCursor(update.objectStore("blocks").index("lru").openKeyCursor(), item => {
|
|
180
|
+
if (used + added <= available) return false;
|
|
181
|
+
const [scopeKey, id] = JSON.parse(String(item.primaryKey)) as [string, string];
|
|
182
|
+
if (String(item.primaryKey) !== entryKey && !this.protectedBlocks.get(scopeKey)?.has(id)) {
|
|
183
|
+
const size = Number((item.key as number[])[1]); if (!Number.isSafeInteger(size) || size < 0) throw new Error("文件缓存计账已损坏");
|
|
184
|
+
used -= size; update.objectStore("blocks").delete(item.primaryKey);
|
|
185
|
+
const file = all.find(value => value.key === scopeKey); if (file) { file.bytes = Math.max(0, file.bytes - size); update.objectStore("files").put(file); }
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
if (used + added > available) { await committed; return false; }
|
|
189
|
+
const previous = all.find(file => file.key === key);
|
|
190
|
+
update.objectStore("blocks").put({ key: entryKey, scope: key, id, bytes: bytes.slice().buffer, size: bytes.byteLength, touched: Date.now() } satisfies CachedBlock);
|
|
191
|
+
update.objectStore("files").put({ ...previous, key, namespace: scope.namespace, bytes: Math.max(0, (previous?.bytes ?? 0) + added), touched: Date.now() } satisfies CachedFile);
|
|
192
|
+
await committed; return true;
|
|
193
|
+
} catch { try { transaction?.abort(); } catch { /* A failed request may already have aborted it. */ } this.fallback = true; return this.memoryPut(scope, id, bytes); }
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
removeBlock(scope: FileSyncScope, id: string) {
|
|
197
|
+
return this.serialize(async () => {
|
|
198
|
+
const key = fileSyncScopeKey(scope), entryKey = blockKey(key, id), previous = this.memory.get(entryKey); this.memory.delete(entryKey);
|
|
199
|
+
const memoryFile = this.files.get(key); if (memoryFile && previous) memoryFile.bytes = Math.max(0, memoryFile.bytes - previous.bytes.byteLength);
|
|
200
|
+
const db = await this.db(); if (!db) return;
|
|
201
|
+
try {
|
|
202
|
+
const tx = db.transaction(["blocks", "files"], "readwrite"), done = completed(tx);
|
|
203
|
+
const block = await request(tx.objectStore("blocks").get(entryKey)) as CachedBlock | undefined;
|
|
204
|
+
const file = await request(tx.objectStore("files").get(key)) as CachedFile | undefined;
|
|
205
|
+
tx.objectStore("blocks").delete(entryKey);
|
|
206
|
+
if (file && block) tx.objectStore("files").put({ ...file, bytes: Math.max(0, file.bytes - (isBuffer(block.bytes) ? block.bytes.byteLength : Number(block.size) || 0)) });
|
|
207
|
+
await done;
|
|
208
|
+
} catch { this.fallback = true; }
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
authorize(scope: FileSyncScope, revision: string) {
|
|
212
|
+
return this.serialize(async () => {
|
|
213
|
+
const key = fileSyncScopeKey(scope), db = await this.db();
|
|
214
|
+
const remember = () => { const old = this.files.get(key); this.files.set(key, { ...old, key, namespace: scope.namespace, bytes: old?.bytes ?? 0, touched: Date.now(), authorizedRevision: revision }); };
|
|
215
|
+
if (!db || this.fallback) { remember(); return; }
|
|
216
|
+
try { const tx = db.transaction("files", "readwrite"), done = completed(tx), files = tx.objectStore("files"); const old = await request(files.get(key)) as CachedFile | undefined; files.put({ ...old, key, namespace: scope.namespace, bytes: old?.bytes ?? 0, touched: Date.now(), authorizedRevision: revision }); await done; }
|
|
217
|
+
catch { this.fallback = true; remember(); }
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
putManifest(scope: FileSyncScope, manifest: FileSyncManifest) {
|
|
221
|
+
return this.serialize(async () => {
|
|
222
|
+
const key = fileSyncScopeKey(scope), db = await this.db();
|
|
223
|
+
if (!db || this.fallback) { const previous = this.files.get(key); if (previous?.authorizedRevision && previous.authorizedRevision !== manifest.revision) return; this.files.set(key, { ...previous, key, namespace: scope.namespace, bytes: previous?.bytes ?? 0, touched: Date.now(), manifest }); return; }
|
|
224
|
+
try { const tx = db.transaction("files", "readwrite"), done = completed(tx); const previous = await request(tx.objectStore("files").get(key)) as CachedFile | undefined; if (!previous?.authorizedRevision || previous.authorizedRevision === manifest.revision) tx.objectStore("files").put({ ...previous, key, namespace: scope.namespace, bytes: previous?.bytes ?? 0, touched: Date.now(), manifest }); await done; }
|
|
225
|
+
catch { this.fallback = true; }
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async getManifest(scope: FileSyncScope): Promise<FileSyncManifest | null> {
|
|
229
|
+
const key = fileSyncScopeKey(scope); if (this.files.get(key)?.manifest) return this.files.get(key)!.manifest!;
|
|
230
|
+
const db = await this.db(); if (!db) return null;
|
|
231
|
+
try { const tx = db.transaction("files", "readonly"); return ((await request(tx.objectStore("files").get(key))) as CachedFile | undefined)?.manifest ?? null; } catch { return null; }
|
|
232
|
+
}
|
|
233
|
+
purgeFile(scope: FileSyncScope) {
|
|
234
|
+
return this.serialize(async () => {
|
|
235
|
+
const key = fileSyncScopeKey(scope); this.files.delete(key); this.touches.delete(key);
|
|
236
|
+
for (const [id, block] of this.memory) if (block.scope === key) this.memory.delete(id);
|
|
237
|
+
const db = await this.db(); if (!db) return;
|
|
238
|
+
try { const tx = db.transaction(["blocks", "files"], "readwrite"), done = completed(tx); await this.deleteDiskFile(tx, key); await done; }
|
|
239
|
+
catch { this.fallback = true; }
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
purgeNamespace(namespace: string) {
|
|
243
|
+
return this.serialize(async () => {
|
|
244
|
+
for (const [key, file] of this.files) if (file.namespace === namespace) { this.files.delete(key); for (const [id, block] of this.memory) if (block.scope === key) this.memory.delete(id); }
|
|
245
|
+
const db = await this.db(); if (!db) return;
|
|
246
|
+
try { const tx = db.transaction(["blocks", "files"], "readwrite"), done = completed(tx); const files = await request(tx.objectStore("files").getAll()) as CachedFile[]; for (const file of files) if (file.namespace === namespace) await this.deleteDiskFile(tx, file.key); await done; } catch { this.fallback = true; }
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async close() { await this.writes; (await this.database)?.close(); }
|
|
250
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { decodeText } from "../core/textDecode";
|
|
2
|
+
|
|
3
|
+
const aborted = () => new DOMException("文件同步已取消", "AbortError");
|
|
4
|
+
const integrity = (message: string) => Object.assign(new Error(message), { status: 422, code: "integrity" });
|
|
5
|
+
const digest = async (bytes: Uint8Array<ArrayBuffer>) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
|
6
|
+
type Processed = { bytes: Uint8Array<ArrayBuffer>; text?: string; encoding?: string };
|
|
7
|
+
|
|
8
|
+
/** One worker owns the assembled buffer; chunks are transferred, not copied into a second main-thread file. */
|
|
9
|
+
export class FileSyncProcessor {
|
|
10
|
+
private worker: Worker | null = null;
|
|
11
|
+
private url: string | null = null;
|
|
12
|
+
private sequence = 0;
|
|
13
|
+
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
14
|
+
private output: Uint8Array<ArrayBuffer> | null = null;
|
|
15
|
+
private offset = 0;
|
|
16
|
+
private size = 0;
|
|
17
|
+
private stopped = false;
|
|
18
|
+
private failed: Error | null = null;
|
|
19
|
+
constructor(private signal: AbortSignal) {
|
|
20
|
+
if (typeof Worker !== "undefined" && typeof URL.createObjectURL === "function") try {
|
|
21
|
+
const script = `const decode=${decodeText.toString()};let output=null,offset=0;
|
|
22
|
+
const hash=async b=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',b)),v=>v.toString(16).padStart(2,'0')).join('');
|
|
23
|
+
onmessage=async({data:m})=>{try{let value;
|
|
24
|
+
if(m.op==='start'){output=new Uint8Array(m.size);offset=0;value=true;}
|
|
25
|
+
if(m.op==='verify'){if(await hash(m.bytes)!==m.hash)throw Error('文件块校验失败');value={bytes:m.bytes};}
|
|
26
|
+
if(m.op==='append'){if(!output||offset+m.bytes.byteLength>output.length||await hash(m.bytes)!==m.hash)throw Error('文件块校验失败');output.set(new Uint8Array(m.bytes),offset);offset+=m.bytes.byteLength;value=true;}
|
|
27
|
+
if(m.op==='finish'){if(!output||offset!==output.length||(m.hash&&await hash(output)!==m.hash))throw Error('完整文件校验失败');const bytes=m.start===0&&m.length===output.length?output:output.slice(m.start,m.start+m.length);const decoded=m.decode?decode(bytes,{fromMiddle:m.fromMiddle}):{};value={bytes:bytes.buffer,...decoded};output=null;}
|
|
28
|
+
postMessage({id:m.id,value},value&&value.bytes?[value.bytes]:[]);}catch(error){postMessage({id:m.id,error:error.message});}};`;
|
|
29
|
+
this.url = URL.createObjectURL(new Blob([script], { type: "text/javascript" }));
|
|
30
|
+
this.worker = new Worker(this.url);
|
|
31
|
+
this.worker.onmessage = event => { const item = this.pending.get(event.data.id); if (!item) return; this.pending.delete(event.data.id); clearTimeout(item.timer); if (event.data.error) item.reject(integrity(event.data.error)); else item.resolve(event.data.value); };
|
|
32
|
+
this.worker.onerror = () => this.fail(new Error("文件校验 Worker 不可用"));
|
|
33
|
+
} catch { this.worker?.terminate(); this.worker = null; }
|
|
34
|
+
signal.addEventListener("abort", this.cancel, { once: true });
|
|
35
|
+
}
|
|
36
|
+
private fail(error: Error) { this.failed = error; this.worker?.terminate(); this.worker = null; for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(error); } this.pending.clear(); }
|
|
37
|
+
private cancel = () => { this.stopped = true; this.worker?.terminate(); for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(aborted()); } this.pending.clear(); this.output = null; };
|
|
38
|
+
private check() { if (this.stopped || this.signal.aborted) throw aborted(); if (this.failed) throw this.failed; }
|
|
39
|
+
private rpc(op: string, data: Record<string, unknown>, transfers: Transferable[] = []) {
|
|
40
|
+
this.check();
|
|
41
|
+
return new Promise<any>((resolve, reject) => { const id = ++this.sequence, timer = setTimeout(() => this.fail(new Error("文件校验 Worker 超时")), op === "start" ? 5000 : 120_000); this.pending.set(id, { resolve, reject, timer }); try { this.worker!.postMessage({ id, op, ...data }, transfers); } catch (error) { this.pending.delete(id); clearTimeout(timer); reject(error); } });
|
|
42
|
+
}
|
|
43
|
+
async start(size: number) {
|
|
44
|
+
this.check();
|
|
45
|
+
this.size = size;
|
|
46
|
+
if (this.worker) {
|
|
47
|
+
try { await this.rpc("start", { size }); return; }
|
|
48
|
+
catch (error) { if (this.signal.aborted || this.stopped) throw aborted(); this.worker?.terminate(); this.worker = null; this.failed = null; if (error instanceof DOMException && error.name === "AbortError") throw error; }
|
|
49
|
+
}
|
|
50
|
+
this.output = new Uint8Array(size); this.offset = 0;
|
|
51
|
+
}
|
|
52
|
+
async verify(bytes: Uint8Array<ArrayBuffer>, hash: string): Promise<Uint8Array<ArrayBuffer>> {
|
|
53
|
+
this.check();
|
|
54
|
+
if (this.worker) { const result = await this.rpc("verify", { bytes: bytes.buffer, hash }, [bytes.buffer]); this.check(); return new Uint8Array(result.bytes); }
|
|
55
|
+
if (await digest(bytes) !== hash) throw integrity("文件块校验失败"); this.check(); return bytes;
|
|
56
|
+
}
|
|
57
|
+
async append(bytes: Uint8Array<ArrayBuffer>, hash: string) {
|
|
58
|
+
this.check();
|
|
59
|
+
if (this.worker) { await this.rpc("append", { bytes: bytes.buffer, hash }, [bytes.buffer]); this.check(); return; }
|
|
60
|
+
if (!this.output || this.offset + bytes.length > this.output.length || await digest(bytes) !== hash) throw integrity("文件块校验失败");
|
|
61
|
+
this.check(); this.output.set(bytes, this.offset); this.offset += bytes.length;
|
|
62
|
+
}
|
|
63
|
+
async finish(hash: string | null, decode: boolean, range = { start: 0, length: this.size, fromMiddle: false }): Promise<Processed> {
|
|
64
|
+
this.check();
|
|
65
|
+
if (this.worker) { const result = await this.rpc("finish", { hash, decode, ...range }); this.check(); return { ...result, bytes: new Uint8Array(result.bytes) }; }
|
|
66
|
+
if (!this.output || this.offset !== this.output.length || (hash && await digest(this.output) !== hash)) throw integrity("完整文件校验失败");
|
|
67
|
+
this.check(); const bytes = range.start === 0 && range.length === this.output.length ? this.output : this.output.slice(range.start, range.start + range.length);
|
|
68
|
+
return { bytes, ...(decode ? decodeText(bytes, { fromMiddle: range.fromMiddle }) : {}) };
|
|
69
|
+
}
|
|
70
|
+
dispose() { this.signal.removeEventListener("abort", this.cancel); this.cancel(); if (this.url) URL.revokeObjectURL(this.url); }
|
|
71
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** A finite FIFO for one transport. Waiting work never owns a buffer or request. */
|
|
2
|
+
export function createFileWorkQueue(concurrency: number, maxQueued = 128, waitMs = 180_000) {
|
|
3
|
+
let active = 0;
|
|
4
|
+
type Waiting = { start: () => void; cancel: () => void };
|
|
5
|
+
const waiting: Waiting[] = [];
|
|
6
|
+
const drain = () => { while (active < concurrency && waiting.length) waiting.shift()!.start(); };
|
|
7
|
+
return {
|
|
8
|
+
run<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
9
|
+
return new Promise<T>((resolve, reject) => {
|
|
10
|
+
const abort = () => new DOMException("文件同步已取消", "AbortError");
|
|
11
|
+
if (signal?.aborted) { reject(abort()); return; }
|
|
12
|
+
if (active >= concurrency && waiting.length >= maxQueued) {
|
|
13
|
+
reject(Object.assign(new Error("文件同步队列繁忙,请稍后重试"), { status: 429, code: "client_queue_full", retryAfterMs: 1000 })); return;
|
|
14
|
+
}
|
|
15
|
+
let started = false, running = false, settled = false, released = false;
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
17
|
+
const settle = (ok: boolean, result: unknown) => {
|
|
18
|
+
if (settled) return;
|
|
19
|
+
settled = true; signal?.removeEventListener("abort", item.cancel);
|
|
20
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
21
|
+
if (ok) resolve(result as T); else reject(result);
|
|
22
|
+
};
|
|
23
|
+
const release = () => {
|
|
24
|
+
if (released) return;
|
|
25
|
+
released = true;
|
|
26
|
+
if (started) active--;
|
|
27
|
+
else { const index = waiting.indexOf(item); if (index !== -1) waiting.splice(index, 1); }
|
|
28
|
+
drain();
|
|
29
|
+
};
|
|
30
|
+
const finish = (ok: boolean, result: unknown) => { settle(ok, result); release(); };
|
|
31
|
+
const item: Waiting = {
|
|
32
|
+
start: () => {
|
|
33
|
+
if (settled) return;
|
|
34
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
35
|
+
started = true; active++;
|
|
36
|
+
// A caller can cancel before this microtask starts; do not execute retired work.
|
|
37
|
+
void Promise.resolve().then(() => { if (settled) throw abort(); running = true; return work(); }).then(value => finish(true, value), error => finish(false, error));
|
|
38
|
+
},
|
|
39
|
+
cancel: () => {
|
|
40
|
+
settle(false, abort());
|
|
41
|
+
// Uncooperative legacy hosts may ignore cancellation. Keep their actual
|
|
42
|
+
// request/buffer counted until work settles, while rejecting the caller now.
|
|
43
|
+
if (!running) release();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
signal?.addEventListener("abort", item.cancel, { once: true });
|
|
47
|
+
if (active < concurrency) item.start();
|
|
48
|
+
else {
|
|
49
|
+
waiting.push(item);
|
|
50
|
+
timer = setTimeout(() => finish(false, Object.assign(new Error("文件同步等待超时,请稍后重试"), { status: 429, code: "client_queue_timeout", retryAfterMs: 1000 })), waitMs);
|
|
51
|
+
}
|
|
52
|
+
if (signal?.aborted) item.cancel();
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { FileSyncError, validateFileSyncManifest, type FileSyncManifest, type FileSyncTransport } from "./fileSync";
|
|
2
|
+
|
|
3
|
+
const MANIFEST_MAX = 2 * 1024 * 1024;
|
|
4
|
+
const BATCH_MAX = 1024 * 1024;
|
|
5
|
+
const abortError = () => new DOMException("文件同步已取消", "AbortError");
|
|
6
|
+
|
|
7
|
+
/** Short, bounded requests only; a 304 is reusable only in the same authenticated namespace. */
|
|
8
|
+
export function createFileSyncTransport(fetcher: typeof fetch, pathBuilder: (jobId: string, operation: "manifest" | "chunks") => string,
|
|
9
|
+
headers: () => Record<string, string> = () => ({}), options: { requestTimeoutMs?: number } = {}): FileSyncTransport {
|
|
10
|
+
const namespaces = new Map<string, string>();
|
|
11
|
+
const call = async (url: string, init: RequestInit, limit: number, signal?: AbortSignal) => {
|
|
12
|
+
if (signal?.aborted) throw abortError();
|
|
13
|
+
const controller = new AbortController(); let timedOut = false;
|
|
14
|
+
const cancel = () => controller.abort(); signal?.addEventListener("abort", cancel, { once: true });
|
|
15
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.requestTimeoutMs ?? 30_000);
|
|
16
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
17
|
+
const stopped = new Promise<never>((_resolve, reject) => controller.signal.addEventListener("abort", () => reject(signal?.aborted ? abortError() : new FileSyncError("文件同步请求超时", 0, "timeout")), { once: true }));
|
|
18
|
+
try {
|
|
19
|
+
const fetching = fetcher(url, { ...init, headers: { ...headers(), ...init.headers }, signal: controller.signal, cache: "no-store" });
|
|
20
|
+
void fetching.then(response => { if (controller.signal.aborted) void response.body?.cancel().catch(() => undefined); }, () => undefined);
|
|
21
|
+
const response = await Promise.race([fetching, stopped]);
|
|
22
|
+
if (response.status === 304) { void response.body?.cancel().catch(() => undefined); return { response, bytes: new Uint8Array(0) }; }
|
|
23
|
+
const maximum = response.ok && response.status !== 202 ? limit : 8192;
|
|
24
|
+
const declared = Number(response.headers.get("Content-Length") ?? 0);
|
|
25
|
+
if (declared > maximum) { void response.body?.cancel().catch(() => undefined); throw new FileSyncError("文件同步响应超过协议上限", response.status, "response_too_large"); }
|
|
26
|
+
reader = response.body?.getReader();
|
|
27
|
+
const parts: Uint8Array[] = []; let size = 0;
|
|
28
|
+
if (reader) {
|
|
29
|
+
while (true) {
|
|
30
|
+
const next = await Promise.race([reader.read(), stopped]);
|
|
31
|
+
if (next.done) break;
|
|
32
|
+
size += next.value.byteLength;
|
|
33
|
+
if (size > maximum) throw new FileSyncError("文件同步响应超过协议上限", response.status, "response_too_large");
|
|
34
|
+
parts.push(next.value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const bytes = new Uint8Array(size); let offset = 0;
|
|
38
|
+
for (const part of parts) { bytes.set(part, offset); offset += part.byteLength; }
|
|
39
|
+
if (!response.ok || response.status === 202) {
|
|
40
|
+
let error: { error?: string; detail?: string } = {};
|
|
41
|
+
try { error = JSON.parse(new TextDecoder().decode(bytes)); } catch { /* Status remains authoritative. */ }
|
|
42
|
+
const retry = response.headers.get("Retry-After");
|
|
43
|
+
const retryAfterMs = retry ? (/^\d+(\.\d+)?$/.test(retry) ? Number(retry) * 1000 : Math.max(0, Date.parse(retry) - Date.now())) : undefined;
|
|
44
|
+
const missingRoute = response.status === 501 || (response.status === 404 && !["file_not_found", "task_not_found", "job_not_found"].includes(error.error ?? ""));
|
|
45
|
+
const code = missingRoute ? "unsupported" : error.error ?? "http_error";
|
|
46
|
+
const common: Record<string, string> = { node_unreachable: "执行节点暂时连不上,请稍后重试", unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_sync_unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_not_found: "文件不存在(可能已删除或改名)", file_forbidden: "文件访问权限失效,请重新登录或确认权限", file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
|
|
47
|
+
const message = common[code] ?? ([401, 403].includes(response.status) ? "文件访问权限失效,请重新登录或确认权限" : error.detail ?? `文件同步失败(HTTP ${response.status})`);
|
|
48
|
+
throw new FileSyncError(message, response.status, code, retryAfterMs);
|
|
49
|
+
}
|
|
50
|
+
return { response, bytes };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (signal?.aborted) throw abortError();
|
|
53
|
+
if (timedOut) throw new FileSyncError("文件同步请求超时", 0, "timeout");
|
|
54
|
+
throw error;
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer); signal?.removeEventListener("abort", cancel);
|
|
57
|
+
if (reader) { void reader.cancel().catch(() => undefined); try { reader.releaseLock(); } catch { /* A cancelled read can still be retiring. */ } }
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
async manifest(jobId, path, settings = {}) {
|
|
62
|
+
const key = JSON.stringify([jobId, path]);
|
|
63
|
+
const read = async (revision?: string): Promise<FileSyncManifest | null> => {
|
|
64
|
+
const endpoint = pathBuilder(jobId, "manifest"), url = `${endpoint}${endpoint.includes("?") ? "&" : "?"}path=${encodeURIComponent(path)}`;
|
|
65
|
+
const { response, bytes } = await call(url, { headers: revision ? { "If-None-Match": `"${revision}"` } : {} }, MANIFEST_MAX, settings.signal);
|
|
66
|
+
if (response.status === 304) {
|
|
67
|
+
const namespace = response.headers.get("X-File-Sync-Namespace");
|
|
68
|
+
if (revision && namespace && namespace === namespaces.get(key)) return null;
|
|
69
|
+
if (revision) return read();
|
|
70
|
+
throw new FileSyncError("文件修订清单缺失", 502, "invalid_manifest");
|
|
71
|
+
}
|
|
72
|
+
let manifest: FileSyncManifest;
|
|
73
|
+
try { manifest = validateFileSyncManifest(JSON.parse(new TextDecoder().decode(bytes))); }
|
|
74
|
+
catch (error) { throw error instanceof FileSyncError ? error : new FileSyncError("文件修订清单格式错误", 502, "invalid_manifest"); }
|
|
75
|
+
namespaces.set(key, manifest.namespace); return manifest;
|
|
76
|
+
};
|
|
77
|
+
return read(settings.revision);
|
|
78
|
+
},
|
|
79
|
+
async chunks(jobId, input, settings = {}) {
|
|
80
|
+
if (!input.ids.length) return new Uint8Array(0);
|
|
81
|
+
const { bytes } = await call(pathBuilder(jobId, "chunks"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input) }, BATCH_MAX, settings.signal);
|
|
82
|
+
return bytes;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|