@aleph-alpha/chat-kit 2.1.0 → 2.2.0
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/dist/{CkInput.vue_vue_type_script_setup_true_lang-DPZedAQS.js → CkInput.vue_vue_type_script_setup_true_lang-CjfKRuZs.js} +291 -61
- package/dist/chat-kit.css +40 -5
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneIllustration.vue.d.ts +4 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneIllustration.vue.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.stories.d.ts +17 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.stories.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts +31 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/index.d.ts +3 -0
- package/dist/components/base/CkDropZoneOverlay/index.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/types.d.ts +88 -0
- package/dist/components/base/CkDropZoneOverlay/types.d.ts.map +1 -0
- package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts.map +1 -1
- package/dist/components/base/index.d.ts +2 -0
- package/dist/components/base/index.d.ts.map +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +19 -18
- package/dist/composables/fileUpload/index.d.ts +1 -1
- package/dist/composables/fileUpload/index.d.ts.map +1 -1
- package/dist/composables/fileUpload/types.d.ts +11 -1
- package/dist/composables/fileUpload/types.d.ts.map +1 -1
- package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
- package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/{index-DrtHeZpl.js → index-CfXEGjc7.js} +73 -27
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -20
- package/package.json +1 -1
- package/src/components/base/CkDropZoneOverlay/CkDropZoneIllustration.vue +125 -0
- package/src/components/base/CkDropZoneOverlay/CkDropZoneOverlay.spec.ts +104 -0
- package/src/components/base/CkDropZoneOverlay/CkDropZoneOverlay.stories.ts +130 -0
- package/src/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue +272 -0
- package/src/components/base/CkDropZoneOverlay/index.ts +2 -0
- package/src/components/base/CkDropZoneOverlay/types.ts +93 -0
- package/src/components/base/CkFeedbackDialog/CkFeedbackDialog.vue +22 -1
- package/src/components/base/index.ts +7 -0
- package/src/components/index.ts +5 -0
- package/src/composables/fileUpload/index.spec.ts +39 -7
- package/src/composables/fileUpload/index.ts +41 -19
- package/src/composables/fileUpload/types.ts +15 -1
- package/src/composables/fileUpload/useFileUpload.spec.ts +147 -0
- package/src/composables/fileUpload/useFileUpload.ts +18 -4
- package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +202 -0
- package/src/composables/fileUpload/useFileUploadWrapper.ts +25 -3
- package/src/index.ts +5 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Why a dropped file was rejected. */
|
|
2
|
+
export type CkDropZoneRejectionReason =
|
|
3
|
+
| 'unsupported-type'
|
|
4
|
+
| 'too-large'
|
|
5
|
+
| 'too-many';
|
|
6
|
+
|
|
7
|
+
export interface CkDropZoneRejection {
|
|
8
|
+
/** The offending file, when the rejection concerns a specific file. */
|
|
9
|
+
file?: File;
|
|
10
|
+
/** Machine-readable reason, for consumers building their own messaging. */
|
|
11
|
+
reason: CkDropZoneRejectionReason;
|
|
12
|
+
/** Default human-readable (English) message used by the built-in toast. */
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Full-window drag-and-drop overlay. Listens for file drags anywhere on the
|
|
18
|
+
* page, reveals a teleported overlay, validates dropped files against `accept`,
|
|
19
|
+
* `maxFileSizeBytes` and `maxFiles`, and emits the accepted files.
|
|
20
|
+
*
|
|
21
|
+
* Purely presentational and self-contained: it owns its own drag state and has
|
|
22
|
+
* no knowledge of the host app. All limits and copy are configurable, and
|
|
23
|
+
* rejection handling can be delegated to the consumer via the `rejected` event.
|
|
24
|
+
*
|
|
25
|
+
* @category Form Inputs
|
|
26
|
+
* @useCases page-level file attachment, drag-and-drop upload, chat composer
|
|
27
|
+
* @keywords drag and drop, drop zone, file upload, overlay, attachments
|
|
28
|
+
* @related CkFileItemsList, CkPromptInput
|
|
29
|
+
*/
|
|
30
|
+
export interface CkDropZoneOverlayProps {
|
|
31
|
+
/**
|
|
32
|
+
* HTML `accept` string used to validate dropped files (extensions, MIME
|
|
33
|
+
* types and `type/*` wildcards). Empty string accepts everything.
|
|
34
|
+
* @default ''
|
|
35
|
+
*/
|
|
36
|
+
accept?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Reject files larger than this many bytes.
|
|
39
|
+
* @default Number.POSITIVE_INFINITY
|
|
40
|
+
*/
|
|
41
|
+
maxFileSizeBytes?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Maximum number of files accepted per drop. Excess files are dropped and
|
|
44
|
+
* reported as a single rejection.
|
|
45
|
+
* @default Number.POSITIVE_INFINITY
|
|
46
|
+
*/
|
|
47
|
+
maxFiles?: number;
|
|
48
|
+
/**
|
|
49
|
+
* When true, the overlay never appears and window drag events are ignored,
|
|
50
|
+
* so the page behaves normally (e.g. while a response is streaming or the
|
|
51
|
+
* attachment limit is already reached).
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Heading text. Defaults to a sensible singular/plural string derived from
|
|
57
|
+
* `maxFiles` (e.g. "Drop a file here to add to chat" vs "Drop files here to
|
|
58
|
+
* add to chat").
|
|
59
|
+
*/
|
|
60
|
+
heading?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Secondary hint shown under the heading (e.g. supported formats / limits).
|
|
63
|
+
* Pass an empty string to hide it.
|
|
64
|
+
* @default 'Add txt, docx, pdf, md, or csv'
|
|
65
|
+
*/
|
|
66
|
+
hint?: string;
|
|
67
|
+
/**
|
|
68
|
+
* `aria-label` for the overlay dialog.
|
|
69
|
+
* @default 'Drop files anywhere to attach'
|
|
70
|
+
*/
|
|
71
|
+
ariaLabel?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Render the built-in error toast for rejected files. Set to `false` to take
|
|
74
|
+
* over messaging via the `rejected` event.
|
|
75
|
+
* @default true
|
|
76
|
+
*/
|
|
77
|
+
showErrors?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* How long the built-in error toast stays visible, in milliseconds.
|
|
80
|
+
* @default 4000
|
|
81
|
+
*/
|
|
82
|
+
errorTimeoutMs?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CkDropZoneOverlayEmits {
|
|
86
|
+
/** Files that passed validation, capped to `maxFiles`. */
|
|
87
|
+
(event: 'files', files: File[]): void;
|
|
88
|
+
/**
|
|
89
|
+
* Rejected files / drops. Emitted alongside the built-in toast so consumers
|
|
90
|
+
* can integrate their own notification system (and disable `showErrors`).
|
|
91
|
+
*/
|
|
92
|
+
(event: 'rejected', rejections: CkDropZoneRejection[]): void;
|
|
93
|
+
}
|
|
@@ -215,7 +215,7 @@ function handleSubmit(): void {
|
|
|
215
215
|
v-for="reason in reasons"
|
|
216
216
|
:key="reason.code"
|
|
217
217
|
:value="reason.code"
|
|
218
|
-
class="ck-feedback-dialog__chip"
|
|
218
|
+
class="ck-feedback-dialog__chip shadow-none"
|
|
219
219
|
>
|
|
220
220
|
{{ reason.label }}
|
|
221
221
|
</UiToggleGroupItem>
|
|
@@ -229,6 +229,7 @@ function handleSubmit(): void {
|
|
|
229
229
|
:id="freeTextId"
|
|
230
230
|
v-model="freeText"
|
|
231
231
|
name="ck-feedback-dialog-free-text"
|
|
232
|
+
class="ck-feedback-dialog__freetext"
|
|
232
233
|
:placeholder="freeTextPlaceholder"
|
|
233
234
|
:disabled="disabled || submitting"
|
|
234
235
|
:maxlength="maxFreeTextLength"
|
|
@@ -279,6 +280,26 @@ function handleSubmit(): void {
|
|
|
279
280
|
line-height: 1rem;
|
|
280
281
|
}
|
|
281
282
|
|
|
283
|
+
/* Selected chip: soft-active treatment (per Figma). Token vars keep it
|
|
284
|
+
* theme-aware; scoped here because the data-[state=on]:bg-* utility isn't
|
|
285
|
+
* emitted by the consumer's UnoCSS build. The :hover selector holds the
|
|
286
|
+
* styling when hovering a selected chip (the outline variant ships a
|
|
287
|
+
* data-[state=on]:hover override of equal specificity otherwise). */
|
|
288
|
+
.ck-feedback-dialog__chip[data-state='on'],
|
|
289
|
+
.ck-feedback-dialog__chip[data-state='on']:hover {
|
|
290
|
+
border-color: oklch(var(--accent-default));
|
|
291
|
+
background-color: oklch(var(--background-accent-soft));
|
|
292
|
+
color: oklch(var(--content-on-accent-soft));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/*
|
|
296
|
+
* The free-text field stays vertically resizable, but cap its height so
|
|
297
|
+
* dragging the resize handle can't push it past the dialog bounds.
|
|
298
|
+
*/
|
|
299
|
+
.ck-feedback-dialog__freetext {
|
|
300
|
+
max-height: 11rem;
|
|
301
|
+
}
|
|
302
|
+
|
|
282
303
|
.ck-feedback-dialog__response :deep(p),
|
|
283
304
|
.ck-feedback-dialog__response :deep(li) {
|
|
284
305
|
line-height: 1.5rem;
|
|
@@ -3,6 +3,13 @@ export { CkAgentMessage } from './CkAgentMessage';
|
|
|
3
3
|
export { CkConversationLayout } from './CkConversationLayout';
|
|
4
4
|
export type { CkConversationLayoutProps } from './CkConversationLayout';
|
|
5
5
|
export { CkCopyMessageActionButton } from './CkCopyMessageActionButton';
|
|
6
|
+
export { CkDropZoneOverlay } from './CkDropZoneOverlay';
|
|
7
|
+
export type {
|
|
8
|
+
CkDropZoneOverlayProps,
|
|
9
|
+
CkDropZoneOverlayEmits,
|
|
10
|
+
CkDropZoneRejection,
|
|
11
|
+
CkDropZoneRejectionReason,
|
|
12
|
+
} from './CkDropZoneOverlay';
|
|
6
13
|
export { CkFeedbackButton } from './CkFeedbackButton';
|
|
7
14
|
export type {
|
|
8
15
|
CkFeedbackButtonProps,
|
package/src/components/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
CkAgentMessage,
|
|
4
4
|
CkConversationLayout,
|
|
5
5
|
CkCopyMessageActionButton,
|
|
6
|
+
CkDropZoneOverlay,
|
|
6
7
|
CkFeedbackButton,
|
|
7
8
|
CkFeedbackDialog,
|
|
8
9
|
CkFileItemsList,
|
|
@@ -21,6 +22,10 @@ export {
|
|
|
21
22
|
} from './base';
|
|
22
23
|
export type {
|
|
23
24
|
CkConversationLayoutProps,
|
|
25
|
+
CkDropZoneOverlayProps,
|
|
26
|
+
CkDropZoneOverlayEmits,
|
|
27
|
+
CkDropZoneRejection,
|
|
28
|
+
CkDropZoneRejectionReason,
|
|
24
29
|
CkFeedbackButtonProps,
|
|
25
30
|
CkFeedbackDialogEmits,
|
|
26
31
|
CkFeedbackDialogProps,
|
|
@@ -10,6 +10,7 @@ vi.mock('./useFileUploadWrapper', () => ({
|
|
|
10
10
|
upload: {},
|
|
11
11
|
storeId: { value: '' },
|
|
12
12
|
fileInput: { value: null },
|
|
13
|
+
maxFilesPerUpload: config.maxFilesPerUpload ?? 20,
|
|
13
14
|
uploadFiles: vi.fn(),
|
|
14
15
|
unassignedFiles: { value: [] },
|
|
15
16
|
assignFilesToMessage: vi.fn(),
|
|
@@ -62,11 +63,11 @@ describe('fileUpload index', () => {
|
|
|
62
63
|
expect(workflowConfig.workflow_name).toBe('DocumentAI');
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
it('
|
|
66
|
+
it('does not send the legacy workflow definition field', () => {
|
|
67
|
+
// The search-stores API dropped the redundant `definition` field
|
|
68
|
+
// (it duplicated `workflow_name`); sending it now breaks store creation.
|
|
66
69
|
const { workflowConfig } = defaultDocumentUploadConfig;
|
|
67
|
-
expect(workflowConfig.definition).
|
|
68
|
-
workflow_name: 'DocumentAI',
|
|
69
|
-
});
|
|
70
|
+
expect(workflowConfig.definition).toBeUndefined();
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
it('configures initialization parameters', () => {
|
|
@@ -82,7 +83,16 @@ describe('fileUpload index', () => {
|
|
|
82
83
|
.parameters as Record<string, unknown>;
|
|
83
84
|
const extraction = params.document_extraction as Record<string, unknown>;
|
|
84
85
|
|
|
85
|
-
expect(extraction.pdf_reader).toEqual({
|
|
86
|
+
expect(extraction.pdf_reader).toEqual({
|
|
87
|
+
kind: 'vision',
|
|
88
|
+
batch_size: 10,
|
|
89
|
+
ignore_headers_footers: true,
|
|
90
|
+
max_tokens: 16384,
|
|
91
|
+
model: 'gemini-2.5-flash',
|
|
92
|
+
retries: 3,
|
|
93
|
+
timeout: 120,
|
|
94
|
+
workers: 4,
|
|
95
|
+
});
|
|
86
96
|
expect(extraction.docx_reader).toEqual({
|
|
87
97
|
kind: 'docx',
|
|
88
98
|
batch_size: 10,
|
|
@@ -120,10 +130,12 @@ describe('fileUpload index', () => {
|
|
|
120
130
|
unknown
|
|
121
131
|
>;
|
|
122
132
|
expect(chunkExtraction.chunker).toEqual({
|
|
123
|
-
kind: '
|
|
133
|
+
kind: 'recursive',
|
|
124
134
|
batch_size: 1,
|
|
125
135
|
include_image_descriptions: false,
|
|
126
|
-
|
|
136
|
+
chunk_overlap: 200,
|
|
137
|
+
chunk_size: 1200,
|
|
138
|
+
separators: ['\n\n', '\n', ' ', '.', ',', ''],
|
|
127
139
|
});
|
|
128
140
|
});
|
|
129
141
|
|
|
@@ -143,6 +155,10 @@ describe('fileUpload index', () => {
|
|
|
143
155
|
additionalProperties: true,
|
|
144
156
|
});
|
|
145
157
|
});
|
|
158
|
+
|
|
159
|
+
it('does not set maxFilesPerUpload by default', () => {
|
|
160
|
+
expect(defaultDocumentUploadConfig.maxFilesPerUpload).toBeUndefined();
|
|
161
|
+
});
|
|
146
162
|
});
|
|
147
163
|
|
|
148
164
|
describe('createDocumentUploadProvider', () => {
|
|
@@ -187,6 +203,22 @@ describe('fileUpload index', () => {
|
|
|
187
203
|
};
|
|
188
204
|
expect(provider._config).toBe(customConfig);
|
|
189
205
|
});
|
|
206
|
+
|
|
207
|
+
it('passes maxFilesPerUpload through config', () => {
|
|
208
|
+
const customConfig: DocumentUploadConfig = {
|
|
209
|
+
...defaultDocumentUploadConfig,
|
|
210
|
+
maxFilesPerUpload: 5,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const provider = createDocumentUploadProvider(
|
|
214
|
+
customConfig,
|
|
215
|
+
) as unknown as {
|
|
216
|
+
useDocumentUpload: (id: string) => { maxFilesPerUpload: number };
|
|
217
|
+
_config: DocumentUploadConfig;
|
|
218
|
+
};
|
|
219
|
+
const result = provider.useDocumentUpload('conv-1');
|
|
220
|
+
expect(result.maxFilesPerUpload).toBe(5);
|
|
221
|
+
});
|
|
190
222
|
});
|
|
191
223
|
|
|
192
224
|
describe('useDocumentUploadWithDefaultConfig', () => {
|
|
@@ -22,40 +22,60 @@ export const defaultDocumentUploadConfig: DocumentUploadConfig = {
|
|
|
22
22
|
workflowConfig: {
|
|
23
23
|
name: WORKFLOW_ID,
|
|
24
24
|
workflow_name: 'DocumentAI',
|
|
25
|
-
definition: { workflow_name: 'DocumentAI' },
|
|
26
25
|
parameters: {
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
chunk_extraction: {
|
|
27
|
+
chunker: {
|
|
28
|
+
batch_size: 1,
|
|
29
|
+
chunk_overlap: 200,
|
|
30
|
+
chunk_size: 1200,
|
|
31
|
+
include_image_descriptions: false,
|
|
32
|
+
kind: 'recursive',
|
|
33
|
+
separators: ['\n\n', '\n', ' ', '.', ',', ''],
|
|
34
|
+
},
|
|
29
35
|
},
|
|
30
36
|
document_extraction: {
|
|
31
|
-
pdf_reader: { kind: 'pypdf', batch_size: 10 },
|
|
32
37
|
docx_reader: {
|
|
33
|
-
kind: 'docx',
|
|
34
38
|
batch_size: 10,
|
|
39
|
+
kind: 'docx',
|
|
35
40
|
split_by_sections: false,
|
|
36
41
|
},
|
|
37
|
-
|
|
38
|
-
xlsx_reader: {
|
|
39
|
-
kind: 'xlsx',
|
|
42
|
+
markdown_reader: {
|
|
40
43
|
batch_size: 10,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
kind: 'markdown',
|
|
45
|
+
},
|
|
46
|
+
pdf_reader: {
|
|
47
|
+
batch_size: 10,
|
|
48
|
+
ignore_headers_footers: true,
|
|
49
|
+
kind: 'vision',
|
|
50
|
+
max_tokens: 16384,
|
|
51
|
+
model: 'gemini-2.5-flash',
|
|
52
|
+
retries: 3,
|
|
53
|
+
timeout: 120,
|
|
54
|
+
workers: 4,
|
|
44
55
|
},
|
|
45
|
-
markdown_reader: { kind: 'markdown', batch_size: 10 },
|
|
46
56
|
plaintext_reader: {
|
|
47
|
-
kind: 'plaintext',
|
|
48
57
|
batch_size: 10,
|
|
58
|
+
kind: 'plaintext',
|
|
49
59
|
max_content_size: 500000,
|
|
50
60
|
min_space_ratio: 0.02,
|
|
51
61
|
},
|
|
62
|
+
pptx_reader: {
|
|
63
|
+
batch_size: 10,
|
|
64
|
+
include_notes: true,
|
|
65
|
+
kind: 'pptx',
|
|
66
|
+
},
|
|
67
|
+
xlsx_reader: {
|
|
68
|
+
batch_size: 10,
|
|
69
|
+
include_empty_sheets: false,
|
|
70
|
+
kind: 'xlsx',
|
|
71
|
+
max_cols: 100,
|
|
72
|
+
max_rows: 10000,
|
|
73
|
+
},
|
|
52
74
|
},
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
include_image_descriptions: false,
|
|
58
|
-
chunk_size: 200,
|
|
75
|
+
initialization: {
|
|
76
|
+
store: {
|
|
77
|
+
batch_size: 10,
|
|
78
|
+
pages_per_part: 25,
|
|
59
79
|
},
|
|
60
80
|
},
|
|
61
81
|
},
|
|
@@ -83,6 +103,8 @@ export { useDocumentUploadWithDefaultConfig, createDocumentUploadProvider };
|
|
|
83
103
|
export type {
|
|
84
104
|
DocumentUploadConfig,
|
|
85
105
|
DocumentUploadProvider,
|
|
106
|
+
DocumentUploadReturn,
|
|
107
|
+
HandleFilesSelectedOptions,
|
|
86
108
|
FileEntry,
|
|
87
109
|
FileStatus,
|
|
88
110
|
} from './types';
|
|
@@ -345,6 +345,7 @@ export interface DocumentUploadConfig {
|
|
|
345
345
|
workflowConfig: Record<string, unknown>;
|
|
346
346
|
persist?: boolean;
|
|
347
347
|
maxConcurrency?: number;
|
|
348
|
+
maxFilesPerUpload?: number;
|
|
348
349
|
headers?: Record<string, string>;
|
|
349
350
|
onEvent?: (event: UploadEvent) => void;
|
|
350
351
|
}
|
|
@@ -359,13 +360,25 @@ export interface DocumentUploadReturn {
|
|
|
359
360
|
upload: UseFileUploadReturn;
|
|
360
361
|
storeId: Ref<string>;
|
|
361
362
|
fileInput: Ref<HTMLInputElement | null>;
|
|
363
|
+
maxFilesPerUpload: number;
|
|
362
364
|
uploadFiles: (files: File[]) => Promise<UploadResult | undefined>;
|
|
363
365
|
unassignedFiles: ComputedRef<FileEntry[]>;
|
|
364
366
|
assignFilesToMessage: (messageId: string) => void;
|
|
365
367
|
getFilesForMessage: (messageId: string) => FileEntry[];
|
|
366
368
|
stampResponseId: (responseId: string, localMessageId: string) => void;
|
|
367
369
|
purgeCompletedFiles: () => void;
|
|
368
|
-
handleFilesSelected: (
|
|
370
|
+
handleFilesSelected: (
|
|
371
|
+
event: Event,
|
|
372
|
+
options?: HandleFilesSelectedOptions,
|
|
373
|
+
) => void;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface HandleFilesSelectedOptions {
|
|
377
|
+
onFilesExceeded?: (info: {
|
|
378
|
+
selected: number;
|
|
379
|
+
allowed: number;
|
|
380
|
+
kept: number;
|
|
381
|
+
}) => void;
|
|
369
382
|
}
|
|
370
383
|
|
|
371
384
|
// ---------------------------------------------------------------------------
|
|
@@ -379,6 +392,7 @@ export interface UseFileUploadOptions {
|
|
|
379
392
|
endpoints?: EndpointResolver;
|
|
380
393
|
persist?: PersistOptions | boolean;
|
|
381
394
|
maxConcurrency?: number;
|
|
395
|
+
maxFilesPerUpload?: number;
|
|
382
396
|
retryPolicy?: RetryPolicy;
|
|
383
397
|
validators?: ValidatorFn[];
|
|
384
398
|
onEvent?: (event: UploadEvent) => void;
|
|
@@ -29,6 +29,10 @@ function makeEntry(id: string, remoteFileId: string): FileEntry {
|
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function makeSuccessEntry(id: string, remoteFileId: string): FileEntry {
|
|
33
|
+
return { ...makeEntry(id, remoteFileId), status: 'success', progress: 100 };
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
/**
|
|
33
37
|
* Mock `HttpClient` whose `stream` captures the subscriber so the test can
|
|
34
38
|
* drive SSE events synchronously. `post` resolves the workflow run id.
|
|
@@ -47,6 +51,18 @@ function createMockClient(captured: { cb?: (event: SseEvent) => void }) {
|
|
|
47
51
|
} as unknown as HttpClient;
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
function createUploadMockClient() {
|
|
55
|
+
return {
|
|
56
|
+
get: vi.fn(),
|
|
57
|
+
delete: vi.fn(),
|
|
58
|
+
post: vi.fn().mockResolvedValue([{ id: 'remote-1' }]),
|
|
59
|
+
stream: vi.fn(() => ({
|
|
60
|
+
subscribe: vi.fn(),
|
|
61
|
+
close: vi.fn(),
|
|
62
|
+
})),
|
|
63
|
+
} as unknown as HttpClient;
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
function completeEvent(
|
|
51
67
|
documents: Array<{ document_id?: string; file_id?: string }>,
|
|
52
68
|
): SseEvent {
|
|
@@ -60,6 +76,10 @@ function completeEvent(
|
|
|
60
76
|
};
|
|
61
77
|
}
|
|
62
78
|
|
|
79
|
+
function createFile(name: string, size = 100): File {
|
|
80
|
+
return new File(['x'.repeat(size)], name, { type: 'application/pdf' });
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
describe('useFileUpload runWorkflow', () => {
|
|
64
84
|
it('stamps documentId onto each file from the completed event, matched by file_id', async () => {
|
|
65
85
|
const captured: { cb?: (event: SseEvent) => void } = {};
|
|
@@ -109,3 +129,130 @@ describe('useFileUpload runWorkflow', () => {
|
|
|
109
129
|
expect(upload.getFileState('key-1')?.metadata.documentId).toBeUndefined();
|
|
110
130
|
});
|
|
111
131
|
});
|
|
132
|
+
|
|
133
|
+
describe('useFileUpload maxFilesPerUpload', () => {
|
|
134
|
+
it('uses default maxFilesPerUpload of 20 when not configured', () => {
|
|
135
|
+
const upload = useFileUpload({
|
|
136
|
+
client: createUploadMockClient(),
|
|
137
|
+
workflowId: 'wf',
|
|
138
|
+
storeId: 'ss-1',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const constraints = upload.getSupportedFileTypes();
|
|
142
|
+
expect(constraints.maxFilesPerUpload).toBe(20);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('respects custom maxFilesPerUpload from options', () => {
|
|
146
|
+
const upload = useFileUpload({
|
|
147
|
+
client: createUploadMockClient(),
|
|
148
|
+
workflowId: 'wf',
|
|
149
|
+
storeId: 'ss-1',
|
|
150
|
+
maxFilesPerUpload: 5,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const constraints = upload.getSupportedFileTypes();
|
|
154
|
+
expect(constraints.maxFilesPerUpload).toBe(5);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('throws MAX_FILES_EXCEEDED when batch exceeds limit', async () => {
|
|
158
|
+
const upload = useFileUpload({
|
|
159
|
+
client: createUploadMockClient(),
|
|
160
|
+
workflowId: 'wf',
|
|
161
|
+
storeId: 'ss-1',
|
|
162
|
+
maxFilesPerUpload: 2,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const files = [
|
|
166
|
+
createFile('a.pdf'),
|
|
167
|
+
createFile('b.pdf'),
|
|
168
|
+
createFile('c.pdf'),
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
await expect(upload.upload({ id: 'g1', files })).rejects.toMatchObject({
|
|
172
|
+
code: 'MAX_FILES_EXCEEDED',
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('throws MAX_FILES_EXCEEDED when active files plus new batch exceeds limit', async () => {
|
|
177
|
+
const client = createUploadMockClient();
|
|
178
|
+
(client.post as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
179
|
+
{ id: 'remote-1' },
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
const upload = useFileUpload({
|
|
183
|
+
client,
|
|
184
|
+
workflowId: 'wf',
|
|
185
|
+
storeId: 'ss-1',
|
|
186
|
+
maxFilesPerUpload: 3,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
upload.files.value.set('existing-1', makeSuccessEntry('existing-1', 'r-1'));
|
|
190
|
+
upload.files.value.set('existing-2', makeSuccessEntry('existing-2', 'r-2'));
|
|
191
|
+
|
|
192
|
+
await expect(
|
|
193
|
+
upload.upload({
|
|
194
|
+
id: 'g2',
|
|
195
|
+
files: [createFile('a.pdf'), createFile('b.pdf')],
|
|
196
|
+
}),
|
|
197
|
+
).rejects.toMatchObject({
|
|
198
|
+
code: 'MAX_FILES_EXCEEDED',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('allows upload when active files plus new batch is within limit', async () => {
|
|
203
|
+
const client = createUploadMockClient();
|
|
204
|
+
(client.post as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
205
|
+
{ id: 'remote-new' },
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const upload = useFileUpload({
|
|
209
|
+
client,
|
|
210
|
+
workflowId: 'wf',
|
|
211
|
+
storeId: 'ss-1',
|
|
212
|
+
maxFilesPerUpload: 3,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
upload.files.value.set('existing-1', makeSuccessEntry('existing-1', 'r-1'));
|
|
216
|
+
|
|
217
|
+
const result = await upload.upload({
|
|
218
|
+
id: 'g3',
|
|
219
|
+
files: [createFile('a.pdf'), createFile('b.pdf')],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.results).toHaveLength(2);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('does not count failed/cancelled/removed files towards active count', async () => {
|
|
226
|
+
const client = createUploadMockClient();
|
|
227
|
+
(client.post as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
228
|
+
{ id: 'remote-new' },
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
const upload = useFileUpload({
|
|
232
|
+
client,
|
|
233
|
+
workflowId: 'wf',
|
|
234
|
+
storeId: 'ss-1',
|
|
235
|
+
maxFilesPerUpload: 2,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const failedEntry: FileEntry = {
|
|
239
|
+
...makeEntry('failed-1', 'r-f1'),
|
|
240
|
+
status: 'failed',
|
|
241
|
+
error: { code: 'UPLOAD_FAILED', message: 'err', occurredAt: 'now' },
|
|
242
|
+
};
|
|
243
|
+
const cancelledEntry: FileEntry = {
|
|
244
|
+
...makeEntry('cancelled-1', 'r-c1'),
|
|
245
|
+
status: 'cancelled',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
upload.files.value.set('failed-1', failedEntry);
|
|
249
|
+
upload.files.value.set('cancelled-1', cancelledEntry);
|
|
250
|
+
|
|
251
|
+
const result = await upload.upload({
|
|
252
|
+
id: 'g4',
|
|
253
|
+
files: [createFile('a.pdf'), createFile('b.pdf')],
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(result.results).toHaveLength(2);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -473,7 +473,12 @@ export function useFileUpload(
|
|
|
473
473
|
let storeId = options.storeId ?? '';
|
|
474
474
|
const endpoints: EndpointResolver = options.endpoints ?? defaultEndpoints;
|
|
475
475
|
const retryPolicy: RetryPolicy = options.retryPolicy ?? DEFAULT_RETRY_POLICY;
|
|
476
|
-
const constraints: FileConstraints = {
|
|
476
|
+
const constraints: FileConstraints = {
|
|
477
|
+
...DEFAULT_CONSTRAINTS,
|
|
478
|
+
...(options.maxFilesPerUpload != null && {
|
|
479
|
+
maxFilesPerUpload: options.maxFilesPerUpload,
|
|
480
|
+
}),
|
|
481
|
+
};
|
|
477
482
|
const validators: ValidatorFn[] = options.validators ?? [];
|
|
478
483
|
const maxConcurrency = options.maxConcurrency ?? 3;
|
|
479
484
|
|
|
@@ -701,10 +706,17 @@ export function useFileUpload(
|
|
|
701
706
|
? params.files
|
|
702
707
|
: [params.files];
|
|
703
708
|
|
|
704
|
-
|
|
709
|
+
const activeFileCount = [...files.value.values()].filter(
|
|
710
|
+
(e) =>
|
|
711
|
+
e.status !== 'failed' &&
|
|
712
|
+
e.status !== 'cancelled' &&
|
|
713
|
+
e.status !== 'removed',
|
|
714
|
+
).length;
|
|
715
|
+
|
|
716
|
+
if (activeFileCount + fileList.length > constraints.maxFilesPerUpload) {
|
|
705
717
|
throw new SdkError(
|
|
706
718
|
'MAX_FILES_EXCEEDED',
|
|
707
|
-
`Cannot upload ${fileList.length}
|
|
719
|
+
`Cannot upload ${fileList.length} file(s) — ${activeFileCount} already active, maximum is ${constraints.maxFilesPerUpload}.`,
|
|
708
720
|
);
|
|
709
721
|
}
|
|
710
722
|
|
|
@@ -1133,7 +1145,9 @@ export function useFileUpload(
|
|
|
1133
1145
|
if (data.status === 'completed') {
|
|
1134
1146
|
workflowRun.value.status = 'completed';
|
|
1135
1147
|
workflowRun.value.completedAt = now();
|
|
1136
|
-
completeInvolvedFiles(
|
|
1148
|
+
completeInvolvedFiles(
|
|
1149
|
+
Array.isArray(data.documents) ? data.documents : [],
|
|
1150
|
+
);
|
|
1137
1151
|
emit({ type: 'workflow:completed', runId });
|
|
1138
1152
|
} else if (data.status === 'failed' || data.status === 'error') {
|
|
1139
1153
|
workflowRun.value.status = 'failed';
|