@aleph-alpha/chat-kit 3.2.0 → 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 (87) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-B-4xnH2c.js → CkInput.vue_vue_type_script_setup_true_lang-BAcBFs6I.js} +302 -71
  2. package/dist/chat-kit.css +6 -0
  3. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +7 -0
  4. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
  5. package/dist/components/base/CkCitation/CkCitation.stories.d.ts +47 -0
  6. package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -0
  7. package/dist/components/base/CkCitation/CkCitation.vue.d.ts +15 -0
  8. package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -0
  9. package/dist/components/base/CkCitation/index.d.ts +3 -0
  10. package/dist/components/base/CkCitation/index.d.ts.map +1 -0
  11. package/dist/components/base/CkCitation/types.d.ts +79 -0
  12. package/dist/components/base/CkCitation/types.d.ts.map +1 -0
  13. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +51 -0
  14. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -0
  15. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts +13 -0
  16. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -0
  17. package/dist/components/base/CkCitationCard/index.d.ts +3 -0
  18. package/dist/components/base/CkCitationCard/index.d.ts.map +1 -0
  19. package/dist/components/base/CkCitationCard/types.d.ts +51 -0
  20. package/dist/components/base/CkCitationCard/types.d.ts.map +1 -0
  21. package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts +1 -1
  22. package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts +2 -2
  23. package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +1 -1
  24. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +15 -0
  25. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
  26. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts +16 -2
  27. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts.map +1 -1
  28. package/dist/components/base/CkMarkdownRenderer/index.d.ts +1 -1
  29. package/dist/components/base/CkMarkdownRenderer/index.d.ts.map +1 -1
  30. package/dist/components/base/CkMarkdownRenderer/types.d.ts +25 -0
  31. package/dist/components/base/CkMarkdownRenderer/types.d.ts.map +1 -1
  32. package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts +17 -0
  33. package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts.map +1 -0
  34. package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
  35. package/dist/components/base/index.d.ts +5 -1
  36. package/dist/components/base/index.d.ts.map +1 -1
  37. package/dist/components/composed/CkInput/CkInput.vue.d.ts +1 -1
  38. package/dist/components/index.d.ts +2 -2
  39. package/dist/components/index.d.ts.map +1 -1
  40. package/dist/components/index.js +25 -23
  41. package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
  42. package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
  43. package/dist/composables/index.js +1 -1
  44. package/dist/handlers/index.d.ts +2 -0
  45. package/dist/handlers/index.d.ts.map +1 -1
  46. package/dist/handlers/index.js +3 -2
  47. package/dist/handlers/useChatKitLabels.d.ts +11 -0
  48. package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
  49. package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
  50. package/dist/helpers/unified/remarkCitations.d.ts +17 -0
  51. package/dist/helpers/unified/remarkCitations.d.ts.map +1 -0
  52. package/dist/{index-DAmWGYNH.js → index-C6njJ8EB.js} +85 -70
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +28 -26
  56. package/dist/{useChatKitLabels-D5gNv8se.js → useChatKitLabels-BM2I7-9o.js} +30 -3
  57. package/package.json +4 -2
  58. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +112 -0
  59. package/src/components/base/CkCitation/CkCitation.spec.ts +79 -0
  60. package/src/components/base/CkCitation/CkCitation.stories.ts +136 -0
  61. package/src/components/base/CkCitation/CkCitation.vue +145 -0
  62. package/src/components/base/CkCitation/index.ts +2 -0
  63. package/src/components/base/CkCitation/types.ts +80 -0
  64. package/src/components/base/CkCitationCard/CkCitationCard.spec.ts +77 -0
  65. package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +144 -0
  66. package/src/components/base/CkCitationCard/CkCitationCard.vue +76 -0
  67. package/src/components/base/CkCitationCard/index.ts +2 -0
  68. package/src/components/base/CkCitationCard/types.ts +51 -0
  69. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +124 -0
  70. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +118 -0
  71. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +70 -28
  72. package/src/components/base/CkMarkdownRenderer/index.ts +1 -0
  73. package/src/components/base/CkMarkdownRenderer/types.ts +26 -0
  74. package/src/components/base/CkMarkdownRenderer/useCitationRendering.spec.ts +94 -0
  75. package/src/components/base/CkMarkdownRenderer/useCitationRendering.ts +52 -0
  76. package/src/components/base/index.ts +12 -0
  77. package/src/components/index.ts +8 -0
  78. package/src/composables/fileUpload/useFileUpload.spec.ts +2 -61
  79. package/src/composables/fileUpload/useFileUpload.ts +2 -9
  80. package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +195 -14
  81. package/src/composables/fileUpload/useFileUploadWrapper.ts +114 -81
  82. package/src/handlers/index.ts +2 -0
  83. package/src/handlers/useChatKitLabels.ts +13 -0
  84. package/src/handlers/useMarkdownParser.ts +5 -2
  85. package/src/helpers/unified/remarkCitations.spec.ts +112 -0
  86. package/src/helpers/unified/remarkCitations.ts +55 -0
  87. package/src/index.ts +8 -0
@@ -1,4 +1,5 @@
1
1
  import type { PluggableList } from 'unified';
2
+ import type { CkCitationSource } from '../CkCitation';
2
3
 
3
4
  export interface CkMarkdownElementClasses {
4
5
  h1?: string;
@@ -50,4 +51,29 @@ export interface CkMarkdownRendererProps {
50
51
  classes?: CkMarkdownElementClasses;
51
52
  /** Extra [unified](https://unifiedjs.com/) plugins to extend parsing. */
52
53
  plugins?: PluggableList;
54
+ /**
55
+ * Citation sources for inline `[n]` references in `content`. When provided,
56
+ * each `[n]` whose number matches a source's `index` is rendered as an
57
+ * interactive `CkCitation` (numbered marker + hover card); unmatched markers
58
+ * stay as plain text. Fully opt-in — omit it and markdown renders unchanged.
59
+ *
60
+ * Notes for callers:
61
+ * - Any numeric `[n]` in prose is turned into a citation when source `n`
62
+ * exists — including incidental ones like array indexing (`arr[1]`). Wrap
63
+ * such text in a code span (`` `arr[1]` ``) to keep it literal. `[n]` inside
64
+ * code spans/blocks is never converted.
65
+ * - `index` must be unique across sources; on a collision the last wins.
66
+ * - Pass a stable/memoized array reference. A new array identity on every
67
+ * render forces the markdown processor to rebuild.
68
+ */
69
+ citations?: CkCitationSource[];
70
+ }
71
+
72
+ export interface CkMarkdownRendererEmits {
73
+ /**
74
+ * Emitted when the user opens a citation's source from its card. The
75
+ * payload is the source `url`. Forward it to a handler that performs the
76
+ * (often auth-proxied) navigation.
77
+ */
78
+ (e: 'open-source', url: string): void;
53
79
  }
@@ -0,0 +1,94 @@
1
+ import { h, type VNode } from 'vue';
2
+ import { useCitationRendering } from './useCitationRendering';
3
+ import { CkCitation } from '../CkCitation';
4
+ import { remarkCitations } from '../../../helpers/unified/remarkCitations';
5
+ import type { CkCitationSource } from '../CkCitation';
6
+
7
+ const SOURCES: CkCitationSource[] = [
8
+ {
9
+ index: 1,
10
+ fileName: 'q3-report.pdf',
11
+ snippet: 'the cited chunk text',
12
+ locationLabel: 'Page 3',
13
+ url: '/api/v1/.../public-urls',
14
+ },
15
+ ];
16
+
17
+ function citationPlaceholder(index: number): Element {
18
+ const el = document.createElement('span');
19
+ el.setAttribute('data-citation-index', String(index));
20
+ return el;
21
+ }
22
+
23
+ describe('useCitationRendering', () => {
24
+ describe('citationPlugins', () => {
25
+ it('adds no plugins when there are no citations', () => {
26
+ const { citationPlugins } = useCitationRendering(
27
+ () => undefined,
28
+ vi.fn(),
29
+ );
30
+ expect(citationPlugins.value).toEqual([]);
31
+ });
32
+
33
+ it('adds the remarkCitations plugin when citations are present', () => {
34
+ const { citationPlugins } = useCitationRendering(() => SOURCES, vi.fn());
35
+ expect(citationPlugins.value).toHaveLength(1);
36
+ const [plugin] = citationPlugins.value[0] as [unknown, unknown];
37
+ expect(plugin).toBe(remarkCitations);
38
+ });
39
+ });
40
+
41
+ describe('renderCitationNode', () => {
42
+ it('renders a matching placeholder as a CkCitation with the source props', () => {
43
+ const onOpenSource = vi.fn();
44
+ const { renderCitationNode } = useCitationRendering(
45
+ () => SOURCES,
46
+ onOpenSource,
47
+ );
48
+
49
+ const vnode = renderCitationNode(citationPlaceholder(1)) as VNode;
50
+
51
+ expect(vnode.type).toBe(CkCitation);
52
+ expect(vnode.props).toMatchObject({
53
+ index: 1,
54
+ fileName: 'q3-report.pdf',
55
+ url: '/api/v1/.../public-urls',
56
+ onOpenSource,
57
+ });
58
+ });
59
+
60
+ it('returns null for a placeholder with no matching source', () => {
61
+ const { renderCitationNode } = useCitationRendering(
62
+ () => SOURCES,
63
+ vi.fn(),
64
+ );
65
+ expect(renderCitationNode(citationPlaceholder(9))).toBeNull();
66
+ });
67
+
68
+ it('returns null for a non-citation element', () => {
69
+ const { renderCitationNode } = useCitationRendering(
70
+ () => SOURCES,
71
+ vi.fn(),
72
+ );
73
+ expect(renderCitationNode(document.createElement('p'))).toBeNull();
74
+ });
75
+
76
+ it('delegates to the slot when provided, passing source and openSource', () => {
77
+ const onOpenSource = vi.fn();
78
+ const slotResult = [h('span', 'custom')];
79
+ const slot = vi.fn(() => slotResult);
80
+ const { renderCitationNode } = useCitationRendering(
81
+ () => SOURCES,
82
+ onOpenSource,
83
+ );
84
+
85
+ const result = renderCitationNode(citationPlaceholder(1), slot);
86
+
87
+ expect(slot).toHaveBeenCalledWith({
88
+ source: SOURCES[0],
89
+ openSource: onOpenSource,
90
+ });
91
+ expect(result).toBe(slotResult);
92
+ });
93
+ });
94
+ });
@@ -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,
@@ -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
  });
@@ -707,17 +707,10 @@ export function useFileUpload(
707
707
  ? params.files
708
708
  : [params.files];
709
709
 
710
- const activeFileCount = [...files.value.values()].filter(
711
- (e) =>
712
- e.status !== 'failed' &&
713
- e.status !== 'cancelled' &&
714
- e.status !== 'removed',
715
- ).length;
716
-
717
- if (activeFileCount + fileList.length > constraints.maxFilesPerUpload) {
710
+ if (fileList.length > constraints.maxFilesPerUpload) {
718
711
  throw new SdkError(
719
712
  'MAX_FILES_EXCEEDED',
720
- `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}.`,
721
714
  );
722
715
  }
723
716
 
@@ -94,30 +94,42 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
94
94
  fetchMock.mockImplementation((url: string, init?: RequestInit) => {
95
95
  const method = init?.method ?? 'GET';
96
96
 
97
- if (method === 'GET' && url.includes('/workflows')) {
97
+ if (method === 'GET' && url.endsWith('search-stores')) {
98
98
  return createMockFetchResponse({
99
99
  items: [
100
100
  {
101
- id: 'workflow-api-1',
102
- name: 'wf-1',
103
- created_at: '2024-01-01T00:00:00Z',
101
+ id: 'store-1',
102
+ name: 'conv-1',
103
+ models: [{ embedding_model: 'test-model' }],
104
104
  },
105
105
  ],
106
106
  total: 1,
107
107
  });
108
108
  }
109
- if (method === 'GET' && url.includes('search-stores')) {
109
+ if (method === 'GET' && url.includes('/workflows')) {
110
110
  return createMockFetchResponse({
111
- items: [
112
- {
113
- id: 'store-1',
114
- name: 'test-store',
115
- models: [{ embedding_model: 'test-model' }],
116
- },
117
- ],
118
- total: 1,
111
+ items: [{ id: 'wf-1', name: 'wf-1' }],
119
112
  });
120
113
  }
114
+ if (method === 'POST' && url.endsWith('search-stores')) {
115
+ return createMockFetchResponse({ id: 'new-store-id' });
116
+ }
117
+ if (method === 'POST' && url.includes('/files')) {
118
+ return createMockFetchResponse([{ id: 'remote-1' }]);
119
+ }
120
+ if (method === 'POST' && url.includes('/workflows')) {
121
+ return createMockFetchResponse({ id: 'run-1' });
122
+ }
123
+ if (url.includes('/event-stream')) {
124
+ return createMockFetchResponse(null, 200).then((res) => ({
125
+ ...res,
126
+ body: new ReadableStream({
127
+ start(controller) {
128
+ controller.close();
129
+ },
130
+ }),
131
+ }));
132
+ }
121
133
  return createMockFetchResponse({});
122
134
  });
123
135
  });
@@ -222,7 +234,6 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
222
234
  });
223
235
  await settleAsyncUploads();
224
236
  expect(uploadMock.upload).not.toHaveBeenCalled();
225
- expect(fetchMock).not.toHaveBeenCalled();
226
237
  });
227
238
 
228
239
  it('does not call onFilesExceeded when exactly at the limit', async () => {
@@ -258,3 +269,173 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
258
269
  expect(uploadMock.upload).toHaveBeenCalledOnce();
259
270
  });
260
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
+ });
440
+ });
441
+ });