@aleph-alpha/chat-kit 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1242 @@
1
+ import { ref, computed, toRaw } from 'vue';
2
+ import {
3
+ defaultEndpoints,
4
+ SdkError,
5
+ type EndpointResolver,
6
+ type FileConstraints,
7
+ type FileEntry,
8
+ type FileStateMap,
9
+ type FileUploadError,
10
+ type FileThumbnail,
11
+ type PersistOptions,
12
+ type RemoveFileOptions,
13
+ type RetryPolicy,
14
+ type ScopedFileView,
15
+ type SseEvent,
16
+ type UploadEvent,
17
+ type UploadFileResult,
18
+ type UploadParams,
19
+ type UploadResult,
20
+ type UseFileUploadOptions,
21
+ type UseFileUploadReturn,
22
+ type ValidatorFn,
23
+ type WorkflowRun,
24
+ type WorkflowRunEvent,
25
+ type WorkflowStream,
26
+ } from './types';
27
+
28
+ export * from './types';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Constants
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const DEFAULT_CONSTRAINTS: FileConstraints = {
35
+ allowedMimeTypes: [
36
+ 'application/pdf',
37
+ 'text/plain',
38
+ 'text/csv',
39
+ 'application/msword',
40
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
41
+ 'application/vnd.ms-excel',
42
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
43
+ 'application/vnd.ms-powerpoint',
44
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
45
+ 'image/png',
46
+ 'image/jpeg',
47
+ 'image/gif',
48
+ 'image/webp',
49
+ ],
50
+ allowedExtensions: [
51
+ '.pdf',
52
+ '.txt',
53
+ '.csv',
54
+ '.doc',
55
+ '.docx',
56
+ '.xls',
57
+ '.xlsx',
58
+ '.ppt',
59
+ '.pptx',
60
+ '.png',
61
+ '.jpg',
62
+ '.jpeg',
63
+ '.gif',
64
+ '.webp',
65
+ ],
66
+ maxFileSizeBytes: 100 * 1024 * 1024,
67
+ maxFilesPerUpload: 20,
68
+ };
69
+
70
+ const DEFAULT_RETRY_POLICY: RetryPolicy = {
71
+ maxRetries: 2,
72
+ delayMs: 1000,
73
+ backoffMultiplier: 2,
74
+ retryableStatusCodes: [408, 429, 500, 502, 503],
75
+ };
76
+
77
+ const FILE_ICON_SVG = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3C/svg%3E`;
78
+
79
+ const FILE_ICONS: Record<string, string> = {
80
+ _default: FILE_ICON_SVG,
81
+ 'application/pdf': `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 24 24' fill='none' stroke='%23ef4444' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3Cpath d='M10 12h4'/%3E%3Cpath d='M10 16h4'/%3E%3C/svg%3E`,
82
+ 'text/plain': `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3Cpath d='M10 12h4'/%3E%3Cpath d='M10 16h4'/%3E%3C/svg%3E`,
83
+ 'text/csv': `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 24 24' fill='none' stroke='%2322c55e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3Cpath d='M8 15h2'/%3E%3Cpath d='M14 15h2'/%3E%3Cpath d='M8 11h2'/%3E%3Cpath d='M14 11h2'/%3E%3C/svg%3E`,
84
+ };
85
+
86
+ const DB_VERSION = 1;
87
+
88
+ const ACTIVE_UPLOAD_STATUSES = new Set(['queued', 'validating', 'uploading']);
89
+ const ACTIVE_OR_PROCESSING = new Set([
90
+ 'queued',
91
+ 'validating',
92
+ 'uploading',
93
+ 'processing',
94
+ ]);
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Internal Helpers
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function now(): string {
101
+ return new Date().toISOString();
102
+ }
103
+
104
+ function extensionOf(name: string): string {
105
+ const dot = name.lastIndexOf('.');
106
+ return dot === -1 ? '' : name.slice(dot).toLowerCase();
107
+ }
108
+
109
+ function debounce(fn: () => void, ms: number) {
110
+ let timer: ReturnType<typeof setTimeout> | null = null;
111
+ return () => {
112
+ if (timer) clearTimeout(timer);
113
+ timer = setTimeout(fn, ms);
114
+ };
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Concurrency Semaphore
119
+ // ---------------------------------------------------------------------------
120
+
121
+ class Semaphore {
122
+ private permits: number;
123
+ private queue: Array<() => void> = [];
124
+
125
+ constructor(permits: number) {
126
+ this.permits = permits;
127
+ }
128
+
129
+ acquire(): Promise<void> {
130
+ if (this.permits > 0) {
131
+ this.permits--;
132
+ return Promise.resolve();
133
+ }
134
+ return new Promise((resolve) => {
135
+ this.queue.push(resolve);
136
+ });
137
+ }
138
+
139
+ release(): void {
140
+ if (this.queue.length > 0) {
141
+ this.queue.shift()!();
142
+ } else {
143
+ this.permits++;
144
+ }
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Thumbnail Generation
150
+ // ---------------------------------------------------------------------------
151
+
152
+ function iconFallback(file: File): FileThumbnail {
153
+ const dataUrl = FILE_ICONS[file.type] || FILE_ICONS._default;
154
+ return {
155
+ dataUrl: dataUrl ?? '',
156
+ width: 128,
157
+ height: 128,
158
+ generatedAt: now(),
159
+ strategy: 'icon-fallback',
160
+ };
161
+ }
162
+
163
+ function generateCanvasThumbnail(
164
+ file: File,
165
+ maxW = 128,
166
+ maxH = 128,
167
+ ): Promise<FileThumbnail> {
168
+ return new Promise((resolve, reject) => {
169
+ if (typeof document === 'undefined') {
170
+ reject(new Error('Canvas is not available in this environment'));
171
+ return;
172
+ }
173
+
174
+ const url = URL.createObjectURL(file);
175
+ const img = new Image();
176
+
177
+ img.onload = () => {
178
+ let w = img.naturalWidth;
179
+ let h = img.naturalHeight;
180
+ const ratio = Math.min(maxW / w, maxH / h, 1);
181
+ w = Math.round(w * ratio);
182
+ h = Math.round(h * ratio);
183
+
184
+ const canvas = document.createElement('canvas');
185
+ canvas.width = w;
186
+ canvas.height = h;
187
+ const ctx = canvas.getContext('2d')!;
188
+ ctx.drawImage(img, 0, 0, w, h);
189
+
190
+ const dataUrl = canvas.toDataURL('image/png');
191
+ URL.revokeObjectURL(url);
192
+ resolve({
193
+ dataUrl,
194
+ width: w,
195
+ height: h,
196
+ generatedAt: now(),
197
+ strategy: 'canvas',
198
+ });
199
+ };
200
+
201
+ img.onerror = () => {
202
+ URL.revokeObjectURL(url);
203
+ reject(new Error('Failed to load image for thumbnail generation'));
204
+ };
205
+
206
+ img.src = url;
207
+ });
208
+ }
209
+
210
+ async function generateThumbnail(file: File): Promise<FileThumbnail> {
211
+ if (file.type.startsWith('image/')) {
212
+ try {
213
+ return await generateCanvasThumbnail(file);
214
+ } catch {
215
+ return iconFallback(file);
216
+ }
217
+ }
218
+ return iconFallback(file);
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Validation
223
+ // ---------------------------------------------------------------------------
224
+
225
+ function validateFileConstraints(
226
+ file: File,
227
+ constraints: FileConstraints,
228
+ ): FileUploadError | null {
229
+ const ext = extensionOf(file.name);
230
+
231
+ if (file.size > constraints.maxFileSizeBytes) {
232
+ return {
233
+ code: 'FILE_TOO_LARGE',
234
+ message: `File "${file.name}" exceeds the maximum size of ${constraints.maxFileSizeBytes} bytes.`,
235
+ occurredAt: now(),
236
+ };
237
+ }
238
+
239
+ if (
240
+ constraints.allowedMimeTypes.length > 0 &&
241
+ !constraints.allowedMimeTypes.includes(file.type)
242
+ ) {
243
+ return {
244
+ code: 'FILE_TYPE_NOT_ALLOWED',
245
+ message: `MIME type "${file.type}" is not allowed for file "${file.name}".`,
246
+ occurredAt: now(),
247
+ };
248
+ }
249
+
250
+ if (
251
+ constraints.allowedExtensions.length > 0 &&
252
+ ext !== '' &&
253
+ !constraints.allowedExtensions.includes(ext)
254
+ ) {
255
+ return {
256
+ code: 'FILE_TYPE_NOT_ALLOWED',
257
+ message: `Extension "${ext}" is not allowed for file "${file.name}".`,
258
+ occurredAt: now(),
259
+ };
260
+ }
261
+
262
+ return null;
263
+ }
264
+
265
+ async function runCustomValidators(
266
+ file: File,
267
+ constraints: FileConstraints,
268
+ validators: ValidatorFn[],
269
+ ): Promise<FileUploadError | null> {
270
+ for (const validator of validators) {
271
+ const result = await validator(file, constraints);
272
+ if (!result.valid) {
273
+ return {
274
+ code: 'VALIDATION_FAILED',
275
+ message: result.reason ?? `Validation failed for "${file.name}".`,
276
+ occurredAt: now(),
277
+ };
278
+ }
279
+ }
280
+ return null;
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Retry
285
+ // ---------------------------------------------------------------------------
286
+
287
+ function isRetryable(err: unknown, policy: RetryPolicy): boolean {
288
+ if (!policy.retryableStatusCodes?.length) return true;
289
+ const status =
290
+ (err as any)?.httpStatus ??
291
+ (err as any)?.status ??
292
+ (err as any)?.response?.status;
293
+ return status != null && policy.retryableStatusCodes.includes(status);
294
+ }
295
+
296
+ async function withRetry<T>(
297
+ fn: () => Promise<T>,
298
+ policy: RetryPolicy,
299
+ signal: AbortSignal,
300
+ ): Promise<T> {
301
+ let lastError: unknown;
302
+ for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
303
+ if (signal.aborted)
304
+ throw new SdkError('UPLOAD_ABORTED', 'Upload cancelled.');
305
+ try {
306
+ return await fn();
307
+ } catch (err) {
308
+ lastError = err;
309
+ if (signal.aborted) throw err;
310
+ if (attempt < policy.maxRetries && isRetryable(err, policy)) {
311
+ const delay =
312
+ policy.delayMs * Math.pow(policy.backoffMultiplier ?? 2, attempt);
313
+ await new Promise((r) => setTimeout(r, delay));
314
+ }
315
+ }
316
+ }
317
+ throw lastError;
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // IndexedDB Persistence
322
+ // ---------------------------------------------------------------------------
323
+
324
+ function openDB(dbName: string, storeName: string): Promise<IDBDatabase> {
325
+ return new Promise((resolve, reject) => {
326
+ const request = indexedDB.open(dbName, DB_VERSION);
327
+ request.onupgradeneeded = () => {
328
+ const db = request.result;
329
+ if (!db.objectStoreNames.contains(storeName)) {
330
+ db.createObjectStore(storeName, { keyPath: 'id' });
331
+ }
332
+ };
333
+ request.onsuccess = () => resolve(request.result);
334
+ request.onerror = () => reject(request.error);
335
+ });
336
+ }
337
+
338
+ function persistEntries(
339
+ db: IDBDatabase,
340
+ storeName: string,
341
+ entries: FileEntry[],
342
+ filter?: (entry: FileEntry) => boolean,
343
+ ): Promise<void> {
344
+ const filtered = filter ? entries.filter(filter) : entries;
345
+ const tx = db.transaction(storeName, 'readwrite');
346
+ const store = tx.objectStore(storeName);
347
+ for (const entry of filtered) {
348
+ store.put(structuredClone(toRaw(entry)));
349
+ }
350
+ return new Promise((resolve, reject) => {
351
+ tx.oncomplete = () => resolve();
352
+ tx.onerror = () => reject(tx.error);
353
+ });
354
+ }
355
+
356
+ function deletePersistedEntry(
357
+ db: IDBDatabase,
358
+ storeName: string,
359
+ id: string,
360
+ ): Promise<void> {
361
+ const tx = db.transaction(storeName, 'readwrite');
362
+ tx.objectStore(storeName).delete(id);
363
+ return new Promise((resolve, reject) => {
364
+ tx.oncomplete = () => resolve();
365
+ tx.onerror = () => reject(tx.error);
366
+ });
367
+ }
368
+
369
+ function restoreEntries(
370
+ db: IDBDatabase,
371
+ storeName: string,
372
+ maxAge?: number,
373
+ ): Promise<FileEntry[]> {
374
+ const tx = db.transaction(storeName, 'readonly');
375
+ const request = tx.objectStore(storeName).getAll();
376
+ return new Promise((resolve, reject) => {
377
+ request.onsuccess = () => {
378
+ let entries: FileEntry[] = request.result;
379
+ if (maxAge) {
380
+ const cutoff = Date.now() - maxAge;
381
+ entries = entries.filter(
382
+ (e) => new Date(e.queuedAt).getTime() > cutoff,
383
+ );
384
+ }
385
+ resolve(entries.map(rehydrateEntry));
386
+ };
387
+ request.onerror = () => reject(request.error);
388
+ });
389
+ }
390
+
391
+ function purgeStore(db: IDBDatabase, storeName: string): Promise<void> {
392
+ const tx = db.transaction(storeName, 'readwrite');
393
+ tx.objectStore(storeName).clear();
394
+ return new Promise((resolve, reject) => {
395
+ tx.oncomplete = () => resolve();
396
+ tx.onerror = () => reject(tx.error);
397
+ });
398
+ }
399
+
400
+ function rehydrateEntry(entry: FileEntry): FileEntry {
401
+ if (
402
+ entry.status === 'uploading' ||
403
+ entry.status === 'validating' ||
404
+ entry.status === 'queued'
405
+ ) {
406
+ return {
407
+ ...entry,
408
+ status: 'failed',
409
+ error: {
410
+ code: 'UPLOAD_INTERRUPTED',
411
+ message: 'Upload was interrupted by a page reload or browser close.',
412
+ occurredAt: now(),
413
+ },
414
+ };
415
+ }
416
+ if (entry.status === 'processing') {
417
+ return {
418
+ ...entry,
419
+ status: 'failed',
420
+ error: {
421
+ code: 'PROCESSING_INTERRUPTED',
422
+ message: 'Workflow processing was interrupted before completion.',
423
+ occurredAt: now(),
424
+ },
425
+ };
426
+ }
427
+ if (entry.status === 'removing') {
428
+ return {
429
+ ...entry,
430
+ status: 'failed',
431
+ error: {
432
+ code: 'REMOVE_INTERRUPTED',
433
+ message: 'File removal was interrupted.',
434
+ occurredAt: now(),
435
+ },
436
+ };
437
+ }
438
+ return entry;
439
+ }
440
+
441
+ function normalizePersistConfig(
442
+ opt: PersistOptions | boolean | undefined,
443
+ ): PersistOptions | null {
444
+ if (!opt) return null;
445
+ if (opt === true) return {};
446
+ return opt;
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // State Key Derivation
451
+ // ---------------------------------------------------------------------------
452
+
453
+ function deriveStateKey(
454
+ groupId: string,
455
+ fileName: string,
456
+ existingKeys: Set<string>,
457
+ ): string {
458
+ const base = `${groupId}::${fileName}`;
459
+ if (!existingKeys.has(base)) return base;
460
+ let suffix = 2;
461
+ while (existingKeys.has(`${base}::${suffix}`)) suffix++;
462
+ return `${base}::${suffix}`;
463
+ }
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // Composable
467
+ // ---------------------------------------------------------------------------
468
+
469
+ export function useFileUpload(
470
+ options: UseFileUploadOptions,
471
+ ): UseFileUploadReturn {
472
+ const { client, workflowId } = options;
473
+ let storeId = options.storeId ?? '';
474
+ const endpoints: EndpointResolver = options.endpoints ?? defaultEndpoints;
475
+ const retryPolicy: RetryPolicy = options.retryPolicy ?? DEFAULT_RETRY_POLICY;
476
+ const constraints: FileConstraints = { ...DEFAULT_CONSTRAINTS };
477
+ const validators: ValidatorFn[] = options.validators ?? [];
478
+ const maxConcurrency = options.maxConcurrency ?? 3;
479
+
480
+ // ---- Reactive state ----
481
+ const files = ref<FileStateMap>(new Map());
482
+ const workflowRun = ref<WorkflowRun | null>(null);
483
+ const isRestoring = ref(false);
484
+
485
+ // ---- Internal registries ----
486
+ const uploadControllers = new Map<string, AbortController>();
487
+ const semaphore = new Semaphore(maxConcurrency);
488
+
489
+ // ---- Persistence ----
490
+ const persistConfig = normalizePersistConfig(options.persist);
491
+ let db: IDBDatabase | null = null;
492
+ const persistDbName = persistConfig?.dbName ?? 'file-upload-sdk';
493
+ const persistStoreName = persistConfig?.storeName ?? 'file-entries';
494
+ const persistDebounceMs = persistConfig?.debounceMs ?? 300;
495
+
496
+ const schedulePersist = debounce(async () => {
497
+ if (!db) return;
498
+ try {
499
+ const entries = [...files.value.values()];
500
+ await persistEntries(
501
+ db,
502
+ persistStoreName,
503
+ entries,
504
+ persistConfig?.filter,
505
+ );
506
+ emit({ type: 'state:persisted', entryCount: entries.length });
507
+ } catch (err) {
508
+ emit({ type: 'state:persist-error', error: err as Error });
509
+ }
510
+ }, persistDebounceMs);
511
+
512
+ function persistIfEnabled() {
513
+ if (persistConfig && db) schedulePersist();
514
+ }
515
+
516
+ // ---- Events ----
517
+
518
+ function emit(event: UploadEvent) {
519
+ options.onEvent?.(event);
520
+ }
521
+
522
+ // ---- State helpers ----
523
+
524
+ function setEntry(entry: FileEntry) {
525
+ files.value.set(entry.id, entry);
526
+ persistIfEnabled();
527
+ }
528
+
529
+ function updateEntry(id: string, updates: Partial<FileEntry>) {
530
+ const entry = files.value.get(id);
531
+ if (!entry) return;
532
+ Object.assign(entry, updates);
533
+ persistIfEnabled();
534
+ }
535
+
536
+ // ---- Public API ----
537
+
538
+ function getSupportedFileTypes(): FileConstraints {
539
+ return { ...constraints };
540
+ }
541
+
542
+ // ---- Upload ----
543
+
544
+ async function validateAndQueueBatch(
545
+ batch: Array<{ file: File; stateKey: string }>,
546
+ groupId: string,
547
+ mergedMeta: Record<string, unknown>,
548
+ ): Promise<void> {
549
+ for (const { file, stateKey } of batch) {
550
+ const constraintErr = validateFileConstraints(file, constraints);
551
+ if (constraintErr) {
552
+ const entry = createEntry(stateKey, groupId, file, mergedMeta);
553
+ entry.status = 'failed';
554
+ entry.error = constraintErr;
555
+ setEntry(entry);
556
+ emit({ type: 'file:failed', fileId: stateKey, error: constraintErr });
557
+ continue;
558
+ }
559
+
560
+ let thumbnail: FileThumbnail | null = null;
561
+ try {
562
+ thumbnail = await generateThumbnail(file);
563
+ } catch (err) {
564
+ emit({
565
+ type: 'file:thumbnail-failed',
566
+ fileId: stateKey,
567
+ reason: (err as Error).message,
568
+ });
569
+ }
570
+
571
+ const entry = createEntry(stateKey, groupId, file, mergedMeta, thumbnail);
572
+ setEntry(entry);
573
+ emit({ type: 'file:queued', fileId: stateKey, entry });
574
+ }
575
+ }
576
+
577
+ async function runValidationPhase(
578
+ validBatch: Array<{ file: File; stateKey: string }>,
579
+ ): Promise<void> {
580
+ for (const { file, stateKey } of validBatch) {
581
+ updateEntry(stateKey, { status: 'validating' });
582
+ emit({ type: 'file:validating', fileId: stateKey });
583
+
584
+ const validationErr = await runCustomValidators(
585
+ file,
586
+ constraints,
587
+ validators,
588
+ );
589
+ if (validationErr) {
590
+ updateEntry(stateKey, { status: 'failed', error: validationErr });
591
+ emit({ type: 'file:failed', fileId: stateKey, error: validationErr });
592
+ }
593
+ }
594
+ }
595
+
596
+ function handleUploadSuccess(
597
+ uploadReady: Array<{ file: File; stateKey: string }>,
598
+ response: unknown,
599
+ ): void {
600
+ const completedAt = now();
601
+ const results = Array.isArray(response) ? response : [];
602
+
603
+ for (let i = 0; i < uploadReady.length; i++) {
604
+ const { stateKey } = uploadReady[i] as { file: File; stateKey: string };
605
+ const entry = files.value.get(stateKey)!;
606
+ const durationMs =
607
+ new Date(completedAt).getTime() -
608
+ new Date(entry.uploadStartedAt!).getTime();
609
+
610
+ const remoteResult = results[i] as
611
+ | { id?: string; documentId?: string }
612
+ | undefined;
613
+ const remoteFileId = remoteResult?.id ?? null;
614
+ const documentId = remoteResult?.documentId ?? null;
615
+
616
+ if (remoteFileId) {
617
+ updateEntry(stateKey, {
618
+ status: 'success',
619
+ progress: 100,
620
+ remoteFileId,
621
+ uploadCompletedAt: completedAt,
622
+ uploadDurationMs: durationMs,
623
+ });
624
+ if (documentId) {
625
+ updateFileMetadata(stateKey, { documentId });
626
+ }
627
+ emit({
628
+ type: 'file:success',
629
+ fileId: stateKey,
630
+ remoteFileId,
631
+ durationMs,
632
+ });
633
+ } else {
634
+ const err: FileUploadError = {
635
+ code: 'UPLOAD_FAILED',
636
+ message: `No remote ID returned for "${entry.fileName}".`,
637
+ occurredAt: completedAt,
638
+ };
639
+ updateEntry(stateKey, {
640
+ status: 'failed',
641
+ error: err,
642
+ uploadCompletedAt: completedAt,
643
+ uploadDurationMs: durationMs,
644
+ });
645
+ emit({ type: 'file:failed', fileId: stateKey, error: err });
646
+ }
647
+ }
648
+ }
649
+
650
+ function handleUploadError(
651
+ uploadReady: Array<{ file: File; stateKey: string }>,
652
+ err: unknown,
653
+ mergedSignal: AbortSignal,
654
+ batch: Array<{ file: File; stateKey: string }>,
655
+ groupId: string,
656
+ ): void {
657
+ const completedAt = now();
658
+
659
+ if (mergedSignal.aborted) {
660
+ for (const { stateKey } of uploadReady) {
661
+ transitionToCancelled(stateKey);
662
+ }
663
+ uploadControllers.delete(groupId);
664
+ const result = buildUploadResult(groupId, batch);
665
+ emit({
666
+ type: 'upload:complete',
667
+ groupId,
668
+ results: result.results,
669
+ });
670
+ throw new SdkError(
671
+ 'UPLOAD_ABORTED',
672
+ `Upload "${groupId}" was cancelled.`,
673
+ );
674
+ }
675
+
676
+ const uploadErr: FileUploadError = {
677
+ code: 'UPLOAD_FAILED',
678
+ message: (err as Error).message ?? 'Upload failed.',
679
+ httpStatus: (err as any)?.httpStatus ?? (err as any)?.status,
680
+ occurredAt: completedAt,
681
+ };
682
+
683
+ for (const { stateKey } of uploadReady) {
684
+ const entry = files.value.get(stateKey)!;
685
+ const durationMs = entry.uploadStartedAt
686
+ ? new Date(completedAt).getTime() -
687
+ new Date(entry.uploadStartedAt).getTime()
688
+ : null;
689
+ updateEntry(stateKey, {
690
+ status: 'failed',
691
+ error: uploadErr,
692
+ uploadCompletedAt: completedAt,
693
+ uploadDurationMs: durationMs,
694
+ });
695
+ emit({ type: 'file:failed', fileId: stateKey, error: uploadErr });
696
+ }
697
+ }
698
+
699
+ async function upload(params: UploadParams): Promise<UploadResult> {
700
+ const fileList = Array.isArray(params.files)
701
+ ? params.files
702
+ : [params.files];
703
+
704
+ if (fileList.length > constraints.maxFilesPerUpload) {
705
+ throw new SdkError(
706
+ 'MAX_FILES_EXCEEDED',
707
+ `Cannot upload ${fileList.length} files — maximum is ${constraints.maxFilesPerUpload}.`,
708
+ );
709
+ }
710
+
711
+ const mergedMeta: Record<string, unknown> = {
712
+ ...(params.metadata ?? {}),
713
+ ...(params.forwardedProps ?? {}),
714
+ };
715
+
716
+ const existingKeys = new Set(files.value.keys() as Iterable<string>);
717
+ const batch: Array<{ file: File; stateKey: string }> = [];
718
+
719
+ for (const file of fileList) {
720
+ const stateKey = deriveStateKey(params.id, file.name, existingKeys);
721
+ existingKeys.add(stateKey);
722
+ batch.push({ file, stateKey });
723
+ }
724
+
725
+ await validateAndQueueBatch(batch, params.id, mergedMeta);
726
+
727
+ const validBatch = batch.filter((b) => {
728
+ const entry = files.value.get(b.stateKey);
729
+ return entry && entry.status === 'queued';
730
+ });
731
+
732
+ if (validBatch.length === 0) {
733
+ return buildUploadResult(params.id, batch);
734
+ }
735
+
736
+ await runValidationPhase(validBatch);
737
+
738
+ const uploadReady = validBatch.filter((b) => {
739
+ const entry = files.value.get(b.stateKey);
740
+ return entry && entry.status === 'validating';
741
+ });
742
+
743
+ if (uploadReady.length === 0) {
744
+ return buildUploadResult(params.id, batch);
745
+ }
746
+
747
+ const formData = new FormData();
748
+ for (const { file } of uploadReady) {
749
+ formData.append('files', file, file.name);
750
+ }
751
+
752
+ const controller = new AbortController();
753
+ uploadControllers.set(params.id, controller);
754
+
755
+ const mergedSignal = params.signal
756
+ ? AbortSignal.any([controller.signal, params.signal])
757
+ : controller.signal;
758
+
759
+ const uploadStarted = now();
760
+ for (const { stateKey } of uploadReady) {
761
+ updateEntry(stateKey, {
762
+ status: 'uploading',
763
+ uploadStartedAt: uploadStarted,
764
+ attemptCount: (files.value.get(stateKey)?.attemptCount ?? 0) + 1,
765
+ lastAttemptAt: uploadStarted,
766
+ });
767
+ emit({
768
+ type: 'file:uploading',
769
+ fileId: stateKey,
770
+ attemptCount: files.value.get(stateKey)!.attemptCount,
771
+ });
772
+ }
773
+
774
+ const totalBytes = uploadReady.reduce(
775
+ (sum, { file }) => sum + file.size,
776
+ 0,
777
+ );
778
+
779
+ const onUploadProgress = (event: { loaded: number; total: number }) => {
780
+ const ratio = event.total > 0 ? event.loaded / event.total : 0;
781
+ const loadedBytes = ratio * totalBytes;
782
+
783
+ for (const { file, stateKey } of uploadReady) {
784
+ const fileShare =
785
+ totalBytes > 0 ? loadedBytes * (file.size / totalBytes) : 0;
786
+ const progress = Math.min(
787
+ 100,
788
+ Math.round((fileShare / Math.max(file.size, 1)) * 100),
789
+ );
790
+ updateEntry(stateKey, { progress });
791
+ emit({ type: 'file:progress', fileId: stateKey, progress });
792
+ }
793
+ };
794
+
795
+ await semaphore.acquire();
796
+
797
+ try {
798
+ const response = await withRetry(
799
+ () =>
800
+ client.post<unknown[]>(endpoints.files(storeId), formData, {
801
+ signal: mergedSignal,
802
+ onUploadProgress,
803
+ }),
804
+ retryPolicy,
805
+ mergedSignal,
806
+ );
807
+ handleUploadSuccess(uploadReady, response);
808
+ } catch (err) {
809
+ handleUploadError(uploadReady, err, mergedSignal, batch, params.id);
810
+ } finally {
811
+ semaphore.release();
812
+ uploadControllers.delete(params.id);
813
+ }
814
+
815
+ const result = buildUploadResult(params.id, batch);
816
+ emit({
817
+ type: 'upload:complete',
818
+ groupId: params.id,
819
+ results: result.results,
820
+ });
821
+ return result;
822
+ }
823
+
824
+ function createEntry(
825
+ id: string,
826
+ groupId: string,
827
+ file: File,
828
+ metadata: Record<string, unknown>,
829
+ thumbnail: FileThumbnail | null = null,
830
+ ): FileEntry {
831
+ return {
832
+ id,
833
+ groupId,
834
+ fileName: file.name,
835
+ fileSize: file.size,
836
+ fileType: file.type,
837
+ status: 'queued',
838
+ progress: 0,
839
+ queuedAt: now(),
840
+ uploadStartedAt: null,
841
+ uploadCompletedAt: null,
842
+ cancelledAt: null,
843
+ removedAt: null,
844
+ uploadDurationMs: null,
845
+ remoteFileId: null,
846
+ error: null,
847
+ attemptCount: 0,
848
+ lastAttemptAt: null,
849
+ thumbnail,
850
+ metadata: { ...metadata },
851
+ };
852
+ }
853
+
854
+ function buildUploadResult(
855
+ groupId: string,
856
+ batch: Array<{ stateKey: string }>,
857
+ ): UploadResult {
858
+ return {
859
+ id: groupId,
860
+ results: batch.map(({ stateKey }) => {
861
+ const entry = files.value.get(stateKey)!;
862
+ return {
863
+ fileId: stateKey,
864
+ success: entry.status === 'success',
865
+ status: entry.status,
866
+ remoteFileId: entry.remoteFileId ?? undefined,
867
+ error: entry.error ?? undefined,
868
+ uploadDate: entry.uploadCompletedAt ?? entry.queuedAt,
869
+ metadata: entry.metadata,
870
+ } satisfies UploadFileResult;
871
+ }),
872
+ };
873
+ }
874
+
875
+ // ---- Cancel ----
876
+
877
+ function cancel(groupId: string): void {
878
+ const controller = uploadControllers.get(groupId);
879
+ if (controller) {
880
+ controller.abort();
881
+ uploadControllers.delete(groupId);
882
+ }
883
+
884
+ for (const [, entry] of files.value) {
885
+ if (entry.groupId === groupId) {
886
+ transitionToCancelled(entry.id);
887
+ }
888
+ }
889
+ }
890
+
891
+ function transitionToCancelled(id: string): void {
892
+ const entry = files.value.get(id);
893
+ if (!entry || !ACTIVE_UPLOAD_STATUSES.has(entry.status)) return;
894
+
895
+ const ts = now();
896
+ updateEntry(id, {
897
+ status: 'cancelled',
898
+ cancelledAt: ts,
899
+ error: {
900
+ code: 'UPLOAD_ABORTED',
901
+ message: 'Upload cancelled.',
902
+ occurredAt: ts,
903
+ },
904
+ });
905
+ emit({ type: 'file:cancelled', fileId: id });
906
+ }
907
+
908
+ // ---- Remove ----
909
+
910
+ async function removeFile(
911
+ fileId: string,
912
+ opts?: RemoveFileOptions,
913
+ ): Promise<void> {
914
+ const removeFromState = opts?.removeFromState ?? true;
915
+ const removeFromRemote = opts?.removeFromRemote ?? true;
916
+
917
+ const entry = files.value.get(fileId);
918
+
919
+ if (removeFromRemote && entry?.remoteFileId) {
920
+ updateEntry(fileId, { status: 'removing' });
921
+ try {
922
+ await client.delete(endpoints.file(storeId, entry.remoteFileId));
923
+ } catch (err) {
924
+ const status =
925
+ (err as any)?.httpStatus ??
926
+ (err as any)?.status ??
927
+ (err as any)?.response?.status;
928
+
929
+ if (status === 404) {
930
+ // File no longer exists on the server — proceed with local removal.
931
+ } else {
932
+ const removeErr: FileUploadError = {
933
+ code: 'REMOVE_FAILED',
934
+ message: (err as Error).message ?? 'File removal failed.',
935
+ occurredAt: now(),
936
+ };
937
+ updateEntry(fileId, { status: 'failed', error: removeErr });
938
+ throw new SdkError('REMOVE_FAILED', removeErr.message);
939
+ }
940
+ }
941
+ }
942
+
943
+ if (removeFromState) {
944
+ files.value.delete(fileId);
945
+ if (db) {
946
+ try {
947
+ await deletePersistedEntry(db, persistStoreName, fileId);
948
+ } catch {
949
+ /* non-fatal */
950
+ }
951
+ }
952
+ emit({ type: 'file:removed', fileId });
953
+ } else if (entry) {
954
+ updateEntry(fileId, { status: 'removed', removedAt: now() });
955
+ emit({ type: 'file:removed', fileId });
956
+ }
957
+ }
958
+
959
+ // ---- State queries ----
960
+
961
+ function getFileState(id: string): FileEntry | undefined {
962
+ return files.value.get(id);
963
+ }
964
+
965
+ function updateFileMetadata(id: string, meta: Record<string, unknown>): void {
966
+ const entry = files.value.get(id);
967
+ if (!entry) return;
968
+ Object.assign(entry.metadata, meta);
969
+ persistIfEnabled();
970
+ }
971
+
972
+ function clearState(): void {
973
+ files.value.clear();
974
+ if (db) {
975
+ purgeStore(db, persistStoreName).catch(() => {
976
+ /* non-fatal */
977
+ });
978
+ }
979
+ }
980
+
981
+ // ---- Scoped views ----
982
+
983
+ function scopeBy(metadataKey: string, value: string): ScopedFileView {
984
+ const scopedFiles = computed(() =>
985
+ [...files.value.values()].filter(
986
+ (f) => f.metadata[metadataKey] === value,
987
+ ),
988
+ );
989
+
990
+ const progress = computed(() => {
991
+ const active = scopedFiles.value.filter((f) =>
992
+ ACTIVE_OR_PROCESSING.has(f.status),
993
+ );
994
+ if (!active.length) return 100;
995
+ return Math.round(
996
+ active.reduce((sum, f) => sum + f.progress, 0) / active.length,
997
+ );
998
+ });
999
+
1000
+ const isUploading = computed(() =>
1001
+ scopedFiles.value.some((f) => ACTIVE_UPLOAD_STATUSES.has(f.status)),
1002
+ );
1003
+
1004
+ const hasErrors = computed(() =>
1005
+ scopedFiles.value.some((f) => f.status === 'failed'),
1006
+ );
1007
+
1008
+ return {
1009
+ files: scopedFiles,
1010
+ count: computed(() => scopedFiles.value.length),
1011
+ progress,
1012
+ isUploading,
1013
+ hasErrors,
1014
+ clear: () => {
1015
+ for (const entry of scopedFiles.value) {
1016
+ files.value.delete(entry.id);
1017
+ if (db) {
1018
+ deletePersistedEntry(db, persistStoreName, entry.id).catch(
1019
+ () => {},
1020
+ );
1021
+ }
1022
+ }
1023
+ },
1024
+ cancel: () => {
1025
+ const groupIds = new Set<string>(
1026
+ scopedFiles.value.map((e) => e.groupId),
1027
+ );
1028
+ for (const gid of groupIds) cancel(gid);
1029
+ },
1030
+ retry: (fileId: string) => {
1031
+ const entry = files.value.get(fileId);
1032
+ if (entry && entry.status === 'failed') {
1033
+ updateEntry(fileId, { status: 'queued', error: null, progress: 0 });
1034
+ }
1035
+ },
1036
+ };
1037
+ }
1038
+
1039
+ // ---- Workflow ----
1040
+
1041
+ async function runWorkflow(fileIds: string[]): Promise<WorkflowStream> {
1042
+ const source = fileIds.map((id) => `files://${id}`);
1043
+ const input = { source };
1044
+
1045
+ const controller = new AbortController();
1046
+
1047
+ const involvedStateKeys = [...files.value.values()]
1048
+ .filter((e) => e.remoteFileId && fileIds.includes(e.remoteFileId))
1049
+ .map((e) => e.id);
1050
+
1051
+ let sseEventCount = 0;
1052
+
1053
+ for (const key of involvedStateKeys) {
1054
+ updateEntry(key, { status: 'processing', progress: 0 });
1055
+ }
1056
+
1057
+ const runResponse = await client.post<{ id: string }>(
1058
+ endpoints.workflowRun(storeId, workflowId),
1059
+ { input },
1060
+ { signal: controller.signal },
1061
+ );
1062
+
1063
+ const runId = runResponse.id;
1064
+
1065
+ workflowRun.value = {
1066
+ runId,
1067
+ workflowId,
1068
+ storeId,
1069
+ status: 'connecting',
1070
+ events: [],
1071
+ error: null,
1072
+ startedAt: now(),
1073
+ completedAt: null,
1074
+ };
1075
+
1076
+ emit({ type: 'workflow:started', runId });
1077
+
1078
+ const sseEndpoint = endpoints.workflowRunEventStream(
1079
+ storeId,
1080
+ workflowId,
1081
+ runId,
1082
+ );
1083
+ const sseStream = client.stream(sseEndpoint, {
1084
+ signal: controller.signal,
1085
+ });
1086
+
1087
+ sseStream.subscribe((event: SseEvent) => {
1088
+ if (!workflowRun.value) return;
1089
+
1090
+ let data: { id?: string; status?: string };
1091
+ try {
1092
+ data = JSON.parse(event.data);
1093
+ } catch {
1094
+ data = {};
1095
+ }
1096
+
1097
+ const runEvent: WorkflowRunEvent = {
1098
+ id: data.id ?? '',
1099
+ status: data.status ?? event.event,
1100
+ };
1101
+
1102
+ workflowRun.value.events.push(runEvent);
1103
+ emit({ type: 'workflow:event', event: runEvent });
1104
+
1105
+ if (data.status === 'completed') {
1106
+ workflowRun.value.status = 'completed';
1107
+ workflowRun.value.completedAt = now();
1108
+ for (const key of involvedStateKeys) {
1109
+ updateEntry(key, { status: 'success', progress: 100 });
1110
+ }
1111
+ emit({ type: 'workflow:completed', runId });
1112
+ } else if (data.status === 'failed' || data.status === 'error') {
1113
+ workflowRun.value.status = 'failed';
1114
+ workflowRun.value.completedAt = now();
1115
+ workflowRun.value.error = {
1116
+ code: 'WORKFLOW_RUN_FAILED',
1117
+ message: `Workflow run ${runId} failed.`,
1118
+ };
1119
+ const workflowErr: FileUploadError = {
1120
+ code: 'UPLOAD_FAILED',
1121
+ message: `Workflow run ${runId} failed.`,
1122
+ occurredAt: now(),
1123
+ };
1124
+ for (const key of involvedStateKeys) {
1125
+ updateEntry(key, { status: 'failed', error: workflowErr });
1126
+ }
1127
+ emit({
1128
+ type: 'workflow:failed',
1129
+ runId,
1130
+ error: workflowRun.value.error,
1131
+ });
1132
+ } else {
1133
+ workflowRun.value.status = 'running';
1134
+ sseEventCount++;
1135
+ const estimated = Math.round(100 * (1 - 1 / (1 + sseEventCount * 0.3)));
1136
+ for (const key of involvedStateKeys) {
1137
+ updateEntry(key, { progress: estimated });
1138
+ emit({ type: 'file:progress', fileId: key, progress: estimated });
1139
+ }
1140
+ }
1141
+ });
1142
+
1143
+ return {
1144
+ close() {
1145
+ controller.abort();
1146
+ sseStream.close();
1147
+ if (
1148
+ workflowRun.value &&
1149
+ !['completed', 'failed', 'cancelled'].includes(
1150
+ workflowRun.value.status,
1151
+ )
1152
+ ) {
1153
+ workflowRun.value.status = 'cancelled';
1154
+ workflowRun.value.completedAt = now();
1155
+ }
1156
+ },
1157
+ };
1158
+ }
1159
+
1160
+ // ---- Persistence: restore / purge ----
1161
+
1162
+ async function restoreState(): Promise<void> {
1163
+ if (!persistConfig) return;
1164
+ isRestoring.value = true;
1165
+ try {
1166
+ if (!db) db = await openDB(persistDbName, persistStoreName);
1167
+ const entries = await restoreEntries(
1168
+ db,
1169
+ persistStoreName,
1170
+ persistConfig.maxAge,
1171
+ );
1172
+ for (const entry of entries) {
1173
+ files.value.set(entry.id, entry);
1174
+ }
1175
+ emit({ type: 'state:restored', entryCount: entries.length });
1176
+ } catch (err) {
1177
+ emit({ type: 'state:persist-error', error: err as Error });
1178
+ } finally {
1179
+ isRestoring.value = false;
1180
+ }
1181
+ }
1182
+
1183
+ async function purgePersistedState(): Promise<void> {
1184
+ if (!db) return;
1185
+ try {
1186
+ await purgeStore(db, persistStoreName);
1187
+ } catch (err) {
1188
+ emit({ type: 'state:persist-error', error: err as Error });
1189
+ }
1190
+ }
1191
+
1192
+ // ---- Init ----
1193
+
1194
+ if (persistConfig) {
1195
+ openDB(persistDbName, persistStoreName)
1196
+ .then((database) => {
1197
+ db = database;
1198
+ return restoreState();
1199
+ })
1200
+ .catch((err) => {
1201
+ emit({ type: 'state:persist-error', error: err as Error });
1202
+ });
1203
+ }
1204
+
1205
+ // ---- Return ----
1206
+
1207
+ function setStoreId(id: string) {
1208
+ storeId = id;
1209
+ }
1210
+
1211
+ async function getPublicUrl(fileId: string): Promise<string> {
1212
+ const res = await client.post<{ url: string; expires_at: string }>(
1213
+ endpoints.filePublicUrl(storeId, fileId),
1214
+ {},
1215
+ );
1216
+ return res.url;
1217
+ }
1218
+
1219
+ return {
1220
+ getSupportedFileTypes,
1221
+
1222
+ upload,
1223
+ cancel,
1224
+ removeFile,
1225
+
1226
+ files,
1227
+ getFileState,
1228
+ updateFileMetadata,
1229
+ clearState,
1230
+ setStoreId,
1231
+ getPublicUrl,
1232
+
1233
+ scopeBy,
1234
+
1235
+ workflowRun,
1236
+ runWorkflow,
1237
+
1238
+ isRestoring,
1239
+ restoreState,
1240
+ purgePersistedState,
1241
+ };
1242
+ }