@aleph-alpha/chat-kit 3.1.3 → 3.3.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.
Files changed (99) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-LPrW7CSQ.js → CkInput.vue_vue_type_script_setup_true_lang-BAcBFs6I.js} +302 -71
  2. package/dist/adapters/responses-api.d.ts.map +1 -1
  3. package/dist/adapters/responses-api.js +63 -37
  4. package/dist/chat-kit.css +6 -0
  5. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +7 -0
  6. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
  7. package/dist/components/base/CkCitation/CkCitation.stories.d.ts +47 -0
  8. package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -0
  9. package/dist/components/base/CkCitation/CkCitation.vue.d.ts +15 -0
  10. package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -0
  11. package/dist/components/base/CkCitation/index.d.ts +3 -0
  12. package/dist/components/base/CkCitation/index.d.ts.map +1 -0
  13. package/dist/components/base/CkCitation/types.d.ts +79 -0
  14. package/dist/components/base/CkCitation/types.d.ts.map +1 -0
  15. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +51 -0
  16. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -0
  17. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts +13 -0
  18. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -0
  19. package/dist/components/base/CkCitationCard/index.d.ts +3 -0
  20. package/dist/components/base/CkCitationCard/index.d.ts.map +1 -0
  21. package/dist/components/base/CkCitationCard/types.d.ts +51 -0
  22. package/dist/components/base/CkCitationCard/types.d.ts.map +1 -0
  23. package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts +1 -1
  24. package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts +2 -2
  25. package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +1 -1
  26. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +15 -0
  27. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
  28. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts +16 -2
  29. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts.map +1 -1
  30. package/dist/components/base/CkMarkdownRenderer/index.d.ts +1 -1
  31. package/dist/components/base/CkMarkdownRenderer/index.d.ts.map +1 -1
  32. package/dist/components/base/CkMarkdownRenderer/types.d.ts +25 -0
  33. package/dist/components/base/CkMarkdownRenderer/types.d.ts.map +1 -1
  34. package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts +17 -0
  35. package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts.map +1 -0
  36. package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
  37. package/dist/components/base/index.d.ts +5 -1
  38. package/dist/components/base/index.d.ts.map +1 -1
  39. package/dist/components/composed/CkInput/CkInput.vue.d.ts +1 -1
  40. package/dist/components/index.d.ts +2 -2
  41. package/dist/components/index.d.ts.map +1 -1
  42. package/dist/components/index.js +25 -23
  43. package/dist/composables/fileUpload/types.d.ts +3 -0
  44. package/dist/composables/fileUpload/types.d.ts.map +1 -1
  45. package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
  46. package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
  47. package/dist/composables/index.js +1 -1
  48. package/dist/handlers/index.d.ts +3 -0
  49. package/dist/handlers/index.d.ts.map +1 -1
  50. package/dist/handlers/index.js +4 -1
  51. package/dist/handlers/useChatKitLabels.d.ts +11 -0
  52. package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
  53. package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
  54. package/dist/helpers/unified/remarkCitations.d.ts +17 -0
  55. package/dist/helpers/unified/remarkCitations.d.ts.map +1 -0
  56. package/dist/helpers/unified/remarkNormalizeMath.d.ts +24 -0
  57. package/dist/helpers/unified/remarkNormalizeMath.d.ts.map +1 -0
  58. package/dist/{index-DVxCNAs3.js → index-C6njJ8EB.js} +96 -49
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +28 -26
  62. package/dist/{useChatKitLabels-Ctz1dHpx.js → useChatKitLabels-BM2I7-9o.js} +126 -3
  63. package/package.json +6 -3
  64. package/src/adapters/responses-api.spec.ts +156 -0
  65. package/src/adapters/responses-api.ts +125 -58
  66. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +112 -0
  67. package/src/components/base/CkCitation/CkCitation.spec.ts +79 -0
  68. package/src/components/base/CkCitation/CkCitation.stories.ts +136 -0
  69. package/src/components/base/CkCitation/CkCitation.vue +145 -0
  70. package/src/components/base/CkCitation/index.ts +2 -0
  71. package/src/components/base/CkCitation/types.ts +80 -0
  72. package/src/components/base/CkCitationCard/CkCitationCard.spec.ts +77 -0
  73. package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +144 -0
  74. package/src/components/base/CkCitationCard/CkCitationCard.vue +76 -0
  75. package/src/components/base/CkCitationCard/index.ts +2 -0
  76. package/src/components/base/CkCitationCard/types.ts +51 -0
  77. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +124 -0
  78. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +118 -0
  79. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +70 -28
  80. package/src/components/base/CkMarkdownRenderer/index.ts +1 -0
  81. package/src/components/base/CkMarkdownRenderer/types.ts +26 -0
  82. package/src/components/base/CkMarkdownRenderer/useCitationRendering.spec.ts +94 -0
  83. package/src/components/base/CkMarkdownRenderer/useCitationRendering.ts +52 -0
  84. package/src/components/base/index.ts +12 -0
  85. package/src/components/index.ts +8 -0
  86. package/src/composables/fileUpload/types.ts +6 -0
  87. package/src/composables/fileUpload/useFileUpload.spec.ts +2 -61
  88. package/src/composables/fileUpload/useFileUpload.ts +9 -10
  89. package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +241 -10
  90. package/src/composables/fileUpload/useFileUploadWrapper.ts +126 -53
  91. package/src/handlers/index.ts +6 -0
  92. package/src/handlers/useChatKitLabels.ts +13 -0
  93. package/src/handlers/useMarkdownParser.spec.ts +34 -0
  94. package/src/handlers/useMarkdownParser.ts +15 -3
  95. package/src/helpers/unified/remarkCitations.spec.ts +112 -0
  96. package/src/helpers/unified/remarkCitations.ts +55 -0
  97. package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
  98. package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
  99. package/src/index.ts +8 -0
@@ -0,0 +1,52 @@
1
+ import { computed, h, toValue, type MaybeRefOrGetter, type VNode } from 'vue';
2
+ import type { PluggableList } from 'unified';
3
+ import { remarkCitations } from '../../../helpers/unified/remarkCitations';
4
+ import { CkCitation } from '../CkCitation';
5
+ import type { CkCitationSource } from '../CkCitation';
6
+
7
+ /** Scope passed to the `citation` slot when overriding how a source renders. */
8
+ export type CkCitationSlot = (props: {
9
+ source: CkCitationSource;
10
+ openSource: (url: string) => void;
11
+ }) => VNode[];
12
+
13
+ /**
14
+ * The citation concern of `CkMarkdownRenderer`, extracted so the renderer stays
15
+ * markdown-focused and this logic is testable on its own.
16
+ */
17
+ export function useCitationRendering(
18
+ citations: MaybeRefOrGetter<CkCitationSource[] | undefined>,
19
+ onOpenSource: (url: string) => void,
20
+ ) {
21
+ const sourcesByIndex = computed(
22
+ () =>
23
+ new Map(
24
+ (toValue(citations) ?? []).map((source) => [source.index, source]),
25
+ ),
26
+ );
27
+
28
+ const citationPlugins = computed<PluggableList>(() => {
29
+ const list = toValue(citations);
30
+ if (!list?.length) return [];
31
+ const indices = new Set(list.map((source) => source.index));
32
+ return [[remarkCitations, { indices }]];
33
+ });
34
+
35
+ // Returns null for non-citation elements so the renderer handles them normally.
36
+ function renderCitationNode(
37
+ el: Element,
38
+ slot?: CkCitationSlot,
39
+ ): VNode | VNode[] | null {
40
+ if (!el.hasAttribute('data-citation-index')) return null;
41
+
42
+ const index = Number(el.getAttribute('data-citation-index'));
43
+ const source = sourcesByIndex.value.get(index);
44
+ if (!source) return null;
45
+
46
+ // Expose the same handler so slot content can forward `open-source`.
47
+ if (slot) return slot({ source, openSource: onOpenSource });
48
+ return h(CkCitation, { ...source, onOpenSource });
49
+ }
50
+
51
+ return { citationPlugins, renderCitationNode };
52
+ }
@@ -1,5 +1,16 @@
1
1
  export { CkSendIcon, CkStopIcon } from './icons';
2
2
  export { CkAgentMessage } from './CkAgentMessage';
3
+ export { CkCitation } from './CkCitation';
4
+ export type {
5
+ CkCitationProps,
6
+ CkCitationEmits,
7
+ CkCitationSource,
8
+ } from './CkCitation';
9
+ export { CkCitationCard } from './CkCitationCard';
10
+ export type {
11
+ CkCitationCardProps,
12
+ CkCitationCardEmits,
13
+ } from './CkCitationCard';
3
14
  export { CkConversationLayout } from './CkConversationLayout';
4
15
  export type { CkConversationLayoutProps } from './CkConversationLayout';
5
16
  export { CkCopyMessageActionButton } from './CkCopyMessageActionButton';
@@ -36,6 +47,7 @@ export type { CkHistoryWrapperProps } from './CkHistoryWrapper';
36
47
  export { CkMarkdownRenderer } from './CkMarkdownRenderer';
37
48
  export type {
38
49
  CkMarkdownRendererProps,
50
+ CkMarkdownRendererEmits,
39
51
  CkMarkdownElementClasses,
40
52
  } from './CkMarkdownRenderer';
41
53
  export { CkMessageActionButton } from './CkMessageActionButton';
@@ -1,6 +1,8 @@
1
1
  // Base (presentational) components
2
2
  export {
3
3
  CkAgentMessage,
4
+ CkCitation,
5
+ CkCitationCard,
4
6
  CkConversationLayout,
5
7
  CkCopyMessageActionButton,
6
8
  CkDropZoneOverlay,
@@ -22,6 +24,11 @@ export {
22
24
  CkUserMessage,
23
25
  } from './base';
24
26
  export type {
27
+ CkCitationProps,
28
+ CkCitationEmits,
29
+ CkCitationSource,
30
+ CkCitationCardProps,
31
+ CkCitationCardEmits,
25
32
  CkConversationLayoutProps,
26
33
  CkDropZoneOverlayProps,
27
34
  CkDropZoneOverlayEmits,
@@ -42,6 +49,7 @@ export type {
42
49
  CkHistoryWrapperProps,
43
50
  CkMarkdownElementClasses,
44
51
  CkMarkdownRendererProps,
52
+ CkMarkdownRendererEmits,
45
53
  CkModelOption,
46
54
  CkModelSelectorProps,
47
55
  CkModelSelectorEmits,
@@ -34,6 +34,8 @@ export interface EndpointResolver {
34
34
  files: (storeId: string) => string;
35
35
  file: (storeId: string, fileId: string) => string;
36
36
  filePublicUrl: (storeId: string, fileId: string) => string;
37
+ workflows: (storeId: string) => string;
38
+ createWorkflow: (storeId: string) => string;
37
39
  workflowRun: (storeId: string, workflowId: string) => string;
38
40
  workflowRunEventStream: (
39
41
  storeId: string,
@@ -47,6 +49,9 @@ export const defaultEndpoints: EndpointResolver = {
47
49
  file: (storeId, fileId) => `search-stores/${storeId}/files/${fileId}`,
48
50
  filePublicUrl: (storeId, fileId) =>
49
51
  `search-stores/${storeId}/files/${fileId}/public-urls?ttl=1h`,
52
+ workflows: (storeId) =>
53
+ `search-stores/${storeId}/workflows?limit=20&offset=0&created_after=&created_before=`,
54
+ createWorkflow: (storeId) => `search-stores/${storeId}/workflows`,
50
55
  workflowRun: (storeId, workflowId) =>
51
56
  `search-stores/${storeId}/workflows/${workflowId}/runs`,
52
57
  workflowRunEventStream: (storeId, workflowId, runId) =>
@@ -410,6 +415,7 @@ export interface UseFileUploadReturn {
410
415
  updateFileMetadata: (id: string, meta: Record<string, unknown>) => void;
411
416
  clearState: () => void;
412
417
  setStoreId: (id: string) => void;
418
+ setWorkflowId: (id: string) => void;
413
419
  getPublicUrl: (fileId: string) => Promise<string>;
414
420
 
415
421
  scopeBy: (metadataKey: string, value: string) => ScopedFileView;
@@ -199,33 +199,7 @@ describe('useFileUpload maxFilesPerUpload', () => {
199
199
  });
200
200
  });
201
201
 
202
- it('throws MAX_FILES_EXCEEDED when active files plus new batch exceeds limit', async () => {
203
- const client = createUploadMockClient();
204
- (client.post as ReturnType<typeof vi.fn>).mockResolvedValue([
205
- { id: 'remote-1' },
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
- upload.files.value.set('existing-2', makeSuccessEntry('existing-2', 'r-2'));
217
-
218
- await expect(
219
- upload.upload({
220
- id: 'g2',
221
- files: [createFile('a.pdf'), createFile('b.pdf')],
222
- }),
223
- ).rejects.toMatchObject({
224
- code: 'MAX_FILES_EXCEEDED',
225
- });
226
- });
227
-
228
- it('allows upload when active files plus new batch is within limit', async () => {
202
+ it('allows upload when batch is within limit regardless of existing active files', async () => {
229
203
  const client = createUploadMockClient();
230
204
  (client.post as ReturnType<typeof vi.fn>).mockResolvedValue([
231
205
  { id: 'remote-new' },
@@ -239,6 +213,7 @@ describe('useFileUpload maxFilesPerUpload', () => {
239
213
  });
240
214
 
241
215
  upload.files.value.set('existing-1', makeSuccessEntry('existing-1', 'r-1'));
216
+ upload.files.value.set('existing-2', makeSuccessEntry('existing-2', 'r-2'));
242
217
 
243
218
  const result = await upload.upload({
244
219
  id: 'g3',
@@ -247,38 +222,4 @@ describe('useFileUpload maxFilesPerUpload', () => {
247
222
 
248
223
  expect(result.results).toHaveLength(2);
249
224
  });
250
-
251
- it('does not count failed/cancelled/removed files towards active count', async () => {
252
- const client = createUploadMockClient();
253
- (client.post as ReturnType<typeof vi.fn>).mockResolvedValue([
254
- { id: 'remote-new' },
255
- ]);
256
-
257
- const upload = useFileUpload({
258
- client,
259
- workflowId: 'wf',
260
- storeId: 'ss-1',
261
- maxFilesPerUpload: 2,
262
- });
263
-
264
- const failedEntry: FileEntry = {
265
- ...makeEntry('failed-1', 'r-f1'),
266
- status: 'failed',
267
- error: { code: 'UPLOAD_FAILED', message: 'err', occurredAt: 'now' },
268
- };
269
- const cancelledEntry: FileEntry = {
270
- ...makeEntry('cancelled-1', 'r-c1'),
271
- status: 'cancelled',
272
- };
273
-
274
- upload.files.value.set('failed-1', failedEntry);
275
- upload.files.value.set('cancelled-1', cancelledEntry);
276
-
277
- const result = await upload.upload({
278
- id: 'g4',
279
- files: [createFile('a.pdf'), createFile('b.pdf')],
280
- });
281
-
282
- expect(result.results).toHaveLength(2);
283
- });
284
225
  });
@@ -469,8 +469,9 @@ function deriveStateKey(
469
469
  export function useFileUpload(
470
470
  options: UseFileUploadOptions,
471
471
  ): UseFileUploadReturn {
472
- const { client, workflowId } = options;
472
+ const { client } = options;
473
473
  let storeId = options.storeId ?? '';
474
+ let workflowId = options.workflowId;
474
475
  const endpoints: EndpointResolver = options.endpoints ?? defaultEndpoints;
475
476
  const retryPolicy: RetryPolicy = options.retryPolicy ?? DEFAULT_RETRY_POLICY;
476
477
  const constraints: FileConstraints = {
@@ -706,17 +707,10 @@ export function useFileUpload(
706
707
  ? params.files
707
708
  : [params.files];
708
709
 
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) {
710
+ if (fileList.length > constraints.maxFilesPerUpload) {
717
711
  throw new SdkError(
718
712
  'MAX_FILES_EXCEEDED',
719
- `Cannot upload ${fileList.length} file(s) — ${activeFileCount} already active, maximum is ${constraints.maxFilesPerUpload}.`,
713
+ `Cannot upload ${fileList.length} file(s) — maximum per selection is ${constraints.maxFilesPerUpload}.`,
720
714
  );
721
715
  }
722
716
 
@@ -1257,6 +1251,10 @@ export function useFileUpload(
1257
1251
  storeId = id;
1258
1252
  }
1259
1253
 
1254
+ function setWorkflowId(id: string) {
1255
+ workflowId = id;
1256
+ }
1257
+
1260
1258
  async function getPublicUrl(fileId: string): Promise<string> {
1261
1259
  const res = await client.post<{ url: string; expires_at: string }>(
1262
1260
  endpoints.filePublicUrl(storeId, fileId),
@@ -1277,6 +1275,7 @@ export function useFileUpload(
1277
1275
  updateFileMetadata,
1278
1276
  clearState,
1279
1277
  setStoreId,
1278
+ setWorkflowId,
1280
1279
  getPublicUrl,
1281
1280
 
1282
1281
  scopeBy,
@@ -1,6 +1,34 @@
1
+ import { flushPromises } from '@vue/test-utils';
2
+ import { ref } from 'vue';
1
3
  import { createDocumentUploadProvider } from './useFileUploadWrapper';
2
4
  import type { DocumentUploadConfig } from './types';
3
5
 
6
+ const uploadMock = vi.hoisted(() => ({
7
+ upload: vi.fn().mockResolvedValue({ results: [] }),
8
+ }));
9
+
10
+ vi.mock('./useFileUpload', () => ({
11
+ useFileUpload: vi.fn((options) => {
12
+ const scopedFiles = ref([]);
13
+ return {
14
+ files: ref(new Map()),
15
+ getSupportedFileTypes: () => ({
16
+ maxFilesPerUpload: options.maxFilesPerUpload ?? 20,
17
+ allowedMimeTypes: ['application/pdf'],
18
+ allowedExtensions: ['.pdf'],
19
+ maxFileSizeBytes: 100 * 1024 * 1024,
20
+ }),
21
+ scopeBy: () => ({ files: scopedFiles }),
22
+ upload: uploadMock.upload,
23
+ runWorkflow: vi.fn().mockResolvedValue({ close: vi.fn() }),
24
+ setStoreId: vi.fn(),
25
+ setWorkflowId: vi.fn(),
26
+ updateFileMetadata: vi.fn(),
27
+ removeFile: vi.fn(),
28
+ };
29
+ }),
30
+ }));
31
+
4
32
  function createFile(name: string, size = 100): File {
5
33
  return new File(['x'.repeat(size)], name, { type: 'application/pdf' });
6
34
  }
@@ -31,6 +59,7 @@ function buildConfig(
31
59
  return {
32
60
  baseUrl: 'https://test.example.com',
33
61
  workflowId: 'wf-1',
62
+ persist: false,
34
63
  searchStoreConfig: {
35
64
  models: [
36
65
  {
@@ -48,28 +77,43 @@ function buildConfig(
48
77
  };
49
78
  }
50
79
 
80
+ async function settleAsyncUploads() {
81
+ await flushPromises();
82
+ }
83
+
51
84
  describe('useFileUploadWrapper handleFilesSelected', () => {
52
85
  let fetchMock: ReturnType<typeof vi.fn>;
53
86
 
54
87
  beforeEach(() => {
88
+ uploadMock.upload.mockClear();
89
+ uploadMock.upload.mockResolvedValue({ results: [] });
90
+
55
91
  fetchMock = vi.fn();
56
92
  vi.stubGlobal('fetch', fetchMock);
57
93
 
58
94
  fetchMock.mockImplementation((url: string, init?: RequestInit) => {
59
95
  const method = init?.method ?? 'GET';
60
96
 
61
- if (method === 'GET' && url.includes('search-stores')) {
97
+ if (method === 'GET' && url.endsWith('search-stores')) {
62
98
  return createMockFetchResponse({
63
99
  items: [
64
100
  {
65
101
  id: 'store-1',
66
- name: 'test-store',
102
+ name: 'conv-1',
67
103
  models: [{ embedding_model: 'test-model' }],
68
104
  },
69
105
  ],
70
106
  total: 1,
71
107
  });
72
108
  }
109
+ if (method === 'GET' && url.includes('/workflows')) {
110
+ return createMockFetchResponse({
111
+ items: [{ id: 'wf-1', name: 'wf-1' }],
112
+ });
113
+ }
114
+ if (method === 'POST' && url.endsWith('search-stores')) {
115
+ return createMockFetchResponse({ id: 'new-store-id' });
116
+ }
73
117
  if (method === 'POST' && url.includes('/files')) {
74
118
  return createMockFetchResponse([{ id: 'remote-1' }]);
75
119
  }
@@ -90,7 +134,8 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
90
134
  });
91
135
  });
92
136
 
93
- afterEach(() => {
137
+ afterEach(async () => {
138
+ await settleAsyncUploads();
94
139
  vi.unstubAllGlobals();
95
140
  });
96
141
 
@@ -115,26 +160,31 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
115
160
  const { handleFilesSelected } = provider.useDocumentUpload('conv-1');
116
161
 
117
162
  const onFilesExceeded = vi.fn();
118
- const event = makeInputEvent([createFile('a.pdf'), createFile('b.pdf')]);
163
+ const files = [createFile('a.pdf'), createFile('b.pdf')];
164
+ const event = makeInputEvent(files);
119
165
 
120
166
  handleFilesSelected(event, { onFilesExceeded });
121
167
 
122
168
  expect(onFilesExceeded).not.toHaveBeenCalled();
169
+ await settleAsyncUploads();
170
+ expect(uploadMock.upload).toHaveBeenCalledOnce();
171
+ expect(uploadMock.upload.mock.calls[0]?.[0].files).toEqual(files);
123
172
  });
124
173
 
125
- it('truncates files to available slots and calls onFilesExceeded', () => {
174
+ it('truncates files to available slots and calls onFilesExceeded', async () => {
126
175
  const provider = createDocumentUploadProvider(
127
176
  buildConfig({ maxFilesPerUpload: 2 }),
128
177
  );
129
178
  const { handleFilesSelected } = provider.useDocumentUpload('conv-1');
130
179
 
131
180
  const onFilesExceeded = vi.fn();
132
- const event = makeInputEvent([
181
+ const files = [
133
182
  createFile('a.pdf'),
134
183
  createFile('b.pdf'),
135
184
  createFile('c.pdf'),
136
185
  createFile('d.pdf'),
137
- ]);
186
+ ];
187
+ const event = makeInputEvent(files);
138
188
 
139
189
  handleFilesSelected(event, { onFilesExceeded });
140
190
 
@@ -143,9 +193,13 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
143
193
  allowed: 2,
144
194
  kept: 2,
145
195
  });
196
+ await settleAsyncUploads();
197
+ expect(uploadMock.upload.mock.calls[0]?.[0].files).toEqual(
198
+ files.slice(0, 2),
199
+ );
146
200
  });
147
201
 
148
- it('resets input value after handling', () => {
202
+ it('resets input value after handling', async () => {
149
203
  const provider = createDocumentUploadProvider(
150
204
  buildConfig({ maxFilesPerUpload: 5 }),
151
205
  );
@@ -159,6 +213,7 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
159
213
  handleFilesSelected(event);
160
214
 
161
215
  expect(valueSetter).toHaveBeenCalledWith('');
216
+ await settleAsyncUploads();
162
217
  });
163
218
 
164
219
  it('does not upload when no slots are available', async () => {
@@ -177,9 +232,11 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
177
232
  allowed: 0,
178
233
  kept: 0,
179
234
  });
235
+ await settleAsyncUploads();
236
+ expect(uploadMock.upload).not.toHaveBeenCalled();
180
237
  });
181
238
 
182
- it('does not call onFilesExceeded when exactly at the limit', () => {
239
+ it('does not call onFilesExceeded when exactly at the limit', async () => {
183
240
  const provider = createDocumentUploadProvider(
184
241
  buildConfig({ maxFilesPerUpload: 3 }),
185
242
  );
@@ -195,9 +252,11 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
195
252
  handleFilesSelected(event, { onFilesExceeded });
196
253
 
197
254
  expect(onFilesExceeded).not.toHaveBeenCalled();
255
+ await settleAsyncUploads();
256
+ expect(uploadMock.upload).toHaveBeenCalledOnce();
198
257
  });
199
258
 
200
- it('works without onFilesExceeded callback (no crash)', () => {
259
+ it('works without onFilesExceeded callback (no crash)', async () => {
201
260
  const provider = createDocumentUploadProvider(
202
261
  buildConfig({ maxFilesPerUpload: 1 }),
203
262
  );
@@ -206,5 +265,177 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
206
265
  const event = makeInputEvent([createFile('a.pdf'), createFile('b.pdf')]);
207
266
 
208
267
  expect(() => handleFilesSelected(event)).not.toThrow();
268
+ await settleAsyncUploads();
269
+ expect(uploadMock.upload).toHaveBeenCalledOnce();
270
+ });
271
+ });
272
+
273
+ describe('useFileUploadWrapper search store resolution', () => {
274
+ let fetchMock: ReturnType<typeof vi.fn>;
275
+
276
+ beforeEach(() => {
277
+ fetchMock = vi.fn();
278
+ vi.stubGlobal('fetch', fetchMock);
279
+ });
280
+
281
+ afterEach(() => {
282
+ vi.unstubAllGlobals();
283
+ });
284
+
285
+ it('reuses an existing search store named after the conversation id', async () => {
286
+ fetchMock.mockImplementation((url: string, init?: RequestInit) => {
287
+ const method = init?.method ?? 'GET';
288
+
289
+ if (method === 'GET' && url.endsWith('search-stores')) {
290
+ return createMockFetchResponse({
291
+ items: [
292
+ {
293
+ id: 'existing-store',
294
+ name: 'conv-1',
295
+ models: [{ embedding_model: 'test-model' }],
296
+ },
297
+ ],
298
+ total: 1,
299
+ });
300
+ }
301
+ if (method === 'GET' && url.includes('/workflows')) {
302
+ return createMockFetchResponse({
303
+ items: [{ id: 'wf-1', name: 'wf-1' }],
304
+ });
305
+ }
306
+ return createMockFetchResponse({});
307
+ });
308
+
309
+ const provider = createDocumentUploadProvider(buildConfig());
310
+ const { storeId } = provider.useDocumentUpload('conv-1');
311
+
312
+ await vi.waitFor(() => {
313
+ expect(storeId.value).toBe('existing-store');
314
+ });
315
+
316
+ expect(fetchMock).toHaveBeenCalledWith(
317
+ 'https://test.example.com/search-stores',
318
+ expect.objectContaining({ method: 'GET' }),
319
+ );
320
+ expect(
321
+ fetchMock.mock.calls.some(
322
+ ([url, init]) =>
323
+ init?.method === 'POST' && String(url).endsWith('search-stores'),
324
+ ),
325
+ ).toBe(false);
326
+ });
327
+
328
+ it('creates a search store and workflow when none exists for the conversation', async () => {
329
+ fetchMock.mockImplementation((url: string, init?: RequestInit) => {
330
+ const method = init?.method ?? 'GET';
331
+
332
+ if (method === 'GET' && url.endsWith('search-stores')) {
333
+ return createMockFetchResponse({ items: [], total: 0 });
334
+ }
335
+ if (method === 'POST' && url.endsWith('search-stores')) {
336
+ const body = JSON.parse(String(init?.body));
337
+ expect(body.name).toBe('conv-2');
338
+ return createMockFetchResponse({ id: 'created-store' });
339
+ }
340
+ if (method === 'POST' && url.includes('/workflows')) {
341
+ return createMockFetchResponse({ id: 'wf-run-1' });
342
+ }
343
+ return createMockFetchResponse({});
344
+ });
345
+
346
+ const provider = createDocumentUploadProvider(buildConfig());
347
+ const { storeId } = provider.useDocumentUpload('conv-2');
348
+
349
+ await vi.waitFor(() => {
350
+ expect(storeId.value).toBe('created-store');
351
+ });
352
+
353
+ expect(
354
+ fetchMock.mock.calls.some(
355
+ ([url, init]) =>
356
+ init?.method === 'POST' &&
357
+ String(url).endsWith('search-stores/created-store/workflows'),
358
+ ),
359
+ ).toBe(true);
360
+ });
361
+
362
+ it('creates a workflow when reusing a store that has none', async () => {
363
+ fetchMock.mockImplementation((url: string, init?: RequestInit) => {
364
+ const method = init?.method ?? 'GET';
365
+
366
+ if (method === 'GET' && url.endsWith('search-stores')) {
367
+ return createMockFetchResponse({
368
+ items: [
369
+ {
370
+ id: 'orphan-store',
371
+ name: 'conv-3',
372
+ models: [{ embedding_model: 'test-model' }],
373
+ },
374
+ ],
375
+ total: 1,
376
+ });
377
+ }
378
+ if (method === 'GET' && url.includes('/workflows')) {
379
+ return createMockFetchResponse({ items: [] });
380
+ }
381
+ if (method === 'POST' && url.includes('/workflows')) {
382
+ return createMockFetchResponse({ id: 'wf-1' });
383
+ }
384
+ return createMockFetchResponse({});
385
+ });
386
+
387
+ const provider = createDocumentUploadProvider(buildConfig());
388
+ const { storeId } = provider.useDocumentUpload('conv-3');
389
+
390
+ await vi.waitFor(() => {
391
+ expect(storeId.value).toBe('orphan-store');
392
+ });
393
+
394
+ expect(
395
+ fetchMock.mock.calls.some(
396
+ ([url, init]) =>
397
+ init?.method === 'POST' &&
398
+ String(url).endsWith('search-stores/orphan-store/workflows'),
399
+ ),
400
+ ).toBe(true);
401
+ });
402
+
403
+ it('resolves a different store per conversation id', async () => {
404
+ fetchMock.mockImplementation((url: string, init?: RequestInit) => {
405
+ const method = init?.method ?? 'GET';
406
+
407
+ if (method === 'GET' && url.endsWith('search-stores')) {
408
+ return createMockFetchResponse({
409
+ items: [
410
+ {
411
+ id: 'store-a',
412
+ name: 'conv-a',
413
+ models: [{ embedding_model: 'test-model' }],
414
+ },
415
+ {
416
+ id: 'store-b',
417
+ name: 'conv-b',
418
+ models: [{ embedding_model: 'test-model' }],
419
+ },
420
+ ],
421
+ total: 2,
422
+ });
423
+ }
424
+ if (method === 'GET' && url.includes('/workflows')) {
425
+ return createMockFetchResponse({
426
+ items: [{ id: 'wf-1', name: 'wf-1' }],
427
+ });
428
+ }
429
+ return createMockFetchResponse({});
430
+ });
431
+
432
+ const provider = createDocumentUploadProvider(buildConfig());
433
+ const uploadA = provider.useDocumentUpload('conv-a');
434
+ const uploadB = provider.useDocumentUpload('conv-b');
435
+
436
+ await vi.waitFor(() => {
437
+ expect(uploadA.storeId.value).toBe('store-a');
438
+ expect(uploadB.storeId.value).toBe('store-b');
439
+ });
209
440
  });
210
441
  });